diff --git a/apps/_template/package.json b/apps/_template/package.json index 9227dcf64..7f1f98585 100644 --- a/apps/_template/package.json +++ b/apps/_template/package.json @@ -19,7 +19,7 @@ "@tanstack/react-table": "^8.21.2", "axios": "^1.8.1", "dayjs": "^1.11.13", - "next": "15.3.8", + "next": "15.5.18", "react-hook-form": "^7.54.2", "zod": "^3.24.2" }, @@ -33,4 +33,4 @@ "tailwindcss": "^3.4.14", "typescript": "^5.7.2" } -} \ No newline at end of file +} diff --git a/apps/as/package.json b/apps/as/package.json index cf2d547ba..5122cbbb5 100644 --- a/apps/as/package.json +++ b/apps/as/package.json @@ -27,7 +27,7 @@ "dayjs": "^1.11.13", "js-cookie": "^3.0.5", "lucide-react": "^0.474.0", - "next": "15.3.8", + "next": "15.5.18", "react-hook-form": "^7.54.2", "tus-js-client": "^4.3.1", "zod": "^3.24.2" diff --git a/apps/cm/package.json b/apps/cm/package.json index 2472516e4..5eaca0435 100644 --- a/apps/cm/package.json +++ b/apps/cm/package.json @@ -37,7 +37,7 @@ "dayjs": "^1.11.13", "js-cookie": "^3.0.5", "lucide-react": "^0.474.0", - "next": "15.3.8", + "next": "15.5.18", "next-qrcode": "^2.5.1", "next-themes": "^0.4.6", "react-chartjs-2": "^5.3.0", @@ -63,4 +63,4 @@ "tailwindcss": "^3.4.14", "typescript": "^5.7.2" } -} \ No newline at end of file +} diff --git a/apps/common/features/package.json b/apps/common/features/package.json index 7118ae49d..6c87b8f74 100644 --- a/apps/common/features/package.json +++ b/apps/common/features/package.json @@ -28,7 +28,7 @@ "chart.js": "^4.4.8", "dayjs": "^1.11.13", "js-cookie": "^3.0.5", - "next": "15.3.8", + "next": "15.5.18", "next-themes": "^0.4.6", "react-chartjs-2": "^5.3.0", "react-dnd": "^16.0.1", diff --git a/apps/cs/package.json b/apps/cs/package.json index 5bf3b53e7..b7e1421d8 100644 --- a/apps/cs/package.json +++ b/apps/cs/package.json @@ -35,7 +35,7 @@ "dayjs": "^1.11.13", "js-cookie": "^3.0.5", "lucide-react": "^0.474.0", - "next": "15.3.8", + "next": "15.5.18", "next-qrcode": "^2.5.1", "next-themes": "^0.4.6", "react-chartjs-2": "^5.3.0", diff --git a/apps/cs/src/entities/auth/api/auth-service.ts b/apps/cs/src/entities/auth/api/auth-service.ts index e48569677..9837aac8e 100644 --- a/apps/cs/src/entities/auth/api/auth-service.ts +++ b/apps/cs/src/entities/auth/api/auth-service.ts @@ -1,16 +1,17 @@ -import { getRemainingMs } from "@common/auth"; import { - ENDPOINT, - CLAIM_NAME, ACCESS_TOKEN, AUTH_USER_ID, + CLAIM_NAME, + ENDPOINT, } from "@/shared/config"; -import type { ILogin } from "../model/auth-interface"; +import { getRemainingMs } from "@common/auth"; + import { - setToken, clearAuthToken, + setToken, setupRefreshTimer, } from "../lib/token-manager"; +import type { AuthPayload, AuthService } from "../model/auth-interface"; function resolveLoginError(message: string | undefined): string { switch (message) { @@ -27,7 +28,7 @@ function resolveLoginError(message: string | undefined): string { } } -export async function login(payload: ILogin): Promise { +async function login(payload: AuthPayload): Promise { const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -56,7 +57,7 @@ export async function login(payload: ILogin): Promise { } } -export async function silentRefresh(): Promise { +async function silentRefresh(): Promise { console.log(`[silentRefresh] Called at ${new Date().toISOString()}`); try { const response = await fetch( @@ -97,9 +98,15 @@ export async function silentRefresh(): Promise { } } -export async function logout(): Promise { +async function logout(): Promise { clearAuthToken(); if (typeof window !== "undefined") { window.dispatchEvent(new CustomEvent("auth:logout")); } } + +export const authService: AuthService = { + login, + silentRefresh, + logout, +}; diff --git a/apps/cs/src/entities/auth/index.ts b/apps/cs/src/entities/auth/index.ts index 76b22ca39..7f0d0081a 100644 --- a/apps/cs/src/entities/auth/index.ts +++ b/apps/cs/src/entities/auth/index.ts @@ -1,3 +1,3 @@ -export { login, silentRefresh, logout } from "./api/auth-service"; +export { authService } from "./api/auth-service"; export { AuthWrapper } from "./lib/auth-wrapper"; -export { tokenManager } from "./lib/token-manager"; \ No newline at end of file +export { tokenManager } from "./lib/token-manager"; diff --git a/apps/cs/src/entities/auth/lib/auth-wrapper.tsx b/apps/cs/src/entities/auth/lib/auth-wrapper.tsx index 3c4dea9c7..4b6c181a8 100644 --- a/apps/cs/src/entities/auth/lib/auth-wrapper.tsx +++ b/apps/cs/src/entities/auth/lib/auth-wrapper.tsx @@ -2,20 +2,24 @@ import { useEffect } from "react"; import { useRouter } from "next/navigation"; + import { isAxiosError } from "axios"; + +import { client } from "@/shared/api"; +import { + AUTH_LOGIN_REDIRECT_URL, + AUTH_USER_ID, + CLAIM_NAME, + ENDPOINT, +} from "@/shared/config"; +import { useUserStore } from "@/shared/lib"; +import type { IUserInfo } from "@/shared/store"; import { getRemainingMs } from "@common/auth"; import { isAtLoginRedirectUrl } from "@common/network"; -import { client } from "@/shared/api"; -import type { IUserInfo } from "@/shared/store"; -import { useUserStore } from "@/shared/lib"; -import { - ENDPOINT, - AUTH_USER_ID, - AUTH_LOGIN_REDIRECT_URL, - CLAIM_NAME, -} from "@/shared/config"; + +import { authService } from "../api/auth-service"; + import { getToken, setupRefreshTimer } from "./token-manager"; -import { silentRefresh } from "../api/auth-service"; const AuthWrapper = ({ children }: { children: React.ReactNode }) => { const router = useRouter(); @@ -31,7 +35,7 @@ const AuthWrapper = ({ children }: { children: React.ReactNode }) => { console.log( `[AuthWrapper] Refresh timer set: ${remainingMs}ms (${remainingMs / 1000}s)`, ); - setupRefreshTimer(silentRefresh, remainingMs); + setupRefreshTimer(authService.silentRefresh, remainingMs); } } diff --git a/apps/cs/src/entities/auth/model/auth-interface.ts b/apps/cs/src/entities/auth/model/auth-interface.ts index 012f81457..c4b2d2691 100644 --- a/apps/cs/src/entities/auth/model/auth-interface.ts +++ b/apps/cs/src/entities/auth/model/auth-interface.ts @@ -1,4 +1,10 @@ -export interface ILogin { +export interface AuthPayload { userId: string; password: string; } + +export interface AuthService { + login(payload: AuthPayload): Promise; + silentRefresh(): Promise; + logout(): Promise; +} diff --git a/apps/cs/src/entities/user/model/user-interface.ts b/apps/cs/src/entities/user/model/user-interface.ts index 012fe9e92..5f4524eb0 100644 --- a/apps/cs/src/entities/user/model/user-interface.ts +++ b/apps/cs/src/entities/user/model/user-interface.ts @@ -1,4 +1,5 @@ import { z } from "zod"; + import type { PageableList, PageableParams } from "@/shared/model"; export const USER_STATE_CODE = { @@ -153,6 +154,7 @@ export const userUpdateSchema = z.object({ autzrId: z.string().uuid().optional(), expryYmd: z.string().max(8), instSeCd: z.string().optional(), + instSeNm: z.string().optional(), dtlInstCd: z.string().optional(), aprvYmd: z.string().max(8).optional(), enoCd: z.string().optional(), diff --git a/apps/cs/src/pages/auth/login/ui/login-page.tsx b/apps/cs/src/pages/auth/login/ui/login-page.tsx index fa7b84185..6a7fe120d 100644 --- a/apps/cs/src/pages/auth/login/ui/login-page.tsx +++ b/apps/cs/src/pages/auth/login/ui/login-page.tsx @@ -2,8 +2,10 @@ import { useState } from "react"; import Image from "next/image"; + +import { authService } from "@/entities/auth"; import { images } from "@common/assets"; -import { login } from "@/entities/auth"; + import "./login.css"; function LoginPage() { @@ -18,7 +20,7 @@ function LoginPage() { const password = formData.get("password") as string; try { - await login({ userId, password }); + await authService.login({ userId, password }); window.location.href = "/dashboard"; } catch (error) { const message = @@ -31,12 +33,14 @@ function LoginPage() {
-

+

유역하수도통합플랫폼
하수도플랫폼 통합관리(CMS)시스템

-

유역하수도통합플랫폼 통합관리(CMS)시스템에 오신 것을 환영합니다.

+

+ 유역하수도통합플랫폼 통합관리(CMS)시스템에 오신 것을 환영합니다. +

logo diff --git a/apps/cs/src/pages/users/index.ts b/apps/cs/src/pages/users/index.ts index e518fb440..c4c302b85 100644 --- a/apps/cs/src/pages/users/index.ts +++ b/apps/cs/src/pages/users/index.ts @@ -1,3 +1,3 @@ -export { UsersPage } from "./ui/users-page"; export { CreatePage } from "./ui/create-page"; export { UserDetail } from "./ui/user-detail"; +export { UsersPage } from "./ui/users-page"; diff --git a/apps/cs/src/pages/users/model/table-columns.tsx b/apps/cs/src/pages/users/model/table-columns.tsx index 1aad6de27..fbb05db39 100644 --- a/apps/cs/src/pages/users/model/table-columns.tsx +++ b/apps/cs/src/pages/users/model/table-columns.tsx @@ -1,5 +1,6 @@ -import dayjs from "dayjs"; import type { ColumnDef } from "@tanstack/react-table"; +import dayjs from "dayjs"; + import { USER_STATE_CODE, type UserListResponse, diff --git a/apps/cs/src/pages/users/ui/agency-search-dialog.tsx b/apps/cs/src/pages/users/ui/agency-search-dialog.tsx index 9759d42be..5b56ad2e9 100644 --- a/apps/cs/src/pages/users/ui/agency-search-dialog.tsx +++ b/apps/cs/src/pages/users/ui/agency-search-dialog.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; + import { keepPreviousData, useQuery, @@ -9,25 +10,26 @@ import { getPaginationRowModel, type PaginationState, } from "@tanstack/react-table"; + +import { ENDPOINT } from "@/shared/config"; +import { useDataTable } from "@/shared/lib"; import { + type LwpoCode, + LwpoCodeApi, + type LwpoCodeParams, +} from "@/entities/code"; +import { + Button, Dialog, + DialogClose, DialogContent, - DialogHeader, DialogFooter, + DialogHeader, DialogTitle, DialogTrigger, - DialogClose, - Button, Input, Label, } from "@common/components"; -import { - LwpoCodeApi, - type LwpoCode, - type LwpoCodeParams, -} from "@/entities/code"; -import { ENDPOINT } from "@/shared/config"; -import { useDataTable } from "@/shared/lib"; interface AgencySearchDialogProps { onSelectAgency: (data: { lwpoCd: string; lwpoCdNm: string }) => void; diff --git a/apps/cs/src/pages/users/ui/create-page.tsx b/apps/cs/src/pages/users/ui/create-page.tsx index 2d55a1cd2..33ec5ec0b 100644 --- a/apps/cs/src/pages/users/ui/create-page.tsx +++ b/apps/cs/src/pages/users/ui/create-page.tsx @@ -1,10 +1,13 @@ "use client"; -import { useRouter } from "next/navigation"; import { useForm } from "react-hook-form"; +import { useRouter } from "next/navigation"; + import { zodResolver } from "@hookform/resolvers/zod"; -import { useMutationWrapper, useAlertStore } from "@/shared/lib"; -import { UserApi, userSaveSchema, type UserSaveData } from "@/entities/user"; + +import { useAlertStore, useMutationWrapper } from "@/shared/lib"; +import { UserApi, type UserSaveData, userSaveSchema } from "@/entities/user"; + import { UsersForm } from "./users-form"; function CreatePage() { diff --git a/apps/cs/src/pages/users/ui/modify-page.tsx b/apps/cs/src/pages/users/ui/modify-page.tsx index fc93e4b05..b9b83b800 100644 --- a/apps/cs/src/pages/users/ui/modify-page.tsx +++ b/apps/cs/src/pages/users/ui/modify-page.tsx @@ -1,17 +1,20 @@ "use client"; import React from "react"; -import { useRouter } from "next/navigation"; import { useForm } from "react-hook-form"; +import { useRouter } from "next/navigation"; + import { zodResolver } from "@hookform/resolvers/zod"; import { useQuery } from "@tanstack/react-query"; + +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore, useMutationWrapper } from "@/shared/lib"; import { UserApi, - userUpdateSchema, type UserUpdateData, + userUpdateSchema, } from "@/entities/user"; -import { ENDPOINT } from "@/shared/config"; -import { useMutationWrapper, useAlertStore } from "@/shared/lib"; + import { UsersForm } from "./users-form"; interface ModifyPageProps { @@ -47,6 +50,7 @@ export function ModifyPage({ userUnqId }: ModifyPageProps) { aprvYn: userData?.aprvYn ?? "", expryYmd: userData?.expryYmd ?? "", instSeCd: userData?.instSeCd ?? "", + instSeNm: userData?.instSeNm ?? "", dtlInstCd: userData?.dtlInstCd ?? "", aprvYmd: userData?.aprvYmd ?? "", enoCd: userData?.enoCd ?? "", diff --git a/apps/cs/src/pages/users/ui/user-detail.tsx b/apps/cs/src/pages/users/ui/user-detail.tsx index fbfa016c2..1438c4864 100644 --- a/apps/cs/src/pages/users/ui/user-detail.tsx +++ b/apps/cs/src/pages/users/ui/user-detail.tsx @@ -1,9 +1,14 @@ "use client"; -import dayjs from "dayjs"; import { usePathname, useRouter } from "next/navigation"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; + import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import dayjs from "dayjs"; + +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore, useMutationWrapper } from "@/shared/lib"; +import { PasswordResetDialog, USER_STATE_CODE, UserApi } from "@/entities/user"; import { Button, Table, @@ -12,9 +17,6 @@ import { TableHead, TableRow, } from "@common/components"; -import { USER_STATE_CODE, UserApi, PasswordResetDialog } from "@/entities/user"; -import { ENDPOINT } from "@/shared/config"; -import { useAlertStore, useMutationWrapper } from "@/shared/lib"; function UserDetail({ userUnqId }: { userUnqId: string }) { const router = useRouter(); diff --git a/apps/cs/src/pages/users/ui/users-form.tsx b/apps/cs/src/pages/users/ui/users-form.tsx index 963e0f87e..4e11b958c 100644 --- a/apps/cs/src/pages/users/ui/users-form.tsx +++ b/apps/cs/src/pages/users/ui/users-form.tsx @@ -1,42 +1,45 @@ -import { useState, useMemo, useCallback } from "react"; +import { useCallback, useMemo, useState } from "react"; import { - useWatch, type Path, type PathValue, type UseFormReturn, + useWatch, } from "react-hook-form"; + import { useQuery } from "@tanstack/react-query"; + +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore, useMutationWrapper } from "@/shared/lib"; import { - Button, - Input, - Table, - TableBody, - TableCell, - TableHead, - TableRow, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@common/components"; -import { FormWrapper } from "@/widgets/form-wrapper"; + FormControl, + FormField, + FormItem, + FormMessage, +} from "@/shared/ui/form"; import { CodeApi, CodeSelect } from "@/entities/code"; import { UserApi, type UserSaveData, type UserUpdateData, } from "@/entities/user"; +import { FormWrapper } from "@/widgets/form-wrapper"; import { - FormField, - FormItem, - FormControl, - FormMessage, -} from "@/shared/ui/form"; -import { ENDPOINT } from "@/shared/config"; -import { useAlertStore, useMutationWrapper } from "@/shared/lib"; -import { ZipSearchDialog } from "./zip-search-dialog"; + Button, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Table, + TableBody, + TableCell, + TableHead, + TableRow, +} from "@common/components"; + import { AgencySearchDialog } from "./agency-search-dialog"; +import { ZipSearchDialog } from "./zip-search-dialog"; type UsersFormProps = { userId?: string; @@ -61,7 +64,6 @@ function UsersForm({ const [isValidUserId, setIsValidUserId] = useState(false); const [isValidEmail, setIsValidEmail] = useState(false); const [passwordMatch, setPasswordMatch] = useState(true); - const [instSeCdNm, setInstSeCdNm] = useState(""); const { data: authSeCd } = useQuery({ queryKey: [ENDPOINT.CMS_SERVICE.CODE, "AUTHSECD"], @@ -130,7 +132,7 @@ function UsersForm({ (code) => code.lwpoCd.startsWith("G3") && code.lwpoCd.length === 4, ) ?? [] ); - if (watchDtlInstCd === "ROLE_USER5") + if (watchDtlInstCd === "ROLE_USER05") return ( laeSeCd?.filter( (code) => code.lwpoCd.startsWith("G3") && code.lwpoCd.length === 7, @@ -163,7 +165,10 @@ function UsersForm({ "instSeCd" as Path, data.lwpoCd as PathValue>, ); - setInstSeCdNm(data.lwpoCdNm); + form.setValue( + "instSeNm" as Path, + data.lwpoCdNm as PathValue>, + ); }, [form], ); @@ -622,8 +627,8 @@ function UsersForm({ onValueChange={field.onChange} disabled={ !( - watchDtlInstCd === "ROLE_USER4" || - watchDtlInstCd === "ROLE_USER5" + watchDtlInstCd === "ROLE_USER04" || + watchDtlInstCd === "ROLE_USER05" ) } > @@ -650,10 +655,21 @@ function UsersForm({ 기관/업체명
- } + render={({ field }) => ( + + + + + + )} />
diff --git a/apps/cs/src/pages/users/ui/users-page.tsx b/apps/cs/src/pages/users/ui/users-page.tsx index 5670eb9d3..17b44707c 100644 --- a/apps/cs/src/pages/users/ui/users-page.tsx +++ b/apps/cs/src/pages/users/ui/users-page.tsx @@ -1,10 +1,21 @@ "use client"; -import dayjs from "dayjs"; import { useState } from "react"; import { useRouter } from "next/navigation"; + import { useQuery } from "@tanstack/react-query"; import { getCoreRowModel, type PaginationState } from "@tanstack/react-table"; +import dayjs from "dayjs"; + +import { ENDPOINT } from "@/shared/config"; +import { useDataTable } from "@/shared/lib"; +import { InputDateRangePicker } from "@/shared/ui/input-date-range-picker"; +import { CodeApi } from "@/entities/code"; +import { + UserApi, + type UserListResponse, + type UserSearchParams, +} from "@/entities/user"; import { Button, Checkbox, @@ -16,20 +27,12 @@ import { SelectTrigger, SelectValue, } from "@common/components"; -import { CodeApi } from "@/entities/code"; -import { - UserApi, - type UserListResponse, - type UserSearchParams, -} from "@/entities/user"; -import { ENDPOINT } from "@/shared/config"; -import { useDataTable } from "@/shared/lib"; -import { InputDateRangePicker } from "@/shared/ui/input-date-range-picker"; + import { columns } from "../model/table-columns"; type DateRange = { - from?: Date | undefined; - to?: Date | undefined; + from?: Date | null; + to?: Date | null; }; function UsersPage() { diff --git a/apps/cs/src/pages/users/ui/zip-search-dialog.tsx b/apps/cs/src/pages/users/ui/zip-search-dialog.tsx index bf2160ea8..de5ae9696 100644 --- a/apps/cs/src/pages/users/ui/zip-search-dialog.tsx +++ b/apps/cs/src/pages/users/ui/zip-search-dialog.tsx @@ -1,4 +1,5 @@ -import { useState, useRef, useEffect } from "react"; +import { useEffect, useRef, useState } from "react"; + import { keepPreviousData, useQuery, @@ -9,21 +10,23 @@ import { getPaginationRowModel, type PaginationState, } from "@tanstack/react-table"; + +import { ENDPOINT } from "@/shared/config"; +import { useDataTable } from "@/shared/lib"; import { + Button, Dialog, + DialogClose, DialogContent, - DialogHeader, DialogFooter, + DialogHeader, DialogTitle, DialogTrigger, - DialogClose, - Button, Input, Label, } from "@common/components"; -import { ENDPOINT } from "@/shared/config"; -import { useDataTable } from "@/shared/lib"; -import { ZipApi, type Zip, type ZipSearchParams } from "../api/zip-service"; + +import { type Zip, ZipApi, type ZipSearchParams } from "../api/zip-service"; interface ZipSearchDialogProps { onSelectAddress: (data: { zip: string; addr: string }) => void; diff --git a/apps/dc/package.json b/apps/dc/package.json index 5835b9ab6..db1470e5a 100644 --- a/apps/dc/package.json +++ b/apps/dc/package.json @@ -23,7 +23,7 @@ "dayjs": "^1.11.13", "js-cookie": "^3.0.5", "lucide-react": "^0.474.0", - "next": "15.3.8", + "next": "15.5.18", "picocolors": "^1.1.1", "react-chartjs-2": "^5.3.0", "react-dnd": "^16.0.1", @@ -49,4 +49,4 @@ "tailwindcss": "^3.4.14", "typescript": "^5.7.2" } -} \ No newline at end of file +} diff --git a/apps/dm/.env.local b/apps/dm/.env.local index df18c9228..7e0ec15fc 100644 --- a/apps/dm/.env.local +++ b/apps/dm/.env.local @@ -19,7 +19,8 @@ NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y} NEXT_PUBLIC_GATEWAY_URL=http://con.mind-one.co.kr:11083 # 개발 서버 API # NEXT_PUBLIC_GATEWAY_URL=http://localhost:8883 # 개발 서버 API - +CONTENTS_INTRO = 31 +CONTENTS_EXAMPLE = 32 NEXT_PUBLIC_FINDADRESS_URL=https://business.juso.go.kr/addrlink/addrLinkApi.do NEXT_PRIVATE_WR_ADDRESS_KEY = devU01TX0FVVEgyMDI1MDgyNTEwMzkwNDExNjEwNTM= diff --git a/apps/dm/.env.mindone b/apps/dm/.env.mindone index 22fb6274c..1a11ec6a9 100644 --- a/apps/dm/.env.mindone +++ b/apps/dm/.env.mindone @@ -20,7 +20,8 @@ NEXT_PUBLIC_SEWERAGE_API_URL=http://con.mind-one.co.kr:11083/sewerage-service NEXT_PUBLIC_GATEWAY_URL=http://con.mind-one.co.kr:11083 # 개발 서버 API # NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.128:8883/sewerage-service/api # 최용성 #NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.66:8883/sewerage-service/api # 박이정 - +CONTENTS_INTRO = 31 +CONTENTS_EXAMPLE = 32 NEXT_PUBLIC_FINDADRESS_URL=https://business.juso.go.kr/addrlink/addrLinkApi.do NEXT_PRIVATE_WR_ADDRESS_KEY = devU01TX0FVVEgyMDI1MDgyNTEwMzkwNDExNjEwNTM= diff --git a/apps/dm/.env.production b/apps/dm/.env.production index 587001101..b2617e68e 100644 --- a/apps/dm/.env.production +++ b/apps/dm/.env.production @@ -18,7 +18,8 @@ NEXT_PUBLIC_SEWERAGE_API_URL = http://apigateway.iipbs-was.svc.cluster.local:800 NEXT_PUBLIC_NGII_URL = http://172.29.143.87/poiact NEXT_PUBLIC_NGII_PARAM = BDE2BEF39DAFEC293AB6730150F222AC&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={y}&tilerow={x} NEXT_PUBLIC_GATEWAY_URL = http://apigateway.iipbs-was.svc.cluster.local:8000 - +CONTENTS_INTRO = 31 +CONTENTS_EXAMPLE = 32 NEXT_PUBLIC_FINDADRESS_URL=https://business.juso.go.kr/addrlink/addrLinkApi.do NEXT_PRIVATE_WR_ADDRESS_KEY = U01TXOFVVEgyMDI1MDgyODE0MTAxNzExNjEyMjA= diff --git a/apps/dm/app/(with-layout)/contents/page.tsx b/apps/dm/app/(with-layout)/contents/page.tsx index 27d0974be..02b97af14 100644 --- a/apps/dm/app/(with-layout)/contents/page.tsx +++ b/apps/dm/app/(with-layout)/contents/page.tsx @@ -1,9 +1,26 @@ -export const dynamic = "force-dynamic"; -import { ContentsPage } from "@/components/ui/contents"; +import { revalidateTag } from "next/cache"; +import { Button } from "@common/components/ui/button"; export default async function Contents() { - const contsSn = 28; - const menuCd = "24"; + /** + * 'dm-content-detail' 태그를 가진 캐시 데이터를 즉시 무효화하는 서버 액션입니다. + * 버튼 클릭 시 실행되며, 이후 이 태그를 사용하는 모든 페이지는 최신 데이터를 다시 가져옵니다. + */ + async function revalidateAction() { + "use server"; + revalidateTag("dm-content-detail"); + console.log("Cache revalidated for tag: dm-content-detail"); + } - return ; + return ( +
+
+

콘텐츠 관리 도구

+

변경된 콘텐츠 내용을 즉시 반영하려면 아래 버튼을 클릭하세요.

+
+ +
+
+
+ ); } diff --git a/apps/dm/app/(with-layout)/example/_components/GlobalCaseSection.tsx b/apps/dm/app/(with-layout)/example/_components/GlobalCaseSection.tsx index 73492a21d..26998ce78 100644 --- a/apps/dm/app/(with-layout)/example/_components/GlobalCaseSection.tsx +++ b/apps/dm/app/(with-layout)/example/_components/GlobalCaseSection.tsx @@ -1,780 +1,548 @@ "use client"; -import Image from "next/image"; -import CaseImageSlider from "@/components/CaseImageSlider"; - -type Slide = { - src: string; - alt: string; -}; - -type ContentBlock = { - title: string; - items: TextItem[]; -}; - -type InfoCard = { - title: string; - desc: string; -}; - -type TableData = { - headers: string[]; - rows: string[][]; -}; - -type OverviewCompareRow = { - country: string; - flag?: string; - overseas: string; - current: string; - future: string; -}; - -type GlobalCase = - | { - type: "overview"; - id: string; - title: string; - } - | { - type: "default"; - id: string; - title: string; - slides: Slide[]; - blocks?: ContentBlock[]; - cards?: InfoCard[]; - table?: TableData; - }; - -function SectionTitle({ children }: { children: React.ReactNode }) { - return ( -
- -

{children}

-
- ); -} - -function BlockTitle({ children }: { children: React.ReactNode }) { - return ( -
- - - - -

{children}

-
- ); -} - -function Hr({ className = "" }: { className?: string }) { - return
; -} - -type TextItem = - | string - | { - text: React.ReactNode; - depth?: number; // 0: 기본 / 1: 하위 / 2: 더 하위 - }; - -function ContentList({ items }: { items: TextItem[] }) { - return ( -
    - {items.map((item, index) => { - const isString = typeof item === "string"; - const text = isString ? item : item.text; - const depth = isString ? 0 : item.depth ?? 0; - - if (depth === 3) { - return ( -
  • - * {text} -
  • - ); - } - - if (depth === 2) { - return ( -
  • - {text} -
  • - ); - } - - if (depth === 1) { - return ( -
  • - - {text} -
  • - ); - } - - return ( -
  • - {text} -
  • - ); - })} -
- ); -} - -function ContentBlocks({ - blocks, - cards, - table, -}: { - blocks?: ContentBlock[]; - cards?: InfoCard[]; - table?: TableData; -}) { - if (!blocks?.length) return null; - - return ( -
- {blocks.map((block, index) => ( -
- {block.title} - - {!!block.items.length && ( - - )} - - {block.title.includes("재생 수자원 개발법") && ( - - )} - - {block.title.includes("대만 재이용 시설별 재이용수 공급단지") && ( - - )} -
- ))} -
- ); -} - -function InfoCards({ cards }: { cards?: InfoCard[] }) { - if (!cards?.length) return null; - - return ( -
- {cards.map((card) => ( -
- - {card.title} - -

- {card.desc} -

-
- ))} -
- ); -} - -function DataTable({ table }: { table?: TableData }) { - if (!table) return null; - - return ( -
- - - - {table.headers.map((header, index) => ( - - ))} - - - - - {table.rows.map((row, rowIndex) => ( - - {row.map((cell, cellIndex) => ( - - ))} - - ))} - -
- {header} -
- {cell} -
-
- ); -} - -/* ========================= - 사례개요 전용 컴포넌트 -========================= */ - -const compareRows: OverviewCompareRow[] = [ - { - country: "한국", - flag: "/images/landing/business/globalCase/flag_kr.png", - overseas: "12.1%", - current: "15.6%", - future: "17%", - }, - { - country: "대만", - flag: "/images/landing/business/globalCase/flag_tw.png", - overseas: "2.4%", - current: "4.3%", - future: "16.5%", - }, -]; - -const reuseRateRows = [ - ["이스라엘", "중동&북아프리카", "92.3", "튀르키예", "동유럽&중앙아시아", "26.6"], - ["쿠웨이트", "중동&북아프리카", "89.3", "독일", "서유럽", "25.0"], - ["호주", "동아시아&태평양", "49.0", "스페인", "서유럽", "24.4"], - ["싱가포르", "동아시아&태평양", "44.4", "한국", "동아시아&태평양", "12.1"], - ["그리스", "서유럽", "39.1", "미국", "북아메리카", "13.1"], - ["사우디아라비아", "중동&북아프리카", "32.8", "프랑스", "서유럽", "9.5"], - ["이탈리아", "서유럽", "31.7", "대만", "동아시아&태평양", "2.4"], - ["룩셈부르크", "서유럽", "29.1", "일본", "동아시아&태평양", "0.8"], - ["포르투갈", "서유럽", "29.0", "", "", ""], -]; - -function WaterBullet() { - return ( - bullet - ); -} - -function OverviewNoticeBox() { - return ( -
-
- -
- - 우리나라 하수처리수 재이용률은 15% 수준 - - -
    -
  • - 2024년 하수도 통계 기준 15.6%이며, 제2차 물 재이용 기본 계획을 고려한{" "} - 2030년 목표는 17% -
  • -
  • - 대만은 가뭄과 공업용수 증가에 대응하여 현재 4.3% 수준이며, 16.5%까지 확대 계획 수립 -
  • -
-
-
-
- ); -} - -function OverviewCompareTable() { - return ( -
- - - - - - - - - - - - {compareRows.map((row) => ( - - - - - - - ))} - -
- 국가 - - 해외정보 - - 현재 - - 미래(목표, 예측치) -
-
- {row.flag && ( - {`${row.country} - )} - {row.country} -
-
- {row.overseas} - - {row.current} - - {row.future} -
-
- ); -} - -function OverviewDescriptionBox() { - return ( -
-
    -
  • - 전 세계적으로 물 재이용은 호주, 유럽, 아프리카, 미국 등 가뭄이 잦거나 심각한 가뭄이 발생한 국가에서 많이 이루어짐 -
  • -
  • - 이스라엘, 쿠웨이트 등 중동 및 북아프리카 지역은 가용 수자원이 제한되어 있어 재이용량 비중도 매우 높은 편 -
  • -
-
- ); -} - -function OverviewReuseRateTable() { - return ( -
- - - - {["국가명", "지역", "재이용률", "국가명", "지역", "재이용률"].map((header, index) => ( - - ))} - - - - - {reuseRateRows.map((row, rowIndex) => ( - - {row.map((cell, cellIndex) => ( - - ))} - - ))} - -
- {header} -
- {cell} -
-
- ); -} - -function OverviewSection() { - return ( -
-
- 주요 국가 물 재이용 계획 - - -
- -
- 주요 국가의 하수처리수 재이용률 (%) - - -
-
- ); -} - -/* ========================= - 데이터 -========================= */ - -const globalCases: GlobalCase[] = [ - { - type: "overview", - id: "example-global-overview", - title: "사례 개요", - }, - { - type: "default", - id: "example-global-taiwan", - title: "[대만] 국가 주도의 공업용수 공급", - slides: [ - { - src: "/images/landing/business/globalCase/taiwan_01.png", - alt: "대만 국가 주도의 공업용수 공급 1", - }, - { - src: "/images/landing/business/globalCase/taiwan_02.png", - alt: "대만 국가 주도의 공업용수 공급 2", - }, - { - src: "/images/landing/business/globalCase/taiwan_03.png", - alt: "대만 국가 주도의 공업용수 공급 3", - }, - ], - blocks: [ - { - title: "재생 수자원 개발법 제정 (2015년제정, 2022개정)", - items: [ - { text: "목적: 수자원 지속 가능성 향상, 재생 수자원 활용을 통해 물부족 문제 해결" }, - { text: "내용: 재생 수자원 정의·분류, 이용 의무, 이용 제한, 수질 기준 등" } - ], - }, - { - title: "대만정부 하수재이용 사업 시행", - items: [ - { text: "대만은 물 재이용에 적극적이며 국가주도로 하수재이용 사업 시행(안핑)" }, - { text: "1,518억의 사업비 중 92% 국비, 지방비 8% 투입", depth: 1 }, - { text: "재이용수는 24km 관로로 타이난 TSMC로 공급", depth: 1 }, - { text: "안핑을 포함한 16건 공사 완료시 628,100 CMD 공급 가능" }, - { text: "공급단가는 수질과 운영조건에 따라 다르게 책정" }, - ], - }, - { - title: "대만 재이용 시설별 재이용수 공급단지", - items: [], - }, - { - title: "특이사항", - items: [ - "대만은 강수량이 풍부하지만 지역별 편차로 수자원 확보에 어려움을 겪고 있으며, 특히 남부 지역은 우기와 건기의 강수 차이가 큼", - "최근 수년간 심각한 가뭄이 발생하면서 반도체 산업을 국가 핵심 산업으로 지정, 공업용수 수요가 크게 증가", - "주요 반도체 공장이 남부에 집중되어 있어 물 부족 문제가 지속적으로 발생, 2021년 가뭄 당시 산업단지 용수 공급에도 차질 발생", - "정부는 2023년부터 산업용수 추가 요금을 부과하고, 농업용수 규제를 강화하는 등 용수 관리 정책 시행", - "기후변화 대응을 위해 수자원 보호 및 재이용 정책을 지속적으로 확대", - "산업용수 다소비 산업과 하수처리수를 재이용수 주요 공급원으로 활용 계획", - ], - }, - ], - cards: [ - { - title: "제4조.", - desc: "물 소비 계획에 따른 재이용수 이용 관련 내용 (일정 비율 재이용수 사용 규정)", - }, - { - title: "제5조.", - desc: "재이용수의 운영자, 사용자 관련 내용 (지자체에서 하수 무상공급 제공 가능)", - }, - { - title: "제7조.", - desc: "재이용수 이용 범위, 이용량 재이용수 수질 기준 내용", - }, - { - title: "제8조.", - desc: "재이용수 사업 주체, 책임 등에 관한 내용 (광역인 경우, 정부가 지자체 지정)", - }, - ], - table: { - headers: ["사업", "가격(NTD/㎥)", "가격(원/㎥)"], - rows: [ - ["Fongshan(가오슝)", "23.39", "1,038원"], - ["Linhai(가오슝)", "19.28", "858원"], - ["Anping(타이난)", "38.28", "1,699원"], - ["Yongkang(타이난)", "23.67", "1,050원"], - ["Rende(타이난)", "28.26", "1,254원"], - ["Shuinan(타이중)", "15.00", "668원"], - ["Quaitou(가오슝)", "35.44", "1,573원"], - ], - }, - }, - { - type: "default", - id: "example-global-singapore", - title: "[싱가포르] 수요가 높은 산업의 물 재이용 의무화", - slides: [ - { - src: "/images/landing/business/globalCase/singapore_01.png", - alt: "싱가포르 NEWater 사례 1", - }, - { - src: "/images/landing/business/globalCase/singapore_02.png", - alt: "싱가포르 NEWater 사례 2", - }, - { - src: "/images/landing/business/globalCase/singapore_03.png", - alt: "싱가포르 NEWater 사례 3", - }, - ], - blocks: [ - { - title: "싱가포르 NEWater 비중 확대", - items: [ - "2003년 공식 NEWater 공장을 가동했으며 신뢰확보를 위해 NEWater Visitor Center를 함께 건설", - "NEWater 전체파이프 공급 → 반도체 공장에서 가장 많이 사용", - "2060년까지 NEWater 비중 55%까지 높이는 것이 싱가포르의 목표", - ], - }, - { - title: "싱가포르 재이용수 의무화 및 지원정책", - items: [ - { text: "공업용수 수요가 높은 산업에 대해 물 재이용을 의무화" }, - { text: "반도체업계 전체 비 재이용수(non-domestic용수) 17% 차지 → 공업용수 수요 원인", depth: 1 }, - { text: "2024년 1월 1일부터 연간 최소 60,000㎥를 소비하는 사업자는 재이용수 사용 의무화", depth: 1 }, - { text: "반도체 공장의 경우 최소 50% 이상의 재이용수를 요구함(현재는 43% 정도)", depth: 1 }, - { text: "재정 지원을 통해 재이용 활성화를 지원" }, - { text: "재이용 설비를 기준으로 2025년까지 싱가포르 수자원공사의 2가지 금융상품 제공", depth: 1 }, - { text: "1. Water Efficiency Fund", depth: 2 }, - { text: "2. Industrial Water Solutions Demonstration Fund", depth: 2 }, - { text: "2025년 기준 지원 한도 상향 → 물 재이용 활성 도모", depth: 1 }, - { text: "투입비용 50% → 75% 지원 / WSDP: $400만 → $500만", depth: 3 }, - { text: "물 효율이 높은 장비를 도입하는 데에도 최대 $300만 지원 가능", depth: 3 }, - ], - }, - { - title: "특이사항", - items: [ - { text: "싱가포르는 천연 수자원이 많지 않아, 말레이시아 조호르와 1962년 맺은 협약으로 물을 수입해왔으며, 재이용수를 늘리기 위한 노력을 계속하고 있음" }, - { text: "싱가포르는 국토 내에 호수와 하천이 적고 집수공간도 적어 빗물로 확보할 수 있는 수자원량은 전체 물 수요의 20%에 불과함", depth: 1 }, - - { text: "싱가포르는 2002년부터 하수 재이용수인 NEWater를 개발, 비음용수와 간접음용수로 활용하고 있음" }, - { text: "재이용수를 NEWater라는 이름으로 명명하고 NEWater에 대한 신뢰 확보를 위해 NEWater 방문자 센터도 건설함", depth: 1 }, - { text: "비음용수로 많이 사용하며 건기에는 NEWater를 저수지에 추가하여 원수와 섞음. 원수는 상수도에서 처리 한 후 가정에 수돗물로 공급함", depth: 1 }, - { text: "NEWater는 전용 파이프를 통해 주로 웨이퍼 제조 공장, 산업단지, 상업용 건물에서 산업 및 냉각수로 사용. 제일 많이 사용하는 곳은 웨이퍼 제조 공장", depth: 1 }, - - { text: "2024년 4월 기준 1㎥ 당 공업용수 가격은 $1.66이며, Non-domestic 기준 NEWater 가격은 $2.41" }, - { text: "공업용수 가격 $1.66은 수도요금(water tariff)과 수자원세(waterborne tax)로 구분됨. $1.66에는 $1만큼의 수자원세가 포함됨" }, - { text: "2023년 싱가포르는 산업용수 수요가 높은 산업에 대해 물 재이용을 의무화했으며, 정부는 기금을 통해 물 재이용을 지원함" }, - ], - }, - ], - }, - { - type: "default", - id: "example-global-australia", - title: "[호주] 생활 속 물 재이용 / 광역 물 재이용수 공급", - slides: [ - { - src: "/images/landing/business/globalCase/australia_01.png", - alt: "호주 생활 속 물 재이용 1", - }, - { - src: "/images/landing/business/globalCase/australia_02.png", - alt: "호주 광역 물 재이용수 공급 2", - }, - { - src: "/images/landing/business/globalCase/australia_03.png", - alt: "호주 광역 물 재이용수 공급 3", - }, - ], - blocks: [ - { - title: "생활용수로 재이용수의 적극 활용", - items: [ - "재이용수를 공원, 골프장 등의 관계용수로 공급(초록색 지역)", - "Dual pipe로 재이용수를 특정 주거단지로 공급(보라색 지역)", - "현재 7,000여 가구가 재이용수를 사용", - "2030년까지 43,000가구로 확대 예정", - ], - }, - { - title: "호주 재이용수 광역 활용(퀸즈랜드주 브리즈번)", - items: [ - { text: "SEQ Water Grid는 브리즈번 남동부지역의 식수 공급을 위해 설정한 광역 수로망" }, - { text: "이 광역수로망을 활용하여 물이 부족한 지역으로 물을 공급" }, - - { - text: ( - <> - 12개 댐, 32개 수처리 시설, 3개 재이용시설, 담수화 플랜트 1개, 저수지 28개와{" "} - - 600km 이상의 물 공급 관로를 건설 - - - ), - depth: 1, - }, - { - text: ( - <> - 지역적으로 강수량이 고르지 않아,{" "} - - 댐의 저수율이 불균등 할 때 - {" "} - 지역의 물 공급을 보충하는 중요한 역할 수행 - - ), - depth: 1, - }, - - - ], - }, - { - title: "특이사항", - items: [ - "호주는 2000~2009년 강수량 감소로 밀레니엄 가뭄이 발생하여 심각한 물 부족 현상이 발생함", - "호주는 해안 도시에 인구가 밀집하여 거주하고 있으며 인구 증가율이 타 선진국 대비 높아, 향후 도시에 공급할 수자원 확보가 필요함", - "호주정부는 1990년대부터 하·폐수가 방류될 경우의 환경 피해에 대해 인식하기 시작하고 배출규제와 위원회를 구성하기 시작", - "연방정부는 주에 인센티브를 주는 방식으로 물 정책을 장려하고 토지소유권과 물 권리를 구분하여 거래를 허용하고 물 재이용 활성화를 위한 Water Smart Australia 프로그램을 시행", - "호주의 용수공급 체계는 영국과 미국의 특성들이 존재하지만, 주의 특성과 주정부의 정책 방향에 따라 주마다 많은 차이가 있음", - ], - }, - ], - }, - { - type: "default", - id: "example-global-eu", - title: "[EU] 법률을 제정, EU국가에서 물 재이용 유도", - slides: [ - { - src: "/images/landing/business/globalCase/eu_01.png", - alt: "EU 물 재이용 기술 사례", - }, - { - src: "/images/landing/business/globalCase/eu_02.png", - alt: "EU 물 재이용 유도 사례", - }, - ], - blocks: [ - { - title: "물 재이용에 대한 법률을 제정(2020 제정, 2023 시행)", - items: [ - "유럽 지역의 물 부족 문제를 인식하고 ’20년 물 재이용 법률을 제정 (2020/741)", - "2023년부터 모든 EU국가에서 시행", - ], - }, - { - title: "유럽연합 회원국 물 재이용 지원정책", - items: [ - { text: "유럽연합 차원에서 다양한 물 재이용 프로그램 자금 지원" }, - - { text: "2021년부터 3년간 Life RECYCLIO 프로그램 지원 → 세부내용 물 재이용 프로그램", depth: 1 }, - - { text: "1. 환경 관련 사업에 자금을 지원하는 Life 프로그램의 일환으로 80만 유로를 지원", depth: 2 }, - { text: "2. 식품·농업 분야 물 재이용 확대를 위해 기술 이전 및 평가 실시", depth: 2 }, - - { text: "물 재이용 정책 수행을 위해 BOOST-INN 180만 유로를 지원", depth: 1 }, - - { text: "프랑스, 이탈리아, 스페인 등 유럽 선진국 솔루션 공유", depth: 3 }, - - { text: "EU산하 JRC(공동연구센터)에서 물 재이용 기술제공 지원" }, - - { text: ( - <> - ‘물 재이용 회원 관리 실무 커뮤니티(Water Reuse Management Community of Practice)’ 시범사업 수행 -
-   유럽 전역에 성공적인 모델 활용 및 혁신적인 물 재이용 솔루션을 확장할 수 있는 기술을 제공 - - ), - depth: 1 - } - ], - }, - { - title: "특이사항", - items: [ - "2019년 기준 EU 인구의 38%가 물 부족의 영향을 받을 것으로 예상됨. 이 흐름대로면 2030년까지 유럽 유역 전반에 물 부족 문제가 발생하게 될 것으로 예상. 인구 증가, 도시화의 확대, 염분화 및 기후변화들이 수자원 문제들의 주요 요인이며, 식습관 변화와 산업용수 수요 증가 등으로 악화되고 있는 상황", - "유럽의 물 부족 상황은 지역에 따라 다르게 나타나 스페인을 비롯한 남부유럽에서 특히 심각함. 전반적으로 북유럽에 비해 남유럽의 물 스트레스가 심하며, 특히 스페인과 포르투갈, 프랑스 동부지역의 물 스트레스가 심각함", - "유럽 연합에서도 물 부족 문제를 인식하고 2000년 EU Water Framework Directive를 개발, 이후 2020년 재이용에 관한 법률을 제정", - ], - }, - ], - }, -]; - -function DefaultGlobalSection({ - item, -}: { - item: Extract; -}) { - return ( - <> - - - - - ); -} +import { useEffect, useRef } from "react"; export default function GlobalCaseSection() { - const title = "해외 재이용 사례"; + const rootRef = useRef(null); - return ( -
-
-

- {title} - + useEffect(() => { + const root = rootRef.current; + if (!root) return; + + const cleanups: Array<() => void> = []; + const sliders = Array.from(root.querySelectorAll(".image-slider")); + + sliders.forEach((sliderElement) => { + const slider = sliderElement as HTMLElement; + const viewport = slider.querySelector( + ".image-slider-viewport", + ) as HTMLElement | null; + const track = slider.querySelector(".image-slider-track") as HTMLElement | null; + const dotsWrap = slider.querySelector( + ".image-slider-dots", + ) as HTMLElement | null; + const pages = Array.from( + slider.querySelectorAll(".image-slider-page"), + ) as HTMLElement[]; + + if (!viewport || !track || !dotsWrap || pages.length === 0) return; + + let page = 0; + let dragging = false; + let isHover = false; + let startX = 0; + let deltaX = 0; + let timer: number | null = null; + + const autoPlayMs = Number(slider.dataset.autoplay ?? "4500"); + const canSlide = pages.length > 1; + + if (!canSlide) { + dotsWrap.style.display = "none"; + } else { + dotsWrap.style.display = ""; + } + + const render = () => { + track.style.transform = `translateX(-${page * 100}%)`; + + Array.from(dotsWrap.querySelectorAll("button")).forEach((dot, index) => { + dot.classList.toggle("is-active", index === page); + }); + }; + + const goTo = (nextPage: number) => { + if (!canSlide) return; + + if (nextPage < 0) page = pages.length - 1; + else if (nextPage >= pages.length) page = 0; + else page = nextPage; + + render(); + }; + + const stopTimer = () => { + if (timer !== null) { + window.clearInterval(timer); + timer = null; + } + }; + + const startTimer = () => { + stopTimer(); + if (!canSlide || dragging || isHover) return; + + timer = window.setInterval(() => { + page = page + 1 >= pages.length ? 0 : page + 1; + render(); + }, autoPlayMs); + }; + + const dotButtons = pages.map((_, index) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "image-slider-dot"; + + const onClick = () => { + goTo(index); + startTimer(); + }; + + button.addEventListener("click", onClick); + dotsWrap.appendChild(button); + + cleanups.push(() => { + button.removeEventListener("click", onClick); + }); + + return button; + }); + + const onMouseEnter = () => { + isHover = true; + stopTimer(); + }; + + const onMouseLeave = () => { + isHover = false; + startTimer(); + }; + + const onPointerDown = (event: PointerEvent) => { + if (!canSlide) return; + + dragging = true; + startX = event.clientX; + deltaX = 0; + viewport.setPointerCapture(event.pointerId); + }; + + const onPointerMove = (event: PointerEvent) => { + if (!dragging) return; + deltaX = event.clientX - startX; + }; + + const onPointerUp = () => { + if (!dragging) return; + + dragging = false; + + if (deltaX > 50) goTo(page - 1); + if (deltaX < -50) goTo(page + 1); + + deltaX = 0; + startTimer(); + }; + + viewport.addEventListener("mouseenter", onMouseEnter); + viewport.addEventListener("mouseleave", onMouseLeave); + viewport.addEventListener("pointerdown", onPointerDown); + viewport.addEventListener("pointermove", onPointerMove); + viewport.addEventListener("pointerup", onPointerUp); + viewport.addEventListener("pointercancel", onPointerUp); + + cleanups.push(() => { + stopTimer(); + viewport.removeEventListener("mouseenter", onMouseEnter); + viewport.removeEventListener("mouseleave", onMouseLeave); + viewport.removeEventListener("pointerdown", onPointerDown); + viewport.removeEventListener("pointermove", onPointerMove); + viewport.removeEventListener("pointerup", onPointerUp); + viewport.removeEventListener("pointercancel", onPointerUp); + dotButtons.forEach((button) => button.remove()); + }); + + render(); + startTimer(); + }); + + return () => { + cleanups.forEach((cleanup) => cleanup()); + }; + }, []); + + const contentsHtml = ` +
+
+

+ 해외 재이용 사례 +  

-
- {globalCases.map((item, index) => ( -
- {item.title} +
+
+
+   +

사례 개요

+
- {item.type === "overview" ? ( - - ) : ( - - )} +
+
+
+ +   +   + +

주요 국가 물 재이용 계획

+
- {index !== globalCases.length - 1 &&
} -
- ))} +
+
+ bullet +
+ 우리나라 하수처리수 재이용률은 15% 수준 +
    +
  • 2024년 하수도 통계 기준 15.6%이며, 제2차 물 재이용 기본 계획을 고려한 2030년 목표는 17%
  • +
  • 대만은 가뭄과 공업용수 증가에 대응하여 현재 4.3% 수준이며, 16.5%까지 확대 계획 수립
  • +
+
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
국가해외정보현재미래(목표, 예측치)
+
+ 한국 국기 + 한국 +
+
12.1%15.6%17%
+
+ 대만 국기 + 대만 +
+
2.4%4.3%16.5%
+
+
+ +
+
+ +   +   + +

주요 국가의 하수처리수 재이용률 (%)

+
+ +
+
    +
  • 전 세계적으로 물 재이용은 호주, 유럽, 아프리카, 미국 등 가뭄이 잦거나 심각한 가뭄이 발생한 국가에서 많이 이루어짐
  • +
  • 이스라엘, 쿠웨이트 등 중동 및 북아프리카 지역은 가용 수자원이 제한되어 있어 재이용량 비중도 매우 높은 편
  • +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
국가명지역재이용률국가명지역재이용률
이스라엘중동&북아프리카92.3튀르키예동유럽&중앙아시아26.6
쿠웨이트중동&북아프리카89.3독일서유럽25.0
호주동아시아&태평양49.0스페인서유럽24.4
싱가포르동아시아&태평양44.4한국동아시아&태평양12.1
그리스서유럽39.1미국북아메리카13.1
사우디아라비아중동&북아프리카32.8프랑스서유럽9.5
이탈리아서유럽31.7대만동아시아&태평양2.4
룩셈부르크서유럽29.1일본동아시아&태평양0.8
포르투갈서유럽29.0
+
+
+
+ + +
+ +
+
+   +

[대만] 국가 주도의 공업용수 공급

+
+ +
+
+
+
+
+
대만 국가 주도의 공업용수 공급 1
+
대만 국가 주도의 공업용수 공급 2
+
+
+
대만 국가 주도의 공업용수 공급 3
+
+
+
+
+
+
+ +
+
+
  

재생 수자원 개발법 제정 (2015년제정, 2022개정)

+
    +
  • 목적: 수자원 지속 가능성 향상, 재생 수자원 활용을 통해 물부족 문제 해결
  • +
  • 내용: 재생 수자원 정의·분류, 이용 의무, 이용 제한, 수질 기준 등
  • +
+
+
제4조.

물 소비 계획에 따른 재이용수 이용 관련 내용 (일정 비율 재이용수 사용 규정)

+
제5조.

재이용수의 운영자, 사용자 관련 내용 (지자체에서 하수 무상공급 제공 가능)

+
제7조.

재이용수 이용 범위, 이용량 재이용수 수질 기준 내용

+
제8조.

재이용수 사업 주체, 책임 등에 관한 내용 (광역인 경우, 정부가 지자체 지정)

+
+
+ +
+
  

대만정부 하수재이용 사업 시행

+
    +
  • 대만은 물 재이용에 적극적이며 국가주도로 하수재이용 사업 시행(안핑)
  • +
  • 1,518억의 사업비 중 92% 국비, 지방비 8% 투입
  • +
  • 재이용수는 24km 관로로 타이난 TSMC로 공급
  • +
  • 안핑을 포함한 16건 공사 완료시 628,100 CMD 공급 가능
  • +
  • 공급단가는 수질과 운영조건에 따라 다르게 책정
  • +
+
+ +
+
  

대만 재이용 시설별 재이용수 공급단지

+
+ + + + + + + + + + + + + +
사업가격(NTD/㎥)가격(원/㎥)
Fongshan(가오슝)23.391,038원
Linhai(가오슝)19.28858원
Anping(타이난)38.281,699원
Yongkang(타이난)23.671,050원
Rende(타이난)28.261,254원
Shuinan(타이중)15.00668원
Quaitou(가오슝)35.441,573원
+
+
+ +
+
  

특이사항

+
    +
  • 대만은 강수량이 풍부하지만 지역별 편차로 수자원 확보에 어려움을 겪고 있으며, 특히 남부 지역은 우기와 건기의 강수 차이가 큼
  • +
  • 최근 수년간 심각한 가뭄이 발생하면서 반도체 산업을 국가 핵심 산업으로 지정, 공업용수 수요가 크게 증가
  • +
  • 주요 반도체 공장이 남부에 집중되어 있어 물 부족 문제가 지속적으로 발생, 2021년 가뭄 당시 산업단지 용수 공급에도 차질 발생
  • +
  • 정부는 2023년부터 산업용수 추가 요금을 부과하고, 농업용수 규제를 강화하는 등 용수 관리 정책 시행
  • +
  • 기후변화 대응을 위해 수자원 보호 및 재이용 정책을 지속적으로 확대
  • +
  • 산업용수 다소비 산업과 하수처리수를 재이용수 주요 공급원으로 활용 계획
  • +
+
+
+
+ +
+ +
+
 

[싱가포르] 수요가 높은 산업의 물 재이용 의무화

+
+
+
+
+
+
싱가포르 NEWater 사례 1
+
싱가포르 NEWater 사례 2
+
+
+
싱가포르 NEWater 사례 3
+
+
+
+
+
+
+
+
+
  

싱가포르 NEWater 비중 확대

+
    +
  • 2003년 공식 NEWater 공장을 가동했으며 신뢰확보를 위해 NEWater Visitor Center를 함께 건설
  • +
  • NEWater 전체파이프 공급 → 반도체 공장에서 가장 많이 사용
  • +
  • 2060년까지 NEWater 비중 55%까지 높이는 것이 싱가포르의 목표
  • +
+
+
+
  

싱가포르 재이용수 의무화 및 지원정책

+
    +
  • 공업용수 수요가 높은 산업에 대해 물 재이용을 의무화
  • +
  • 반도체업계 전체 비 재이용수(non-domestic용수) 17% 차지 → 공업용수 수요 원인
  • +
  • 2024년 1월 1일부터 연간 최소 60,000㎥를 소비하는 사업자는 재이용수 사용 의무화
  • +
  • 반도체 공장의 경우 최소 50% 이상의 재이용수를 요구함(현재는 43% 정도)
  • +
  • 재정 지원을 통해 재이용 활성화를 지원
  • +
  • 재이용 설비를 기준으로 2025년까지 싱가포르 수자원공사의 2가지 금융상품 제공
  • +
  • 1. Water Efficiency Fund
  • +
  • 2. Industrial Water Solutions Demonstration Fund
  • +
  • 2025년 기준 지원 한도 상향 → 물 재이용 활성 도모
  • +
  • * 투입비용 50% → 75% 지원 / WSDP: $400만 → $500만
  • +
  • * 물 효율이 높은 장비를 도입하는 데에도 최대 $300만 지원 가능
  • +
+
+
+
  

특이사항

+
    +
  • 싱가포르는 천연 수자원이 많지 않아, 말레이시아 조호르와 1962년 맺은 협약으로 물을 수입해왔으며, 재이용수를 늘리기 위한 노력을 계속하고 있음
  • +
  • 싱가포르는 국토 내에 호수와 하천이 적고 집수공간도 적어 빗물로 확보할 수 있는 수자원량은 전체 물 수요의 20%에 불과함
  • +
  • 싱가포르는 2002년부터 하수 재이용수인 NEWater를 개발, 비음용수와 간접음용수로 활용하고 있음
  • +
  • 재이용수를 NEWater라는 이름으로 명명하고 NEWater에 대한 신뢰 확보를 위해 NEWater 방문자 센터도 건설함
  • +
  • 비음용수로 많이 사용하며 건기에는 NEWater를 저수지에 추가하여 원수와 섞음. 원수는 상수도에서 처리 한 후 가정에 수돗물로 공급함
  • +
  • NEWater는 전용 파이프를 통해 주로 웨이퍼 제조 공장, 산업단지, 상업용 건물에서 산업 및 냉각수로 사용. 제일 많이 사용하는 곳은 웨이퍼 제조 공장
  • +
  • 2024년 4월 기준 1㎥ 당 공업용수 가격은 $1.66이며, Non-domestic 기준 NEWater 가격은 $2.41
  • +
  • 공업용수 가격 $1.66은 수도요금(water tariff)과 수자원세(waterborne tax)로 구분됨. $1.66에는 $1만큼의 수자원세가 포함됨
  • +
  • 2023년 싱가포르는 산업용수 수요가 높은 산업에 대해 물 재이용을 의무화했으며, 정부는 기금을 통해 물 재이용을 지원함
  • +
+
+
+
+ +
+ +
+
 

[호주] 생활 속 물 재이용 / 광역 물 재이용수 공급

+
+
+
+
+
+
호주 생활 속 물 재이용 1
+
호주 광역 물 재이용수 공급 2
+
+
+
호주 광역 물 재이용수 공급 3
+
+
+
+
+
+
+
+
+
  

생활용수로 재이용수의 적극 활용

+
    +
  • 재이용수를 공원, 골프장 등의 관계용수로 공급(초록색 지역)
  • +
  • Dual pipe로 재이용수를 특정 주거단지로 공급(보라색 지역)
  • +
  • 현재 7,000여 가구가 재이용수를 사용
  • +
  • 2030년까지 43,000가구로 확대 예정
  • +
+
+
+
  

호주 재이용수 광역 활용(퀸즈랜드주 브리즈번)

+
    +
  • SEQ Water Grid는 브리즈번 남동부지역의 식수 공급을 위해 설정한 광역 수로망
  • +
  • 이 광역수로망을 활용하여 물이 부족한 지역으로 물을 공급
  • +
  • 12개 댐, 32개 수처리 시설, 3개 재이용시설, 담수화 플랜트 1개, 저수지 28개와 600km 이상의 물 공급 관로를 건설
  • +
  • 지역적으로 강수량이 고르지 않아, 댐의 저수율이 불균등 할 때 지역의 물 공급을 보충하는 중요한 역할 수행
  • +
+
+
+
  

특이사항

+
    +
  • 호주는 2000~2009년 강수량 감소로 밀레니엄 가뭄이 발생하여 심각한 물 부족 현상이 발생함
  • +
  • 호주는 해안 도시에 인구가 밀집하여 거주하고 있으며 인구 증가율이 타 선진국 대비 높아, 향후 도시에 공급할 수자원 확보가 필요함
  • +
  • 호주정부는 1990년대부터 하·폐수가 방류될 경우의 환경 피해에 대해 인식하기 시작하고 배출규제와 위원회를 구성하기 시작
  • +
  • 연방정부는 주에 인센티브를 주는 방식으로 물 정책을 장려하고 토지소유권과 물 권리를 구분하여 거래를 허용하고 물 재이용 활성화를 위한 Water Smart Australia 프로그램을 시행
  • +
  • 호주의 용수공급 체계는 영국과 미국의 특성들이 존재하지만, 주의 특성과 주정부의 정책 방향에 따라 주마다 많은 차이가 있음
  • +
+
+
+
+ +
+ +
+
 

[EU] 법률을 제정, EU국가에서 물 재이용 유도

+
+
+
+
+
+
EU 물 재이용 기술 사례
+
EU 물 재이용 유도 사례
+
+
+
+
+
+
+
+
+
  

물 재이용에 대한 법률을 제정(2020 제정, 2023 시행)

+
    +
  • 유럽 지역의 물 부족 문제를 인식하고 ’20년 물 재이용 법률을 제정 (2020/741)
  • +
  • 2023년부터 모든 EU국가에서 시행
  • +
+
+
+
  

유럽연합 회원국 물 재이용 지원정책

+
    +
  • 유럽연합 차원에서 다양한 물 재이용 프로그램 자금 지원
  • +
  • 2021년부터 3년간 Life RECYCLIO 프로그램 지원 → 세부내용 물 재이용 프로그램
  • +
  • 1. 환경 관련 사업에 자금을 지원하는 Life 프로그램의 일환으로 80만 유로를 지원
  • +
  • 2. 식품·농업 분야 물 재이용 확대를 위해 기술 이전 및 평가 실시
  • +
  • 물 재이용 정책 수행을 위해 BOOST-INN 180만 유로를 지원
  • +
  • * 프랑스, 이탈리아, 스페인 등 유럽 선진국 솔루션 공유
  • +
  • EU산하 JRC(공동연구센터)에서 물 재이용 기술제공 지원
  • +
  • ‘물 재이용 회원 관리 실무 커뮤니티(Water Reuse Management Community of Practice)’ 시범사업 수행
     유럽 전역에 성공적인 모델 활용 및 혁신적인 물 재이용 솔루션을 확장할 수 있는 기술을 제공
  • +
+
+
+
  

특이사항

+
    +
  • 2019년 기준 EU 인구의 38%가 물 부족의 영향을 받을 것으로 예상됨. 이 흐름대로면 2030년까지 유럽 유역 전반에 물 부족 문제가 발생하게 될 것으로 예상. 인구 증가, 도시화의 확대, 염분화 및 기후변화들이 수자원 문제들의 주요 요인이며, 식습관 변화와 산업용수 수요 증가 등으로 악화되고 있는 상황
  • +
  • 유럽의 물 부족 상황은 지역에 따라 다르게 나타나 스페인을 비롯한 남부유럽에서 특히 심각함. 전반적으로 북유럽에 비해 남유럽의 물 스트레스가 심하며, 특히 스페인과 포르투갈, 프랑스 동부지역의 물 스트레스가 심각함
  • +
  • 유럽 연합에서도 물 부족 문제를 인식하고 2000년 EU Water Framework Directive를 개발, 이후 2020년 재이용에 관한 법률을 제정
  • +
+
+
+

+ `; + + return ( +
); -} \ No newline at end of file +} diff --git a/apps/dm/app/(with-layout)/example/_components/GreywaterExampleSection.tsx b/apps/dm/app/(with-layout)/example/_components/GreywaterExampleSection.tsx index 1705a6c84..b3958df87 100644 --- a/apps/dm/app/(with-layout)/example/_components/GreywaterExampleSection.tsx +++ b/apps/dm/app/(with-layout)/example/_components/GreywaterExampleSection.tsx @@ -1,191 +1,415 @@ "use client"; -function SectionTitle({ children }: { children: React.ReactNode }) { - return ( -
- -

{children}

-
- ); -} - -function BlockTitle({ children }: { children: React.ReactNode }) { - return ( -
- -

{children}

-
- ); -} - -// 구분선 -function Hr({ className = "" }: { className?: string }) { - return
; -} - -type SupplyRow = { - facility: string; - usage: string; - amount: string; -}; - -function SupplyTable({ rows }: { rows: SupplyRow[] }) { - return ( -
- - - - - - - - - - {rows.map((row, index) => ( - - - - - - ))} - -
- 시설명 - - 이용용도 - - 공급량(㎥) -
- {row.facility} - - {row.usage} - - {row.amount} -
-
- ); -} - -function InfoList({ - projectName, - purpose, - totalCost, - treatment, -}: { - projectName: string; - purpose: string; - totalCost: string; - treatment: string; -}) { - return ( -
-
- 사업명 -
    -
  • {projectName}
  • -
-
- -
- 사업목적 -
    -
  • {purpose}
  • -
-
- -
- 사업 주요 내용 -
    -
  • 총사업비 : {totalCost}
  • -
  • 처리공법 : {treatment}
  • -
-
- -
- 사업량 -
-
- ); -} +import { useEffect, useRef } from "react"; export default function GreywaterExampleSection() { - const title = "중수도 사례"; - return ( -
- {/* 상단 타이틀 */} -
-

- {title} - + const rootRef = useRef(null); + + useEffect(() => { + const root = rootRef.current; + if (!root) return; + + const cleanups: Array<() => void> = []; + const sliders = Array.from(root.querySelectorAll(".image-slider")); + + sliders.forEach((sliderElement) => { + const slider = sliderElement as HTMLElement; + const viewport = slider.querySelector( + ".image-slider-viewport", + ) as HTMLElement | null; + const track = slider.querySelector(".image-slider-track") as HTMLElement | null; + const dotsWrap = slider.querySelector( + ".image-slider-dots", + ) as HTMLElement | null; + const pages = Array.from( + slider.querySelectorAll(".image-slider-page"), + ) as HTMLElement[]; + + if (!viewport || !track || !dotsWrap || pages.length === 0) return; + + let page = 0; + let dragging = false; + let isHover = false; + let startX = 0; + let deltaX = 0; + let timer: number | null = null; + + const autoPlayMs = Number(slider.dataset.autoplay ?? "4500"); + const canSlide = pages.length > 1; + + if (!canSlide) { + dotsWrap.style.display = "none"; + } else { + dotsWrap.style.display = ""; + } + + const render = () => { + track.style.transform = `translateX(-${page * 100}%)`; + + Array.from(dotsWrap.querySelectorAll("button")).forEach((dot, index) => { + dot.classList.toggle("is-active", index === page); + }); + }; + + const goTo = (nextPage: number) => { + if (!canSlide) return; + + if (nextPage < 0) page = pages.length - 1; + else if (nextPage >= pages.length) page = 0; + else page = nextPage; + + render(); + }; + + const stopTimer = () => { + if (timer !== null) { + window.clearInterval(timer); + timer = null; + } + }; + + const startTimer = () => { + stopTimer(); + if (!canSlide || dragging || isHover) return; + + timer = window.setInterval(() => { + page = page + 1 >= pages.length ? 0 : page + 1; + render(); + }, autoPlayMs); + }; + + const dotButtons = pages.map((_, index) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "image-slider-dot"; + + const onClick = () => { + goTo(index); + startTimer(); + }; + + button.addEventListener("click", onClick); + dotsWrap.appendChild(button); + + cleanups.push(() => { + button.removeEventListener("click", onClick); + }); + + return button; + }); + + const onMouseEnter = () => { + isHover = true; + stopTimer(); + }; + + const onMouseLeave = () => { + isHover = false; + startTimer(); + }; + + const onPointerDown = (event: PointerEvent) => { + if (!canSlide) return; + + dragging = true; + startX = event.clientX; + deltaX = 0; + viewport.setPointerCapture(event.pointerId); + }; + + const onPointerMove = (event: PointerEvent) => { + if (!dragging) return; + deltaX = event.clientX - startX; + }; + + const onPointerUp = () => { + if (!dragging) return; + + dragging = false; + + if (deltaX > 50) goTo(page - 1); + if (deltaX < -50) goTo(page + 1); + + deltaX = 0; + startTimer(); + }; + + viewport.addEventListener("mouseenter", onMouseEnter); + viewport.addEventListener("mouseleave", onMouseLeave); + viewport.addEventListener("pointerdown", onPointerDown); + viewport.addEventListener("pointermove", onPointerMove); + viewport.addEventListener("pointerup", onPointerUp); + viewport.addEventListener("pointercancel", onPointerUp); + + cleanups.push(() => { + stopTimer(); + viewport.removeEventListener("mouseenter", onMouseEnter); + viewport.removeEventListener("mouseleave", onMouseLeave); + viewport.removeEventListener("pointerdown", onPointerDown); + viewport.removeEventListener("pointermove", onPointerMove); + viewport.removeEventListener("pointerup", onPointerUp); + viewport.removeEventListener("pointercancel", onPointerUp); + dotButtons.forEach((button) => button.remove()); + }); + + render(); + startTimer(); + }); + + return () => { + cleanups.forEach((cleanup) => cleanup()); + }; + }, []); + + const contentsHtml = ` +
+
+

+ 중수도 사례 +  

- -
-
- 경기도 안산시 - +
+
+
+   +

경기도 안산시

+
- +
+
+
+ +   +   + +

사업명

+
+
    +
  • 안산시 중수도 설치사업
  • +
+
+ +
+
+ +   +   + +

사업목적

+
+
    +
  • 수자원을 효율적으로 활용하여 지속가능성 도모 및 물 절약 실현
  • +
+
+ +
+
+ +   +   + +

사업 주요 내용

+
+
    +
  • 총사업비 : 81억원
  • +
  • 처리공법 : MBLM공법
  • +
+
+ +
+
+ +   +   + +

사업량

+
+
+ + + + + + + + + + + + + + + +
시설명이용용도공급량(㎥)
경기테크노파크청소·화장실용수40㎥/일
올림픽수영장청소·화장실/세척·살수용수35㎥/일
와스타디움청소·화장실/조경용수90㎥/일
호수실내수영장청소·화장실/조경/세척·살수용수50㎥/일
한양대학교 에리카캠퍼스청소·화장실용수370㎥/일
+
+
+
-
+
-
- 경기도 용인시 +
+
+   +

경기도 용인시

+
- + +
+
+
+ +   +   + +

사업명

+
+
    +
  • 용인시 중수도 설치사업
  • +
+
- +
+
+ +   +   + +

사업목적

+
+
    +
  • 물 부족 등의 비상상황에 대한 대비, 하수라도 사용할 집결 등 수자원의 효율적 활용
  • +
+
+ +
+
+ +   +   + +

사업 주요 내용

+
+
    +
  • 총사업비 : 40억원
  • +
  • 처리공법 : MBLM공법
  • +
+
+ +
+
+ +   +   + +

사업량

+
+
+ + + + + + + + + + + + + +
시설명이용용도공급량(㎥)
여성회관청소·화장실용수35㎥/일
수지아르피아청소·화장실용수35㎥/일
지상작전사령부청소·화장실/조경/세척·살수용수53㎥/일
+
+
+
-
+
-
- 경기도 평택시 +
+
+   +

경기도 평택시

+
- + +
+
+
+ +   +   + +

사업명

+
+
    +
  • 평택시 중수도 설치사업
  • +
+
- +
+
+ +   +   + +

사업목적

+
+
    +
  • 수자원을 효율적으로 활용하여 지속가능성 도모 및 물 절약 실현
  • +
+
+ +
+
+ +   +   + +

사업 주요 내용

+
+
    +
  • 총사업비 : 21억원
  • +
  • 처리공법 : MBLM공법
  • +
+
+ +
+
+ +   +   + +

사업량

+
+
+ + + + + + + + + + + +
시설명이용용도공급량(㎥)
평택대학교청소·화장실용수50㎥/일
+
+
+
+ `; + + return ( +
); -} \ No newline at end of file +} diff --git a/apps/dm/app/(with-layout)/example/_components/RainExampleSection.tsx b/apps/dm/app/(with-layout)/example/_components/RainExampleSection.tsx index 5f975356a..483263c82 100644 --- a/apps/dm/app/(with-layout)/example/_components/RainExampleSection.tsx +++ b/apps/dm/app/(with-layout)/example/_components/RainExampleSection.tsx @@ -1,225 +1,382 @@ "use client"; -import CaseImageSlider from "@/components/CaseImageSlider"; - -function SectionTitle({ children }: { children: React.ReactNode }) { - return ( -
- -

{children}

-
- ); -} - -function BlockTitle({ children }: { children: React.ReactNode }) { - return ( -
- - - - -

{children}

-
- ); -} - -function Hr({ className = "" }: { className?: string }) { - return
; -} - -function InfoList({ - projectName, - purpose, - details, -}: { - projectName: string; - purpose: string; - details: string[]; -}) { - return ( -
-
- 사업명 -
    -
  • {projectName}
  • -
-
- -
- 사업목적 -
    -
  • {purpose}
  • -
-
- -
- 사업 주요 내용 -
    - {details.map((item, index) => ( -
  • {item}
  • - ))} -
-
-
- ); -} - -type RainTableRow = { - facility: string; - storage: string; - supply: string; - pipe: string; -}; - -function RainTable({ rows }: { rows: RainTableRow[] }) { - return ( -
- - - - - - - - - - - - {rows.map((row, index) => ( - - - - - {index === 0 && ( - - )} - - - - ))} - -
- 시설명 - - 저류조용량(㎥) - - 빗물공급기 - - 빗물분사장치(m) -
- {row.facility} - - {row.storage} - - {row.supply} - - {row.pipe} -
-
- ); -} +import { useEffect, useRef } from "react"; export default function RainExampleSection() { - const title = "빗물이용시설 사례"; + const rootRef = useRef(null); - return ( -
-
-

- {title} - + useEffect(() => { + const root = rootRef.current; + if (!root) return; + + const cleanups: Array<() => void> = []; + const sliders = Array.from(root.querySelectorAll(".image-slider")); + + sliders.forEach((sliderElement) => { + const slider = sliderElement as HTMLElement; + const viewport = slider.querySelector(".image-slider-viewport") as HTMLElement | null; + const track = slider.querySelector(".image-slider-track") as HTMLElement | null; + const dotsWrap = slider.querySelector(".image-slider-dots") as HTMLElement | null; + const pages = Array.from( + slider.querySelectorAll(".image-slider-page"), + ) as HTMLElement[]; + + if (!viewport || !track || !dotsWrap || pages.length === 0) return; + + let page = 0; + let dragging = false; + let isHover = false; + let startX = 0; + let deltaX = 0; + let timer: number | null = null; + + const autoPlayMs = Number(slider.dataset.autoplay ?? "4500"); + const canSlide = pages.length > 1; + + if (!canSlide) { + dotsWrap.style.display = "none"; + } else { + dotsWrap.style.display = ""; + } + + const render = () => { + track.style.transform = `translateX(-${page * 100}%)`; + + Array.from(dotsWrap.querySelectorAll("button")).forEach((dot, index) => { + dot.classList.toggle("is-active", index === page); + }); + }; + + const goTo = (nextPage: number) => { + if (!canSlide) return; + + if (nextPage < 0) page = pages.length - 1; + else if (nextPage >= pages.length) page = 0; + else page = nextPage; + + render(); + }; + + const stopTimer = () => { + if (timer !== null) { + window.clearInterval(timer); + timer = null; + } + }; + + const startTimer = () => { + stopTimer(); + if (!canSlide || dragging || isHover) return; + + timer = window.setInterval(() => { + page = page + 1 >= pages.length ? 0 : page + 1; + render(); + }, autoPlayMs); + }; + + const dotButtons = pages.map((_, index) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "image-slider-dot"; + + const onClick = () => { + goTo(index); + startTimer(); + }; + + button.addEventListener("click", onClick); + dotsWrap.appendChild(button); + + cleanups.push(() => { + button.removeEventListener("click", onClick); + }); + + return button; + }); + + const onMouseEnter = () => { + isHover = true; + stopTimer(); + }; + + const onMouseLeave = () => { + isHover = false; + startTimer(); + }; + + const onPointerDown = (event: PointerEvent) => { + if (!canSlide) return; + + dragging = true; + startX = event.clientX; + deltaX = 0; + viewport.setPointerCapture(event.pointerId); + }; + + const onPointerMove = (event: PointerEvent) => { + if (!dragging) return; + deltaX = event.clientX - startX; + }; + + const onPointerUp = () => { + if (!dragging) return; + + dragging = false; + + if (deltaX > 50) goTo(page - 1); + if (deltaX < -50) goTo(page + 1); + + deltaX = 0; + startTimer(); + }; + + viewport.addEventListener("mouseenter", onMouseEnter); + viewport.addEventListener("mouseleave", onMouseLeave); + viewport.addEventListener("pointerdown", onPointerDown); + viewport.addEventListener("pointermove", onPointerMove); + viewport.addEventListener("pointerup", onPointerUp); + viewport.addEventListener("pointercancel", onPointerUp); + + cleanups.push(() => { + stopTimer(); + viewport.removeEventListener("mouseenter", onMouseEnter); + viewport.removeEventListener("mouseleave", onMouseLeave); + viewport.removeEventListener("pointerdown", onPointerDown); + viewport.removeEventListener("pointermove", onPointerMove); + viewport.removeEventListener("pointerup", onPointerUp); + viewport.removeEventListener("pointercancel", onPointerUp); + dotButtons.forEach((button) => button.remove()); + }); + + render(); + startTimer(); + }); + + return () => { + cleanups.forEach((cleanup) => cleanup()); + }; + }, []); + + const contentsHtml = ` +
+
+

+ 빗물이용시설 사례 +  

-
-
- 경기도 수원시 +
+
+
+   +

경기도 수원시

+
- +
+
+
+
+
+
+ 수원시 빗물이용시설 사례 1 +
+
+ 수원시 빗물이용시설 사례 2 +
+
+
+
+ 수원시 빗물이용시설 사례 3 +
+
+ 수원시 빗물이용시설 사례 4 +
+
+
+
+ 수원시 빗물이용시설 사례 5 +
+
+
+
+
+
+
- +
+
+
+ +   +   + +

사업명

+
+
    +
  • 수원시 빗물이용시설 설치사업
  • +
+
-
- 사업량 +
+
+ +   +   + +

사업목적

+
+
    +
  • 월드컵경기장 등 4개소에 노면 빗물분사시스템 및 빗물공급기를 설치하여 축서기 도시 열섬효과 및 도로 비산먼지 저감
  • +
+
- +
+
+ +   +   + +

사업 주요 내용

+
+
    +
  • 총사업비 : 20억원
  • +
  • 위치 : 수원시 영통구, 팔달구 및 장안구 일원
  • +
+
+ +
+
+ +   +   + +

사업량

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
시설명저류조용량(㎥)빗물공급기빗물분사장치(m)
광교중학교4,5001대160
수원월드컵경기장12,000451
수원종합운동장10,000231
수원컨벤션센터3,600460
+
+
-
+
-
- 제주특별자치도 +
+
+   +

제주특별자치도

+
- +
+
+
+
+
+
+ 제주특별자치도 빗물이용시설 사례 1 +
+
+ 제주특별자치도 빗물이용시설 사례 2 +
+
+
+
+ 제주특별자치도 빗물이용시설 사례 3 +
+
+
+
+
+
+
- +
+
+
+ +   +   + +

사업명

+
+
    +
  • 제주특별자치도 중규모 빗물이용시설 설치사업
  • +
+
+ +
+
+ +   +   + +

사업목적

+
+
    +
  • 강우시 비닐하우스에서 발생하는 대체수자원인 빗물을 순환 활용하고자 하는 사업으로, 지하수 사용 저감과 집중호우시 도로와 농경지 침수피해 예방
  • +
+
+ +
+
+ +   +   + +

사업 주요 내용

+
+
    +
  • 총사업비 : 278억원
  • +
  • 위치 : 제주특별자치도 서귀포시 남원읍 위미리 일원
  • +
  • 사업량 : 저류조 7,000㎥, 배수갑 500㎡, 관로 21.7km
  • +
  • 사업기간 : 2024. 5. ~ 2028. 12.
  • +
+
+
+ `; + + return ( +
); -} \ No newline at end of file +} diff --git a/apps/dm/app/(with-layout)/example/_components/SewageExampleSection.tsx b/apps/dm/app/(with-layout)/example/_components/SewageExampleSection.tsx index cd4c1d420..266a30060 100644 --- a/apps/dm/app/(with-layout)/example/_components/SewageExampleSection.tsx +++ b/apps/dm/app/(with-layout)/example/_components/SewageExampleSection.tsx @@ -1,367 +1,582 @@ "use client"; -import CaseImageSlider from "@/components/CaseImageSlider"; - -type SewageCase = { - id: string; - title: string; - slides: { - src: string; - alt: string; - }[]; - projectName: string; - purpose: string[]; - method?: string[]; - details: string[]; - note?: string[]; -}; - -function SectionTitle({ children }: { children: React.ReactNode }) { - return ( -
- -

{children}

-
- ); -} - -function BlockTitle({ children }: { children: React.ReactNode }) { - return ( -
- - - - -

{children}

-
- ); -} - -function Hr({ className = "" }: { className?: string }) { - return
; -} - -function InfoBlock({ - title, - items, -}: { - title: string; - items?: string[]; -}) { - if (!items || items.length === 0) return null; - - return ( -
- {title} -
    - {items.map((item, index) => ( -
  • {item}
  • - ))} -
-
- ); -} - -function InfoList({ item }: { item: SewageCase }) { - return ( -
- - - - - -
- ); -} - -const sewageCases: SewageCase[] = [ - { - id: "example-sewage-guri", - title: "경기도 구리시", - slides: [ - { - src: "/images/landing/business/sewage/guri_01.png", - alt: "구리시 도로자동청소시스템", - }, - { - src: "/images/landing/business/sewage/guri_02.png", - alt: "구리시 도로자동청소시스템 위치도", - }, - { - src: "/images/landing/business/sewage/guri_03.png", - alt: "구리시 도로자동청소시스템 위치도2", - }, - { - src: "/images/landing/business/sewage/guri_04.png", - alt: "구리시 도로자동청소시스템 원리", - }, - ], - projectName: "구리시 도로자동청소시스템 설치사업", - purpose: [ - "고농도 미세먼지 발생 및 폭염의 급증에 따라 공공하수처리시설 발생 재이용수 일부를 도로자동청소시스템에 활용하여 미세먼지 저감 및 폭염 취약성 완화", - ], - details: [ - "총사업비 : 61억원", - "위치 : 구리시 경춘로 및 갈매중앙로 일대 등", - "관로 : 5.8km", - "공급량 : 178㎥/일", - ], - }, - { - id: "example-sewage-bucheon", - title: "경기도 부천시", - slides: [ - { - src: "/images/landing/business/sewage/bucheon_01.png", - alt: "부천시 하수 재이용수 생산시설", - }, - { - src: "/images/landing/business/sewage/bucheon_02.png", - alt: "부천시 하수처리수 재이용 사례", - }, - ], - projectName: "부천시 하수 재이용수 생산시설 설치 운영", - purpose: [ - "굴포하수처리시설 하수처리수 재이용수 생산시설 설치 운영을 통해 여월천 등 부천 관내 하천의 건천화 방지, 하천 생태계 보존 및 주민들에게 친수 공간 제공", - ], - details: [ - "총사업비 : 212억원", - "위치 : 부천시 여월천, 삼정천, 심곡천 및 시민의 강", - "관로 : 23.6km", - "공급량 : 50,000㎥/일", - ], - }, - { - id: "example-sewage-suwon", - title: "경기도 수원시", - slides: [ - { - src: "/images/landing/business/sewage/suwon_01.png", - alt: "수원시 황구지천1", - }, - { - src: "/images/landing/business/sewage/suwon_02.png", - alt: "수원시 황구지천2", - }, - { - src: "/images/landing/business/sewage/suwon_03.png", - alt: "수원시 황구지천 지도", - }, - ], - projectName: "수원시 황구지천 하수처리수 재이용사업", - purpose: [ - "황구지천 공공하수처리시설 하수처리수 재이용사업으로 황구 호매실천 및 금곡천 건천화 방지로 주민들에게 친수 공간 제공 및 하천 생태계 보전", - ], - details: [ - "총사업비 : 110억원", - "위치 : 수원시 권선구 호매실천, 금곡천", - "관로 : 3.9km", - "공급량 : 25,000㎥/일(하수처리시설 용량 : 45,000㎥/일)", - ], - }, - { - id: "example-sewage-paju", - title: "경기도 파주시", - slides: [ - { - src: "/images/landing/business/sewage/paju_01.png", - alt: "파주시 하수처리수 시설 전경1", - }, - { - src: "/images/landing/business/sewage/paju_02.png", - alt: "파주시 하수처리수 시설 전경2", - }, - ], - projectName: "파주시 하수처리수 재이용사업", - purpose: [ - "운정 및 금촌하수처리시설의 하수처리수를 막공법으로 재처리하여 파주LCD산업단지 내 3개 업체에 공업용수를 공급(LG디스플레이, 희성전자, 엔비텍)", - "하수처리수 재이용시설 자체 농축폐수 처리공정 개발 및 적용 성공사례", - ], - method: ["민간투자사업(시행사 : 파주그린허브(주), 운영사 : 테크로스)"], - details: [ - "총사업비 : 651억원", - "위치 : 파주시 금촌동 714번지 일원", - "관로 : 13.6km", - "공급량 : 40,000㎥/일(시설용량 : 41,200㎥/일)", - ], - }, - { - id: "example-sewage-pohang", - title: "경상북도 포항시", - slides: [ - { - src: "/images/landing/business/sewage/pohang_01.png", - alt: "포항시 하수처리수 전경1", - }, - { - src: "/images/landing/business/sewage/pohang_02.png", - alt: "포항시 하수처리수 전경2", - }, - { - src: "/images/landing/business/sewage/pohang_03.png", - alt: "포항시 하수처리수 재이용 공급계통", - }, - ], - projectName: "포항시 하수처리수 재이용사업", - purpose: [ - "포항시 내 공단 개발 확대로 지속적이고 안정적인 공업용수 공급 추진(포스코, 동국산업 등)", - ], - method: ["민간투자사업(운영사 : ㈜포웰, 한국수자원공사)"], - details: [ - "총사업비 : 1,400억원", - "위치 : 포항시 남구 상도동 125번지 일원", - "관로 : 11.7km", - "공급량 : 100,000㎥/일(시설용량 : 100,000㎥/일)", - "처리공법 : 전처리시설(U/F) + 주처리시설(R/O)", - ], - }, - { - id: "example-sewage-ulsan", - title: "울산광역시", - slides: [ - { - src: "/images/landing/business/sewage/ulsan_01.png", - alt: "울산광역시 폐수처리수 전경1", - }, - { - src: "/images/landing/business/sewage/ulsan_02.png", - alt: "울산광역시 폐수처리수 전경2", - }, - { - src: "/images/landing/business/sewage/ulsan_03.png", - alt: "울산광역시 폐수처리수 시설1", - }, - { - src: "/images/landing/business/sewage/ulsan_04.png", - alt: "울산광역시 폐수처리수 시설2", - }, - ], - projectName: "울산광역시 폐수처리수 재이용사업", - purpose: [ - "’18년 갈수기시 낙동강 수질 악화로 울산석유화학단지 용수 공급 부족을 해소하기 위해 미포산단 내 한화케미칼 등 8개사 공업용수 공급", - ], - method: ["민간투자사업(시행사 :울산화이텍워터(주))"], - details: [ - "총사업비 : 1,729억원", - "위치 : 울산광역시 울주군 청량읍 상개로 14 일원", - "관로 : 11.7km", - "공급량 : 17,000㎥/일(시설용량 : 86,000㎥/일)", - "처리공법 : 전처리시설(U/F) + 주처리시설(R/O, EDI)", - ], - }, - { - id: "example-sewage-yeosu", - title: "전라남도 여수시", - slides: [ - { - src: "/images/landing/business/sewage/yeosu_01.png", - alt: "여수시 하수처리수 재이용시설1", - }, - { - src: "/images/landing/business/sewage/yeosu_02.png", - alt: "여수시 하수처리수 재이용시설2", - }, - ], - projectName: "여수시 하수처리수 재이용사업", - purpose: [ - "수요자가 원하는 양질의 공업용수를 저렴하게 공급하여 한정된 수자원의 효율적 이용 및 지역적 물 부족 해소, 하수재이용을 통한 오염총량 저감 및 방류수역 수질개선을 위한 여수국가산단 내 공업용수 공급(GS칼텍스, YNCC 등)", - ], - method: ["민간투자사업(시행사 : 여수그린허브(주), 운영사 : 테크로스)"], - details: [ - "총사업비 : 750억원", - "위치 : 여수시 신월로 284 일원", - "관로 : 18.6km", - "공급량 : 50,000㎥/일(시설용량 : 50,000㎥/일)", - "처리공법 : 전처리시설(U/F) + 주처리시설(R/O)", - ], - }, - { - id: "example-sewage-boryeong", - title: "충청남도 보령시", - slides: [ - { - src: "/images/landing/business/sewage/boryeong_01.png", - alt: "보령시 하수처리수 재이용시설1", - }, - { - src: "/images/landing/business/sewage/boryeong_02.png", - alt: "보령시 하수처리수 재이용시설2", - }, - ], - projectName: "보령시 하수처리수 재이용사업", - purpose: [ - "한국중부발전(주)의 보령 및 신보령화력발전소에 10,000㎥/일의 탈황용수 공급을 위해 보령하수처리시설의 하수처리수 재이용 사업 추진", - "국내 유일 하수재이용 공업용수 공급 재정사업으로 2016년 충남 서부권 가뭄 시 지역사회 기여 차원에서 기관간 MOU(기후에너지환경부, 충청남도, 보령시, 한국중부발전(주))를 바탕으로 추진되어 경제성보다 공익성을 바탕으로 국가전력 기반시설의 안정적 운영 도모", - ], - method: ["재정사업(2017년 한국환경공단과 사업대행 위·수탁협약 체결)"], - details: [ - "총사업비 : 315억원", - "위치 : 보령시 대천방조제로 322 일원", - "관로 : 8.7km", - "공급량 : 10,000㎥/일(시설용량 : 10,000㎥/일)", - "처리공법 : 전처리시설(U/F) + 주처리시설(R/O)", - ], - note: [ - "국내 유일 하수재이용 공업용수 공급 재정사업으로 2016년 충남 서부권 가뭄 시 지역사회 기여 차원에서 기관간 MOU(환경부, 충청남도, 보령시, 한국중부발전㈜)를 바탕으로 추진 되어 경제성보다 공익성을 바탕으로 국가전력 기반시설의 안정적 운영 도모", - ], - }, - { - id: "example-sewage-asan", - title: "충청남도 아산시", - slides: [ - { - src: "/images/landing/business/sewage/asan_01.png", - alt: "아산시 하수처리수 재이용시설", - }, - { - src: "/images/landing/business/sewage/asan_02.png", - alt: "아산시 하수처리수 재이용 공정도", - }, - ], - projectName: "아산시 하수처리수 재이용사업", - purpose: [ - "신도시 개발에 따른 인구 급증으로 발생하는 하수를 재처리하여 탕정산업단지(삼성디스플레이)에 공업용수를 안정적으로 공급함으로써 지역기반 및 활성화를 도모", - ], - method: ["민간투자사업(시행사 : 아산스마트원터, 운영사 : 한국수자원공사)"], - details: [ - "총사업비 : 297억원", - "위치 : 아산시 탕정면 갈산리 807 일원", - "관로 : 4.2km", - "공급량 : 27,000㎥/일(시설용량 : 45,000㎥/일)", - "처리공법 : 전처리시설(U/F) + 주처리시설(R/O)", - ], - }, -]; +import { useEffect, useRef } from "react"; export default function SewageExampleSection() { - const title = "하수처리수 재이용 사례"; + const rootRef = useRef(null); - return ( -
-
-

- {title} - + useEffect(() => { + const root = rootRef.current; + if (!root) return; + + const cleanups: Array<() => void> = []; + const sliders = Array.from(root.querySelectorAll(".image-slider")); + + sliders.forEach((sliderElement) => { + const slider = sliderElement as HTMLElement; + const viewport = slider.querySelector( + ".image-slider-viewport", + ) as HTMLElement | null; + const track = slider.querySelector(".image-slider-track") as HTMLElement | null; + const dotsWrap = slider.querySelector( + ".image-slider-dots", + ) as HTMLElement | null; + const pages = Array.from( + slider.querySelectorAll(".image-slider-page"), + ) as HTMLElement[]; + + if (!viewport || !track || !dotsWrap || pages.length === 0) return; + + let page = 0; + let dragging = false; + let isHover = false; + let startX = 0; + let deltaX = 0; + let timer: number | null = null; + + const autoPlayMs = Number(slider.dataset.autoplay ?? "4500"); + const canSlide = pages.length > 1; + + if (!canSlide) { + dotsWrap.style.display = "none"; + } else { + dotsWrap.style.display = ""; + } + + const render = () => { + track.style.transform = `translateX(-${page * 100}%)`; + + Array.from(dotsWrap.querySelectorAll("button")).forEach((dot, index) => { + dot.classList.toggle("is-active", index === page); + }); + }; + + const goTo = (nextPage: number) => { + if (!canSlide) return; + + if (nextPage < 0) page = pages.length - 1; + else if (nextPage >= pages.length) page = 0; + else page = nextPage; + + render(); + }; + + const stopTimer = () => { + if (timer !== null) { + window.clearInterval(timer); + timer = null; + } + }; + + const startTimer = () => { + stopTimer(); + if (!canSlide || dragging || isHover) return; + + timer = window.setInterval(() => { + page = page + 1 >= pages.length ? 0 : page + 1; + render(); + }, autoPlayMs); + }; + + const dotButtons = pages.map((_, index) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "image-slider-dot"; + + const onClick = () => { + goTo(index); + startTimer(); + }; + + button.addEventListener("click", onClick); + dotsWrap.appendChild(button); + + cleanups.push(() => { + button.removeEventListener("click", onClick); + }); + + return button; + }); + + const onMouseEnter = () => { + isHover = true; + stopTimer(); + }; + + const onMouseLeave = () => { + isHover = false; + startTimer(); + }; + + const onPointerDown = (event: PointerEvent) => { + if (!canSlide) return; + + dragging = true; + startX = event.clientX; + deltaX = 0; + viewport.setPointerCapture(event.pointerId); + }; + + const onPointerMove = (event: PointerEvent) => { + if (!dragging) return; + deltaX = event.clientX - startX; + }; + + const onPointerUp = () => { + if (!dragging) return; + + dragging = false; + + if (deltaX > 50) goTo(page - 1); + if (deltaX < -50) goTo(page + 1); + + deltaX = 0; + startTimer(); + }; + + viewport.addEventListener("mouseenter", onMouseEnter); + viewport.addEventListener("mouseleave", onMouseLeave); + viewport.addEventListener("pointerdown", onPointerDown); + viewport.addEventListener("pointermove", onPointerMove); + viewport.addEventListener("pointerup", onPointerUp); + viewport.addEventListener("pointercancel", onPointerUp); + + cleanups.push(() => { + stopTimer(); + viewport.removeEventListener("mouseenter", onMouseEnter); + viewport.removeEventListener("mouseleave", onMouseLeave); + viewport.removeEventListener("pointerdown", onPointerDown); + viewport.removeEventListener("pointermove", onPointerMove); + viewport.removeEventListener("pointerup", onPointerUp); + viewport.removeEventListener("pointercancel", onPointerUp); + dotButtons.forEach((button) => button.remove()); + }); + + render(); + startTimer(); + }); + + return () => { + cleanups.forEach((cleanup) => cleanup()); + }; + }, []); + + const contentsHtml = ` +
+
+

+ 하수처리수 재이용 사례 +  

-
- {sewageCases.map((item, index) => ( -
- {item.title} +
+
+
+   +

경기도 구리시

+
- +
+
+
+
+
+
구리시 도로자동청소시스템
+
구리시 도로자동청소시스템 위치도
+
+
+
구리시 도로자동청소시스템 위치도2
+
구리시 도로자동청소시스템 원리
+
+
+
+
+
+
- +
+
+
  

사업명

+
  • 구리시 도로자동청소시스템 설치사업
+
+
+
  

사업목적

+
  • 고농도 미세먼지 발생 및 폭염의 급증에 따라 공공하수처리시설 발생 재이용수 일부를 도로자동청소시스템에 활용하여 미세먼지 저감 및 폭염 취약성 완화
+
+
+
  

사업 주요 내용

+
    +
  • 총사업비 : 61억원
  • +
  • 위치 : 구리시 경춘로 및 갈매중앙로 일대 등
  • +
  • 관로 : 5.8km
  • +
  • 공급량 : 178㎥/일
  • +
+
+
+
- {index !== sewageCases.length - 1 &&
} -
- ))} +
+ +
+
 

경기도 부천시

+
+
+
+
+
+
부천시 하수 재이용수 생산시설
+
부천시 하수처리수 재이용 사례
+
+
+
+
+
+
+
+
+
  

사업명

+
  • 부천시 하수 재이용수 생산시설 설치 운영
+
+
+
  

사업목적

+
  • 굴포하수처리시설 하수처리수 재이용수 생산시설 설치 운영을 통해 여월천 등 부천 관내 하천의 건천화 방지, 하천 생태계 보존 및 주민들에게 친수 공간 제공
+
+
+
  

사업 주요 내용

+
    +
  • 총사업비 : 212억원
  • +
  • 위치 : 부천시 여월천, 삼정천, 심곡천 및 시민의 강
  • +
  • 관로 : 23.6km
  • +
  • 공급량 : 50,000㎥/일
  • +
+
+
+
+ +
+ +
+
 

경기도 수원시

+
+
+
+
+
+
수원시 황구지천1
+
수원시 황구지천2
+
+
+
수원시 황구지천 지도
+
+
+
+
+
+
+
+
+
  

사업명

+
  • 수원시 황구지천 하수처리수 재이용사업
+
+
+
  

사업목적

+
  • 황구지천 공공하수처리시설 하수처리수 재이용사업으로 황구 호매실천 및 금곡천 건천화 방지로 주민들에게 친수 공간 제공 및 하천 생태계 보전
+
+
+
  

사업 주요 내용

+
    +
  • 총사업비 : 110억원
  • +
  • 위치 : 수원시 권선구 호매실천, 금곡천
  • +
  • 관로 : 3.9km
  • +
  • 공급량 : 25,000㎥/일(하수처리시설 용량 : 45,000㎥/일)
  • +
+
+
+
+ +
+ +
+
 

경기도 파주시

+
+
+
+
+
+
파주시 하수처리수 시설 전경1
+
파주시 하수처리수 시설 전경2
+
+
+
+
+
+
+
+
+
  

사업명

+
  • 파주시 하수처리수 재이용사업
+
+
+
  

사업목적

+
    +
  • 운정 및 금촌하수처리시설의 하수처리수를 막공법으로 재처리하여 파주LCD산업단지 내 3개 업체에 공업용수를 공급(LG디스플레이, 희성전자, 엔비텍)
  • +
  • 하수처리수 재이용시설 자체 농축폐수 처리공정 개발 및 적용 성공사례
  • +
+
+
+
  

사업방식

+
  • 민간투자사업(시행사 : 파주그린허브(주), 운영사 : 테크로스)
+
+
+
  

사업 주요 내용

+
    +
  • 총사업비 : 651억원
  • +
  • 위치 : 파주시 금촌동 714번지 일원
  • +
  • 관로 : 13.6km
  • +
  • 공급량 : 40,000㎥/일(시설용량 : 41,200㎥/일)
  • +
+
+
+
+ +
+ +
+
 

경상북도 포항시

+
+
+
+
+
+
포항시 하수처리수 전경1
+
포항시 하수처리수 전경2
+
+
+
포항시 하수처리수 재이용 공급계통
+
+
+
+
+
+
+
+
+
  

사업명

+
  • 포항시 하수처리수 재이용사업
+
+
+
  

사업목적

+
  • 포항시 내 공단 개발 확대로 지속적이고 안정적인 공업용수 공급 추진(포스코, 동국산업 등)
+
+
+
  

사업방식

+
  • 민간투자사업(운영사 : ㈜포웰, 한국수자원공사)
+
+
+
  

사업 주요 내용

+
    +
  • 총사업비 : 1,400억원
  • +
  • 위치 : 포항시 남구 상도동 125번지 일원
  • +
  • 관로 : 11.7km
  • +
  • 공급량 : 100,000㎥/일(시설용량 : 100,000㎥/일)
  • +
  • 처리공법 : 전처리시설(U/F) + 주처리시설(R/O)
  • +
+
+
+
+ +
+ +
+
 

울산광역시

+
+
+
+
+
+
울산광역시 폐수처리수 전경1
+
울산광역시 폐수처리수 전경2
+
+
+
울산광역시 폐수처리수 시설1
+
울산광역시 폐수처리수 시설2
+
+
+
+
+
+
+
+
+
  

사업명

+
  • 울산광역시 폐수처리수 재이용사업
+
+
+
  

사업목적

+
  • ’18년 갈수기시 낙동강 수질 악화로 울산석유화학단지 용수 공급 부족을 해소하기 위해 미포산단 내 한화케미칼 등 8개사 공업용수 공급
+
+
+
  

사업방식

+
  • 민간투자사업(시행사 :울산화이텍워터(주))
+
+
+
  

사업 주요 내용

+
    +
  • 총사업비 : 1,729억원
  • +
  • 위치 : 울산광역시 울주군 청량읍 상개로 14 일원
  • +
  • 관로 : 11.7km
  • +
  • 공급량 : 17,000㎥/일(시설용량 : 86,000㎥/일)
  • +
  • 처리공법 : 전처리시설(U/F) + 주처리시설(R/O, EDI)
  • +
+
+
+
+ +
+ +
+
 

전라남도 여수시

+
+
+
+
+
+
여수시 하수처리수 재이용시설1
+
여수시 하수처리수 재이용시설2
+
+
+
+
+
+
+
+
+
  

사업명

+
  • 여수시 하수처리수 재이용사업
+
+
+
  

사업목적

+
  • 수요자가 원하는 양질의 공업용수를 저렴하게 공급하여 한정된 수자원의 효율적 이용 및 지역적 물 부족 해소, 하수재이용을 통한 오염총량 저감 및 방류수역 수질개선을 위한 여수국가산단 내 공업용수 공급(GS칼텍스, YNCC 등)
+
+
+
  

사업방식

+
  • 민간투자사업(시행사 : 여수그린허브(주), 운영사 : 테크로스)
+
+
+
  

사업 주요 내용

+
    +
  • 총사업비 : 750억원
  • +
  • 위치 : 여수시 신월로 284 일원
  • +
  • 관로 : 18.6km
  • +
  • 공급량 : 50,000㎥/일(시설용량 : 50,000㎥/일)
  • +
  • 처리공법 : 전처리시설(U/F) + 주처리시설(R/O)
  • +
+
+
+
+ +
+ +
+
 

충청남도 보령시

+
+
+
+
+
+
보령시 하수처리수 재이용시설1
+
보령시 하수처리수 재이용시설2
+
+
+
+
+
+
+
+
+
  

사업명

+
  • 보령시 하수처리수 재이용사업
+
+
+
  

사업목적

+
    +
  • 한국중부발전(주)의 보령 및 신보령화력발전소에 10,000㎥/일의 탈황용수 공급을 위해 보령하수처리시설의 하수처리수 재이용 사업 추진
  • +
  • 국내 유일 하수재이용 공업용수 공급 재정사업으로 2016년 충남 서부권 가뭄 시 지역사회 기여 차원에서 기관간 MOU(기후에너지환경부, 충청남도, 보령시, 한국중부발전(주))를 바탕으로 추진되어 경제성보다 공익성을 바탕으로 국가전력 기반시설의 안정적 운영 도모
  • +
+
+
+
  

사업방식

+
  • 재정사업(2017년 한국환경공단과 사업대행 위·수탁협약 체결)
+
+
+
  

사업 주요 내용

+
    +
  • 총사업비 : 315억원
  • +
  • 위치 : 보령시 대천방조제로 322 일원
  • +
  • 관로 : 8.7km
  • +
  • 공급량 : 10,000㎥/일(시설용량 : 10,000㎥/일)
  • +
  • 처리공법 : 전처리시설(U/F) + 주처리시설(R/O)
  • +
+
+
+
  

특이사항

+
  • 국내 유일 하수재이용 공업용수 공급 재정사업으로 2016년 충남 서부권 가뭄 시 지역사회 기여 차원에서 기관간 MOU(환경부, 충청남도, 보령시, 한국중부발전㈜)를 바탕으로 추진 되어 경제성보다 공익성을 바탕으로 국가전력 기반시설의 안정적 운영 도모
+
+
+
+ +
+ +
+
 

충청남도 아산시

+
+
+
+
+
+
아산시 하수처리수 재이용시설
+
아산시 하수처리수 재이용 공정도
+
+
+
+
+
+
+
+
+
  

사업명

+
  • 아산시 하수처리수 재이용사업
+
+
+
  

사업목적

+
  • 신도시 개발에 따른 인구 급증으로 발생하는 하수를 재처리하여 탕정산업단지(삼성디스플레이)에 공업용수를 안정적으로 공급함으로써 지역기반 및 활성화를 도모
+
+
+
  

사업방식

+
  • 민간투자사업(시행사 : 아산스마트원터, 운영사 : 한국수자원공사)
+
+
+
  

사업 주요 내용

+
    +
  • 총사업비 : 297억원
  • +
  • 위치 : 아산시 탕정면 갈산리 807 일원
  • +
  • 관로 : 4.2km
  • +
  • 공급량 : 27,000㎥/일(시설용량 : 45,000㎥/일)
  • +
  • 처리공법 : 전처리시설(U/F) + 주처리시설(R/O)
  • +
+
+
+
+ `; + + return ( +
); -} \ No newline at end of file +} diff --git a/apps/dm/app/(with-layout)/example/exampleSection.tsx b/apps/dm/app/(with-layout)/example/exampleSection.tsx new file mode 100644 index 000000000..42ae60f08 --- /dev/null +++ b/apps/dm/app/(with-layout)/example/exampleSection.tsx @@ -0,0 +1,8 @@ +import { ContentsPage } from "@/components/ui/contents"; + +export default function ExampleSection() { + const contsSn = Number(process.env.CONTENTS_EXAMPLE ?? 32); + const menuCd = "24"; + + return ; +} diff --git a/apps/dm/app/(with-layout)/example/page.tsx b/apps/dm/app/(with-layout)/example/page.tsx index 94e908d81..0bcd8f1de 100644 --- a/apps/dm/app/(with-layout)/example/page.tsx +++ b/apps/dm/app/(with-layout)/example/page.tsx @@ -1,13 +1,15 @@ /** * 물 재이용 사례 */ +export const dynamic = "force-dynamic"; import SubPageHeader from "@/components/SubPageHeader"; import BusinessSubNav, { type BusinessSubNavMenu } from "@/components/BusinessSubNav"; -import RainExampleSection from "./_components/RainExampleSection"; -import GreywaterExampleSection from "./_components/GreywaterExampleSection"; -import SewageExampleSection from "./_components/SewageExampleSection"; -import GlobalCaseSection from "./_components/GlobalCaseSection"; +import ExampleSection from "./exampleSection"; +// import RainExampleSection from "./_components/RainExampleSection"; +// import GreywaterExampleSection from "./_components/GreywaterExampleSection"; +// import SewageExampleSection from "./_components/SewageExampleSection"; +// import GlobalCaseSection from "./_components/GlobalCaseSection"; const menus: BusinessSubNavMenu[] = [ { @@ -78,11 +80,12 @@ export default function ExamplePage() {
- + + {/* - + */}
); -} \ No newline at end of file +} diff --git a/apps/dm/app/(with-layout)/intro/_components/LawIntroSection.tsx b/apps/dm/app/(with-layout)/intro/_components/LawIntroSection.tsx index e366c38ad..0ca6ccf69 100644 --- a/apps/dm/app/(with-layout)/intro/_components/LawIntroSection.tsx +++ b/apps/dm/app/(with-layout)/intro/_components/LawIntroSection.tsx @@ -1,123 +1,93 @@ "use client"; -import React from "react"; -import Image, { type StaticImageData } from "next/image"; - -import lawPlan from "@/public/images/landing/intro/law_timeline.png"; -import planPerformance from "@/public/images/landing/intro/performance_left.png"; -import domesticChange from "@/public/images/landing/intro/domestic_textbox.png"; -import policyHistory from "@/public/images/landing/intro/policy_timeline.png"; -import lawRelation from "@/public/images/landing/intro/rel_note.png"; -import domesticLinkage from "@/public/images/landing/intro/linkage.png"; - -function SectionTitle({ children }: { children: React.ReactNode }) { - return ( -
- -

{children}

-
- ); -} - -function Hr({ className = "" }: { className?: string }) { - return
; -} - -function ImgCard({ src, alt }: { src: StaticImageData; alt: string }) { - return ( -
- {alt} -
- ); -} - -function SectionShell({ - id, - title, - children, - className = "", -}: { - id: string; - title: string; - children: React.ReactNode; - className?: string; -}) { - return ( -
- {title} -
{children}
-
- ); -} - export default function LawIntroSection() { - const title = "물재이용법"; - - return ( -
-
-

- {title} - + const contentsHtml = ` +
+
+

+ 물재이용법 +  

-
- - - +
+
+
+   +

법률 제·개정 및 기본계획 수립

+
+
+ 법률 제·개정 및 기본계획 수립 +
+
-
+
- - - +
+
+   +

제2차 계획 대비 실적 현황

+
+
+ 제2차 계획 대비 실적 현황 +
+
-
+
- - - +
+
+   +

우리나라의 수자원 현황 및 물이용 여건

+
+
+ 우리나라의 수자원 현황 및 물이용 여건 +
+
-
+
- - - +
+
+   +

물 재이용 정책 변화 추이

+
+
+ 물 재이용 정책 변화 추이 +
+
-
+
- - - +
+
+   +

타법령과의 관계 검토

+
+
+ 타법령과의 관계 검토 +
+
-
+
- - - +
+
+   +

주요 국가계획의 정책방향과 연계한 물 재이용 정책 필요

+
+
+ 주요 국가계획의 정책방향과 연계한 물 재이용 정책 필요 +
+
+ `; + + return ( +
); -} \ No newline at end of file +} diff --git a/apps/dm/app/(with-layout)/intro/introSection.tsx b/apps/dm/app/(with-layout)/intro/introSection.tsx new file mode 100644 index 000000000..fd958073e --- /dev/null +++ b/apps/dm/app/(with-layout)/intro/introSection.tsx @@ -0,0 +1,232 @@ +import { ContentsPage } from "@/components/ui/contents"; + +export default function IntroSection() { + // const contentsHtml = ` + //
+ //
+ //

+ // 물재이용법 + //   + //

+ //
+ + //
+ //
+ //
+ //   + //

법률 제·개정 및 기본계획 수립

+ //
+ //
+ // 법률 제·개정 및 기본계획 수립 + //
+ //
+ + //
+ + //
+ //
+ //   + //

제2차 계획 대비 실적 현황

+ //
+ //
+ // 제2차 계획 대비 실적 현황 + //
+ //
+ + //
+ + //
+ //
+ //   + //

우리나라의 수자원 현황 및 물이용 여건

+ //
+ //
+ // 우리나라의 수자원 현황 및 물이용 여건 + //
+ //
+ + //
+ + //
+ //
+ //   + //

물 재이용 정책 변화 추이

+ //
+ //
+ // 물 재이용 정책 변화 추이 + //
+ //
+ + //
+ + //
+ //
+ //   + //

타법령과의 관계 검토

+ //
+ //
+ // 타법령과의 관계 검토 + //
+ //
+ + //
+ + //
+ //
+ //   + //

주요 국가계획의 정책방향과 연계한 물 재이용 정책 필요

+ //
+ //
+ // 주요 국가계획의 정책방향과 연계한 물 재이용 정책 필요 + //
+ //
+ //
+ //
+ + //
+ //
+ //

+ // 빗물이용시설 + //   + //

+ //
+ + //
+ //
+ //
+ //   + //

빗물 재이용 소개

+ //
+ //
+ // 빗물 재이용 소개 + //
+ //
+ + //
+ + //
+ //
+ //   + //

빗물이용시설 설치 관리 법적 의무 대상

+ //
+ //
+ // 빗물이용시설 설치 관리 법적 의무 대상 + //
+ //
+ + //
+ + //
+ //
+ //   + //

빗물이용시설 설계 방안

+ //
+ //
+ // 빗물이용시설 설계 방안 + //
+ //
+ + //
+ + //
+ //
+ //   + //

빗물이용시설 시설기준 및 관리기준

+ //
+ //
+ // 빗물이용시설 시설기준 및 관리기준 + //
+ //
+ //
+ //
+ + //
+ //
+ //

+ // 중수도 + //   + //

+ //
+ + //
+ //
+ //
+ //   + //

중수 재이용 소개

+ //
+ //
+ // 중수 재이용 소개 + //
+ //
+ + //
+ + //
+ //
+ //   + //

중수도 설치 의무 대상

+ //
+ //
+ // 중수도 설치 의무 대상 + //
+ //
+ + //
+ + //
+ //
+ //   + //

중수도 시설기준 및 관리기준

+ //
+ //
+ // 중수도 시설기준 및 관리기준 + //
+ //
+ //
+ //
+ + //
+ //
+ //

+ // 하수처리수 재이용 + //   + //

+ //
+ + //
+ //
+ //
+ //   + //

하수처리수 소개

+ //
+ //
+ // 하수처리수 + //
+ //
+ + //
+ + //
+ //
+ //   + //

하수처리수 재이용시설 의무 대상

+ //
+ //
+ // 하수처리수 재이용시설 의무 대상 + //
+ //
+ //
+ //
+ // `; + + const contsSn = Number(process.env.CONTENTS_INTRO ?? 31); + const menuCd = "24"; + + return ; + // return ( + //
+ // ); +} diff --git a/apps/dm/app/(with-layout)/intro/page.tsx b/apps/dm/app/(with-layout)/intro/page.tsx index 7b643e55d..eb67e7a7e 100644 --- a/apps/dm/app/(with-layout)/intro/page.tsx +++ b/apps/dm/app/(with-layout)/intro/page.tsx @@ -1,10 +1,9 @@ +export const dynamic = "force-dynamic"; + // DM 물 재이용 소개 페이지 import BusinessSubNav, { type BusinessSubNavMenu } from "@/components/BusinessSubNav"; import SubPageHeader from "@/components/SubPageHeader"; -import LawIntroSection from "./_components/LawIntroSection"; -import RainIntroSection from "./_components/RainIntroSection"; -import ReuseWaterIntroSection from "./_components/ReuseWaterIntroSection"; -import TreatedIntroSection from "./_components/TreatedIntroSection"; +import IntroSection from "./introSection"; const menus: BusinessSubNavMenu[] = [ { @@ -76,11 +75,8 @@ export default function IntroPage() { {/* 본문 */}
- - - - +
); -} \ No newline at end of file +} diff --git a/apps/dm/app/contents-page.css b/apps/dm/app/contents-page.css new file mode 100644 index 000000000..0bc23308b --- /dev/null +++ b/apps/dm/app/contents-page.css @@ -0,0 +1,565 @@ +.contents-page { + color: #0f172a; + font-size: 16px; + line-height: 1.5; +} + +.contents-page .page-title { + display: inline-block; +} + +.contents-page .page-title h2 { + position: relative; + display: inline-block; + padding: 0 0.5rem; + font-size: 28px; + font-weight: 800; + line-height: 1; + letter-spacing: -0.025em; + color: #0f172a; +} + +.contents-page .page-title .title-text { + position: relative; + z-index: 1; +} + +.contents-page .page-title .title-bg { + position: absolute; + left: 0; + bottom: -4px; + z-index: 0; + height: 12px; + width: 100%; + background: linear-gradient(to right, #7dd3fc, #86efac); +} + +.contents-page .sections { + margin-top: 2.5rem; +} + +.contents-page .section { + scroll-margin-top: 120px; +} + +.contents-page .section.first { + margin-top: 2.5rem; +} + +.contents-page .section-title { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.contents-page .section-bar { + height: 20px; + width: 3px; + flex-shrink: 0; + background: #0ea5e9; +} + +.contents-page .section-title h3 { + font-size: 24px; + font-weight: 700; + line-height: 1.2; + color: #171717; +} + +.contents-page .image-wrap { + margin-top: 2rem; + width: 100%; + overflow-x: auto; +} + +.contents-page .image-wrap img { + display: block; + margin: 0 auto; + max-width: none; + height: auto; +} + +.contents-page .divider { + height: 2px; + width: 100%; + margin: 2.75rem 0; + background: rgba(0, 0, 0, 0.1); +} + +.contents-page .rain-example-sections { + padding-bottom: 120px; +} + +.contents-page .image-slider { + margin-top: 2rem; +} + +.contents-page .image-slider-inner { + max-width: 1240px; + margin: 0 auto; +} + +.contents-page .image-slider-viewport { + overflow: hidden; + user-select: none; + touch-action: pan-y; + cursor: grab; +} + +.contents-page .image-slider-viewport:active { + cursor: grabbing; +} + +.contents-page .image-slider-track { + display: flex; + transition: transform 0.5s ease-out; +} + +.contents-page .image-slider-page { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1.25rem; + min-width: 100%; +} + +.contents-page .image-slide { + position: relative; + height: 340px; + overflow: hidden; + border-radius: 20px; + background: #f5f5f5; +} + +.contents-page .image-slide img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.contents-page .image-slider-dots { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + margin-top: 1.25rem; +} + +.contents-page .image-slider-dot { + width: 8px; + height: 8px; + border: 0; + border-radius: 9999px; + background: #d8dde3; + transition: all 0.2s ease; + cursor: pointer; +} + +.contents-page .image-slider-dot.is-active { + background: #18bceb; +} + +@media (max-width: 768px) { + .contents-page .image-slider-page { + grid-template-columns: 1fr; + } + + .contents-page .image-slide { + height: 280px; + } +} + +.contents-page .content-group { + margin-top: 2.5rem; + padding: 0 2.5rem; +} + +.contents-page .content-block + .content-block { + margin-top: 2.25rem; +} + +.contents-page .block-title { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.contents-page .block-title-icon { + position: relative; + display: inline-block; + width: 27px; + height: 16px; + flex-shrink: 0; +} + +.contents-page .block-title-icon .bubble { + position: absolute; + top: 0; + width: 16px; + height: 16px; + border-radius: 9999px; +} + +.contents-page .block-title-icon .bubble-sky { + left: 0; + background: rgba(1, 172, 229, 0.4); +} + +.contents-page .block-title-icon .bubble-green { + left: 7px; + background: rgba(109, 187, 79, 0.4); +} + +.contents-page .block-title h4 { + font-size: 20px; + font-weight: 600; + color: #1a1a1a; +} + +.contents-page .bullet-list { + margin-top: 1rem; + padding-left: 2rem; + color: #4f4f4f; + font-size: 16px; + line-height: 1.8; + list-style: disc; +} + +.contents-page .table-wrap { + margin-top: 2rem; + overflow-x: auto; +} + +.contents-page .contents-table { + width: 100%; + min-width: 860px; + border-collapse: collapse; + border: 1px solid #d8dde3; + text-align: center; +} + +.contents-page .contents-table th, +.contents-page .contents-table td { + border: 1px solid #d8dde3; + padding: 0.75rem 1rem; +} + +.contents-page .contents-table th { + font-size: 18px; + font-weight: 500; + color: #fff; +} + +.contents-page .contents-table td { + background: #fff; + font-size: 16px; + color: #4f4f4f; +} + +.contents-page .contents-table .th-blue { + background: #14a9d9; +} + +.contents-page .contents-table .th-teal { + background: #39b2bd; +} + +.contents-page .contents-table .th-mint { + background: #58b99a; +} + +.contents-page .contents-table .th-green { + background: #70bf4b; +} + +.contents-page .contents-table .th-teal-greywater { + background: #40b5a5; +} + +.contents-page .contents-table th.th-gray, +.contents-page .global-table th.th-gray { + background: #bfc9d2; + color: #222; +} + +.contents-page .contents-table th.th-blue, +.contents-page .global-table th.th-blue { + background: #14a9d9; + color: #fff; +} + +.contents-page .global-table th.th-teal-2 { + background: #55bda5; + color: #fff; +} + +.contents-page .global-table th.th-green-2 { + background: #6ab84e; + color: #fff; +} + +.contents-page .bg-cyan { + background: #eaf7f8 !important; +} + +.contents-page .bg-green { + background: #eaf8e6 !important; +} + +.contents-page .global-case-sections { + padding-bottom: 120px; +} + +.contents-page .greywater-example-sections { + padding-bottom: 120px; +} + +.contents-page .sewage-example-sections { + padding-bottom: 120px; +} + +.contents-page .global-content-group { + gap: 0; +} + +.contents-page .content-rich-list { + margin-top: 1rem; + color: #4f4f4f; + font-size: 16px; + line-height: 1.8; +} + +.contents-page .content-rich-list li + li { + margin-top: 0.5rem; +} + +.contents-page .content-rich-list .depth-0 { + margin-left: 1.5rem; + padding-left: 0.25rem; + list-style: disc; + font-weight: 600; + color: #555; +} + +.contents-page .content-rich-list .depth-1 { + margin-left: 2.125rem; + list-style: none; + color: #777; +} + +.contents-page .content-rich-list .depth-1::before { + content: "- "; +} + +.contents-page .content-rich-list .depth-2 { + margin-left: 3.25rem; + list-style: none; + color: #666; +} + +.contents-page .content-rich-list .depth-3 { + margin-left: 3.5rem; + list-style: none; + color: #777; +} + +.contents-page .global-notice-box, +.contents-page .global-description-box { + max-width: 1000px; + margin: 1.25rem auto 0; + border-radius: 20px; + background: #eaf7f8; +} + +.contents-page .global-notice-box { + padding: 1.75rem 2rem; +} + +.contents-page .global-description-box { + padding: 1.5rem 1.75rem; +} + +.contents-page .global-notice-row { + display: flex; + align-items: flex-start; + gap: 0.5rem; +} + +.contents-page .global-water-bullet { + width: 27px; + height: 33px; + margin-top: 0.25rem; + flex-shrink: 0; +} + +.contents-page .global-notice-title { + display: block; + font-size: 26px; + font-weight: 800; + color: #007fa4; +} + +.contents-page .global-text-dark { + color: #1a1a1a; +} + +.contents-page .global-plain-list { + margin-top: 1.25rem; + padding-left: 1.25rem; + list-style: disc; + color: #555; + font-size: 16px; + line-height: 1.7; +} + +.contents-page .global-plain-list li + li { + margin-top: 0.5rem; +} + +.contents-page .global-table-wrap { + overflow-x: auto; +} + +.contents-page .global-overview-table-wrap { + max-width: 1000px; + margin: 1.25rem auto 0; +} + +.contents-page .global-table { + width: 100%; + text-align: center; + border-collapse: collapse; +} + +.contents-page .global-table th { + padding: 0.75rem 1rem; + font-size: 17px; + font-weight: 700; + color: #fff; +} + +.contents-page .overview-compare-table { + min-width: 760px; +} + +.contents-page .overview-compare-table th { + padding-top: 1rem; + padding-bottom: 1rem; + font-size: 18px; + font-weight: 500; + border-top-left-radius: 10px; + border-top-right-radius: 10px; +} + +.contents-page .overview-compare-table td { + border: 1px solid #aeb9c2; + padding: 1rem; + font-size: 28px; + font-weight: 700; + color: #4f4f4f; + background: #fff; +} + +.contents-page .overview-compare-table .country-cell { + font-size: 20px; + color: #333; +} + +.contents-page .country-wrap { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; +} + +.contents-page .country-flag { + width: 42px; + height: 28px; + object-fit: cover; +} + +.contents-page .reuse-rate-table { + min-width: 960px; +} + +.contents-page .reuse-rate-table th { + border-top-left-radius: 10px; + border-top-right-radius: 10px; +} + +.contents-page .reuse-rate-table td { + border: 1px solid #aeb9c2; + padding: 0.75rem 1rem; + font-size: 15px; + color: #4f4f4f; + background: #fff; +} + +.contents-page .reuse-rate-table .cell-strong { + font-weight: 600; +} + +.contents-page .info-card-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 1.25rem; + margin-top: 2rem; +} + +.contents-page .info-card { + border: 2px solid #01ace5; + border-radius: 8px; + background: #eaf5f5; + padding: 1.25rem; +} + +.contents-page .info-card strong { + display: block; + font-size: 20px; + font-weight: 700; + color: #333; +} + +.contents-page .info-card p { + margin-top: 0.75rem; + font-size: 15px; + line-height: 1.6; + color: #555; +} + +.contents-page .global-table-padding { + margin-top: 2rem; + padding: 0 120px; +} + +.contents-page .global-price-table td { + background: #fff; +} + +.contents-page .global-accent { + color: #01ace5; +} + +@media (max-width: 1200px) { + .contents-page .info-card-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .contents-page .global-table-padding { + padding: 0; + } +} + +@media (max-width: 768px) { + .contents-page .global-notice-row { + flex-direction: column; + } + + .contents-page .global-notice-title { + font-size: 22px; + } + + .contents-page .info-card-grid { + grid-template-columns: 1fr; + } +} diff --git a/apps/dm/app/globals.css b/apps/dm/app/globals.css index 2fee793a1..d3fa1c6e0 100644 --- a/apps/dm/app/globals.css +++ b/apps/dm/app/globals.css @@ -1,5 +1,6 @@ /* @import "../../../packages/common/components/src/base.css"; */ /* @import "../../../packages/common/components/src/base.css"; */ +@import "./contents-page.css"; @tailwind base; @tailwind components; diff --git a/apps/dm/components/LoginModal.tsx b/apps/dm/components/LoginModal.tsx index d50a13192..086deedff 100644 --- a/apps/dm/components/LoginModal.tsx +++ b/apps/dm/components/LoginModal.tsx @@ -63,10 +63,15 @@ export default function LoginModal({ setSubmitting(false); }; + const handleClose = () => { + resetForm(); + onClose(); + }; + useEffect(() => { if (!open) return; const onKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); // ESC 키로 모달 닫기 + if (e.key === "Escape") handleClose(); // ESC 키로 모달 닫기 }; window.addEventListener("keydown", onKeyDown); @@ -77,9 +82,8 @@ export default function LoginModal({ return () => { window.removeEventListener("keydown", onKeyDown); document.body.style.overflow = prev; - resetForm(); }; - }, [open, onClose]); + }, [open]); useEffect(() => { if (noWorker) { @@ -160,7 +164,7 @@ export default function LoginModal({ if (sessionUser) { const parsed = JSON.parse(sessionUser) as { dmdCoNm?: string }; onSuccess?.({ userName: parsed.dmdCoNm ?? "수요처" }); - onClose(); + handleClose(); router.push("/apply/status"); } } finally { @@ -180,7 +184,7 @@ export default function LoginModal({ diff --git a/apps/st/src/components/button/DeleteArticleButton.tsx b/apps/st/src/components/button/DeleteArticleButton.tsx index 1a6432a64..e350b404b 100644 --- a/apps/st/src/components/button/DeleteArticleButton.tsx +++ b/apps/st/src/components/button/DeleteArticleButton.tsx @@ -6,7 +6,7 @@ interface DeleteArticleButtonProps { const DeleteArticleButton = ({ deleteArticleFunc }: DeleteArticleButtonProps) => { return ( - diff --git a/apps/st/src/components/css/DataGrid.css b/apps/st/src/components/css/DataGrid.css index a79e3ebd4..843289179 100644 --- a/apps/st/src/components/css/DataGrid.css +++ b/apps/st/src/components/css/DataGrid.css @@ -21,6 +21,16 @@ .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-vrf-prcs-ok { @apply !font-semibold !text-[#3372cf]; } + /* 검증결과 다이얼로그: 구분(NV·IMR·HB) */ + .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-vrf-mthd-nv { + @apply !bg-[#f3f4f6] !font-semibold !text-[#4b5563]; + } + .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-vrf-mthd-imr { + @apply !bg-[#fef2f2] !font-semibold !text-[#dc2626]; + } + .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-vrf-mthd-hb { + @apply !bg-[#eff6ff] !font-semibold !text-[#2563eb]; + } /* 개별 반려: 높이/레이아웃은 RealGrid 행에 그대로 두고, 색·테두리·정렬만 버튼 느낌 */ .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-reject-mini-btn { text-align: center !important; @@ -144,6 +154,24 @@ @apply !bg-[#fff7bf]; } + /* + * 검증 오류 셀(rg-cell-verify-error / -imr) 최우선 적용. + * 클래스 이중화로 specificity를 한 단계 올려 동일 specificity의 다른 셀 클래스 + * (rg-cell-rowtype-*, rg-cell-total, rg-cell-readonly 등)보다 항상 이김. + */ + .rg-grid .rg-body .rg-data-cell.rg-cell-verify-error.rg-cell-verify-error, + .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-verify-error.rg-cell-verify-error, + .rg-grid .rg-fixed-body .rg-data-cell.rg-cell-verify-error.rg-cell-verify-error, + .rg-grid .rg-fixed-body .rg-data-row .rg-data-cell.rg-cell-verify-error.rg-cell-verify-error { + @apply !bg-[#ffe6e6] !text-[#c00]; + } + .rg-grid .rg-body .rg-data-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr, + .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr, + .rg-grid .rg-fixed-body .rg-data-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr, + .rg-grid .rg-fixed-body .rg-data-row .rg-data-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr { + @apply !bg-[#fff7bf] !text-[#b45309]; + } + /* 진행상황: 소프트 인라인 요약 (툴바와 자연스럽게 blend) */ .data-grid-status-bubble { @apply rounded-lg border border-slate-200/80 bg-white px-8 py-2.5; @@ -252,6 +280,18 @@ [data-theme="black"] .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-vrf-prcs-ok { color: #3372cf !important; } +[data-theme="black"] .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-vrf-mthd-nv { + background-color: #4b5563 !important; + color: #f9fafb !important; +} +[data-theme="black"] .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-vrf-mthd-imr { + background-color: rgb(127 29 29 / 0.55) !important; + color: #fecaca !important; +} +[data-theme="black"] .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-vrf-mthd-hb { + background-color: rgb(30 58 138 / 0.55) !important; + color: #bfdbfe !important; +} [data-theme="black"] .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-reject-mini-btn { background-color: rgb(127 29 29 / 0.42) !important; border-color: rgb(248 113 113 / 0.5) !important; @@ -314,3 +354,35 @@ background-color: #6bafae !important; color: #ffffff !important; } + + +.rg-grid .rg-body .rg-data-cell.rg-cell-verify-error.rg-cell-verify-error, +.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-verify-error.rg-cell-verify-error, +.rg-grid .rg-fixed-body .rg-data-cell.rg-cell-verify-error.rg-cell-verify-error, +.rg-grid .rg-fixed-body .rg-data-row .rg-data-cell.rg-cell-verify-error.rg-cell-verify-error { + background-color: #ffe6e6 !important; + color: #c00 !important; +} +.rg-grid .rg-body .rg-data-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr, +.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr, +.rg-grid .rg-fixed-body .rg-data-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr, +.rg-grid .rg-fixed-body .rg-data-row .rg-data-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr { + background-color: #fff7bf !important; + color: #b45309 !important; +} + +/* dark theme에서도 검증 오류 색을 유지하고 싶다면 동일 색 유지(가독성 우선) */ +[data-theme="black"] .rg-grid .rg-body .rg-data-cell.rg-cell-verify-error.rg-cell-verify-error, +[data-theme="black"] .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-verify-error.rg-cell-verify-error, +[data-theme="black"] .rg-grid .rg-fixed-body .rg-data-cell.rg-cell-verify-error.rg-cell-verify-error, +[data-theme="black"] .rg-grid .rg-fixed-body .rg-data-row .rg-data-cell.rg-cell-verify-error.rg-cell-verify-error { + background-color: #ffe6e6 !important; + color: #c00 !important; +} +[data-theme="black"] .rg-grid .rg-body .rg-data-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr, +[data-theme="black"] .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr, +[data-theme="black"] .rg-grid .rg-fixed-body .rg-data-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr, +[data-theme="black"] .rg-grid .rg-fixed-body .rg-data-row .rg-data-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr { + background-color: #fff7bf !important; + color: #b45309 !important; +} diff --git a/apps/st/src/components/module/CompactDataGrid.tsx b/apps/st/src/components/module/CompactDataGrid.tsx index 6de651a1d..c1fdbd226 100644 --- a/apps/st/src/components/module/CompactDataGrid.tsx +++ b/apps/st/src/components/module/CompactDataGrid.tsx @@ -116,7 +116,8 @@ const CompactDataGrid = ({ onItemChecked, onItemAllChecked, compactFullWidth = false, - compactGridHeightClass = "h-[65vh]", + compactGridHeightClass = "h-[55vh]", + headerHeight = 60, }: DataGridProps) => { const { columns, columnLayout } = columnInfo; const gridRef = useRef(null); @@ -188,7 +189,7 @@ const CompactDataGrid = ({ fitStyle: GridFitStyle.EVEN }); - gridView.header.height = 60; + gridView.header.height = headerHeight; if (columns.some((c) => Boolean(resolveColumnTooltip(c)))) { (gridView.header as { showTooltip?: boolean }).showTooltip = true; } @@ -449,7 +450,17 @@ const CompactDataGrid = ({ return () => { if (getGridDataRef) getGridDataRef.current = null; }; - }, [columns, columnLayout, fixedColumns, rowData, showHeaderCheckbox, getGridDataRef, compactFullWidth, compactGridHeightClass]); + }, [ + columns, + columnLayout, + fixedColumns, + rowData, + showHeaderCheckbox, + getGridDataRef, + compactFullWidth, + compactGridHeightClass, + headerHeight, + ]); // 행 클릭 이벤트 useEffect(() => { diff --git a/apps/st/src/components/module/DataGrid.tsx b/apps/st/src/components/module/DataGrid.tsx index 0ae40300a..4e8a9c86d 100644 --- a/apps/st/src/components/module/DataGrid.tsx +++ b/apps/st/src/components/module/DataGrid.tsx @@ -20,6 +20,11 @@ import type { } from "../type/DataGrid.type"; import { getComputedValueCallback } from "@/components/util/grid/computedFieldCallbacks"; +import { + prepareGridColumnsForExcelExport, + restoreGridColumnsAfterExcelExport, + shouldPrepareColumnsForExcelExport, +} from "@/components/util/gridExcelExport"; import { useTabStore } from "@/store/tab-store"; // RealGrid 타입 확장 @@ -47,6 +52,159 @@ if (typeof window !== "undefined") { RealGrid.setLicenseKey(process.env.NEXT_PUBLIC_ST_REALGRID_KEY || ""); +function isDropDownEditorColumn(column: { + editor?: { type?: string }; + values?: string[]; +}): boolean { + const type = column.editor?.type; + return ( + type === "dropdown" || + type === "list" || + type === "dropDown" || + (column.values != null && column.values.length > 0) + ); +} + +type GridViewWithSetColumnProperty = { + setColumnProperty?: (col: string, key: string, value: unknown) => void; +}; + +type GridViewWithEditOptions = { + setEditOptions?: (options: { commitWhenLeave?: boolean }) => void; +}; + +/** 포커스가 그리드 밖으로 나갈 때 편집 중인 값을 자동 commit */ +function applyDefaultGridEditOptions( + gridView: GridViewWithEditOptions | null | undefined, +) { + gridView?.setEditOptions?.({ commitWhenLeave: true }); +} + +function isDateEditorColumn(column: { + editor?: { type?: string }; +}): boolean { + return column.editor?.type === "date"; +} + +/** RealGrid 날짜(date) 편집기 컬럼 속성 적용 */ +function applyColumnDateEditorProperties( + gridView: GridViewWithSetColumnProperty, + column: import("../type/DataGrid.type").ColumnConfig, +) { + if (!gridView.setColumnProperty) return; + const fmt = + column.datetimeFormat ?? + column.editor?.datetimeFormat ?? + "yyyy-MM-dd"; + gridView.setColumnProperty(column.name, "datetimeFormat", fmt); + gridView.setColumnProperty(column.name, "editor", { + type: "date", + datetimeFormat: fmt, + dropDownWhenClick: column.editor?.dropDownWhenClick ?? true, + showToday: column.editor?.showToday ?? true, + }); + gridView.setColumnProperty(column.name, "editButtonVisibility", "always"); +} + +/** RealGrid 드롭다운·날짜 등 컬럼 편집기 속성 적용 */ +function applyColumnListEditorProperties( + gridView: GridViewWithSetColumnProperty, + column: import("../type/DataGrid.type").ColumnConfig, +) { + if (!gridView.setColumnProperty) return; + + if (isDateEditorColumn(column)) { + applyColumnDateEditorProperties(gridView, column); + return; + } + + const values = column.values ?? column.editor?.values; + const labels = column.labels ?? column.editor?.labels ?? values; + const isDropDown = isDropDownEditorColumn(column); + + if (!isDropDown) { + if (column.editor) { + gridView.setColumnProperty(column.name, "editor", column.editor); + } + return; + } + + if (values?.length) { + gridView.setColumnProperty(column.name, "values", values); + gridView.setColumnProperty(column.name, "labels", labels ?? values); + } + if (column.lookupDisplay) { + gridView.setColumnProperty(column.name, "lookupDisplay", true); + } + gridView.setColumnProperty(column.name, "editor", { + type: "dropdown", + domainOnly: column.editor?.domainOnly ?? true, + textReadOnly: column.editor?.textReadOnly ?? true, + commitOnSelect: column.editor?.commitOnSelect ?? true, + dropDownWhenClick: column.editor?.dropDownWhenClick ?? true, + values, + labels: labels ?? values, + }); + gridView.setColumnProperty(column.name, "editButtonVisibility", "always"); +} + +/** 그리드 마운트·데이터 로드 후 드롭다운 컬럼 재적용용 */ +export function applyDropDownColumnToGrid( + gridView: GridViewWithSetColumnProperty | null | undefined, + columnName: string, + options: string[], +) { + if (!gridView?.setColumnProperty || options.length === 0) return; + ( + gridView as { + setEditOptions?: (opts: { editable?: boolean }) => void; + } + ).setEditOptions?.({ editable: true }); + gridView.setColumnProperty(columnName, "editable", true); + gridView.setColumnProperty(columnName, "values", options); + gridView.setColumnProperty(columnName, "labels", options); + gridView.setColumnProperty(columnName, "lookupDisplay", true); + gridView.setColumnProperty(columnName, "editor", { + type: "dropdown", + domainOnly: true, + textReadOnly: true, + commitOnSelect: true, + dropDownWhenClick: true, + values: options, + labels: options, + }); + gridView.setColumnProperty(columnName, "editButtonVisibility", "always"); + (gridView as { refresh?: () => void }).refresh?.(); +} + +function reapplyListEditorsOnGrid( + gridView: GridViewWithSetColumnProperty | null | undefined, + cols: import("../type/DataGrid.type").ColumnConfig[], +) { + if (!gridView) return; + cols.forEach((col) => applyColumnListEditorProperties(gridView, col)); + if (cols.some((c) => isDropDownEditorColumn(c) || isDateEditorColumn(c))) { + (gridView as { refresh?: () => void }).refresh?.(); + } +} + +type GridViewWithCheckApi = { + checkItem?: (itemIndex: number, checked: boolean, exclusive?: boolean) => void; + checkRow?: (dataRow: number, checked: boolean, exclusive?: boolean) => void; + checkAll?: (checked: boolean) => void; +}; + +function isRowCheckedFlag(value: unknown): boolean { + return ( + value === true || + value === 1 || + value === "1" || + value === "Y" || + value === "y" || + value === "true" + ); +} + /** setDataComparer(field, comparer) - setFields 없이 숫자 정렬용. comparer(field, row1, row2) => -1|0|1 */ interface RealGridDataProviderComparer { setDataComparer?( @@ -303,6 +461,8 @@ function buildFieldsFromColumns( columns: { fieldName: string; valueType?: ValueType; + datetimeFormat?: string; + editor?: { type?: string; datetimeFormat?: string }; computed?: import("../type/DataGrid.type").ComputedFieldConfig; }[], ) { @@ -310,6 +470,7 @@ function buildFieldsFromColumns( const field: { fieldName: string; dataType: string; + datetimeFormat?: string; valueCallback?: ( prod: unknown, dataRow: number, @@ -319,8 +480,19 @@ function buildFieldsFromColumns( ) => number; } = { fieldName: col.fieldName, - dataType: col.valueType === ValueType.NUMBER ? "number" : "text", + dataType: + col.editor?.type === "date" + ? "datetime" + : col.valueType === ValueType.NUMBER + ? "number" + : "text", }; + if (col.editor?.type === "date") { + field.datetimeFormat = + col.datetimeFormat ?? + col.editor.datetimeFormat ?? + "yyyy-MM-dd"; + } if (col.computed) { const hasFields = "fields" in col.computed && col.computed.fields.length > 0; @@ -386,7 +558,9 @@ const DataGrid = ({ highlightedRowId, idFieldName = "id", enableSort = false, - gridHeight = 65, // vh 단위 + gridHeight = 55, // vh 단위 + headerHeight = 60, + fitStyle = GridFitStyle.EVEN, }: DataGridProps) => { const { columns, columnLayout } = columnInfo; const gridRef = useRef(null); @@ -411,6 +585,7 @@ const DataGrid = ({ const onItemCheckedRef = useRef(onItemChecked); const onItemAllCheckedRef = useRef(onItemAllChecked); + const isSyncingChecksFromRowDataRef = useRef(false); columnsRef.current = columns; rowDataRef.current = rowData; cellStyleCallbackRef.current = cellStyleCallback; @@ -547,6 +722,12 @@ const DataGrid = ({ gridView.setColumnLayout?.(normalizedLayout); } + const useExcelColumnVisibilityPrep = + shouldPrepareColumnsForExcelExport(columns); + const excelExportVisibilitySnapshot = useExcelColumnVisibilityPrep + ? prepareGridColumnsForExcelExport(gridView, columns) + : undefined; + const exportBaseName = excelExportFileSuffix ? `${menuName}_${excelExportFileSuffix}` : menuName; @@ -556,8 +737,14 @@ const DataGrid = ({ target: "local", fileName: `${exportBaseName}.xlsx`, footer: "hidden", - allColumns: true, // 숨김(visible: false) 컬럼도 엑셀에 포함 + allColumns: !useExcelColumnVisibilityPrep, done: () => { + if (excelExportVisibilitySnapshot) { + restoreGridColumnsAfterExcelExport( + gridView, + excelExportVisibilitySnapshot, + ); + } // 헤더 원복 columns.forEach((col) => { if (col.fieldName && gridView.columnByName) { @@ -807,6 +994,7 @@ const DataGrid = ({ const gridView = gridRef.current.gridView; const dataProvider = gridRef.current.dataProvider; + applyDefaultGridEditOptions(gridView as GridViewWithEditOptions); // mergeCells: true인 컬럼이 있으면 onSorting에서 클라이언트 정렬로 처리 const hasMergeCells = columns.some((col) => col.mergeCells === true); @@ -850,23 +1038,15 @@ const DataGrid = ({ ) : () => false; - (gridView.displayOptions as DisplayOptions).fitStyle = - GridFitStyle.EVEN; + (gridView.displayOptions as DisplayOptions).fitStyle = fitStyle; gridView.displayOptions.rowHeight = 30; - gridView.header.height = 60; + gridView.header.height = headerHeight; if (columns.some((c) => Boolean(resolveColumnTooltip(c)))) { (gridView.header as { showTooltip?: boolean }).showTooltip = true; } columns.forEach((column) => { - ( - gridView as { - setColumnProperty?: ( - col: string, - key: string, - value: unknown, - ) => void; - } - ).setColumnProperty?.( + const gv = gridView as GridViewWithSetColumnProperty; + gv.setColumnProperty?.( column.name, "header", realGridHeaderWithTooltip({ @@ -874,7 +1054,11 @@ const DataGrid = ({ tooltip: resolveColumnTooltip(column), }), ); + applyColumnListEditorProperties(gv, column); }); + if (columns.some((c) => isDropDownEditorColumn(c))) { + (gridView as { refresh?: () => void }).refresh?.(); + } ( gridView as { onShowHeaderTooltip?: ( @@ -1059,6 +1243,12 @@ const DataGrid = ({ (currentGridView as GridViewWithSetColumns).setColumns?.( columnsWithFooter, ); + columns.forEach((col) => + applyColumnListEditorProperties( + currentGridView as GridViewWithSetColumnProperty, + col, + ), + ); applyFooterSettings(currentGridView); if (hasFooterUserSpans) { currentGridView.setColumnLayout?.(columnLayout); @@ -1178,6 +1368,9 @@ const DataGrid = ({ rowData, showHeaderCheckbox, getGridDataRef, + headerHeight, + fitStyle, + enableSort, ]); // 행 클릭 이벤트 + 검증 오류 셀 클릭 시 showMessage (공식 문서 방식) @@ -1278,6 +1471,7 @@ const DataGrid = ({ if (onItemChecked) { gridView.onItemChecked = (_grid, itemIndex, checked) => { + if (isSyncingChecksFromRowDataRef.current) return; onItemCheckedRef.current?.(itemIndex, checked); }; } else { @@ -1286,6 +1480,7 @@ const DataGrid = ({ if (onItemAllChecked) { gridView.onItemAllChecked = (_grid, checked) => { + if (isSyncingChecksFromRowDataRef.current) return; onItemAllCheckedRef.current?.(checked); }; } else { @@ -1299,6 +1494,45 @@ const DataGrid = ({ }; }, [onItemChecked, onItemAllChecked]); + useEffect(() => { + if (!gridRef.current?.gridView || !showHeaderCheckbox) return; + + const gridView = gridRef.current.gridView as GridViewWithCheckApi; + const dataProvider = gridRef.current.dataProvider as + | { setRows?: (rows: Record[]) => void } + | undefined; + + if (dataProvider?.setRows) { + dataProvider.setRows(rowData); + } + + const hasCheckedColumn = rowData.some((row) => + Object.prototype.hasOwnProperty.call(row, "__checked"), + ); + if (!hasCheckedColumn) return; + + const timer = window.setTimeout(() => { + isSyncingChecksFromRowDataRef.current = true; + try { + gridView.checkAll?.(false); + rowData.forEach((row, itemIndex) => { + if (!isRowCheckedFlag((row as Record).__checked)) return; + if (gridView.checkItem) { + gridView.checkItem(itemIndex, true, false); + return; + } + gridView.checkRow?.(itemIndex, true, false); + }); + } catch (e) { + console.warn("check sync:", e); + } finally { + isSyncingChecksFromRowDataRef.current = false; + } + }, 0); + + return () => window.clearTimeout(timer); + }, [rowData, showHeaderCheckbox]); + useEffect(() => { const gridView = gridRef.current?.gridView; if (!gridView) return; @@ -1419,6 +1653,12 @@ const DataGrid = ({ autoGenerateField={true} layout={columnLayout} rows={rowData} + onRGDataColumnsChanged={(grid) => { + reapplyListEditorsOnGrid( + grid as GridViewWithSetColumnProperty, + columnsRef.current, + ); + }} onInitialized={(grid) => { if (!grid) return; ( @@ -1458,6 +1698,9 @@ const DataGrid = ({ dataProvider.setRows(rowData); if (getGridRef) getGridRef.current = gridRef.current; const gridView = gridRef.current?.gridView ?? grid; + applyDefaultGridEditOptions( + gridView as GridViewWithEditOptions, + ); if (!gridView?.columnByName) return; setTimeout(() => { const gv = gridRef.current?.gridView; @@ -1573,6 +1816,10 @@ const DataGrid = ({ tooltip: resolveColumnTooltip(column), }), ); + applyColumnListEditorProperties( + gv as GridViewWithSetColumnProperty, + column, + ); if (cellTooltipCallbackRef.current) { ( gv as { @@ -1627,9 +1874,15 @@ const DataGrid = ({ {columns.map((column) => { const baseStyleName = column.styleName; + const columnReactKey = + column.values?.length && + isDropDownEditorColumn(column) + ? `${column.name}-dropdown-${column.values.length}` + : column.name; + return ( , ) => { if (!loading && rows.length === 0) { - return

{emptyMessage}

; + return

{emptyMessage}

; } return ( diff --git a/apps/st/src/components/module/SearchForm.tsx b/apps/st/src/components/module/SearchForm.tsx index c759b4a92..a82822756 100644 --- a/apps/st/src/components/module/SearchForm.tsx +++ b/apps/st/src/components/module/SearchForm.tsx @@ -47,6 +47,47 @@ const getSggListFetcher = async ( }); }; +function buildSearchFormValues( + props: SearchFormProps, + sidoList: ApiResponse | undefined, + defaultYear: number, +): SearchFormValues { + const d = props.defaultValues; + const initialClsfCd: SearchFormClsfCd = + d?.clsfCd === "ALL" || d?.clsfCd === "INPT" || d?.clsfCd === "VRFC" + ? d.clsfCd + : "ALL"; + + if (d?.sido) { + return { + sido: d.sido, + gugun: d.gugun || "", + year: d.year || defaultYear.toString(), + order: d.order || "", + fctName: d.fctName || "", + bsnmCd: d.bsnmCd || "", + isDetail: false, + isSummary: d.isSummary || "", + clsfCd: initialClsfCd, + }; + } + + const first = sidoList?.data?.sidoList?.[0]; + const useFirstSido = (props.sido ?? defaultSearchFormProps.sido) && first; + + return { + sido: useFirstSido ? first.sido : "", + gugun: "", + year: d?.year || defaultYear.toString(), + order: d?.order || "", + fctName: d?.fctName || "", + bsnmCd: useFirstSido ? first.bsnmCd : "", + isDetail: false, + isSummary: d?.isSummary || "", + clsfCd: initialClsfCd, + }; +} + const SearchForm = (props: SearchFormProps = {}) => { const { setMessage: alert } = useAlertStore((state) => state); const userInfo = useUserStore((state) => state.userInfo); @@ -82,23 +123,27 @@ const SearchForm = (props: SearchFormProps = {}) => { }, ); - const initialClsfCd: SearchFormClsfCd = (() => { - const d = props.defaultValues?.clsfCd; - if (d === "ALL" || d === "INPT" || d === "VRFC") return d; - return "ALL"; - })(); + // 마운트 시 SWR 캐시의 시도 목록으로 초기값 설정 (탭 닫았다 열 때 API 재호출 없이 동작) + const [values, setValues] = useState(() => + buildSearchFormValues(props, sidoList, defaultYear), + ); - const [values, setValues] = useState({ - sido: props.defaultValues?.sido || "", - gugun: props.defaultValues?.gugun || "", - year: props.defaultValues?.year || defaultYear.toString(), - order: props.defaultValues?.order || "", - fctName: props.defaultValues?.fctName || "", - bsnmCd: props.defaultValues?.bsnmCd || "", - isDetail: false, - isSummary: props.defaultValues?.isSummary || "", - clsfCd: initialClsfCd, - }); + // 최초 진입만 시도 API가 늦게 오는 경우 + useEffect(() => { + if (props.defaultValues?.sido) return; + const first = sidoList?.data?.sidoList?.[0]; + if (!first || !mergedProps.sido) return; + setValues((prev) => + prev.sido + ? prev + : { + ...prev, + sido: first.sido, + gugun: "", + bsnmCd: first.bsnmCd, + }, + ); + }, [sidoList, props.defaultValues?.sido, mergedProps.sido]); const sggKey: [string, string, string, string] | null = values.sido && values.sido !== "ALL" @@ -133,18 +178,7 @@ const SearchForm = (props: SearchFormProps = {}) => { }; const handleReset = () => { - const resetValues: SearchFormValues = { - sido: "", - gugun: "", - year: String(defaultYear), - order: "", - fctName: "", - bsnmCd: "", - isDetail: false, - isSummary: "", - clsfCd: "ALL", - }; - setValues(resetValues); + setValues(buildSearchFormValues(props, sidoList, defaultYear)); props.onReset?.(); }; diff --git a/apps/st/src/components/module/SidoSggFacilityNameSearchForm.tsx b/apps/st/src/components/module/SidoSggFacilityNameSearchForm.tsx index 82a1bae17..f32bd5300 100644 --- a/apps/st/src/components/module/SidoSggFacilityNameSearchForm.tsx +++ b/apps/st/src/components/module/SidoSggFacilityNameSearchForm.tsx @@ -334,7 +334,23 @@ const SidoSggFacilityNameSearchForm = ({ } }; - const handleSaveAdminRegions = () => { + const handleSaveAdminRegions = async () => { + const statusCodeResponse = await statisticsApi.CheckStatusCode({ + sewerBzmnCd: initialSearchValues?.bsnmCd?.trim() ?? "", + statsCd: 'ST00020002', + statsYr: statsYr?.trim() ?? '' + }); + + if (statusCodeResponse.status === 'success') { + if (!['0', '1', '4'].includes(statusCodeResponse.data)) { + const codeName = statusCodeResponse.data === '2' ? '제출' : (statusCodeResponse.data === '3' ? '승인' : '검증중'); + alert(`시설별 설치 현황 데이터가 ${codeName} 상태일 때는 처리 시설 정보를 변경할 수 없습니다.`); + return; + } + } else { + alert('오류가 발생했습니다.\n관리자에게 문의하세요') + } + if (!statsYr?.trim()) { alert("상단에서 통계연도로 조회한 뒤 이용해 주세요."); return; diff --git a/apps/st/src/components/module/TabDataGrid.tsx b/apps/st/src/components/module/TabDataGrid.tsx index a2327c11b..e4c23eb83 100644 --- a/apps/st/src/components/module/TabDataGrid.tsx +++ b/apps/st/src/components/module/TabDataGrid.tsx @@ -16,6 +16,11 @@ import GridTopActionButton from "./GridTopActionButton"; import SplitDataGrid from "./SplitDataGrid"; import { getComputedValueCallback } from "@/components/util/grid/computedFieldCallbacks" +import { + prepareGridColumnsForExcelExport, + restoreGridColumnsAfterExcelExport, + shouldPrepareColumnsForExcelExport, +} from "@/components/util/gridExcelExport" import { useTabStore } from "@/store/tab-store" // RealGrid 타입 확장 @@ -432,13 +437,21 @@ const TabDataGrid = ({ gridView.setColumnLayout?.(normalizedLayout); } + const useExcelColumnVisibilityPrep = shouldPrepareColumnsForExcelExport(columns); + const excelExportVisibilitySnapshot = useExcelColumnVisibilityPrep + ? prepareGridColumnsForExcelExport(gridView, columns) + : undefined; + gridView.exportGrid({ type: 'excel', target: 'local', fileName: `${menuName}_${tabLabel}.xlsx`, footer: 'hidden', - allColumns: true, // 숨김(visible: false) 컬럼도 엑셀에 포함 + allColumns: !useExcelColumnVisibilityPrep, done: () => { + if (excelExportVisibilitySnapshot) { + restoreGridColumnsAfterExcelExport(gridView, excelExportVisibilitySnapshot); + } // 헤더 원복 columns.forEach((col) => { if (col.fieldName && gridView.columnByName) { diff --git a/apps/st/src/components/module/VerificationResultDialog.tsx b/apps/st/src/components/module/VerificationResultDialog.tsx index acd460f87..1141348aa 100644 --- a/apps/st/src/components/module/VerificationResultDialog.tsx +++ b/apps/st/src/components/module/VerificationResultDialog.tsx @@ -23,7 +23,11 @@ import { } from "@common/components"; import DataGrid from "@/components/module/DataGrid"; import { VerificationResultHistoryPagination } from "@/components/module/VerificationResultHistoryPagination"; -import type { CellEditedCallbackParams, ColumnInfo } from "@/components/type/DataGrid.type"; +import type { + CellEditedCallbackParams, + CellStyleCallbackParams, + ColumnInfo, +} from "@/components/type/DataGrid.type"; import { getUserInfo } from "../../../../../packages/common/components/src/api"; import { statisticsApi } from "@/entities/statistics/api/statistics-service"; import type { VerificationResultListRequest, VerificationResultReportItem } from "@/entities/statistics/model/statistics-interface"; @@ -31,6 +35,16 @@ import { useAlertStore } from "@/shared/lib"; type VerificationResultGridRow = Record; +type VerificationMethodCd = "NV" | "IMR" | "HB"; + +const VERIFICATION_METHOD_CODES: VerificationMethodCd[] = ["NV", "IMR", "HB"]; + +const VRFC_MTHD_NAME_MAP: Record = { + NV: "일반", + IMR: "I-MR", + HB: "HB", +}; + export type VerificationExceptionSubmitItem = { vrfcSn: number; prcsOpnnExpln: string; @@ -60,9 +74,23 @@ const PRCS_YN_LABEL: Record = { "3": "반려", }; -/** History와 동일 + 편집 가능한 처리의견(예외사유, API 필드명 `prcsOpnnExpln`과 동일) */ +function verificationMthdCellStyle({ + fieldName, + row, +}: CellStyleCallbackParams): string | undefined { + if (fieldName !== "vrfcMthdNm") return undefined; + const code = String(row.vrfcMthdCd ?? ""); + if (code === "IMR") return "rg-cell-vrf-mthd-imr"; + if (code === "HB") return "rg-cell-vrf-mthd-hb"; + if (code === "NV") return "rg-cell-vrf-mthd-nv"; + return undefined; +} + +/** History와 동일 + 구분(검증유형) + 편집 가능한 처리의견(예외사유) */ const verificationSubmitResultColumnInfo: ColumnInfo = { columns: [ + { name: "vrfcMthdNm", fieldName: "vrfcMthdNm", header: { text: "구분" }, editable: false }, + { name: "vrfcMthdCd", fieldName: "vrfcMthdCd", header: { text: "검증유형코드" }, editable: false, visible: false }, { name: "ctpvNm", fieldName: "ctpvNm", header: { text: "시도" }, editable: false }, { name: "sggNm", fieldName: "sggNm", header: { text: "시군구" }, editable: false }, { name: "vrfcTrgtNm", fieldName: "vrfcTrgtNm", header: { text: "검증대상" }, editable: false }, @@ -81,6 +109,7 @@ const verificationSubmitResultColumnInfo: ColumnInfo = { }, ], columnLayout: [ + "vrfcMthdNm", "ctpvNm", "sggNm", "vrfcTrgtNm", @@ -121,7 +150,6 @@ export function VerificationResultDialog({ emptyMessage = "표시할 검증 결과가 없습니다.", onExceptionSubmitted, }: VerificationResultDialogProps) { - /** 부모가 매 렌더마다 새 객체를 넘기면 queryContext 참조만 바뀌어 loadInitial이 재실행되므로 필드 값 기준으로 안정화 */ const stableQueryContext = useMemo((): VerificationResultDialogQueryContext | null => { if (!queryContext) return null; return queryContext; @@ -133,47 +161,24 @@ export function VerificationResultDialog({ queryContext?.statsCd, ]); - const [normalPageIndex, setNormalPageIndex] = useState(0); - const [imrPageIndex, setImrPageIndex] = useState(0); - const [normalPageCount, setNormalPageCount] = useState(1); - const [imrPageCount, setImrPageCount] = useState(1); - const [normalLoading, setNormalLoading] = useState(false); - const [imrLoading, setImrLoading] = useState(false); + const [pageIndex, setPageIndex] = useState(0); + const [pageCount, setPageCount] = useState(1); + const [loading, setLoading] = useState(false); const [errorMessage, setErrorMessage] = useState(null); - const [normalRows, setNormalRows] = useState([]); - const [imrRows, setImrRows] = useState([]); - const [normalCheckedIndices, setNormalCheckedIndices] = useState>( - () => new Set(), - ); - const [imrCheckedIndices, setImrCheckedIndices] = useState>(() => new Set()); + const [rows, setRows] = useState([]); + const [checkedIndices, setCheckedIndices] = useState>(() => new Set()); - const normalGridRef = useRef(null); - const imrGridRef = useRef(null); - /** 메인 입력 화면처럼 편집 값은 그리드에 두고, 제출 시에만 스냅샷 조회 → Enter 시 rows 재주입 충돌 방지 */ - const getNormalGridDataRef = useRef<(() => VerificationResultGridRow[]) | null>(null); - const getImrGridDataRef = useRef<(() => VerificationResultGridRow[]) | null>(null); + const gridRef = useRef(null); + const getGridDataRef = useRef<(() => VerificationResultGridRow[]) | null>(null); const alert = useAlertStore((state) => state.setMessage); - const onNormalPrcsOpnnExplnEdited = useCallback((params: CellEditedCallbackParams) => { + const onPrcsOpnnExplnEdited = useCallback((params: CellEditedCallbackParams) => { if (params.fieldName !== "prcsOpnnExpln") return; const text = String(params.newValue ?? ""); const hasValue = text.trim().length > 0; - setRowCheckByItemIndex(normalGridRef, params.itemIndex, hasValue); - setNormalCheckedIndices((prev) => { - const next = new Set(prev); - if (hasValue) next.add(params.itemIndex); - else next.delete(params.itemIndex); - return next; - }); - }, []); - - const onImrPrcsOpnnExplnEdited = useCallback((params: CellEditedCallbackParams) => { - if (params.fieldName !== "prcsOpnnExpln") return; - const text = String(params.newValue ?? ""); - const hasValue = text.trim().length > 0; - setRowCheckByItemIndex(imrGridRef, params.itemIndex, hasValue); - setImrCheckedIndices((prev) => { + setRowCheckByItemIndex(gridRef, params.itemIndex, hasValue); + setCheckedIndices((prev) => { const next = new Set(prev); if (hasValue) next.add(params.itemIndex); else next.delete(params.itemIndex); @@ -182,38 +187,38 @@ export function VerificationResultDialog({ }, []); const resetState = useCallback(() => { - setNormalPageIndex(0); - setImrPageIndex(0); - setNormalPageCount(1); - setImrPageCount(1); - setNormalLoading(false); - setImrLoading(false); + setPageIndex(0); + setPageCount(1); + setLoading(false); setErrorMessage(null); - setNormalRows([]); - setImrRows([]); - setNormalCheckedIndices(new Set()); - setImrCheckedIndices(new Set()); + setRows([]); + setCheckedIndices(new Set()); }, []); - const mapItemToRow = useCallback((item: VerificationResultReportItem): VerificationResultGridRow => { - return { - vrfcSn: item.vrfcSn, - ctpvNm: item.ctpvNm, - sggNm: item.sggNm, - vrfcTrgtNm: item.vrfcTrgtNm, - rsnExpln: item.rsnExpln, - nowVlCn: item.nowVlCn ?? "-", - compVlCn: item.compVlCn ?? "-", - prcsYn: PRCS_YN_LABEL[item.prcsYn] ?? item.prcsYn, - pbcpAprvYn: item.pbcpAprvYn, - regDt: (item.regDt ?? "").slice(0, 16), - mdfcnDt: (item.mdfcnDt ?? "").slice(0, 16), - prcsOpnnExpln: item.prcsOpnnExpln ?? "", - }; - }, []); + const mapItemToRow = useCallback( + (item: VerificationResultReportItem, vrfcMthdCd: VerificationMethodCd): VerificationResultGridRow => { + return { + vrfcSn: item.vrfcSn, + vrfcMthdCd, + vrfcMthdNm: VRFC_MTHD_NAME_MAP[vrfcMthdCd] ?? item.vrfcMthdNm, + ctpvNm: item.ctpvNm, + sggNm: item.sggNm, + vrfcTrgtNm: item.vrfcTrgtNm, + rsnExpln: item.rsnExpln, + nowVlCn: item.nowVlCn ?? "-", + compVlCn: item.compVlCn ?? "-", + prcsYn: PRCS_YN_LABEL[item.prcsYn] ?? item.prcsYn, + pbcpAprvYn: item.pbcpAprvYn, + regDt: (item.regDt ?? "").slice(0, 16), + mdfcnDt: (item.mdfcnDt ?? "").slice(0, 16), + prcsOpnnExpln: item.prcsOpnnExpln ?? "", + }; + }, + [], + ); const fetchVerificationResultPage = useCallback( - async (vrfcMthdCd: "NV" | "IMR", pageIndex: number) => { + async (vrfcMthdCd: VerificationMethodCd, page: number) => { if (!stableQueryContext) return null; const param: VerificationResultListRequest = { sewerBzmnCd: stableQueryContext.sewerBzmnCd, @@ -224,7 +229,7 @@ export function VerificationResultDialog({ pbcpAprvYn: "N", vrfcMthdCd, prcsYnList: "0", - page: pageIndex + 1, + page: page + 1, size: 10000, }; return statisticsApi.getVerificationResultList(param); @@ -232,81 +237,56 @@ export function VerificationResultDialog({ [stableQueryContext], ); - const loadInitial = useCallback(async () => { - if (!stableQueryContext) { - setErrorMessage("조회 조건이 없어 검증 결과를 불러올 수 없습니다."); - return; - } - setErrorMessage(null); - setNormalLoading(true); - setImrLoading(true); - setNormalPageIndex(0); - setImrPageIndex(0); - try { - const [nvRes, imrRes] = await Promise.all([ - fetchVerificationResultPage("NV", 0), - fetchVerificationResultPage("IMR", 0), - ]); - const nvList = nvRes?.data?.verifyResultList ?? []; - const imrList = imrRes?.data?.verifyResultList ?? []; - setNormalRows(nvList.map(mapItemToRow)); - setImrRows(imrList.map(mapItemToRow)); - setNormalPageCount(nvRes?.data?.pageInfo?.totalPages ?? 1); - setImrPageCount(imrRes?.data?.pageInfo?.totalPages ?? 1); - } catch { - setNormalRows([]); - setImrRows([]); - setNormalPageCount(1); - setImrPageCount(1); - setErrorMessage("검증 결과 조회 중 오류가 발생했습니다."); - } finally { - setNormalLoading(false); - setImrLoading(false); - setNormalCheckedIndices(new Set()); - setImrCheckedIndices(new Set()); - } - }, [fetchVerificationResultPage, mapItemToRow, stableQueryContext]); - - const handleNormalPageChange = useCallback( - async (pageIndex: number) => { - if (pageIndex === normalPageIndex || !stableQueryContext) return; + const loadPage = useCallback( + async (nextPageIndex: number) => { + if (!stableQueryContext) { + setErrorMessage("조회 조건이 없어 검증 결과를 불러올 수 없습니다."); + return; + } setErrorMessage(null); - setNormalLoading(true); + setLoading(true); try { - const response = await fetchVerificationResultPage("NV", pageIndex); - const list = response?.data?.verifyResultList ?? []; - setNormalRows(list.map(mapItemToRow)); - setNormalPageCount(response?.data?.pageInfo?.totalPages ?? 1); - setNormalPageIndex(pageIndex); + const responses = await Promise.all( + VERIFICATION_METHOD_CODES.map((code) => + fetchVerificationResultPage(code, nextPageIndex), + ), + ); + const merged: VerificationResultGridRow[] = []; + let maxPageCount = 1; + VERIFICATION_METHOD_CODES.forEach((code, index) => { + const response = responses[index]; + const list = response?.data?.verifyResultList ?? []; + merged.push(...list.map((item) => mapItemToRow(item, code))); + maxPageCount = Math.max( + maxPageCount, + response?.data?.pageInfo?.totalPages ?? 1, + ); + }); + setRows(merged); + setPageCount(maxPageCount); + setPageIndex(nextPageIndex); } catch { - setErrorMessage("일반검증 내역 조회 중 오류가 발생했습니다."); + setRows([]); + setPageCount(1); + setErrorMessage("검증 결과 조회 중 오류가 발생했습니다."); } finally { - setNormalLoading(false); - setNormalCheckedIndices(new Set()); + setLoading(false); + setCheckedIndices(new Set()); } }, - [fetchVerificationResultPage, mapItemToRow, normalPageIndex, stableQueryContext], + [fetchVerificationResultPage, mapItemToRow, stableQueryContext], ); - const handleImrPageChange = useCallback( - async (pageIndex: number) => { - if (pageIndex === imrPageIndex || !stableQueryContext) return; - setErrorMessage(null); - setImrLoading(true); - try { - const response = await fetchVerificationResultPage("IMR", pageIndex); - const list = response?.data?.verifyResultList ?? []; - setImrRows(list.map(mapItemToRow)); - setImrPageCount(response?.data?.pageInfo?.totalPages ?? 1); - setImrPageIndex(pageIndex); - } catch { - setErrorMessage("I-MR 검증내역 조회 중 오류가 발생했습니다."); - } finally { - setImrLoading(false); - setImrCheckedIndices(new Set()); - } + const loadInitial = useCallback(async () => { + await loadPage(0); + }, [loadPage]); + + const handlePageChange = useCallback( + async (nextPageIndex: number) => { + if (nextPageIndex === pageIndex || !stableQueryContext) return; + await loadPage(nextPageIndex); }, - [fetchVerificationResultPage, mapItemToRow, imrPageIndex, stableQueryContext], + [loadPage, pageIndex, stableQueryContext], ); useEffect(() => { @@ -317,58 +297,32 @@ export function VerificationResultDialog({ void loadInitial(); }, [loadInitial, open, resetState]); - /** 조회된 행에 예외사유 텍스트가 있으면 체크바·체크 인덱스와 맞춤 */ useEffect(() => { - if (normalLoading) return; + if (loading) return; const id = requestAnimationFrame(() => { - setNormalCheckedIndices((prev) => { + setCheckedIndices((prev) => { const next = new Set(prev); - normalRows.forEach((row, itemIndex) => { + rows.forEach((row, itemIndex) => { const has = String(row.prcsOpnnExpln ?? "").trim().length > 0; - setRowCheckByItemIndex(normalGridRef, itemIndex, has); + setRowCheckByItemIndex(gridRef, itemIndex, has); if (has) next.add(itemIndex); }); return next; }); }); return () => cancelAnimationFrame(id); - }, [normalRows, normalLoading]); - - useEffect(() => { - if (imrLoading) return; - const id = requestAnimationFrame(() => { - setImrCheckedIndices((prev) => { - const next = new Set(prev); - imrRows.forEach((row, itemIndex) => { - const has = String(row.prcsOpnnExpln ?? "").trim().length > 0; - setRowCheckByItemIndex(imrGridRef, itemIndex, has); - if (has) next.add(itemIndex); - }); - return next; - }); - }); - return () => cancelAnimationFrame(id); - }, [imrRows, imrLoading]); + }, [rows, loading]); const handleExceptionSubmitClick = useCallback(async () => { - const nvGrid = getNormalGridDataRef.current?.() ?? []; - const imGrid = getImrGridDataRef.current?.() ?? []; + const gridRows = getGridDataRef.current?.() ?? []; const payload: VerificationExceptionSubmitItem[] = []; - const pushChecked = ( - indices: Set, - gridRows: VerificationResultGridRow[], - idRows: VerificationResultGridRow[], - ) => { - indices.forEach((idx) => { - const prcsOpnnExpln = String(gridRows[idx]?.prcsOpnnExpln ?? "").trim(); - if (!prcsOpnnExpln) return; - const vrfcSn = Number(idRows[idx]?.vrfcSn); - if (!Number.isFinite(vrfcSn)) return; - payload.push({ vrfcSn, prcsOpnnExpln }); - }); - }; - pushChecked(normalCheckedIndices, nvGrid, normalRows); - pushChecked(imrCheckedIndices, imGrid, imrRows); + checkedIndices.forEach((idx) => { + const prcsOpnnExpln = String(gridRows[idx]?.prcsOpnnExpln ?? "").trim(); + if (!prcsOpnnExpln) return; + const vrfcSn = Number(rows[idx]?.vrfcSn); + if (!Number.isFinite(vrfcSn)) return; + payload.push({ vrfcSn, prcsOpnnExpln }); + }); if (payload.length === 0) { alert("작성된 예외 제출 항목이 없습니다."); return; @@ -402,75 +356,13 @@ export function VerificationResultDialog({ } }, [ alert, - imrCheckedIndices, - imrRows, - normalCheckedIndices, - normalRows, + checkedIndices, + rows, onExceptionSubmitted, onOpenChange, stableQueryContext, ]); - const renderGrid = ( - rows: VerificationResultGridRow[], - setRows: Dispatch>, - pageIndex: number, - pageCount: number, - onPageIndexChange: (page: number) => void, - loading: boolean, - gridRef: MutableRefObject, - getGridDataRef: MutableRefObject<(() => VerificationResultGridRow[]) | null>, - onPrcsOpnnExplnCellEdited: (params: CellEditedCallbackParams) => void, - setCheckedIndices: Dispatch>>, - ) => { - if (!loading && rows.length === 0) { - return

{emptyMessage}

; - } - - return ( -
- []>>} - isExcelImport={false} - isExcelExport={false} - showHeaderCheckbox - enableSort={false} - getGridRef={gridRef} - getGridDataRef={getGridDataRef} - onCellEdited={onPrcsOpnnExplnCellEdited} - onItemChecked={(itemIndex, checked) => { - setCheckedIndices((prev) => { - const next = new Set(prev); - if (checked) next.add(itemIndex); - else next.delete(itemIndex); - return next; - }); - }} - onItemAllChecked={(checked) => { - setCheckedIndices( - checked ? new Set(rows.map((_, i) => i)) : new Set(), - ); - }} - /> - - {loading && ( -
-
- -
-
- )} -
- ); - }; - const titleSuffix = useMemo(() => { if (!stableQueryContext) return ""; const sgg = stableQueryContext.sggNm?.trim() ? ` ${stableQueryContext.sggNm}` : ""; @@ -488,38 +380,61 @@ export function VerificationResultDialog({
{errorMessage && ( -

+

{errorMessage}

)}
-

일반검증 내역

- {renderGrid( - normalRows, - setNormalRows, - normalPageIndex, - normalPageCount, - handleNormalPageChange, - normalLoading, - normalGridRef, - getNormalGridDataRef, - onNormalPrcsOpnnExplnEdited, - setNormalCheckedIndices, - )} -
-
-

I-MR 검증내역

- {renderGrid( - imrRows, - setImrRows, - imrPageIndex, - imrPageCount, - handleImrPageChange, - imrLoading, - imrGridRef, - getImrGridDataRef, - onImrPrcsOpnnExplnEdited, - setImrCheckedIndices, + {!loading && rows.length === 0 ? ( +

+ {emptyMessage} +

+ ) : ( +
+ []> + > + } + isExcelImport={false} + isExcelExport={false} + showHeaderCheckbox + enableSort={false} + getGridRef={gridRef} + getGridDataRef={getGridDataRef} + onCellEdited={onPrcsOpnnExplnEdited} + cellStyleCallback={verificationMthdCellStyle} + onItemChecked={(itemIndex, checked) => { + setCheckedIndices((prev) => { + const next = new Set(prev); + if (checked) next.add(itemIndex); + else next.delete(itemIndex); + return next; + }); + }} + onItemAllChecked={(checked) => { + setCheckedIndices( + checked ? new Set(rows.map((_, i) => i)) : new Set(), + ); + }} + /> + + {loading && ( +
+
+ +
+
+ )} +
)}
diff --git a/apps/st/src/components/module/VerificationResultHistoryDialog.tsx b/apps/st/src/components/module/VerificationResultHistoryDialog.tsx index 342a8c414..6fe0606d7 100644 --- a/apps/st/src/components/module/VerificationResultHistoryDialog.tsx +++ b/apps/st/src/components/module/VerificationResultHistoryDialog.tsx @@ -1,7 +1,18 @@ "use client"; -import { type Dispatch, type SetStateAction, useCallback, useEffect, useMemo, useState } from "react"; +import { + type Dispatch, + type MutableRefObject, + type SetStateAction, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import type { RealGridReact } from "realgrid-react"; +import { getUserInfo } from "../../../../../packages/common/components/src/api"; import { statisticsApi } from "@/entities/statistics/api/statistics-service"; import type { VerificationResultListRequest, VerificationResultReportItem } from "@/entities/statistics/model/statistics-interface"; import { @@ -22,6 +33,7 @@ import { import DataGrid from "@/components/module/DataGrid"; import { VerificationResultHistoryPagination } from "@/components/module/VerificationResultHistoryPagination"; import type { CellStyleCallbackParams, ColumnInfo } from "@/components/type/DataGrid.type"; +import { useAlertStore } from "@/shared/lib"; type VerificationResultGridRow = Record; type VerificationHistoryTabKey = "NV" | "IMR" | "HB"; @@ -32,6 +44,24 @@ const verificationHistoryTabs: { value: VerificationHistoryTabKey; label: string { value: "HB", label: "HB 검증내역" }, ]; +const PAGE_SIZE = 30; + +/** vrfcSn → prcsYn (페이지 이동 후에도 취소 가능 여부 검증용) */ +const createEmptyCheckedVrfcByTab = (): Record> => ({ + NV: new Map(), + IMR: new Map(), + HB: new Map(), +}); + +const initialTabPageState = (): Record< + VerificationHistoryTabKey, + { pageIndex: number; pageCount: number } +> => ({ + NV: { pageIndex: 0, pageCount: 1 }, + IMR: { pageIndex: 0, pageCount: 1 }, + HB: { pageIndex: 0, pageCount: 1 }, +}); + function prcsYnCodeToNumber(raw: string | number | undefined): number | undefined { if (raw === undefined || raw === null) return undefined; if (typeof raw === "number" && Number.isFinite(raw)) return raw; @@ -41,7 +71,62 @@ function prcsYnCodeToNumber(raw: string | number | undefined): number | undefine return Number.isFinite(n) ? n : undefined; } -/** prcsYn 0·4 → 빨간색, 그 외 → 초록색 (처리여부 컬럼) */ +/** 예외 제출 취소 가능: prcsYn = 2 (예외처리) */ +function canExceptionReset(prcsYn: string | number | undefined): boolean { + return prcsYnCodeToNumber(prcsYn) === 2; +} + +const EXCEPTION_RESET_ONLY_PRCS_YN_2_MESSAGE = + "예외 제출 취소는 처리여부가 사용자예외처리인 항목만 선택할 수 있습니다."; + +type GridViewWithCheckApi = { + getCheckedRows?: (allItems?: boolean, visibleOnly?: boolean, exclusive?: boolean) => number[]; + checkItem?: (itemIndex: number, checked: boolean, exclusive?: boolean) => void; +}; + +function uncheckGridRow(gridRef: MutableRefObject, itemIndex: number) { + try { + (gridRef.current?.gridView as GridViewWithCheckApi | undefined)?.checkItem?.(itemIndex, false, false); + } catch { + /* ignore */ + } +} + +function syncCheckedMapFromGrid( + entries: VerificationResultReportItem[], + gridRef: MutableRefObject, + prevMap: Map, +): Map { + const next = new Map(prevMap); + for (const entry of entries) { + const sn = Number(entry.vrfcSn); + if (Number.isFinite(sn)) next.delete(sn); + } + const indices = + (gridRef.current?.gridView as GridViewWithCheckApi | undefined)?.getCheckedRows?.(true, false, true) ?? + []; + for (const itemIndex of indices) { + const entry = entries[itemIndex]; + if (!entry) continue; + const sn = Number(entry.vrfcSn); + if (!Number.isFinite(sn)) continue; + next.set(sn, String(entry.prcsYn ?? "")); + } + return next; +} + +function selectionHasNonExceptionResetAllowed( + map: Map, + currentEntries: VerificationResultReportItem[], +): boolean { + for (const [vrfcSn, storedPrcsYn] of map) { + const onPage = currentEntries.find((e) => Number(e.vrfcSn) === vrfcSn); + const prcsYn = onPage ? onPage.prcsYn : storedPrcsYn; + if (!canExceptionReset(prcsYn)) return true; + } + return false; +} + function verificationPrcsYnNmCellStyle({ fieldName, row }: CellStyleCallbackParams): string | undefined { if (fieldName !== "prcsYnNm") return undefined; const code = prcsYnCodeToNumber(row.prcsYn); @@ -58,12 +143,24 @@ const verificationResultColumnInfo: ColumnInfo = { { name: "nowVlCn", fieldName: "nowVlCn", header: { text: "입력값" }, editable: false }, { name: "compVlCn", fieldName: "compVlCn", header: { text: "기준값" }, editable: false }, { name: "prcsYnNm", fieldName: "prcsYnNm", header: { text: "처리여부" }, editable: false }, - { name: "pbcpAprvYn", fieldName: "pbcpAprvYn", header: { text: "공단승인여부" }, editable: false }, { name: "regDt", fieldName: "regDt", header: { text: "등록일시" }, editable: false }, { name: "mdfcnDt", fieldName: "mdfcnDt", header: { text: "갱신일시" }, editable: false }, { name: "prcsYn", fieldName: "prcsYn", header: { text: "검증결과상태코드" }, editable: false, visible: false }, + { name: "vrfcSn", fieldName: "vrfcSn", header: { text: "검증일련번호" }, editable: false, visible: false }, + ], + columnLayout: [ + "ctpvNm", + "sggNm", + "vrfcTrgtNm", + "rsnExpln", + "nowVlCn", + "compVlCn", + "prcsYnNm", + "regDt", + "mdfcnDt", + "prcsYn", + "vrfcSn", ], - columnLayout: ["ctpvNm", "sggNm", "vrfcTrgtNm", "rsnExpln", "nowVlCn", "compVlCn", "prcsYnNm", "pbcpAprvYn", "regDt", "mdfcnDt", "prcsYn"], }; export interface VerificationResultHistoryDialogProps { @@ -87,152 +184,168 @@ export function VerificationResultHistoryDialog({ title = "검증 결과 내역", emptyMessage = "표시할 검증 결과가 없습니다.", }: VerificationResultHistoryDialogProps) { + const stableQueryContext = useMemo(() => { + if (!queryContext) return null; + return queryContext; + }, [ + queryContext?.sewerBzmnCd, + queryContext?.ctpvNm, + queryContext?.sggNm, + queryContext?.statsYr, + queryContext?.statsCd, + ]); + const [activeTab, setActiveTab] = useState("NV"); - const [normalEntries, setNormalEntries] = useState([]); - const [imrEntries, setImrEntries] = useState([]); - const [hbEntries, setHbEntries] = useState([]); - const [normalPageIndex, setNormalPageIndex] = useState(0); - const [imrPageIndex, setImrPageIndex] = useState(0); - const [hbPageIndex, setHbPageIndex] = useState(0); - const [normalPageCount, setNormalPageCount] = useState(1); - const [imrPageCount, setImrPageCount] = useState(1); - const [hbPageCount, setHbPageCount] = useState(1); - const [normalLoading, setNormalLoading] = useState(false); - const [imrLoading, setImrLoading] = useState(false); - const [hbLoading, setHbLoading] = useState(false); + const [entriesByTab, setEntriesByTab] = useState< + Record + >({ + NV: [], + IMR: [], + HB: [], + }); + const [tabPages, setTabPages] = useState(initialTabPageState); + const [loadingByTab, setLoadingByTab] = useState>({ + NV: false, + IMR: false, + HB: false, + }); const [errorMessage, setErrorMessage] = useState(null); + const [resetSubmitting, setResetSubmitting] = useState(false); + const [checkedVrfcByTab, setCheckedVrfcByTab] = useState(createEmptyCheckedVrfcByTab); + + const nvGridRef = useRef(null); + const imrGridRef = useRef(null); + const hbGridRef = useRef(null); + const alert = useAlertStore((state) => state.setMessage); + + const gridRefByTab: Record> = { + NV: nvGridRef, + IMR: imrGridRef, + HB: hbGridRef, + }; + const checkedVrfcByTabRef = useRef(checkedVrfcByTab); + checkedVrfcByTabRef.current = checkedVrfcByTab; const resetState = useCallback(() => { setActiveTab("NV"); - setNormalEntries([]); - setImrEntries([]); - setHbEntries([]); - setNormalPageIndex(0); - setImrPageIndex(0); - setHbPageIndex(0); - setNormalPageCount(1); - setImrPageCount(1); - setHbPageCount(1); - setNormalLoading(false); - setImrLoading(false); - setHbLoading(false); + setEntriesByTab({ NV: [], IMR: [], HB: [] }); + setTabPages(initialTabPageState()); + setLoadingByTab({ NV: false, IMR: false, HB: false }); setErrorMessage(null); + setResetSubmitting(false); + setCheckedVrfcByTab(createEmptyCheckedVrfcByTab()); }, []); const fetchVerificationResultPage = useCallback( async (vrfcMthdCd: VerificationHistoryTabKey, pageIndex: number) => { - if (!queryContext) return null; + if (!stableQueryContext) return null; const param: VerificationResultListRequest = { - sewerBzmnCd: queryContext.sewerBzmnCd, - ctpvNm: queryContext.ctpvNm, - sggNm: queryContext.sggNm, - statsYr: queryContext.statsYr, - statsCd: queryContext.statsCd, - pbcpAprvYn: 'N', + sewerBzmnCd: stableQueryContext.sewerBzmnCd, + ctpvNm: stableQueryContext.ctpvNm, + sggNm: stableQueryContext.sggNm, + statsYr: stableQueryContext.statsYr, + statsCd: stableQueryContext.statsCd, + pbcpAprvYn: "N", vrfcMthdCd, prcsYnList: "0,2,3", page: pageIndex + 1, - size: 30, + size: PAGE_SIZE, }; return statisticsApi.getVerificationResultList(param); }, - [queryContext], + [stableQueryContext], + ); + + const applyPageResponse = useCallback( + (tab: VerificationHistoryTabKey, pageIndex: number, list: VerificationResultReportItem[], totalPages: number) => { + const pageCount = Math.max(totalPages, 1); + const clampedIndex = Math.min(Math.max(pageIndex, 0), pageCount - 1); + setEntriesByTab((prev) => ({ ...prev, [tab]: list })); + setTabPages((prev) => ({ + ...prev, + [tab]: { pageIndex: clampedIndex, pageCount }, + })); + }, + [], + ); + + const loadTabPage = useCallback( + async (tab: VerificationHistoryTabKey, pageIndex: number) => { + if (!stableQueryContext) { + setErrorMessage("조회 조건이 없어 검증 결과를 불러올 수 없습니다."); + return; + } + setErrorMessage(null); + setLoadingByTab((prev) => ({ ...prev, [tab]: true })); + try { + const response = await fetchVerificationResultPage(tab, pageIndex); + const list = response?.data?.verifyResultList ?? []; + const totalPages = response?.data?.pageInfo?.totalPages ?? 1; + const apiCurrentPage = response?.data?.pageInfo?.currentPage; + const resolvedPageIndex = + apiCurrentPage != null && Number.isFinite(apiCurrentPage) + ? Math.max(0, Number(apiCurrentPage) - 1) + : pageIndex; + applyPageResponse(tab, resolvedPageIndex, list, totalPages); + } catch { + const tabLabel = + verificationHistoryTabs.find((t) => t.value === tab)?.label ?? tab; + setErrorMessage(`${tabLabel} 조회 중 오류가 발생했습니다.`); + } finally { + setLoadingByTab((prev) => ({ ...prev, [tab]: false })); + } + }, + [applyPageResponse, fetchVerificationResultPage, stableQueryContext], ); const loadInitial = useCallback(async () => { - if (!queryContext) { + if (!stableQueryContext) { setErrorMessage("조회 조건이 없어 검증 결과를 불러올 수 없습니다."); return; } setErrorMessage(null); - setNormalLoading(true); - setImrLoading(true); - setHbLoading(true); - setNormalPageIndex(0); - setImrPageIndex(0); - setHbPageIndex(0); + setTabPages(initialTabPageState()); + setLoadingByTab({ NV: true, IMR: true, HB: true }); try { - const [nvRes, imrRes, hbRes] = await Promise.all([ - fetchVerificationResultPage("NV", 0), - fetchVerificationResultPage("IMR", 0), - fetchVerificationResultPage("HB", 0), - ]); - setNormalEntries(nvRes?.data?.verifyResultList ?? []); - setImrEntries(imrRes?.data?.verifyResultList ?? []); - setHbEntries(hbRes?.data?.verifyResultList ?? []); - setNormalPageCount(nvRes?.data?.pageInfo?.totalPages ?? 1); - setImrPageCount(imrRes?.data?.pageInfo?.totalPages ?? 1); - setHbPageCount(hbRes?.data?.pageInfo?.totalPages ?? 1); + const results = await Promise.all( + verificationHistoryTabs.map((tab) => + fetchVerificationResultPage(tab.value, 0), + ), + ); + setEntriesByTab({ + NV: results[0]?.data?.verifyResultList ?? [], + IMR: results[1]?.data?.verifyResultList ?? [], + HB: results[2]?.data?.verifyResultList ?? [], + }); + setTabPages({ + NV: { + pageIndex: 0, + pageCount: Math.max(results[0]?.data?.pageInfo?.totalPages ?? 1, 1), + }, + IMR: { + pageIndex: 0, + pageCount: Math.max(results[1]?.data?.pageInfo?.totalPages ?? 1, 1), + }, + HB: { + pageIndex: 0, + pageCount: Math.max(results[2]?.data?.pageInfo?.totalPages ?? 1, 1), + }, + }); } catch { - setNormalEntries([]); - setImrEntries([]); - setHbEntries([]); - setNormalPageCount(1); - setImrPageCount(1); - setHbPageCount(1); + setEntriesByTab({ NV: [], IMR: [], HB: [] }); + setTabPages(initialTabPageState()); setErrorMessage("검증 결과 내역 조회 중 오류가 발생했습니다."); } finally { - setNormalLoading(false); - setImrLoading(false); - setHbLoading(false); + setLoadingByTab({ NV: false, IMR: false, HB: false }); } - }, [fetchVerificationResultPage, queryContext]); + }, [fetchVerificationResultPage, stableQueryContext]); - const handleNormalPageChange = useCallback( - async (pageIndex: number) => { - if (pageIndex === normalPageIndex || !queryContext) return; - setErrorMessage(null); - setNormalLoading(true); - try { - const response = await fetchVerificationResultPage("NV", pageIndex); - setNormalEntries(response?.data?.verifyResultList ?? []); - setNormalPageCount(response?.data?.pageInfo?.totalPages ?? 1); - setNormalPageIndex(pageIndex); - } catch { - setErrorMessage("일반검증 내역 조회 중 오류가 발생했습니다."); - } finally { - setNormalLoading(false); - } + const handleTabPageChange = useCallback( + (tab: VerificationHistoryTabKey, pageIndex: number) => { + if (pageIndex === tabPages[tab].pageIndex) return; + void loadTabPage(tab, pageIndex); }, - [fetchVerificationResultPage, normalPageIndex, queryContext], - ); - - const handleImrPageChange = useCallback( - async (pageIndex: number) => { - if (pageIndex === imrPageIndex || !queryContext) return; - setErrorMessage(null); - setImrLoading(true); - try { - const response = await fetchVerificationResultPage("IMR", pageIndex); - setImrEntries(response?.data?.verifyResultList ?? []); - setImrPageCount(response?.data?.pageInfo?.totalPages ?? 1); - setImrPageIndex(pageIndex); - } catch { - setErrorMessage("I-MR 검증내역 조회 중 오류가 발생했습니다."); - } finally { - setImrLoading(false); - } - }, - [fetchVerificationResultPage, imrPageIndex, queryContext], - ); - - const handleHbPageChange = useCallback( - async (pageIndex: number) => { - if (pageIndex === hbPageIndex || !queryContext) return; - setErrorMessage(null); - setHbLoading(true); - try { - const response = await fetchVerificationResultPage("HB", pageIndex); - setHbEntries(response?.data?.verifyResultList ?? []); - setHbPageCount(response?.data?.pageInfo?.totalPages ?? 1); - setHbPageIndex(pageIndex); - } catch { - setErrorMessage("HB 검증내역 조회 중 오류가 발생했습니다."); - } finally { - setHbLoading(false); - } - }, - [fetchVerificationResultPage, hbPageIndex, queryContext], + [loadTabPage, tabPages], ); useEffect(() => { @@ -243,56 +356,173 @@ export function VerificationResultHistoryDialog({ void loadInitial(); }, [loadInitial, open, resetState]); - const mapRows = (entries: VerificationResultReportItem[]): VerificationResultGridRow[] => - entries.map((item) => ({ - ctpvNm: item.ctpvNm, - sggNm: item.sggNm, - vrfcTrgtNm: item.vrfcTrgtNm, - rsnExpln: item.rsnExpln, - nowVlCn: item.nowVlCn ?? "-", - compVlCn: item.compVlCn ?? "-", - prcsYnNm: item.prcsYnNm ?? "-", - prcsYn: item.prcsYn ?? "", - pbcpAprvYn: item.pbcpAprvYn, - regDt: (item.regDt ?? "").slice(0, 16), - mdfcnDt: (item.mdfcnDt ?? "").slice(0, 16), - })); + const updateCheckedVrfcForTab = useCallback( + (tab: VerificationHistoryTabKey, updater: (map: Map) => void) => { + setCheckedVrfcByTab((prev) => { + const nextMap = new Map(prev[tab]); + updater(nextMap); + return { ...prev, [tab]: nextMap }; + }); + }, + [], + ); - const normalRows = useMemo(() => mapRows(normalEntries), [normalEntries]); - const imrRows = useMemo(() => mapRows(imrEntries), [imrEntries]); - const hbRows = useMemo(() => mapRows(hbEntries), [hbEntries]); + const mapRows = useCallback( + (entries: VerificationResultReportItem[], checkedMap: Map): VerificationResultGridRow[] => + entries.map((item) => { + const vrfcSn = Number(item.vrfcSn); + const isChecked = Number.isFinite(vrfcSn) && checkedMap.has(vrfcSn); + return { + vrfcSn: item.vrfcSn, + __checked: isChecked ? 1 : 0, + ctpvNm: item.ctpvNm, + sggNm: item.sggNm, + vrfcTrgtNm: item.vrfcTrgtNm, + rsnExpln: item.rsnExpln, + nowVlCn: item.nowVlCn ?? "-", + compVlCn: item.compVlCn ?? "-", + prcsYnNm: item.prcsYnNm ?? "-", + prcsYn: item.prcsYn ?? "", + pbcpAprvYn: item.pbcpAprvYn, + regDt: (item.regDt ?? "").slice(0, 16), + mdfcnDt: (item.mdfcnDt ?? "").slice(0, 16), + }; + }), + [], + ); + + /** 페이지(entries) 변경 시에만 __checked 반영 — 클릭마다 rowData를 바꾸지 않아 체크 UI가 안정적 */ + const rowsByTab = useMemo( + () => { + const checked = checkedVrfcByTabRef.current; + return { + NV: mapRows(entriesByTab.NV, checked.NV), + IMR: mapRows(entriesByTab.IMR, checked.IMR), + HB: mapRows(entriesByTab.HB, checked.HB), + }; + }, + [entriesByTab, mapRows], + ); const noopSetRows = ((_: SetStateAction[]>) => {}) as Dispatch< SetStateAction[]> >; - const renderGrid = ( - rows: VerificationResultGridRow[], - pageIndex: number, - pageCount: number, - onPageIndexChange: (page: number) => void, - loading: boolean, - ) => { + const handleExceptionResetClick = useCallback(async () => { + if (!stableQueryContext) { + alert("조회 조건이 없습니다."); + return; + } + const tab = activeTab; + const currentEntries = entriesByTab[tab]; + const syncedMap = syncCheckedMapFromGrid(currentEntries, gridRefByTab[tab], checkedVrfcByTab[tab]); + if (syncedMap.size === 0) { + alert("예외 제출 취소할 항목을 체크해주세요."); + return; + } + if (selectionHasNonExceptionResetAllowed(syncedMap, currentEntries)) { + alert(EXCEPTION_RESET_ONLY_PRCS_YN_2_MESSAGE); + return; + } + setCheckedVrfcByTab((prev) => ({ ...prev, [tab]: syncedMap })); + const vrfcSnList = Array.from(syncedMap.keys()); + let mdfrId: string; + try { + const userInfo = await getUserInfo(); + mdfrId = userInfo.userUnqId; + } catch { + alert("사용자 정보를 불러오지 못했습니다."); + return; + } + setResetSubmitting(true); + setErrorMessage(null); + try { + const response = await statisticsApi.StatiscicsExceptionReset({ + statsYr: stableQueryContext.statsYr, + sewerBzmnCd: stableQueryContext.sewerBzmnCd, + statsCd: stableQueryContext.statsCd, + mdfrId, + vrfcSnList, + }); + if (response.status !== "success") { + alert(response.message ?? "예외 제출 취소 처리 중 오류가 발생했습니다."); + return; + } + alert("예외 제출 취소 처리되었습니다."); + setCheckedVrfcByTab((prev) => ({ ...prev, [tab]: new Map() })); + await loadTabPage(tab, tabPages[tab].pageIndex); + } catch { + alert("예외 제출 취소 처리 중 오류가 발생했습니다."); + } finally { + setResetSubmitting(false); + } + }, [activeTab, alert, checkedVrfcByTab, entriesByTab, loadTabPage, stableQueryContext, tabPages]); + + const renderActiveTabPanel = (tab: VerificationHistoryTabKey) => { + const entries = entriesByTab[tab]; + const rows = rowsByTab[tab]; + const { pageIndex, pageCount } = tabPages[tab]; + const loading = loadingByTab[tab]; + const gridRef = gridRefByTab[tab]; + if (!loading && rows.length === 0) { - return

{emptyMessage}

; + return

{emptyMessage}

; } return (
{ + const entry = entries[itemIndex]; + if (!entry) return; + const vrfcSn = Number(entry.vrfcSn); + if (!Number.isFinite(vrfcSn)) return; + if (checked && !canExceptionReset(entry.prcsYn)) { + alert(EXCEPTION_RESET_ONLY_PRCS_YN_2_MESSAGE); + uncheckGridRow(gridRef, itemIndex); + return; + } + updateCheckedVrfcForTab(tab, (map) => { + if (checked) map.set(vrfcSn, String(entry.prcsYn ?? "")); + else map.delete(vrfcSn); + }); + }} + onItemAllChecked={(checked) => { + updateCheckedVrfcForTab(tab, (map) => { + entries.forEach((entry, itemIndex) => { + const vrfcSn = Number(entry.vrfcSn); + if (!Number.isFinite(vrfcSn)) return; + if (!checked) { + map.delete(vrfcSn); + return; + } + if (canExceptionReset(entry.prcsYn)) { + map.set(vrfcSn, String(entry.prcsYn ?? "")); + } else { + uncheckGridRow(gridRef, itemIndex); + } + }); + }); + if (checked && entries.some((e) => !canExceptionReset(e.prcsYn))) { + alert(EXCEPTION_RESET_ONLY_PRCS_YN_2_MESSAGE); + } + }} /> handleTabPageChange(tab, nextPage)} className="mx-2 mb-2" /> {loading && ( @@ -318,7 +548,12 @@ export function VerificationResultHistoryDialog({ {errorMessage}

)} - setActiveTab(value as VerificationHistoryTabKey)} className="w-full" variant="default"> + setActiveTab(value as VerificationHistoryTabKey)} + className="w-full" + variant="default" + > {verificationHistoryTabs.map((tab) => ( @@ -326,20 +561,28 @@ export function VerificationResultHistoryDialog({ ))} - - {renderGrid(normalRows, normalPageIndex, normalPageCount, handleNormalPageChange, normalLoading)} - - - {renderGrid(imrRows, imrPageIndex, imrPageCount, handleImrPageChange, imrLoading)} - - - {renderGrid(hbRows, hbPageIndex, hbPageCount, handleHbPageChange, hbLoading)} - + {verificationHistoryTabs.map((tab) => ( + + {activeTab === tab.value ? renderActiveTabPanel(tab.value) : null} + + ))}
+ - diff --git a/apps/st/src/components/module/post-submit-verification-results.ts b/apps/st/src/components/module/post-submit-verification-results.ts new file mode 100644 index 000000000..a46ad79cb --- /dev/null +++ b/apps/st/src/components/module/post-submit-verification-results.ts @@ -0,0 +1,85 @@ +import { statisticsApi } from "@/entities/statistics/api/statistics-service"; +import type { VerificationResultListResponse } from "@/entities/statistics/model/statistics-interface"; + +import type { VerificationResultDialogQueryContext } from "./VerificationResultDialog"; + +const POST_SUBMIT_VRFC_MTHD_CODES = ["NV", "IMR", "HB"] as const; + +export const POST_SUBMIT_COMPLETE_MESSAGE = "제출 완료되었습니다."; + +export type OpenVerificationResultDialogOptions = { + notify?: (message: string) => void; + /** 미통과 검증 결과가 없을 때 제출 완료 알림 (기본 true, IMR 오류 catch 등에서는 false) */ + notifySubmitComplete?: boolean; + submitCompleteMessage?: string; +}; + +function responseHasVerificationResults(response: VerificationResultListResponse | null): boolean { + const total = response?.data?.pageInfo?.totalElements; + if (typeof total === "number" && total > 0) return true; + const list = response?.data?.verifyResultList; + return Array.isArray(list) && list.length > 0; +} + +/** + * 제출 직후 `VerificationResultDialog`에 표시할 미통과(prcsYn=0) 검증 결과가 + * 일반검증(NV)·I-MR(IMR)·HB 중 하나라도 있는지 조회합니다. + */ +export async function hasAnyPostSubmitVerificationResult( + ctx: VerificationResultDialogQueryContext, +): Promise { + const base = { + sewerBzmnCd: ctx.sewerBzmnCd, + ctpvNm: ctx.ctpvNm, + sggNm: ctx.sggNm, + statsYr: ctx.statsYr, + statsCd: ctx.statsCd, + pbcpAprvYn: "N" as const, + prcsYnList: "0", + page: 1, + size: 1, + }; + + const responses = await Promise.all( + POST_SUBMIT_VRFC_MTHD_CODES.map((vrfcMthdCd) => + statisticsApi.getVerificationResultList({ ...base, vrfcMthdCd }), + ), + ); + + return responses.some(responseHasVerificationResults); +} + +/** + * 미통과 검증 결과가 있으면 다이얼로그를 열고, 없으면 제출 완료 알림을 표시합니다. + * @returns 다이얼로그를 열었으면 true + */ +export async function openVerificationResultDialogIfNeeded( + ctx: VerificationResultDialogQueryContext | null | undefined, + setOpen: (open: boolean) => void, + options?: OpenVerificationResultDialogOptions, +): Promise { + const { + notify, + notifySubmitComplete = true, + submitCompleteMessage = POST_SUBMIT_COMPLETE_MESSAGE, + } = options ?? {}; + + const notifyComplete = () => { + if (notifySubmitComplete && notify) { + notify(submitCompleteMessage); + } + }; + + if (!ctx) { + notifyComplete(); + return false; + } + + if (await hasAnyPostSubmitVerificationResult(ctx)) { + setOpen(true); + return true; + } + + notifyComplete(); + return false; +} diff --git a/apps/st/src/components/statistics/VerificationSelectionReport.tsx b/apps/st/src/components/statistics/VerificationSelectionReport.tsx index ec81f97c3..18edbf4cc 100644 --- a/apps/st/src/components/statistics/VerificationSelectionReport.tsx +++ b/apps/st/src/components/statistics/VerificationSelectionReport.tsx @@ -1,11 +1,20 @@ "use client"; -import { useCallback } from "react"; -import { useQueries } from "@tanstack/react-query"; +import { useCallback, useState } from "react"; +import { + type InfiniteData, + keepPreviousData, + useInfiniteQuery, + useQueryClient, +} from "@tanstack/react-query"; import { useTabStore as useGridTabStore } from "@rsone/components"; import { statisticsApi } from "@/entities/statistics/api/statistics-service"; -import type { VerificationResultReportItem } from "@/entities/statistics/model/statistics-interface"; +import type { + VerificationResultListRequest, + VerificationResultListResponse, + VerificationResultReportItem, +} from "@/entities/statistics/model/statistics-interface"; import { useAlertStore, useMenuStore } from "@/shared/lib"; import { findMenuItemByUrlPath, @@ -19,18 +28,92 @@ import { Badge, type BadgeProps, LoadingWithAnimation, + Tabs, + TabsContent, + TabsList, + TabsTrigger, + Label, } from "@common/components"; +import { CodeSelectWithAll } from "@/entities/code"; + +type VerificationReportTabKey = "NV" | "IMR" | "HB"; + +const VERIFICATION_REPORT_TABS: { value: VerificationReportTabKey; label: string }[] = [ + { value: "NV", label: "일반" }, + { value: "IMR", label: "I-MR" }, + { value: "HB", label: "HB" }, +]; const TYPE_CODE_COLOR_MAP: Record = { NV: "gray", IMR: "red", + HB: "blue", }; const TYPE_CODE_NAME_MAP: Record = { NV: "일반", IMR: "I-MR", + HB: "HB", }; +const PAGE_SIZE = 100; +const SCROLL_LOAD_THRESHOLD_PX = 120; + +function getNextVerificationResultPage( + lastPage: VerificationResultListResponse, +): number | undefined { + const pageInfo = lastPage?.data?.pageInfo; + if (!pageInfo) return undefined; + const { currentPage, totalPages } = pageInfo; + if (currentPage + 1 <= totalPages) return currentPage + 1; + return undefined; +} + +function flattenVerificationResults( + data: InfiniteData | undefined, +): VerificationResultReportItem[] { + return ( + data?.pages.flatMap((page) => page?.data?.verifyResultList ?? []) ?? [] + ); +} + +function getVerificationResultTotalCount( + data: InfiniteData | undefined, + loadedCount: number, +): number { + return data?.pages[0]?.data?.pageInfo?.totalElements ?? loadedCount; +} + +function useVerificationReportList( + statsYr: string, + statsCdQueryKey: string, + vrfcMthdCd: VerificationReportTabKey, + statsCd: string, +) { + return useInfiniteQuery({ + queryKey: [ + "verificationResultList", + statsYr, + vrfcMthdCd, + statsCdQueryKey, + ], + initialPageParam: 1, + queryFn: ({ pageParam }) => + statisticsApi.getVerificationResultList({ + statsYr, + pbcpAprvYn: "N", + page: pageParam, + size: PAGE_SIZE, + prcsYnList: "0", + vrfcMthdCd, + ...(!(statsCd === "" || statsCd === "ALL") && { statsCd }), + } satisfies VerificationResultListRequest), + getNextPageParam: getNextVerificationResultPage, + placeholderData: keepPreviousData, + refetchOnWindowFocus: false, + }); +} + type VerificationSelectionReportProps = { onClose?: () => void; }; @@ -38,6 +121,7 @@ type VerificationSelectionReportProps = { const VerificationSelectionReport = ({ onClose, }: VerificationSelectionReportProps) => { + const queryClient = useQueryClient(); const menuList = useTabStore((s) => s.menuList); const addTab = useTabStore((s) => s.addTab); const setCurrentMenuInfo = useMenuStore((s) => s.setCurrentMenuInfo); @@ -46,59 +130,73 @@ const VerificationSelectionReport = ({ (s) => s.setPendingInputTab, ); const alert = useAlertStore((s) => s.setMessage); + const [activeTab, setActiveTab] = useState("NV"); + const [statsCd, setStatsCd] = useState("ALL"); const statsYr = String(dayjs().year() - 1); + const statsCdQueryKey = + statsCd === "" || statsCd === "ALL" ? "" : statsCd; - const baseRequest = { + const nvQuery = useVerificationReportList( statsYr, - pbcpAprvYn: "N" as const, - page: 1, - size: 10000, - prcsYnList: "0", + statsCdQueryKey, + "NV", + statsCd, + ); + const imrQuery = useVerificationReportList( + statsYr, + statsCdQueryKey, + "IMR", + statsCd, + ); + const hbQuery = useVerificationReportList( + statsYr, + statsCdQueryKey, + "HB", + statsCd, + ); + + const queryByTab = { + NV: nvQuery, + IMR: imrQuery, + HB: hbQuery, + } as const; + + const isInitialLoading = + nvQuery.isPending || imrQuery.isPending || hbQuery.isPending; + const isRefreshing = + (nvQuery.isFetching && !nvQuery.isPending) || + (imrQuery.isFetching && !imrQuery.isPending) || + (hbQuery.isFetching && !hbQuery.isPending); + const error = nvQuery.error ?? imrQuery.error ?? hbQuery.error; + + const handleRefresh = useCallback(() => { + const tabCodes: VerificationReportTabKey[] = ["NV", "IMR", "HB"]; + void Promise.all( + tabCodes.map((code) => + queryClient.resetQueries({ + queryKey: [ + "verificationResultList", + statsYr, + code, + statsCdQueryKey, + ], + }), + ), + ); + }, [queryClient, statsYr, statsCdQueryKey]); + + const listsByTab: Record = { + NV: flattenVerificationResults(nvQuery.data), + IMR: flattenVerificationResults(imrQuery.data), + HB: flattenVerificationResults(hbQuery.data), }; - const [nvQuery, imrQuery] = useQueries({ - queries: [ - { - queryKey: [ - "verificationResultList", - statsYr, - "NV", - baseRequest.pbcpAprvYn, - baseRequest.page, - baseRequest.size, - baseRequest.prcsYnList, - ], - queryFn: () => - statisticsApi.getVerificationResultList({ - ...baseRequest, - vrfcMthdCd: "NV", - }), - }, - { - queryKey: [ - "verificationResultList", - statsYr, - "IMR", - baseRequest.pbcpAprvYn, - baseRequest.page, - baseRequest.size, - baseRequest.prcsYnList, - ], - queryFn: () => - statisticsApi.getVerificationResultList({ - ...baseRequest, - vrfcMthdCd: "IMR", - }), - }, - ], - }); - - const isLoading = nvQuery.isLoading || imrQuery.isLoading; - const error = nvQuery.error ?? imrQuery.error; - - const nvList = nvQuery.data?.data?.verifyResultList ?? []; - const imrList = imrQuery.data?.data?.verifyResultList ?? []; + const totalCountByTab: Record = { + NV: getVerificationResultTotalCount(nvQuery.data, listsByTab.NV.length), + IMR: getVerificationResultTotalCount(imrQuery.data, listsByTab.IMR.length), + HB: getVerificationResultTotalCount(hbQuery.data, listsByTab.HB.length), + }; const openInputFromStatsCd = useCallback( (item: VerificationResultReportItem) => { @@ -147,75 +245,123 @@ const VerificationSelectionReport = ({ ], ); - const renderSection = ( - typeCode: "IMR" | "NV", + const handleListScroll = useCallback( + ( + e: React.UIEvent, + hasNextPage: boolean, + isFetchingNextPage: boolean, + fetchNextPage: () => void, + ) => { + const el = e.currentTarget; + const remaining = + el.scrollHeight - el.scrollTop - el.clientHeight; + if (remaining > SCROLL_LOAD_THRESHOLD_PX) return; + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, + [], + ); + + const renderResultList = ( + typeCode: VerificationReportTabKey, items: VerificationResultReportItem[], + hasNextPage: boolean, + isFetchingNextPage: boolean, + fetchNextPage: () => void, ) => ( -
-
- - {TYPE_CODE_NAME_MAP[typeCode]} {items.length} - -
-
- {items.length === 0 ? ( -
- 항목이 없습니다. -
- ) : ( -
- {items.map((item) => ( -
openInputFromStatsCd(item)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - openInputFromStatsCd(item); +
+ handleListScroll( + e, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + ) + } + > + {items.length === 0 && !isFetchingNextPage ? ( +
+ 항목이 없습니다. +
+ ) : ( +
+ {items.map((item) => ( +
openInputFromStatsCd(item)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + openInputFromStatsCd(item); + } + }} + > +
+ + {item.statsNm} + + -
- - {item.statsNm} - - - {TYPE_CODE_NAME_MAP[item.vrfcMthdCd] ?? - item.vrfcMthdNm} - -
-

- {item.rsnExpln} -

-
- {item.admdstNm} - - 현재: {item.nowVlCn ?? "-"} / 비교:{" "} - {item.compVlCn ?? "-"} - -
+ className="text-sm" + > + {TYPE_CODE_NAME_MAP[item.vrfcMthdCd] ?? + item.vrfcMthdNm} +
- ))} -
- )} -
-
+

+ {item.rsnExpln} +

+
+ {item.admdstNm} + + 현재: {item.nowVlCn ?? "-"} / 비교:{" "} + {item.compVlCn ?? "-"} + +
+
+ ))} + {isFetchingNextPage && ( +
+
+ +
+
+ )} +
+ )} +

); return (
-
+
-

이상치 선별 보고서

+
+

이상치 선별 보고서

+ { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleRefresh(); + } + }} + > + + +
-
- {isLoading && ( +
+ + +
+
+ {isInitialLoading && (
@@ -245,11 +401,49 @@ const VerificationSelectionReport = ({ 데이터를 불러올 수 없습니다.
)} - {!isLoading && !error && ( -
- {renderSection("IMR", imrList)} - {renderSection("NV", nvList)} -
+ {!isInitialLoading && !error && ( +
+ + setActiveTab(value as VerificationReportTabKey) + } + className="flex min-h-0 flex-1 flex-col" + variant="default" + > + + {VERIFICATION_REPORT_TABS.map((tab) => ( + + {tab.label} ({totalCountByTab[tab.value]}) + + ))} + + {VERIFICATION_REPORT_TABS.map((tab) => ( + + {activeTab === tab.value + ? renderResultList( + tab.value, + listsByTab[tab.value], + queryByTab[tab.value] + .hasNextPage ?? false, + queryByTab[tab.value] + .isFetchingNextPage, + queryByTab[tab.value] + .fetchNextPage, + ) + : null} + + ))} + +
)}
diff --git a/apps/st/src/components/statistics/input/ExcretaTreatmentFacilityInp.tsx b/apps/st/src/components/statistics/input/ExcretaTreatmentFacilityInp.tsx index 94fbb6a3e..626682536 100644 --- a/apps/st/src/components/statistics/input/ExcretaTreatmentFacilityInp.tsx +++ b/apps/st/src/components/statistics/input/ExcretaTreatmentFacilityInp.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import SearchForm from "@/components/module/SearchForm"; -import { DataGrid } from "@rsone/components"; +import DataGrid from "@/components/module/DataGrid"; import SubmitButton from "@/components/button/SumitButton"; import SaveButton from "@/components/button/SaveButton"; import LastYearDataButton from "@/components/button/LastYearDataButton"; diff --git a/apps/st/src/components/statistics/input/MainFacilityMappingSaveDialog.tsx b/apps/st/src/components/statistics/input/MainFacilityMappingSaveDialog.tsx index a1cc85b52..d9f9de404 100644 --- a/apps/st/src/components/statistics/input/MainFacilityMappingSaveDialog.tsx +++ b/apps/st/src/components/statistics/input/MainFacilityMappingSaveDialog.tsx @@ -240,11 +240,6 @@ export function MainFacilityMappingSaveDialog({
- - - + + + diff --git a/apps/st/src/components/statistics/input/PrivateSewageFacilityInp.tsx b/apps/st/src/components/statistics/input/PrivateSewageFacilityInp.tsx index 0dabb52c8..b430d6584 100644 --- a/apps/st/src/components/statistics/input/PrivateSewageFacilityInp.tsx +++ b/apps/st/src/components/statistics/input/PrivateSewageFacilityInp.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import SearchForm from "@/components/module/SearchForm"; -import { TabDataGrid } from "@rsone/components"; +import TabDataGrid from "@/components/module/TabDataGrid"; import SubmitButton from "@/components/button/SumitButton"; import SaveButton from "@/components/button/SaveButton"; import SubmitExceptionButton from "@/components/button/SubmitExceptionButton"; diff --git a/apps/st/src/components/statistics/input/PublicSewageFacilityInp.tsx b/apps/st/src/components/statistics/input/PublicSewageFacilityInp.tsx index e8c71de1b..1aaa36585 100644 --- a/apps/st/src/components/statistics/input/PublicSewageFacilityInp.tsx +++ b/apps/st/src/components/statistics/input/PublicSewageFacilityInp.tsx @@ -38,6 +38,7 @@ import VerificationResultButton from '@/components/button/VerificationResultButt import DataGrid from '@/components/module/DataGrid'; import SearchForm from '@/components/module/SearchForm'; import SplitDataGrid from '@/components/module/SplitDataGrid'; +import { openVerificationResultDialogIfNeeded } from '@/components/module/post-submit-verification-results'; import { VerificationResultDialog } from '@/components/module/VerificationResultDialog'; import { VerificationResultHistoryDialog } from '@/components/module/VerificationResultHistoryDialog'; import type { CellStyleCallbackParams, ColumnInfo, ColumnInfoList } from '@/components/type/DataGrid.type'; @@ -86,13 +87,16 @@ function computeTabToolbarSnapshot( ): TabToolbarSnapshot { const gugunOk = buildGugunOkForToolbar(searchContext); const enterable = computeTabEnterable(statusInfo, periodEnterable); + const isSubmitted = statusInfo?.laeSbmsnSttsCd === '2'; const showSaveInToolbar = (enterable || isAdminInfo) && gugunOk; const showEnterableToolbarButtons = enterable && gugunOk; + const showSubmittedInInputPeriodToolbar = isSubmitted && periodEnterable && gugunOk; const showActionToolbarCard = (hasRowData || treatAsSearched) && (showSaveInToolbar || - (statusInfo?.laeSbmsnSttsCd === '2' && - (isAdminInfo || (enterable && gugunOk)))); + showEnterableToolbarButtons || + showSubmittedInInputPeriodToolbar || + (isSubmitted && isAdminInfo)); return { showSaveInToolbar, showEnterableToolbarButtons, showActionToolbarCard }; } @@ -161,7 +165,7 @@ const PublicSewageFacilityInp = () => { const [tabStatsCdMap, setTabStatsCdMap] = useState>(new Map()); /** 탭키별 `engColNm -> columnGuide` 캐시 */ const [columnGuidesByTab, setColumnGuidesByTab] = useState>>({}); - const [toolbarSnapshotByTab, setToolbarSnapshotByTab] = useState>(() => new Map()); + const [_toolbarSnapshotByTab, setToolbarSnapshotByTab] = useState>(() => new Map()); const [inputPeriodEnterable, setInputPeriodEnterable] = useState(true); const [periodCheckList, setPeriodCheckList] = useState([]); const [showDailog, setShowDailog] = useState(false); @@ -176,7 +180,7 @@ const PublicSewageFacilityInp = () => { const searchDoneRef = useRef(false); /** ST00060001만: RealGrid 편집값이 state에 없을 수 있어 저장/제출 시 그리드에서 직접 읽음 */ const st60001GridGetRowsRef = useRef<(() => Record[]) | null>(null); - + const submitFunc = () => { void (async () => { if (activeTab !== 'ST00060001') { @@ -187,9 +191,10 @@ const PublicSewageFacilityInp = () => { alert('먼저 조회를 진행해주세요.'); return; } + const treatAsNoApplicable = getNoApplicableForRequest() || isNotApplicable; const currentData = st60001GridGetRowsRef.current?.() ?? rowDataMap.get('ST00060001') ?? []; - if (!currentData || currentData.length === 0) { + if (!treatAsNoApplicable && (!currentData || currentData.length === 0)) { alert('제출할 데이터가 없습니다.'); return; } @@ -266,9 +271,12 @@ const PublicSewageFacilityInp = () => { alert('먼저 조회를 진행해주세요.'); return; } + const noApplicable = getNoApplicableForRequest(); + const treatAsNoApplicable = noApplicable || isNotApplicable; const currentData = st60001GridGetRowsRef.current?.() ?? rowDataMap.get('ST00060001') ?? []; - if (!currentData || currentData.length === 0) { + + if (!treatAsNoApplicable && (!currentData || currentData.length === 0)) { alert('제출할 데이터가 없습니다.'); return; } @@ -276,7 +284,7 @@ const PublicSewageFacilityInp = () => { try { showGlobalProgress({ label: '서버에 요청 중...', progress: 0 }); const userInfo = await getUserInfo(); - const submitList = buildFacilitySaveList(currentData); + const submitList = treatAsNoApplicable ? [] : buildFacilitySaveList(currentData); const activeTabStatus = tabStatusInfoMap.get(activeTab) ?? null; const statsCdForTab = tabStatsCdMap.get(activeTab) ?? activeTab; const requestData = { @@ -285,7 +293,7 @@ const PublicSewageFacilityInp = () => { sggNm: lastSearchedValues.gugun === 'ALL' ? '' : lastSearchedValues.gugun ?? '', sewerBzmnCd: lastSearchedValues.bsnmCd ?? '', userId: userInfo.userUnqId, - noApplicable: getNoApplicableForRequest(), + noApplicable, laePicNm: submitInfo.laePicNm, laeKbrdrJbgdNm: submitInfo.laeKbrdrJbgdNm, laeKbrdrDeptNm: submitInfo.laeKbrdrDeptNm, @@ -342,14 +350,34 @@ const PublicSewageFacilityInp = () => { searchParamRef.current, lastSearchedValues, ); - setVerificationResultDialogOpen(true); + await openVerificationResultDialogIfNeeded( + { + sewerBzmnCd: lastSearchedValues.bsnmCd ?? '', + ctpvNm: lastSearchedValues.sido ?? '', + sggNm: lastSearchedValues.gugun === 'ALL' ? '' : (lastSearchedValues.gugun ?? ''), + statsYr: lastSearchedValues.year ?? '', + statsCd: statsCdForTab, + }, + setVerificationResultDialogOpen, + { notify: alert }, + ); } catch (e) { await loadPublicSewageFacilityActiveTabData( 'ST00060001', searchParamRef.current, lastSearchedValues, ); - setVerificationResultDialogOpen(true); + await openVerificationResultDialogIfNeeded( + { + sewerBzmnCd: lastSearchedValues.bsnmCd ?? '', + ctpvNm: lastSearchedValues.sido ?? '', + sggNm: lastSearchedValues.gugun === 'ALL' ? '' : (lastSearchedValues.gugun ?? ''), + statsYr: lastSearchedValues.year ?? '', + statsCd: statsCdForTab, + }, + setVerificationResultDialogOpen, + { notify: alert, notifySubmitComplete: false }, + ); const msg = e instanceof Error && e.message ? e.message @@ -363,7 +391,17 @@ const PublicSewageFacilityInp = () => { searchParamRef.current, lastSearchedValues, ); - setVerificationResultDialogOpen(true); + await openVerificationResultDialogIfNeeded( + { + sewerBzmnCd: lastSearchedValues.bsnmCd ?? '', + ctpvNm: lastSearchedValues.sido ?? '', + sggNm: lastSearchedValues.gugun === 'ALL' ? '' : (lastSearchedValues.gugun ?? ''), + statsYr: lastSearchedValues.year ?? '', + statsCd: statsCdForTab, + }, + setVerificationResultDialogOpen, + { notify: alert }, + ); } } catch { alert('데이터 제출 중 오류가 발생했습니다.'); @@ -374,12 +412,34 @@ const PublicSewageFacilityInp = () => { const saveFunc = () => { void (async () => { + const verificationBlocked = getVerificationPeriodActionBlockedMessage(periodCheckList); + if (verificationBlocked) { + alert(verificationBlocked); + return; + } + if (!lastSearchedValues || !searchParamRef.current) { alert('먼저 조회를 진행해주세요.'); return; } if (activeTab === 'ST00060002') { + const statusCodeResponse = await statisticsApi.CheckStatusCode({ + sewerBzmnCd: lastSearchedValues.bsnmCd ?? '', + statsCd: 'ST00060001', + statsYr: lastSearchedValues.year ?? '' + }); + + if (statusCodeResponse.status === 'success') { + if (!['0', '1', '4'].includes(statusCodeResponse.data)) { + const codeName = statusCodeResponse.data === '2' ? '제출' : (statusCodeResponse.data === '3' ? '승인' : '검증중'); + alert(`공공 하수처리 시설 데이터가 ${codeName} 상태일 때는 중심 처리 시설 데이터를 변경할 수 없습니다.`); + return; + } + } else { + alert('오류가 발생했습니다.\n관리자에게 문의하세요') + } + if (!(await confirm('저장 하시겠습니까?'))) return; const leftRows = rowDataMap.get('MainFacility') ?? []; @@ -458,22 +518,24 @@ const PublicSewageFacilityInp = () => { try { showGlobalProgress({ label: '저장 진행 중...', progress: 0 }); const userInfo = await getUserInfo(); + const noApplicable = getNoApplicableForRequest(); + const treatAsNoApplicable = noApplicable || isNotApplicable; const currentData = st60001GridGetRowsRef.current?.() ?? rowDataMap.get('ST00060001') ?? []; - if (!currentData || currentData.length === 0) { + if (!treatAsNoApplicable && (!currentData || currentData.length === 0)) { alert('저장할 데이터가 없습니다.'); return; } - const saveList = buildFacilitySaveList(currentData); + const saveList = treatAsNoApplicable ? [] : buildFacilitySaveList(currentData); const param: PublicSewageFacilitySaveRequest = { statsYr: lastSearchedValues.year ?? '', ctpvNm: lastSearchedValues.sido ?? '', sggNm: lastSearchedValues.gugun === 'ALL' ? '' : lastSearchedValues.gugun ?? '', sewerBzmnCd: lastSearchedValues.bsnmCd ?? '', userId: userInfo.userUnqId, - superAdmin: isAdminInfo ? 'superAdmin' : '', - noApplicable: getNoApplicableForRequest(), + // superAdmin: isAdminInfo ? 'superAdmin' : '', + noApplicable, saveList, }; @@ -593,6 +655,8 @@ const PublicSewageFacilityInp = () => { param: PublicSewageFacilityRequestParams, searchContext: SearchFormValues | null, ): Promise => { + // 서버 데이터로 새로 불러오는 시점이므로 "해당없음" 체크는 항상 초기화 + setIsNotApplicable(false); try { if (tabKey === 'ST00060001') { const response = await statisticsInputPublicSewageFacilityApiService.getPublicSewageFacility(param); @@ -949,26 +1013,34 @@ const PublicSewageFacilityInp = () => { }; const searchContextForToolbar = lastSearchedValues ?? searchValues; + const gugunOkOrNotDoList = buildGugunOkForToolbar(searchContextForToolbar); const activeTabStatus = tabStatusInfoMap.get(activeTab) ?? null; - const activeTabToolbar = - toolbarSnapshotByTab.get(activeTab) ?? - computeTabToolbarSnapshot( - activeTabStatus, - tabRowDataExists(activeTab), - hasSearched, - searchContextForToolbar, - inputPeriodEnterable, - isAdminInfo, - ); + const activeTabToolbar = computeTabToolbarSnapshot( + activeTabStatus, + tabRowDataExists(activeTab), + hasSearched, + searchContextForToolbar, + inputPeriodEnterable, + isAdminInfo, + ); const { showSaveInToolbar: baseShowSaveInToolbar, showEnterableToolbarButtons: baseShowEnterableToolbarButtons, showActionToolbarCard: baseShowActionToolbarCard, } = activeTabToolbar; - const showSaveInToolbar = baseShowSaveInToolbar && toolbarGroupAllowed; - const showEnterableToolbarButtons = baseShowEnterableToolbarButtons && toolbarGroupAllowed; - const showActionToolbarCard = baseShowActionToolbarCard && toolbarGroupAllowed; const isMainFacilityTab = activeTab === 'ST00060002'; + const showMainFacilitySaveInToolbar = + isMainFacilityTab && + tabRowDataExists('ST00060002') && + gugunOkOrNotDoList && + (isAdminInfo || inputPeriodEnterable); + const showSaveInToolbar = + toolbarGroupAllowed && (baseShowSaveInToolbar || showMainFacilitySaveInToolbar); + const showEnterableToolbarButtons = + baseShowEnterableToolbarButtons && toolbarGroupAllowed; + const showActionToolbarCard = + toolbarGroupAllowed && + (baseShowActionToolbarCard || showMainFacilitySaveInToolbar); const openVerificationHistoryDialog = () => { const ctx = lastSearchedValues ?? searchValues; @@ -1004,16 +1076,18 @@ const PublicSewageFacilityInp = () => { onValuesChange={setSearchValues} /> {showActionToolbarCard && ( -
+
- + {!isMainFacilityTab && ( + + )} {showEnterableToolbarButtons && !isMainFacilityTab && ( <> {showIsNotApplicable && } - - + + )} {!isMainFacilityTab && activeTabStatus?.laeSbmsnSttsCd === '2' && ( @@ -1028,7 +1102,7 @@ const PublicSewageFacilityInp = () => {
-
+
)} void handleActiveTabChange(v)}> @@ -1039,151 +1113,165 @@ const PublicSewageFacilityInp = () => { ))} - {PublicSewageFactInpColumnList.map((tab) => { - if (tab.isSplitGrid && tab.left && tab.right) { - const leftKey = tab.left.tabKey ?? `${tab.tabKey}_left`; - const rightKey = tab.right.tabKey ?? `${tab.tabKey}_right`; + {PublicSewageFactInpColumnList.map((tab) => { + if (tab.isSplitGrid && tab.left && tab.right) { + const leftKey = tab.left.tabKey ?? `${tab.tabKey}_left`; + const rightKey = tab.right.tabKey ?? `${tab.tabKey}_right`; + return ( + + { + void handleMainFacilityRowClick(row); + } + : undefined + } + leftCellStyleCallback={ + tab.tabKey === 'ST00060002' + ? leftMainFacilityCellStyleCallback + : undefined + } + rightOnItemChecked={ + tab.tabKey === 'ST00060002' + ? (itemIndex, checked) => { + setRowDataMap((prev) => { + const next = new Map(prev); + const rows = [...(next.get('MainFacilityTarget') ?? [])]; + if (!rows[itemIndex]) return prev; + rows[itemIndex] = { + ...rows[itemIndex], + __checked: checked ? 'Y' : '', + }; + next.set('MainFacilityTarget', rows); + return next; + }); + } + : undefined + } + rightOnItemAllChecked={ + tab.tabKey === 'ST00060002' + ? (checked) => { + setRowDataMap((prev) => { + const next = new Map(prev); + const rows = [...(next.get('MainFacilityTarget') ?? [])].map((r) => ({ + ...r, + __checked: checked ? 'Y' : '', + })); + next.set('MainFacilityTarget', rows); + return next; + }); + } + : undefined + } + setLeftRowData={(updater) => { + setRowDataMap((prev) => { + const next = new Map(prev); + const cur = next.get(leftKey) ?? []; + const n = + typeof updater === 'function' + ? (updater as (p: Record[]) => Record[])(cur) + : updater; + next.set(leftKey, n); + return next; + }); + }} + setRightRowData={(updater) => { + setRowDataMap((prev) => { + const next = new Map(prev); + const cur = next.get(rightKey) ?? []; + const n = + typeof updater === 'function' + ? (updater as (p: Record[]) => Record[])(cur) + : updater; + next.set(rightKey, n); + return next; + }); + }} + /> + + ); + } + + const gridColumnInfo = getGridColumnInfo(tab); + const tabStatus = tabStatusInfoMap.get(tab.tabKey) ?? null; + const tabEnterableForGrid = computeTabEnterable( + tabStatus, + inputPeriodEnterable, + ); + const excelToolbarEnabledForTab = + tabRowDataExists(tab.tabKey) && + tabEnterableForGrid && + gugunOkOrNotDoList; + return ( - { - void handleMainFacilityRowClick(row); - } - : undefined - } - leftCellStyleCallback={ - tab.tabKey === 'ST00060002' - ? leftMainFacilityCellStyleCallback - : undefined - } - rightOnItemChecked={ - tab.tabKey === 'ST00060002' - ? (itemIndex, checked) => { - setRowDataMap((prev) => { - const next = new Map(prev); - const rows = [...(next.get('MainFacilityTarget') ?? [])]; - if (!rows[itemIndex]) return prev; - rows[itemIndex] = { - ...rows[itemIndex], - __checked: checked ? 'Y' : '', - }; - next.set('MainFacilityTarget', rows); - return next; - }); - } - : undefined - } - rightOnItemAllChecked={ - tab.tabKey === 'ST00060002' - ? (checked) => { - setRowDataMap((prev) => { - const next = new Map(prev); - const rows = [...(next.get('MainFacilityTarget') ?? [])].map((r) => ({ - ...r, - __checked: checked ? 'Y' : '', - })); - next.set('MainFacilityTarget', rows); - return next; - }); - } - : undefined - } - setLeftRowData={(updater) => { + { setRowDataMap((prev) => { const next = new Map(prev); - const cur = next.get(leftKey) ?? []; + const cur = next.get(tab.tabKey) ?? []; const n = typeof updater === 'function' ? (updater as (p: Record[]) => Record[])(cur) : updater; - next.set(leftKey, n); + next.set(tab.tabKey, n); return next; }); }} - setRightRowData={(updater) => { - setRowDataMap((prev) => { - const next = new Map(prev); - const cur = next.get(rightKey) ?? []; - const n = - typeof updater === 'function' - ? (updater as (p: Record[]) => Record[])(cur) - : updater; - next.set(rightKey, n); - return next; - }); + getGridDataRef={ + tab.tabKey === 'ST00060001' ? st60001GridGetRowsRef : undefined + } + isExcelImport={excelToolbarEnabledForTab} + isExcelExport + showHeaderCheckbox={false} + enableSort={false} + cellStyleCallback={({ row, fieldName }) => { + const verification = (row as GridRowWithVerification).verificationResult; + if (!verification) return; + const errors = verification[fieldName]; + if (Array.isArray(errors) && errors.length > 0) { + const hasImr = errors.some((e) => e.vrfcCd === "IMR" || e.vrfcCd === "HB"); + return hasImr ? "rg-cell-verify-error-imr" : "rg-cell-verify-error"; + } + }} + cellTooltipCallback={({ row, fieldName }) => { + const verification = (row as GridRowWithVerification).verificationResult; + if (!verification) return; + const errors = verification[fieldName]; + if (Array.isArray(errors) && errors.length > 0) { + return errors + .map((e) => { + const prefix = + e.vrfcCd === "IMR" ? "IMR검증 - " : (e.vrfcCd === "NV" ? "일반검증 - " : "HB검증 - "); + const comp = e.compVlCn ?? ""; + const now = e.nowVlCn ?? ""; + return `${prefix}${e.rsnExpln} + (검증 기준값: ${comp} / 현재 입력값: ${now})`; + }) + .filter(Boolean) + .join("\n"); + } }} /> ); - } - - const gridColumnInfo = getGridColumnInfo(tab); - return ( - - { - setRowDataMap((prev) => { - const next = new Map(prev); - const cur = next.get(tab.tabKey) ?? []; - const n = - typeof updater === 'function' - ? (updater as (p: Record[]) => Record[])(cur) - : updater; - next.set(tab.tabKey, n); - return next; - }); - }} - getGridDataRef={ - tab.tabKey === 'ST00060001' ? st60001GridGetRowsRef : undefined - } - showHeaderCheckbox={false} - enableSort={false} - cellStyleCallback={({ row, fieldName }) => { - const verification = (row as GridRowWithVerification).verificationResult; - if (!verification) return; - const errors = verification[fieldName]; - if (Array.isArray(errors) && errors.length > 0) { - const hasImr = errors.some((e) => e.vrfcCd === "IMR" || e.vrfcCd === "HB"); - return hasImr ? "rg-cell-verify-error-imr" : "rg-cell-verify-error"; - } - }} - cellTooltipCallback={({ row, fieldName }) => { - const verification = (row as GridRowWithVerification).verificationResult; - if (!verification) return; - const errors = verification[fieldName]; - if (Array.isArray(errors) && errors.length > 0) { - return errors - .map((e) => { - const prefix = - e.vrfcCd === "IMR" ? "IMR검증 - " : (e.vrfcCd === "NV" ? "일반검증 - " : "HB검증 - "); - const comp = e.compVlCn ?? ""; - const now = e.nowVlCn ?? ""; - return `${prefix}${e.rsnExpln} - (검증 기준값: ${comp} / 현재 입력값: ${now})`; - }) - .filter(Boolean) - .join("\n"); - } - }} - /> - - ); - })} + })}
{ true, false, ); - setVerificationResultDialogOpen(true); + await openVerificationResultDialogIfNeeded( + { + sewerBzmnCd: values.bsnmCd ?? "", + ctpvNm: values.sido ?? "", + sggNm: values.gugun === "ALL" ? "" : (values.gugun ?? ""), + statsYr: values.year ?? "", + statsCd: statsCdForTab, + }, + setVerificationResultDialogOpen, + { notify: alert }, + ); } catch (e) { await loadSewagePipeActiveTabData( tabKey, @@ -1257,7 +1271,17 @@ const SewagePipeInp = () => { true, false, ); - setVerificationResultDialogOpen(true); + await openVerificationResultDialogIfNeeded( + { + sewerBzmnCd: values.bsnmCd ?? "", + ctpvNm: values.sido ?? "", + sggNm: values.gugun === "ALL" ? "" : (values.gugun ?? ""), + statsYr: values.year ?? "", + statsCd: statsCdForTab, + }, + setVerificationResultDialogOpen, + { notify: alert, notifySubmitComplete: false }, + ); const msg = e instanceof Error && e.message ? e.message @@ -1273,7 +1297,17 @@ const SewagePipeInp = () => { true, false, ); - setVerificationResultDialogOpen(true); + await openVerificationResultDialogIfNeeded( + { + sewerBzmnCd: values.bsnmCd ?? "", + ctpvNm: values.sido ?? "", + sggNm: values.gugun === "ALL" ? "" : (values.gugun ?? ""), + statsYr: values.year ?? "", + statsCd: statsCdForTab, + }, + setVerificationResultDialogOpen, + { notify: alert }, + ); } } catch { alert("데이터 제출 중 오류가 발생했습니다."); @@ -1429,7 +1463,7 @@ const SewagePipeInp = () => { sggNm: values.gugun === "ALL" ? "" : (values.gugun ?? ""), sewerBzmnCd: values.bsnmCd ?? "", userId: userInfo.userUnqId, - superAdmin: isAdminInfo ? "superAdmin" : "", + // superAdmin: isAdminInfo ? "superAdmin" : "", saveList, }; @@ -1509,7 +1543,7 @@ const SewagePipeInp = () => { sggNm: values.gugun === "ALL" ? "" : (values.gugun ?? ""), sewerBzmnCd: values.bsnmCd ?? "", userId: userInfo.userUnqId, - superAdmin: isAdminInfo ? "superAdmin" : "", + // superAdmin: isAdminInfo ? "superAdmin" : "", saveList, }; @@ -1594,7 +1628,7 @@ const SewagePipeInp = () => { sggNm: values.gugun === "ALL" ? "" : (values.gugun ?? ""), sewerBzmnCd: values.bsnmCd ?? "", userId: userInfo.userUnqId, - superAdmin: isAdminInfo ? "superAdmin" : "", + // superAdmin: isAdminInfo ? "superAdmin" : "", saveList, }; @@ -1657,7 +1691,7 @@ const SewagePipeInp = () => { sggNm: values.gugun === "ALL" ? "" : (values.gugun ?? ""), sewerBzmnCd: values.bsnmCd ?? "", userId: userInfo.userUnqId, - superAdmin: isAdminInfo ? "superAdmin" : "", + // superAdmin: isAdminInfo ? "superAdmin" : "", saveList, }; @@ -1736,7 +1770,7 @@ const SewagePipeInp = () => { sggNm: values.gugun === "ALL" ? "" : (values.gugun ?? ""), sewerBzmnCd: values.bsnmCd ?? "", userId: userInfo.userUnqId, - superAdmin: isAdminInfo ? "superAdmin" : "", + // superAdmin: isAdminInfo ? "superAdmin" : "", saveList, }; @@ -1856,9 +1890,11 @@ const SewagePipeInp = () => { const rightRowData = rowDataMap.get(rightKey) ?? []; const splitTopButton = info.right.topButton; const showSplitTopButton = + activeTab === tabKey && Boolean(splitTopButton?.text) && (splitTopButton?.visible ?? true) && - excelToolbarEnabledForTab; + tabRowDataExists(rowDataMap, tabKey) && + gugunOkOrNotDoList; const leftColumnInfo: ColumnInfo = { columns: info.left.columns ?? [], columnLayout: info.left.columnLayout ?? [], diff --git a/apps/st/src/components/statistics/input/SewagePipeMaintainInp.tsx b/apps/st/src/components/statistics/input/SewagePipeMaintainInp.tsx index 4b5bed515..49c9cf037 100644 --- a/apps/st/src/components/statistics/input/SewagePipeMaintainInp.tsx +++ b/apps/st/src/components/statistics/input/SewagePipeMaintainInp.tsx @@ -1,25 +1,62 @@ -import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { useAlertStore } from "@/shared/lib"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; + +import { useAlertStore, useGlobalProgressStore, useStatisticsInputToolbarGroupGate } from "@/shared/lib"; +import { ConfirmDialog } from "@/shared/ui/confirm-dialog"; import { STATISTICS_INPUT_URL_PATH, verificationSearchToFormValues, } from "@/shared/lib/stats-cd-input-navigation"; import { statisticsInputSewSupplyRateApi } from "@/entities/statistics/api/input/statistics-input-sew-supply-rate-service"; import { sewagePipeMaintainApiService } from "@/entities/statistics/api/input/statistics-input-sewage-pipe-maintain-service"; -import type { SewagePipeMaintainRequestParams } from "@/entities/statistics/model/input/statistics-input-sewage-pipe-maintain-interface"; -import { LoadingWithAnimation } from "@common/components"; +import { statisticsApi } from "@/entities/statistics/api/statistics-service"; +import type { + SewagePipeMaintainDredgingSaveItem, + SewagePipeMaintainDredgingSaveRequest, + SewagePipeMaintainDredgingSubmitRequest, + SewagePipeMaintainRepairSaveItem, + SewagePipeMaintainRepairSaveRequest, + SewagePipeMaintainRepairSubmitRequest, + SewagePipeMaintainRepairVerificationResult, + SewagePipeMaintainRequestParams, +} from "@/entities/statistics/model/input/statistics-input-sewage-pipe-maintain-interface"; +import { isInputPeriodCurrent } from "@/entities/statistics/model/input/statistics-sewage-supply-rate-input-interface"; +import type { StatisticsInputSubmitInfoFormData } from "@/entities/statistics/model/statistics-input-submit-info-schema"; +import type { + cancelSubmitRequest, + ImrStatusResponse, + SewerageStatusInfo, +} from "@/entities/statistics/model/statistics-interface"; +import { + LoadingWithAnimation, + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@common/components"; +import { getUserInfo } from "../../../../../../packages/common/components/src/api"; + +import SubmitDailog from "./SubmitDailog"; + +import CancelSubmitButton from "@/components/button/CancelSubmitButton"; import LastYearDataButton from "@/components/button/LastYearDataButton"; import SaveButton from "@/components/button/SaveButton"; -import SubmitExceptionButton from "@/components/button/SubmitExceptionButton"; import SubmitButton from "@/components/button/SumitButton"; +import VerificationResultButton from "@/components/button/VerificationResultButton"; +import DataGrid from "@/components/module/DataGrid"; import SearchForm from "@/components/module/SearchForm"; -import TabDataGrid from "@/components/module/TabDataGrid"; +import { openVerificationResultDialogIfNeeded } from "@/components/module/post-submit-verification-results"; +import { VerificationResultDialog } from "@/components/module/VerificationResultDialog"; +import { VerificationResultHistoryDialog } from "@/components/module/VerificationResultHistoryDialog"; +import type { ColumnConfig, ColumnInfo } from "@/components/type/DataGrid.type"; import type { SearchFormValues } from "@/components/type/SearchForm.type"; +import { doList } from "@/components/util/column/input/DoList"; import { PipeDredgingStatusInpColumn } from "@/components/util/column/input/sewagePipeMaintain/PipeDredgingStatusInpColumn"; import { RepairStatusInpColumn } from "@/components/util/column/input/sewagePipeMaintain/RepairStatusInpColumn"; import { toGridRows } from "@/components/util/grid/gridFunction"; +import { useAdminStore } from "@/store/admin-store"; import { useStatisticsInputFocusStore } from "@/store/statistics-input-focus-store"; import { useTabStore } from "@/store/tab-store"; @@ -36,15 +73,224 @@ function buildSewagePipeMaintainSearchParam( }; } +const SewagePipeMaintainInpTabList = [ + RepairStatusInpColumn, + PipeDredgingStatusInpColumn, +] as const; + +const firstTabKey = RepairStatusInpColumn.tabKey; + +const toGridColumnInfo = (tab: (typeof SewagePipeMaintainInpTabList)[number]): ColumnInfo => ({ + columns: tab.columns ?? [], + columnLayout: tab.columnLayout ?? [], +}); + +type GridRowWithVerification = Record & { + verificationResult?: SewagePipeMaintainRepairVerificationResult; +}; + +type TabToolbarSnapshot = { + showSaveInToolbar: boolean; + showEnterableToolbarButtons: boolean; + showActionToolbarCard: boolean; +}; + +const computeTabEnterable = ( + statusInfo: SewerageStatusInfo | null | undefined, + periodEnterable: boolean, +): boolean => { + if (statusInfo?.laeSbmsnSttsCd) { + if (["2", "3"].includes(statusInfo.laeSbmsnSttsCd)) { + return false; + } + return periodEnterable; + } + return periodEnterable; +}; + +function buildGugunOkForToolbar(values: SearchFormValues | null): boolean { + return doList.includes(values?.bsnmCd || "") + ? Boolean(values?.gugun && values.gugun !== "ALL") + : true; +} + +function computeTabToolbarSnapshot( + statusInfo: SewerageStatusInfo | null, + hasRowData: boolean, + treatAsSearched: boolean, + searchContext: SearchFormValues | null, + periodEnterable: boolean, + isAdminInfo: boolean, +): TabToolbarSnapshot { + const gugunOk = buildGugunOkForToolbar(searchContext); + const enterable = computeTabEnterable(statusInfo, periodEnterable); + const isSubmitted = statusInfo?.laeSbmsnSttsCd === "2"; + const showSaveInToolbar = (enterable || isAdminInfo) && gugunOk; + const showEnterableToolbarButtons = enterable && gugunOk; + const showSubmittedInInputPeriodToolbar = + isSubmitted && periodEnterable && gugunOk; + const showActionToolbarCard = + (hasRowData || treatAsSearched) && + (showSaveInToolbar || + showEnterableToolbarButtons || + showSubmittedInInputPeriodToolbar || + (isSubmitted && isAdminInfo)); + return { showSaveInToolbar, showEnterableToolbarButtons, showActionToolbarCard }; +} + +function tabRowDataExists( + m: Map[]>, + tabKey: string, +): boolean { + const rows = m.get(tabKey); + return Array.isArray(rows) && rows.length > 0; +} + +function normalizeGridString(value: unknown): string { + if (value === null || value === undefined) return ""; + return typeof value === "string" ? value : String(value); +} + +function normalizeGridRmrk(value: unknown): string | null { + if (value === null || value === undefined) return null; + const s = typeof value === "string" ? value : String(value); + return s.trim() === "" ? null : s; +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function waitForImrJobCompleted( + jobId: string, + getStatus: (id: string) => Promise, + options?: { + pollIntervalMs?: number; + maxAttempts?: number; + onProgress?: (jobProgress: number) => void; + }, +): Promise { + const pollIntervalMs = options?.pollIntervalMs ?? 2000; + const maxAttempts = options?.maxAttempts ?? 150; + for (let i = 0; i < maxAttempts; i++) { + const res = await getStatus(jobId); + options?.onProgress?.(Math.max(0, Math.min(100, Number(res.job_prog) || 0))); + if (res.status === "COMPLETED" && Number(res.job_prog) === 100) return; + if (res.status === "FAILED" || res.status === "ERROR" || res.status === "CANCELLED") { + throw new Error(res.error_msg || `IMR 검증이 ${res.status} 상태로 종료되었습니다.`); + } + await sleep(pollIntervalMs); + } + throw new Error("IMR 검증 완료 대기 시간이 초과되었습니다."); +} + +function buildColumnFormatNumber( + columns: { fieldName?: string; dataFormat?: string }[], +) { + const columnFormatMap = new Map(); + columns.forEach((col) => { + if (col.fieldName && col.dataFormat) { + columnFormatMap.set(col.fieldName, col.dataFormat); + } + }); + + return (value: string | number | null | undefined, fieldName: string): number => { + if (value === null || value === undefined || value === "") return 0; + const num = + typeof value === "string" ? parseFloat(value.replace(/,/g, "")) : value; + if (isNaN(num)) return 0; + const format = columnFormatMap.get(fieldName); + if (!format) return num; + const decimalPlaces = format.match(/\.(\d+)/)?.[1]?.length ?? 0; + return decimalPlaces > 0 ? parseFloat(num.toFixed(decimalPlaces)) : Math.round(num); + }; +} + +function buildRepairRowList( + currentData: Record[], +): SewagePipeMaintainRepairSaveItem[] { + const formatNumber = buildColumnFormatNumber(RepairStatusInpColumn.columns); + + return currentData.map((d) => ({ + admdstCd: normalizeGridString(d.admdstCd), + sggNm: normalizeGridString(d.sggNm), + admdstNm: normalizeGridString(d.admdstNm), + mendngWhol: formatNumber(d.mendngWhol, "mendngWhol"), + cbsysMendngPrlg: formatNumber(d.cbsysMendngPrlg, "cbsysMendngPrlg"), + sprsysExlnPrlg: formatNumber(d.sprsysExlnPrlg, "sprsysExlnPrlg"), + sprsysDrtwPrlg: formatNumber(d.sprsysDrtwPrlg, "sprsysDrtwPrlg"), + cbsysPartMendngPrlg: formatNumber(d.cbsysPartMendngPrlg, "cbsysPartMendngPrlg"), + sprsysExlnMendngPrlg: formatNumber(d.sprsysExlnMendngPrlg, "sprsysExlnMendngPrlg"), + sprsysDrtwMendngPrlg: formatNumber(d.sprsysDrtwMendngPrlg, "sprsysDrtwMendngPrlg"), + cbsysMnhlCnt: formatNumber(d.cbsysMnhlCnt, "cbsysMnhlCnt"), + sprsysDrtwMnhlCnt: formatNumber(d.sprsysDrtwMnhlCnt, "sprsysDrtwMnhlCnt"), + sprsysExlnMnhlCnt: formatNumber(d.sprsysExlnMnhlCnt, "sprsysExlnMnhlCnt"), + drtwRcvprpCnt: formatNumber(d.drtwRcvprpCnt, "drtwRcvprpCnt"), + exlnRcvprpCnt: formatNumber(d.exlnRcvprpCnt, "exlnRcvprpCnt"), + stofcbrCnt: formatNumber(d.stofcbrCnt, "stofcbrCnt"), + otflCnt: formatNumber(d.otflCnt, "otflCnt"), + cctvExmnPrlg: formatNumber(d.cctvExmnPrlg, "cctvExmnPrlg"), + rmrk: normalizeGridRmrk(d.rmrk), + })); +} + +function buildDredgingRowList( + currentData: Record[], +): SewagePipeMaintainDredgingSaveItem[] { + const formatNumber = buildColumnFormatNumber(PipeDredgingStatusInpColumn.columns); + + return currentData.map((d) => ({ + admdstCd: normalizeGridString(d.admdstCd), + sggNm: normalizeGridString(d.sggNm), + admdstNm: normalizeGridString(d.admdstNm), + wholFcltPrlg: formatNumber(d.wholFcltPrlg, "wholFcltPrlg"), + wholSwagePpl: formatNumber(d.wholSwagePpl, "wholSwagePpl"), + cbsysPplPrlg: formatNumber(d.cbsysPplPrlg, "cbsysPplPrlg"), + sprsysExlnPrlg: formatNumber(d.sprsysExlnPrlg, "sprsysExlnPrlg"), + sprsysDrtwPrlg: formatNumber(d.sprsysDrtwPrlg, "sprsysDrtwPrlg"), + mnhlCnt: formatNumber(d.mnhlCnt, "mnhlCnt"), + exlnDrtwRcvprpCnt: formatNumber(d.exlnDrtwRcvprpCnt, "exlnDrtwRcvprpCnt"), + stofcbrOtflCnt: formatNumber(d.stofcbrOtflCnt, "stofcbrOtflCnt"), + drdgnPlanPrlg: formatNumber(d.drdgnPlanPrlg, "drdgnPlanPrlg"), + drdgnTrgtCtrsDrdgnRt: formatNumber(d.drdgnTrgtCtrsDrdgnRt, "drdgnTrgtCtrsDrdgnRt"), + wholPrlgCtrsDrdgnRt: formatNumber(d.wholPrlgCtrsDrdgnRt, "wholPrlgCtrsDrdgnRt"), + drdgnAmt: formatNumber(d.drdgnAmt, "drdgnAmt"), + drdgnQnt: formatNumber(d.drdgnQnt, "drdgnQnt"), + drdgnUntprcAmt: formatNumber(d.drdgnUntprcAmt, "drdgnUntprcAmt"), + wholDrdgnPrcsQnt: formatNumber(d.wholDrdgnPrcsQnt, "wholDrdgnPrcsQnt"), + rclmQnt: formatNumber(d.rclmQnt, "rclmQnt"), + oceanEmsnQnt: formatNumber(d.oceanEmsnQnt, "oceanEmsnQnt"), + rcycQnt: formatNumber(d.rcycQnt, "rcycQnt"), + etcQnt: formatNumber(d.etcQnt, "etcQnt"), + rmrk: normalizeGridRmrk(d.rmrk), + })); +} + // 하수도 통계 관리 > 03. 하수관로 유지관리 const SewagePipeMaintainInp = () => { const { setMessage: alert } = useAlertStore((state) => state); + const { isAdminInfo } = useAdminStore(); + const toolbarGroupAllowed = useStatisticsInputToolbarGroupGate(); + const { confirm } = useConfirm(); + const showGlobalProgress = useGlobalProgressStore((state) => state.show); + const setGlobalProgress = useGlobalProgressStore((state) => state.setProgress); + const setGlobalProgressLabel = useGlobalProgressStore((state) => state.setLabel); + const hideGlobalProgress = useGlobalProgressStore((state) => state.hide); + const [isLoading, setIsLoading] = useState(false); const [searchValues, setSearchValues] = useState(null); - const [activeTab, setActiveTab] = useState(RepairStatusInpColumn.tabKey); + const [activeTab, setActiveTab] = useState(firstTabKey); const [lastSearchedValues, setLastSearchedValues] = useState(null); - const [lastSearchedYear, setLastSearchedYear] = useState(null); const [hasSearched, setHasSearched] = useState(false); + const [inputPeriodEnterable, setInputPeriodEnterable] = useState(true); + const [tabStatusInfoMap, setTabStatusInfoMap] = useState< + Map + >(new Map()); + const [tabStatsCdMap, setTabStatsCdMap] = useState>(new Map()); + const [toolbarSnapshotByTab, setToolbarSnapshotByTab] = useState< + Map + >(new Map()); + const [verificationHistoryOpen, setVerificationHistoryOpen] = useState(false); + const [verificationResultDialogOpen, setVerificationResultDialogOpen] = useState(false); + const [showSubmitDialog, setShowSubmitDialog] = useState(false); const searchParamRef = useRef(null); const postVerificationSearchRef = useRef<{ values: SearchFormValues; @@ -53,15 +299,19 @@ const SewagePipeMaintainInp = () => { const [verificationSearchDefaults, setVerificationSearchDefaults] = useState(null); const [searchFormRemountKey, setSearchFormRemountKey] = useState(0); + const [rowDataMap, setRowDataMap] = useState< + Map[]> + >(new Map()); - // tab List - const SewagePipeMaintainInpTabList = [ - RepairStatusInpColumn, - PipeDredgingStatusInpColumn - ]; + const searchDoneRef = useRef(false); + const periodEnterableRef = useRef(true); + const activeTabRef = useRef(activeTab); + const getGridDataRef = useRef<(() => Record[]) | null>(null); + activeTabRef.current = activeTab; const activeTabId = useTabStore((s) => s.activeTabId); const inputFocusNonce = useStatisticsInputFocusStore((s) => s.nonce); + useLayoutEffect(() => { if (activeTabId !== STATISTICS_INPUT_URL_PATH.SEWAGE_PIPE_MAINTAIN) return; const pending = useStatisticsInputFocusStore @@ -78,52 +328,126 @@ const SewagePipeMaintainInp = () => { } }, [activeTabId, inputFocusNonce]); - const SubmitExceptionFunc = () => { - console.log('submit exception'); - // 예외 제출 로직 + const commitToolbarSnapshotForLoadedTab = ( + tabKey: string, + statusInfo: SewerageStatusInfo | null, + hasRowData: boolean, + searchContext: SearchFormValues, + ) => { + setToolbarSnapshotByTab((prev) => { + const next = new Map(prev); + next.set( + tabKey, + computeTabToolbarSnapshot( + statusInfo, + hasRowData, + searchDoneRef.current, + searchContext, + periodEnterableRef.current, + isAdminInfo, + ), + ); + return next; + }); + }; + + const applyRowMap = ( + mergeIntoExistingMap: boolean, + apply: (m: Map[]>) => void, + ) => { + if (mergeIntoExistingMap) { + setRowDataMap((prev) => { + const next = new Map(prev); + apply(next); + return next; + }); + } else { + const next = new Map[]>(); + apply(next); + setRowDataMap(next); + } }; const loadActiveTabData = async ( tabKey: string, param: SewagePipeMaintainRequestParams, - append: boolean, - ): Promise => { + searchContext: SearchFormValues, + mergeIntoExistingMap: boolean, + updateLastSearchedValues: boolean, + overrides?: { mode?: string }, + ): Promise => { + const requestParam = overrides?.mode + ? { ...param, mode: overrides.mode } + : param; + if (tabKey === RepairStatusInpColumn.tabKey) { - const response = await sewagePipeMaintainApiService.getSewagePipeMaintainRepair(param); - if (response.status !== "success") { + const response = + await sewagePipeMaintainApiService.getSewagePipeMaintainRepair( + requestParam, + ); + if (response.status !== "success" || !response.data) { alert(response.message || "데이터 조회 중 오류가 발생했습니다."); - return; + return false; } - const commonInfo = response.data.commonInfo; - const convertedData = (response.data.repairStatusList ?? []).map((d) => ({ + const { commonInfo, statusInfo, repairStatusList } = response.data; + const convertedData = (repairStatusList ?? []).map((d) => ({ ctpvNm: commonInfo.ctpvNm, ...d, })); - setRowDataMap((prev) => { - const next = append ? new Map(prev) : new Map[]>(); - next.set(RepairStatusInpColumn.tabKey, toGridRows(convertedData)); - return next; + const gridRows = toGridRows(convertedData); + + applyRowMap(mergeIntoExistingMap, (m) => { + m.set(RepairStatusInpColumn.tabKey, gridRows); }); - return; + setTabStatusInfoMap((prev) => new Map(prev).set(tabKey, statusInfo)); + setTabStatsCdMap((prev) => new Map(prev).set(tabKey, commonInfo.statsCd)); + if (updateLastSearchedValues) { + setLastSearchedValues(searchContext); + } + commitToolbarSnapshotForLoadedTab( + tabKey, + statusInfo, + gridRows.length > 0, + searchContext, + ); + return true; } if (tabKey === PipeDredgingStatusInpColumn.tabKey) { - const response = await sewagePipeMaintainApiService.getSewagePipeMaintainDredging(param); - if (response.status !== "success") { + const response = + await sewagePipeMaintainApiService.getSewagePipeMaintainDredging( + requestParam, + ); + if (response.status !== "success" || !response.data) { alert(response.message || "데이터 조회 중 오류가 발생했습니다."); - return; + return false; } - const commonInfo = response.data.commonInfo; - const convertedData = (response.data.pipeDredgingStatusList ?? []).map((d) => ({ + const { commonInfo, statusInfo, pipeDredgingStatusList } = response.data; + const convertedData = (pipeDredgingStatusList ?? []).map((d) => ({ ctpvNm: commonInfo.ctpvNm, ...d, })); - setRowDataMap((prev) => { - const next = append ? new Map(prev) : new Map[]>(); - next.set(PipeDredgingStatusInpColumn.tabKey, toGridRows(convertedData)); - return next; + const gridRows = toGridRows(convertedData); + + applyRowMap(mergeIntoExistingMap, (m) => { + m.set(PipeDredgingStatusInpColumn.tabKey, gridRows); }); + setTabStatusInfoMap((prev) => new Map(prev).set(tabKey, statusInfo)); + setTabStatsCdMap((prev) => new Map(prev).set(tabKey, commonInfo.statsCd)); + if (updateLastSearchedValues) { + setLastSearchedValues(searchContext); + } + commitToolbarSnapshotForLoadedTab( + tabKey, + statusInfo, + gridRows.length > 0, + searchContext, + ); + return true; } + + alert("해당 탭 조회 API가 없습니다."); + return false; }; const getLastYearDataFunc = async () => { @@ -132,7 +456,7 @@ const SewagePipeMaintainInp = () => { alert("시도를 선택해주세요."); return; } - if (!lastSearchedYear) { + if (!hasSearched) { alert("먼저 데이터를 조회해주세요."); return; } @@ -140,7 +464,14 @@ const SewagePipeMaintainInp = () => { try { setIsLoading(true); const param = buildSewagePipeMaintainSearchParam(values, { mode: "PRE" }); - await loadActiveTabData(activeTab, param, true); + await loadActiveTabData( + activeTabRef.current, + param, + values, + true, + false, + { mode: "PRE" }, + ); } catch { alert("데이터 조회 중 오류가 발생했습니다."); } finally { @@ -148,27 +479,365 @@ const SewagePipeMaintainInp = () => { } }; + const getTabGridRows = (tabKey: string): Record[] => + getGridDataRef.current?.() ?? rowDataMap.get(tabKey) ?? []; + + const getRepairGridRows = (): Record[] => + getTabGridRows(RepairStatusInpColumn.tabKey); + + const getDredgingGridRows = (): Record[] => + getTabGridRows(PipeDredgingStatusInpColumn.tabKey); + const submitFunc = () => { - console.log('submit'); - // 제출 로직 + void (async () => { + const tabKey = activeTabRef.current; + const isRepairTab = tabKey === RepairStatusInpColumn.tabKey; + const isDredgingTab = tabKey === PipeDredgingStatusInpColumn.tabKey; + if (!isRepairTab && !isDredgingTab) { + alert("제출할 수 없는 탭입니다."); + return; + } + if (!lastSearchedValues || !searchParamRef.current) { + alert("먼저 조회를 진행해주세요."); + return; + } + if (!buildGugunOkForToolbar(lastSearchedValues)) { + alert("시군구를 선택해주세요."); + return; + } + const currentData = isRepairTab ? getRepairGridRows() : getDredgingGridRows(); + if (!currentData.length) { + alert("제출할 데이터가 없습니다."); + return; + } + if (await confirm("제출 하시겠습니까?")) { + setShowSubmitDialog(true); + } + })(); + }; + + const executeSubmit = async (submitInfo: StatisticsInputSubmitInfoFormData) => { + const tabKey = activeTabRef.current; + const isRepairTab = tabKey === RepairStatusInpColumn.tabKey; + const isDredgingTab = tabKey === PipeDredgingStatusInpColumn.tabKey; + if (!isRepairTab && !isDredgingTab) { + alert("제출할 수 없는 탭입니다."); + return; + } + if (!lastSearchedValues || !searchParamRef.current) { + alert("먼저 조회를 진행해주세요."); + return; + } + + const currentData = isRepairTab ? getRepairGridRows() : getDredgingGridRows(); + if (!currentData.length) { + alert("제출할 데이터가 없습니다."); + return; + } + + const activeTabStatus = tabStatusInfoMap.get(tabKey) ?? null; + const statsCdForTab = tabStatsCdMap.get(tabKey) ?? tabKey; + + const submitInfoBase = { + statsYr: lastSearchedValues.year ?? "", + ctpvNm: lastSearchedValues.sido ?? "", + sggNm: + lastSearchedValues.gugun === "ALL" + ? "" + : (lastSearchedValues.gugun ?? ""), + sewerBzmnCd: lastSearchedValues.bsnmCd ?? "", + laePicNm: submitInfo.laePicNm, + laeKbrdrJbgdNm: submitInfo.laeKbrdrJbgdNm, + laeKbrdrDeptNm: submitInfo.laeKbrdrDeptNm, + laePicTelNo: submitInfo.laePicTelNo, + laeIdfrNm: submitInfo.laeIdfrNm, + laeIdfrJbgdNm: submitInfo.laeIdfrJbgdNm, + laeIdfrDeptNm: submitInfo.laeIdfrDeptNm, + laeIdfrTelNo: submitInfo.laeIdfrTelNo, + }; + + const verificationQuery = { + sewerBzmnCd: lastSearchedValues.bsnmCd ?? "", + ctpvNm: lastSearchedValues.sido ?? "", + sggNm: + lastSearchedValues.gugun === "ALL" + ? "" + : (lastSearchedValues.gugun ?? ""), + statsYr: lastSearchedValues.year ?? "", + statsCd: statsCdForTab, + }; + + const reloadSubmittedTab = async () => { + await loadActiveTabData( + tabKey, + searchParamRef.current!, + lastSearchedValues, + false, + false, + ); + }; + + try { + showGlobalProgress({ label: "서버에 요청 중...", progress: 0 }); + const userInfo = await getUserInfo(); + + setGlobalProgressLabel("일반 검증 진행 중..."); + const response = isRepairTab + ? await sewagePipeMaintainApiService.submitSewagePipeMaintainRepair({ + ...submitInfoBase, + userId: userInfo.userUnqId, + submitList: buildRepairRowList(currentData), + } satisfies SewagePipeMaintainRepairSubmitRequest) + : await sewagePipeMaintainApiService.submitSewagePipeMaintainDredging({ + ...submitInfoBase, + userId: userInfo.userUnqId, + submitList: buildDredgingRowList(currentData), + } satisfies SewagePipeMaintainDredgingSubmitRequest); + + if (response.status !== "success") { + alert(response.message || "데이터 제출 중 오류가 발생했습니다."); + return; + } + + const finalStatus = response.data?.finalStatus; + const nvpassed = response.data?.nvpassed; + + if (finalStatus === "5") { + setGlobalProgress(50); + setGlobalProgressLabel("IMR 검증 진행 중..."); + const imrParam = { + user_id: userInfo.userUnqId, + sewer_bzmn_cd: lastSearchedValues.bsnmCd ?? "", + stats_cd: statsCdForTab, + stats_yr: lastSearchedValues.year ?? "", + lae_sbmsn_stts_cd: activeTabStatus?.laeSbmsnSttsCd === "4" ? "4" : "2", + gen_verify_stts_cd: nvpassed ? "1" : "2", + }; + + try { + const imrResponse = await statisticsApi.callImrService(imrParam); + if (imrResponse.job_id) { + await waitForImrJobCompleted( + imrResponse.job_id, + (id) => statisticsApi.getImrStatus(id), + { + onProgress: (jobProgress) => { + setGlobalProgress(50 + Math.round(jobProgress / 2)); + }, + }, + ); + } + setGlobalProgress(100); + await reloadSubmittedTab(); + await openVerificationResultDialogIfNeeded( + verificationQuery, + setVerificationResultDialogOpen, + { notify: alert }, + ); + } catch (e) { + await reloadSubmittedTab(); + await openVerificationResultDialogIfNeeded(verificationQuery, setVerificationResultDialogOpen, { + notify: alert, + notifySubmitComplete: false, + }); + const msg = + e instanceof Error && e.message + ? e.message + : "IMR 검증 중 오류가 발생했습니다.\n관리자에게 문의하세요."; + alert(msg); + } + } else { + setGlobalProgress(100); + await reloadSubmittedTab(); + await openVerificationResultDialogIfNeeded( + verificationQuery, + setVerificationResultDialogOpen, + { notify: alert }, + ); + } + } catch { + alert("데이터 제출 중 오류가 발생했습니다."); + } finally { + hideGlobalProgress(); + } }; const saveFunc = () => { - console.log('save'); - // 저장 로직 + void (async () => { + const tabKey = activeTabRef.current; + const isRepairTab = tabKey === RepairStatusInpColumn.tabKey; + const isDredgingTab = tabKey === PipeDredgingStatusInpColumn.tabKey; + if (!isRepairTab && !isDredgingTab) { + alert("저장할 수 없는 탭입니다."); + return; + } + + if (!lastSearchedValues || !searchParamRef.current) { + alert("먼저 조회를 진행해주세요."); + return; + } + + if (!buildGugunOkForToolbar(lastSearchedValues)) { + alert("시군구를 선택해주세요."); + return; + } + + if (!(await confirm("저장 하시겠습니까?"))) return; + + const baseRequest = { + statsYr: lastSearchedValues.year ?? "", + ctpvNm: lastSearchedValues.sido ?? "", + sggNm: + lastSearchedValues.gugun === "ALL" + ? "" + : (lastSearchedValues.gugun ?? ""), + sewerBzmnCd: lastSearchedValues.bsnmCd ?? "", + }; + + try { + showGlobalProgress({ label: "저장 진행 중...", progress: 0 }); + const userInfo = await getUserInfo(); + + if (isRepairTab) { + const currentData = getRepairGridRows(); + if (!currentData.length) { + alert("저장할 데이터가 없습니다."); + return; + } + + const param: SewagePipeMaintainRepairSaveRequest = { + ...baseRequest, + userId: userInfo.userUnqId, + saveList: buildRepairRowList(currentData), + }; + if (isAdminInfo) { + param.superAdmin = "superAdmin"; + } + + const response = + await sewagePipeMaintainApiService.saveSewagePipeMaintainRepair( + param, + ); + + if (response.status === "success") { + setGlobalProgress(50); + await loadActiveTabData( + RepairStatusInpColumn.tabKey, + searchParamRef.current, + lastSearchedValues, + false, + false, + ); + setGlobalProgress(100); + alert("저장되었습니다."); + } else { + alert(response.message || "데이터 저장 중 오류가 발생했습니다."); + } + return; + } + + const currentData = getDredgingGridRows(); + if (!currentData.length) { + alert("저장할 데이터가 없습니다."); + return; + } + + const param: SewagePipeMaintainDredgingSaveRequest = { + ...baseRequest, + userId: userInfo.userUnqId, + saveList: buildDredgingRowList(currentData), + }; + if (isAdminInfo) { + param.superAdmin = "superAdmin"; + } + + const response = + await sewagePipeMaintainApiService.saveSewagePipeMaintainDredging( + param, + ); + + if (response.status === "success") { + setGlobalProgress(50); + await loadActiveTabData( + PipeDredgingStatusInpColumn.tabKey, + searchParamRef.current, + lastSearchedValues, + false, + false, + ); + setGlobalProgress(100); + alert("저장되었습니다."); + } else { + alert(response.message || "데이터 저장 중 오류가 발생했습니다."); + } + } catch { + alert("데이터 저장 중 오류가 발생했습니다."); + } finally { + hideGlobalProgress(); + } + })(); }; - const [rowDataMap, setRowDataMap] = useState[]>>(new Map()); + const cnacelSubmitFunc = () => { + void (async () => { + try { + if (!(await confirm("제출된 내용을 취소하시겠습니까?"))) return; + + if (!lastSearchedValues || !searchParamRef.current) { + alert("먼저 조회를 진행해주세요."); + return; + } + + const userInfo = await getUserInfo(); + const tabKey = activeTabRef.current; + const statsCdForTab = tabStatsCdMap.get(tabKey) ?? tabKey; + + const requestData: cancelSubmitRequest = { + sewerBzmnCd: lastSearchedValues.bsnmCd ?? "", + ctpvNm: lastSearchedValues.sido ?? "", + sggNm: + lastSearchedValues.gugun === "ALL" + ? "" + : (lastSearchedValues.gugun ?? ""), + statsYr: lastSearchedValues.year ?? "", + mdfrId: userInfo.userUnqId, + statsCd: statsCdForTab, + }; + + const response = await statisticsApi.cancelSubmit(requestData); + + if (response.status === "success") { + await loadActiveTabData( + tabKey, + searchParamRef.current, + lastSearchedValues, + false, + false, + ); + alert("제출 취소되었습니다."); + } else { + alert(response.message ?? "제출 취소 중 오류가 발생했습니다."); + } + } catch { + alert("제출 취소 중 오류가 발생했습니다."); + } + })(); + }; const handleReset = () => { setRowDataMap(new Map()); - setActiveTab(RepairStatusInpColumn.tabKey); + setTabStatusInfoMap(new Map()); + setTabStatsCdMap(new Map()); + setToolbarSnapshotByTab(new Map()); + setActiveTab(firstTabKey); setLastSearchedValues(null); - setLastSearchedYear(null); setHasSearched(false); + setInputPeriodEnterable(true); searchParamRef.current = null; setVerificationSearchDefaults(null); setSearchFormRemountKey((k) => k + 1); + searchDoneRef.current = false; + periodEnterableRef.current = true; }; const handleSearch = async ( @@ -181,24 +850,53 @@ const SewagePipeMaintainInp = () => { } try { - const periodResponse = await statisticsInputSewSupplyRateApi.checkInputPeriod(values.year || ""); + const periodResponse = await statisticsInputSewSupplyRateApi.checkInputPeriod( + values.year || "", + ); if (periodResponse.status !== "success") { alert(periodResponse.message); return; } + const periods = Array.isArray(periodResponse.data) + ? periodResponse.data + : []; + const periodOk = isInputPeriodCurrent(periods); + periodEnterableRef.current = periodOk; + setInputPeriodEnterable(periodOk); - const tabForLoad = opts?.forcedTabKey ?? activeTab; + const yearChangedFromLastSearch = + lastSearchedValues != null && + (lastSearchedValues.year ?? "") !== (values.year ?? ""); + + if (yearChangedFromLastSearch) { + setRowDataMap(new Map()); + setTabStatusInfoMap(new Map()); + setTabStatsCdMap(new Map()); + setToolbarSnapshotByTab(new Map()); + activeTabRef.current = firstTabKey; + setActiveTab(firstTabKey); + } + + const tabForLoad = opts?.forcedTabKey ?? activeTabRef.current; if (opts?.forcedTabKey) { + activeTabRef.current = opts.forcedTabKey; setActiveTab(opts.forcedTabKey); } setIsLoading(true); + searchDoneRef.current = true; + setHasSearched(true); const searchParam = buildSewagePipeMaintainSearchParam(values); searchParamRef.current = searchParam; - setLastSearchedValues(values); - setLastSearchedYear(values.year ?? null); - setHasSearched(true); - await loadActiveTabData(tabForLoad, searchParam, false); + + const ok = await loadActiveTabData( + tabForLoad, + searchParam, + values, + !yearChangedFromLastSearch, + true, + ); + if (!ok) return; } catch { alert("데이터 조회 중 오류가 발생했습니다."); } finally { @@ -211,16 +909,22 @@ const SewagePipeMaintainInp = () => { if (!job) return; postVerificationSearchRef.current = null; void handleSearch(job.values, { forcedTabKey: job.tabKey }); - }, [searchFormRemountKey]); const handleActiveTabChange = async (tabKey: string) => { + activeTabRef.current = tabKey; setActiveTab(tabKey); - const hasRows = (rowDataMap.get(tabKey) ?? []).length > 0; - if (hasRows || !searchParamRef.current) return; + if (!hasSearched || !lastSearchedValues || !searchParamRef.current) return; + if (tabRowDataExists(rowDataMap, tabKey)) return; try { setIsLoading(true); - await loadActiveTabData(tabKey, searchParamRef.current, true); + await loadActiveTabData( + tabKey, + searchParamRef.current, + lastSearchedValues, + true, + false, + ); } catch { alert("데이터 조회 중 오류가 발생했습니다."); } finally { @@ -228,41 +932,247 @@ const SewagePipeMaintainInp = () => { } }; - const hasRowData = Array.from(rowDataMap.values()).some((rows) => rows.length > 0); - + const columnInfoByTab = useMemo(() => { + const map = new Map(); + for (const tab of SewagePipeMaintainInpTabList) { + const tabKey = tab.tabKey; + const canEdit = + computeTabEnterable( + tabStatusInfoMap.get(tabKey) ?? null, + inputPeriodEnterable, + ) || isAdminInfo; + const columns = (tab.columns ?? []).map((col: ColumnConfig) => + canEdit + ? col + : { + ...col, + editable: false as const, + styleName: col.styleName ?? "rg-cell-readonly", + }, + ); + map.set(tabKey, { + columns, + columnLayout: tab.columnLayout ?? [], + }); + } + return map; + }, [tabStatusInfoMap, inputPeriodEnterable, isAdminInfo]); + + const searchContextForToolbar = lastSearchedValues ?? searchValues; + const activeTabStatus = tabStatusInfoMap.get(activeTab) ?? null; + const activeTabToolbar = + toolbarSnapshotByTab.get(activeTab) ?? + computeTabToolbarSnapshot( + activeTabStatus, + tabRowDataExists(rowDataMap, activeTab), + hasSearched, + searchContextForToolbar, + inputPeriodEnterable, + isAdminInfo, + ); + + const { + showSaveInToolbar: baseShowSaveInToolbar, + showEnterableToolbarButtons: baseShowEnterableToolbarButtons, + showActionToolbarCard: baseShowActionToolbarCard, + } = activeTabToolbar; + + const showSaveInToolbar = baseShowSaveInToolbar && toolbarGroupAllowed; + const showEnterableToolbarButtons = + baseShowEnterableToolbarButtons && toolbarGroupAllowed; + const showActionToolbarCard = + baseShowActionToolbarCard && toolbarGroupAllowed; + + const openVerificationHistoryDialog = () => { + const ctx = lastSearchedValues ?? searchValues; + const statsCd = tabStatsCdMap.get(activeTab); + if (!ctx?.year || !ctx?.sido || !ctx?.bsnmCd || !statsCd) { + alert("먼저 조회를 진행해주세요."); + return; + } + setVerificationHistoryOpen(true); + }; + + const verificationQueryContext = (() => { + const ctx = lastSearchedValues ?? searchValues; + const statsCd = tabStatsCdMap.get(activeTab); + if (!ctx?.year || !ctx?.sido || !ctx?.bsnmCd || !statsCd) return null; + return { + sewerBzmnCd: ctx.bsnmCd, + ctpvNm: ctx.sido, + sggNm: ctx.gugun === "ALL" ? "" : (ctx.gugun ?? ""), + statsYr: ctx.year, + statsCd, + }; + })(); + + const verificationCellStyle = ({ + fieldName, + row, + }: { + fieldName: string; + row: Record; + }): string | undefined => { + const verification = (row as GridRowWithVerification).verificationResult; + if (!verification) return; + const errors = verification[fieldName]; + if (Array.isArray(errors) && errors.length > 0) { + const hasImr = errors.some((e) => e.vrfcCd === "IMR" || e.vrfcCd === "HB"); + return hasImr ? "rg-cell-verify-error-imr" : "rg-cell-verify-error"; + } + }; + + const verificationCellTooltip = ({ + fieldName, + row, + }: { + fieldName: string; + row: Record; + }): string | undefined => { + const verification = (row as GridRowWithVerification).verificationResult; + if (!verification) return; + const errors = verification[fieldName]; + if (Array.isArray(errors) && errors.length > 0) { + return errors + .map((e) => { + const prefix = + e.vrfcCd === "IMR" ? "IMR검증 - " : e.vrfcCd === "NV" ? "일반검증 - " : "HB검증 - "; + return `${prefix}${e.rsnExpln}(검증 기준값: ${e.compVlCn} / 현재 입력값: ${e.nowVlCn})`; + }) + .filter(Boolean) + .join("\n"); + } + }; + return ( <> - - {(hasRowData || hasSearched) && ( -
-
- - void getLastYearDataFunc()} /> - - +
+ + {showActionToolbarCard && ( +
+
+ + {showEnterableToolbarButtons && ( + <> + void getLastYearDataFunc()} + /> + + + )} + {activeTabStatus?.laeSbmsnSttsCd === "2" && ( + + )} + {showSaveInToolbar && } +
+ )} + {isLoading && ( +
+
+ +
+
+ )} +
+ void handleActiveTabChange(v)} + className="w-full" + variant="default" + > + + {SewagePipeMaintainInpTabList.map((info) => ( + + {info.tabLabel || info.tabKey} + + ))} + + {SewagePipeMaintainInpTabList.map((info) => { + const tabKey = info.tabKey; + const rowData = rowDataMap.get(tabKey) ?? []; + const tabEnterableForGrid = + computeTabEnterable( + tabStatusInfoMap.get(tabKey) ?? null, + inputPeriodEnterable, + ) || isAdminInfo; + const gugunOk = buildGugunOkForToolbar(lastSearchedValues); + const tabStatusInfo = tabStatusInfoMap.get(tabKey) ?? null; + + return ( + + { + setRowDataMap((prev) => { + const next = new Map(prev); + const cur = next.get(tabKey) ?? []; + const n = + typeof updater === "function" + ? updater(cur) + : updater; + next.set(tabKey, n); + return next; + }); + }} + showHeaderCheckbox={false} + enableSort={false} + statusInfo={tabStatusInfo} + isExcelImport={ + tabRowDataExists(rowDataMap, tabKey) && + tabEnterableForGrid && + gugunOk + } + cellStyleCallback={verificationCellStyle} + cellTooltipCallback={verificationCellTooltip} + getGridDataRef={ + activeTab === tabKey ? getGridDataRef : undefined + } + /> + + ); + })} +
- )} - {isLoading &&
-
- -
-
} - + + + + ); }; -export default SewagePipeMaintainInp; \ No newline at end of file +export default SewagePipeMaintainInp; diff --git a/apps/st/src/components/statistics/input/SewageReservoirPumpInp.tsx b/apps/st/src/components/statistics/input/SewageReservoirPumpInp.tsx index 1ea0892c5..d95cb428f 100644 --- a/apps/st/src/components/statistics/input/SewageReservoirPumpInp.tsx +++ b/apps/st/src/components/statistics/input/SewageReservoirPumpInp.tsx @@ -1,85 +1,928 @@ -import { useState } from "react"; +import { + useCallback, + useMemo, + useRef, + useState, + type RefObject, +} from "react"; + +import { flushSync } from "react-dom"; + +import { ValueType } from "realgrid"; +import type { RealGridReact } from "realgrid-react"; +import { LoadingWithAnimation, Tabs, TabsContent, TabsList, TabsTrigger } from "@common/components"; -import SearchForm from "@/components/module/SearchForm"; -import { TabDataGrid } from '@rsone/components'; -import SubmitButton from "@/components/button/SumitButton"; -import SaveButton from "@/components/button/SaveButton"; -import LastYearDataButton from "@/components/button/LastYearDataButton"; -import SubmitExceptionButton from "@/components/button/SubmitExceptionButton"; import AddArticleButton from "@/components/button/AddArticleButton"; +import CancelSubmitButton from "@/components/button/CancelSubmitButton"; import DeleteArticleButton from "@/components/button/DeleteArticleButton"; +import LastYearDataButton from "@/components/button/LastYearDataButton"; import NotApplicableCheckButton from "@/components/button/NotApplicableCheckButton"; - -import { SewageReservoirInpColumn } from "@/components/util/column/input/sewageReservoirPump/SewageReservoirInpColumn"; +import SaveButton from "@/components/button/SaveButton"; +import SubmitButton from "@/components/button/SumitButton"; +import VerificationResultButton from "@/components/button/VerificationResultButton"; +import DataGrid, { applyDropDownColumnToGrid } from "@/components/module/DataGrid"; +import SearchForm from "@/components/module/SearchForm"; +import { VerificationResultHistoryDialog } from "@/components/module/VerificationResultHistoryDialog"; +import type { SearchFormValues } from "@/components/type/SearchForm.type"; +import type { + CellEditedCallbackParams, + CellStyleCallbackParams, + CellTooltipCallbackParams, + ColumnConfig, + ColumnInfo, + ColumnInfoList, +} from "@/components/type/DataGrid.type"; +import { doList } from "@/components/util/column/input/DoList"; import { RainfallPumpStationInpColumn } from "@/components/util/column/input/sewageReservoirPump/RainfallPumpStationInpColumn"; +import { SewageReservoirInpColumn } from "@/components/util/column/input/sewageReservoirPump/SewageReservoirInpColumn"; +import { toGridRows } from "@/components/util/grid/gridFunction"; +import { formatCompactYmdForGrid, normalizeOxYnValue } from "@/components/util/grid/ymdFormat"; +import { statisticsInputSewageReservoirPumpApi } from "@/entities/statistics/api/input/statistics-input-sewage-reservoir-pump-service"; +import { statisticsApi } from "@/entities/statistics/api/statistics-service"; +import { + SEWAGE_RESERVOIR_STATS_CD, + type SewageReservoirItem, + type SewageReservoirRequestParams, + type RainfallPumpStationItem, + type SewageReservoirVerificationResult, +} from "@/entities/statistics/model/input/statistics-input-sewage-reservoir-pump-interface"; +import type { + ApiResponse, + Sgg, + SewerageStatusInfo, +} from "@/entities/statistics/model/statistics-interface"; +import { useAlertStore, useStatisticsInputToolbarGroupGate } from "@/shared/lib"; +import { useAdminStore } from "@/store/admin-store"; + +const SewageReservoirPumpInpTabList = [ + SewageReservoirInpColumn, + RainfallPumpStationInpColumn, +] as ColumnInfoList[]; + +const toGridColumnInfo = (tab: ColumnInfoList): ColumnInfo => ({ + columns: tab.columns ?? [], + columnLayout: tab.columnLayout ?? [], +}); + +const firstTabKey = SewageReservoirPumpInpTabList[0]?.tabKey ?? SEWAGE_RESERVOIR_STATS_CD; +const rainfallPumpTabKey = + RainfallPumpStationInpColumn.tabKey ?? "rainfallPumpStationInp"; + +type GridRowWithVerification = Record & { + verificationResult?: SewageReservoirVerificationResult; +}; + +type TabToolbarSnapshot = { + showSaveInToolbar: boolean; + showEnterableToolbarButtons: boolean; + showActionToolbarCard: boolean; +}; + +const computeTabEnterable = ( + statusInfo: SewerageStatusInfo | null | undefined, + periodEnterable: boolean, +): boolean => { + if (statusInfo?.laeSbmsnSttsCd) { + if (["2", "3"].includes(statusInfo.laeSbmsnSttsCd)) { + return false; + } + return periodEnterable; + } + return periodEnterable; +}; + +function buildGugunOkForToolbar(values: SearchFormValues | null): boolean { + return doList.includes(values?.bsnmCd || "") + ? Boolean(values?.gugun && values.gugun !== "ALL") + : true; +} + +function computeTabToolbarSnapshot( + statusInfo: SewerageStatusInfo | null, + hasRowData: boolean, + treatAsSearched: boolean, + searchContext: SearchFormValues | null, + periodEnterable: boolean, + isAdminInfo: boolean, +): TabToolbarSnapshot { + const gugunOk = buildGugunOkForToolbar(searchContext); + const enterable = computeTabEnterable(statusInfo, periodEnterable); + const isSubmitted = statusInfo?.laeSbmsnSttsCd === "2"; + const showSaveInToolbar = (enterable || isAdminInfo) && gugunOk; + const showEnterableToolbarButtons = enterable && gugunOk; + const showSubmittedInInputPeriodToolbar = + isSubmitted && periodEnterable && gugunOk; + const showActionToolbarCard = + (hasRowData || treatAsSearched) && + (showSaveInToolbar || + showEnterableToolbarButtons || + showSubmittedInInputPeriodToolbar || + (isSubmitted && isAdminInfo)); + return { showSaveInToolbar, showEnterableToolbarButtons, showActionToolbarCard }; +} + +function tabRowDataExists( + m: Map[]>, + tabKey: string, +): boolean { + const rows = m.get(tabKey); + return Array.isArray(rows) && rows.length > 0; +} + +function buildSewageReservoirSearchParam( + values: SearchFormValues, + overrides?: { mode?: string }, +): SewageReservoirRequestParams { + const fcltNm = values.fctName?.trim(); + return { + statsYr: values.year, + sewerBzmnCd: values.bsnmCd, + ctpvNm: values.sido, + sggNm: values.gugun === "ALL" ? "" : values.gugun, + ...(fcltNm ? { fcltNm } : {}), + ...(overrides?.mode ? { mode: overrides.mode } : {}), + }; +} + +/** 항목 추가 시 빈 행 (시도는 조회 시 선택한 시도명) */ +function createEmptySewageReservoirRow( + ctpvNm: string, + sggNm = "", +): GridRowWithVerification { + const row: GridRowWithVerification = { ctpvNm, sggNm }; + for (const col of SewageReservoirInpColumn.columns) { + const fieldName = col.fieldName; + if (fieldName === "ctpvNm" || fieldName === "sggNm") continue; + row[fieldName] = col.valueType === ValueType.NUMBER ? 0 : ""; + } + return row; +} + +/** 빗물펌프장 탭 항목 추가 시 빈 행 */ +function createEmptyRainfallPumpStationRow( + ctpvNm: string, + sggNm = "", +): GridRowWithVerification { + const row: GridRowWithVerification = { ctpvNm, sggNm }; + for (const col of RainfallPumpStationInpColumn.columns) { + const fieldName = col.fieldName; + if (!fieldName) continue; + if (fieldName === "ctpvNm" || fieldName === "sggNm") continue; + row[fieldName] = col.valueType === ValueType.NUMBER ? 0 : ""; + } + return row; +} + +function applySggNmDropDownToReservoirGrid( + gridRef: RefObject, + labels: string[], +) { + if (labels.length === 0) return; + applyDropDownColumnToGrid( + gridRef.current?.gridView as { + setColumnProperty?: (col: string, key: string, value: unknown) => void; + }, + "sggNm", + labels, + ); +} + +function parseSggListFromResponse(res: ApiResponse): Sgg[] { + if (res.status !== "success" || res.data == null) return []; + return Array.isArray(res.data) ? res.data : []; +} + +function buildSggNmColumn( + col: ColumnConfig, + canEdit: boolean, + sggOptions: Sgg[], +): ColumnConfig { + if (!canEdit) { + return { + ...col, + editable: false as const, + styleName: col.styleName ?? "rg-cell-readonly", + }; + } + if (sggOptions.length === 0) { + return { ...col, editable: true }; + } + const labels = sggOptions.map((s) => s.gugun); + return { + ...col, + editable: true, + editor: { + type: "dropdown", + domainOnly: true, + textReadOnly: true, + commitOnSelect: true, + dropDownWhenClick: true, + values: labels, + labels, + }, + values: labels, + labels, + lookupDisplay: true, + }; +} + +function mapSewageReservoirListToGridRows( + list: SewageReservoirItem[], + ctpvNm: string, +): GridRowWithVerification[] { + return list.map((item) => { + const row = toGridRows([ + { + ...item, + ctpvNm, + oprtngStrtYmd: formatCompactYmdForGrid(item.oprtngStrtYmd), + fldngPrvtnYn: normalizeOxYnValue(item.fldngPrvtnYn), + wqPlltnMtgtYn: normalizeOxYnValue(item.wqPlltnMtgtYn), + rtlztnUsgYn: normalizeOxYnValue(item.rtlztnUsgYn), + }, + ])[0] as GridRowWithVerification; + if (item.verificationResult) { + row.verificationResult = item.verificationResult; + } + return row; + }); +} + +function mapRainfallPumpStationListToGridRows( + list: RainfallPumpStationItem[], + ctpvNm: string, +): GridRowWithVerification[] { + return list.map((item) => { + const row = toGridRows([ + { + ...item, + ctpvNm, + oprtngStrtYmd: formatCompactYmdForGrid(item.oprtngStrtYmd), + rsvrYn: normalizeOxYnValue(item.rsvrYn), + prcsgFcltYn: normalizeOxYnValue(item.prcsgFcltYn), + ddrztnEqpmYn: normalizeOxYnValue(item.ddrztnEqpmYn), + oslfEtgtYn: normalizeOxYnValue(item.oslfEtgtYn), + }, + ])[0] as GridRowWithVerification; + + if (item.verificationResult) { + row.verificationResult = item.verificationResult; + } + return row; + }); +} // 하수도 통계 관리 > 04. 하수저류 시설 및 빗물 펌프장 const SewageReservoirPumpInp = () => { - - // tab List - const SewageReservoirPumpInpTabList = [ - SewageReservoirInpColumn, - RainfallPumpStationInpColumn - ]; - - const submitExceptionFunc = () => { - console.log('submit exception'); - // 예외 제출 로직 - }; - - const getLastYearDataFunc = () => { - console.log('get last year data'); - // 지난연도 데이터 가져오기 로직 - }; + const { setMessage: alert } = useAlertStore((state) => state); + const { isAdminInfo } = useAdminStore(); + const toolbarGroupAllowed = useStatisticsInputToolbarGroupGate(); const submitFunc = () => { - console.log('submit'); - // 제출 로직 + console.log("submit"); }; const saveFunc = () => { - console.log('save'); - // 저장 로직 + console.log("save"); }; - const addArticleFunc = () => { - console.log('save'); - // 항목 추가 로직 + const cnacelSubmitFunc = () => { + console.log("cancel submit"); }; const deleteArticleFunc = () => { - console.log('save'); - // 항목 제외 로직 + console.log("delete article"); }; - const [rowDataMap, setRowDataMap] = useState[]>>(new Map()); + const [activeTab, setActiveTab] = useState(firstTabKey); + const [rowDataMap, setRowDataMap] = useState< + Map[]> + >(new Map()); const [isNotApplicable, setIsNotApplicable] = useState(false); - + const [showIsNotApplicable, setShowIsNotApplicable] = useState(false); + const [searchValues, setSearchValues] = useState(null); + const [lastSearchedValues, setLastSearchedValues] = useState( + null, + ); + const [hasSearched, setHasSearched] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [inputPeriodEnterable, setInputPeriodEnterable] = useState(true); + const [tabStatusInfoMap, setTabStatusInfoMap] = useState< + Map + >(new Map()); + const [toolbarSnapshotByTab, setToolbarSnapshotByTab] = useState< + Map + >(new Map()); + const [tabStatsCdMap, setTabStatsCdMap] = useState>(new Map()); + const [verificationHistoryOpen, setVerificationHistoryOpen] = useState(false); + const [sggOptions, setSggOptions] = useState([]); + const reservoirGridRef = useRef(null); + const sggOptionsSidoRef = useRef(null); + + const searchDoneRef = useRef(false); + const periodEnterableRef = useRef(true); + const activeTabRef = useRef(activeTab); + activeTabRef.current = activeTab; + + const commitToolbarSnapshotForLoadedTab = ( + tabKey: string, + statusInfo: SewerageStatusInfo | null, + hasRowData: boolean, + searchContext: SearchFormValues, + ) => { + setToolbarSnapshotByTab((prev) => { + const next = new Map(prev); + next.set( + tabKey, + computeTabToolbarSnapshot( + statusInfo, + hasRowData, + searchDoneRef.current, + searchContext, + periodEnterableRef.current, + isAdminInfo, + ), + ); + return next; + }); + }; + + const applyRowMap = ( + mergeIntoExistingMap: boolean, + apply: (m: Map[]>) => void, + ) => { + if (mergeIntoExistingMap) { + setRowDataMap((prev) => { + const next = new Map(prev); + apply(next); + return next; + }); + } else { + const next = new Map[]>(); + apply(next); + setRowDataMap(next); + } + }; + + const loadActiveTabData = async ( + tabKey: string, + values: SearchFormValues, + mergeIntoExistingMap: boolean, + updateLastSearchedValues: boolean, + overrides?: { mode?: string }, + ): Promise => { + // 서버 데이터로 새로 불러오는 시점이므로 "해당없음" 체크는 항상 초기화 + setIsNotApplicable(false); + if (tabKey === SEWAGE_RESERVOIR_STATS_CD) { + const response = + await statisticsInputSewageReservoirPumpApi.getSewageReservoirList( + buildSewageReservoirSearchParam(values, overrides), + ); + + if (response.status !== "success" || !response.data) { + alert(response.message ?? "데이터 조회 중 오류가 발생했습니다."); + return false; + } + + const { commonInfo, statusInfo, sewageReservoirList } = response.data; + const list = sewageReservoirList ?? []; + const gridRows = mapSewageReservoirListToGridRows( + list, + commonInfo.ctpvNm, + ); + + if (activeTabRef.current === tabKey) { + setShowIsNotApplicable(list.length === 0); + } + + applyRowMap(mergeIntoExistingMap, (m) => { + m.set(tabKey, gridRows); + }); + setTabStatusInfoMap((prev) => new Map(prev).set(tabKey, statusInfo)); + setTabStatsCdMap((prev) => new Map(prev).set(tabKey, commonInfo.statsCd)); + if (updateLastSearchedValues) { + setLastSearchedValues(values); + } + commitToolbarSnapshotForLoadedTab( + tabKey, + statusInfo, + gridRows.length > 0, + values, + ); + return true; + } + + if (tabKey === rainfallPumpTabKey) { + const response = + await statisticsInputSewageReservoirPumpApi.getRainfallPumpStationList( + buildSewageReservoirSearchParam(values, overrides), + ); + + if (response.status !== "success" || !response.data) { + alert(response.message ?? "데이터 조회 중 오류가 발생했습니다."); + return false; + } + + const { commonInfo, statusInfo, rainfallPumpStationList } = response.data; + const list = rainfallPumpStationList ?? []; + const gridRows = mapRainfallPumpStationListToGridRows( + list, + commonInfo.ctpvNm, + ); + + applyRowMap(mergeIntoExistingMap, (m) => { + m.set(tabKey, gridRows); + }); + setTabStatusInfoMap((prev) => new Map(prev).set(tabKey, statusInfo)); + setTabStatsCdMap((prev) => new Map(prev).set(tabKey, commonInfo.statsCd)); + if (updateLastSearchedValues) { + setLastSearchedValues(values); + } + commitToolbarSnapshotForLoadedTab( + tabKey, + statusInfo, + gridRows.length > 0, + values, + ); + return true; + } + + alert("해당 탭 조회 API가 없습니다."); + return false; + }; + + const getLastYearDataFunc = async () => { + const values = lastSearchedValues ?? searchValues; + if (!values?.sido || values.sido === "ALL") { + alert("시도를 선택해주세요."); + return; + } + if (!hasSearched) { + alert("먼저 데이터를 조회해주세요."); + return; + } + try { + setIsLoading(true); + await loadActiveTabData(activeTabRef.current, values, true, false, { + mode: "PRE", + }); + } catch { + alert("데이터 조회 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + const loadSggOptions = useCallback(async (sido: string): Promise => { + if (!sido || sido === "ALL") { + setSggOptions([]); + return []; + } + try { + const res = await statisticsApi.getSggAllList(sido); + const list = parseSggListFromResponse(res); + setSggOptions(list); + return list; + } catch { + setSggOptions([]); + return []; + } + }, []); + + const handleReset = () => { + setRowDataMap(new Map()); + setTabStatusInfoMap(new Map()); + setTabStatsCdMap(new Map()); + setToolbarSnapshotByTab(new Map()); + setLastSearchedValues(null); + setSggOptions([]); + sggOptionsSidoRef.current = null; + setHasSearched(false); + setInputPeriodEnterable(true); + setIsNotApplicable(false); + setShowIsNotApplicable(false); + searchDoneRef.current = false; + periodEnterableRef.current = true; + setActiveTab(firstTabKey); + }; + + const handleSearch = async (values: SearchFormValues) => { + if (!values.sido || values.sido === "ALL") { + alert("시도를 선택해주세요."); + return; + } + + const yearChangedFromLastSearch = + lastSearchedValues != null && + (lastSearchedValues.year ?? "") !== (values.year ?? ""); + + if (yearChangedFromLastSearch) { + setRowDataMap(new Map()); + setTabStatusInfoMap(new Map()); + setTabStatsCdMap(new Map()); + setToolbarSnapshotByTab(new Map()); + setActiveTab(firstTabKey); + } + + try { + setIsLoading(true); + searchDoneRef.current = true; + setHasSearched(true); + setLastSearchedValues(values); + + const tabKeyToLoad = yearChangedFromLastSearch ? firstTabKey : activeTabRef.current; + if (yearChangedFromLastSearch) { + activeTabRef.current = firstTabKey; + setActiveTab(firstTabKey); + } + + const ok = await loadActiveTabData( + tabKeyToLoad, + values, + !yearChangedFromLastSearch, + true, + ); + if (!ok) return; + } catch { + alert("데이터 조회 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + const handleActiveTabChange = async (tabKey: string) => { + setActiveTab(tabKey); + if (tabKey === SEWAGE_RESERVOIR_STATS_CD) { + setShowIsNotApplicable( + hasSearched && !tabRowDataExists(rowDataMap, tabKey), + ); + } else { + setShowIsNotApplicable(false); + } + if (!hasSearched || !lastSearchedValues) return; + if (tabRowDataExists(rowDataMap, tabKey)) return; + + try { + setIsLoading(true); + await loadActiveTabData(tabKey, lastSearchedValues, true, false); + } catch { + alert("데이터 조회 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + const searchContextForToolbar = lastSearchedValues ?? searchValues; + const activeTabStatus = tabStatusInfoMap.get(activeTab) ?? null; + const activeTabToolbar = + toolbarSnapshotByTab.get(activeTab) ?? + computeTabToolbarSnapshot( + activeTabStatus, + tabRowDataExists(rowDataMap, activeTab), + hasSearched, + searchContextForToolbar, + inputPeriodEnterable, + isAdminInfo, + ); + + const { + showSaveInToolbar: baseShowSaveInToolbar, + showEnterableToolbarButtons: baseShowEnterableToolbarButtons, + showActionToolbarCard: baseShowActionToolbarCard, + } = activeTabToolbar; + + const showSaveInToolbar = baseShowSaveInToolbar && toolbarGroupAllowed; + const showEnterableToolbarButtons = + baseShowEnterableToolbarButtons && toolbarGroupAllowed; + const showActionToolbarCard = + baseShowActionToolbarCard && toolbarGroupAllowed; + // 항목추가/제거 버튼은 "데이터가 있든 없든", "과거 데이터(입력 불가 상태)여도" + // 조회를 하고나면 항상 보여야 합니다. + const isReservoirOrRainfallTab = + activeTab === SEWAGE_RESERVOIR_STATS_CD || activeTab === rainfallPumpTabKey; + const showArticleToolbarCard = + hasSearched && isReservoirOrRainfallTab && toolbarGroupAllowed; + + const openVerificationHistoryDialog = () => { + const ctx = lastSearchedValues ?? searchValues; + const activeTabStatsCd = tabStatsCdMap.get(activeTab); + if (!ctx?.year || !ctx?.sido || !ctx?.bsnmCd || !activeTabStatsCd) { + alert("먼저 조회를 진행해주세요."); + return; + } + setVerificationHistoryOpen(true); + }; + + const addArticleFunc = async () => { + const ctx = lastSearchedValues ?? searchValues; + if (!hasSearched || !ctx?.sido || ctx.sido === "ALL") { + alert("먼저 조회를 진행해주세요."); + return; + } + + const activeTabKey = activeTabRef.current; + const isReservoirTab = activeTabKey === SEWAGE_RESERVOIR_STATS_CD; + const isRainfallPumpTab = activeTabKey === rainfallPumpTabKey; + if (!isReservoirTab && !isRainfallPumpTab) { + alert("항목을 추가할 수 없는 탭입니다."); + return; + } + + const sggNm = + ctx.gugun && ctx.gugun !== "ALL" ? ctx.gugun : ""; + + if (isRainfallPumpTab) { + const newRow = createEmptyRainfallPumpStationRow(ctx.sido, sggNm); + setRowDataMap((prev) => { + const next = new Map(prev); + const current = next.get(rainfallPumpTabKey) ?? []; + next.set(rainfallPumpTabKey, [...current, newRow]); + return next; + }); + + const statusInfo = tabStatusInfoMap.get(rainfallPumpTabKey) ?? null; + commitToolbarSnapshotForLoadedTab(rainfallPumpTabKey, statusInfo, true, ctx); + return; + } + + let list = sggOptions; + if (sggOptionsSidoRef.current !== ctx.sido || list.length === 0) { + list = await loadSggOptions(ctx.sido); + sggOptionsSidoRef.current = ctx.sido; + } + if (list.length === 0) { + alert("시군구 목록을 불러오지 못했습니다."); + return; + } + + const tabKey = SEWAGE_RESERVOIR_STATS_CD; + const labels = list.map((s) => s.gugun).filter(Boolean); + const newRow = createEmptySewageReservoirRow(ctx.sido, sggNm); + + flushSync(() => { + setSggOptions(list); + setRowDataMap((prev) => { + const next = new Map(prev); + const current = next.get(tabKey) ?? []; + next.set(tabKey, [...current, newRow]); + return next; + }); + }); + + // 드롭다운 컬럼은 columnInfoByTab(buildSggNmColumn) 렌더링으로 반영한다. + // 행 추가 직후 setColumnProperty를 강제 재적용하면 타이밍 이슈로 + // `fieldIndex is out of bounds`가 발생할 수 있어 제거한다. + + setShowIsNotApplicable(false); + + const statusInfo = tabStatusInfoMap.get(tabKey) ?? null; + commitToolbarSnapshotForLoadedTab(tabKey, statusInfo, true, ctx); + }; + + const columnInfoByTab = useMemo(() => { + const map = new Map(); + for (const tab of SewageReservoirPumpInpTabList) { + const tabKey = tab.tabKey ?? ""; + const canEdit = + computeTabEnterable( + tabStatusInfoMap.get(tabKey) ?? null, + inputPeriodEnterable, + ) || isAdminInfo; + const isReservoirTab = tabKey === SEWAGE_RESERVOIR_STATS_CD; + const columns = (tab.columns ?? []).map((col: ColumnConfig) => { + if (isReservoirTab && col.fieldName === "sggNm") { + return buildSggNmColumn(col, canEdit, sggOptions); + } + return canEdit + ? col + : { + ...col, + editable: false as const, + styleName: col.styleName ?? "rg-cell-readonly", + }; + }); + map.set(tabKey, { + columns, + columnLayout: tab.columnLayout ?? [], + }); + } + return map; + }, [tabStatusInfoMap, inputPeriodEnterable, isAdminInfo, sggOptions]); + + const handleSewageReservoirCellEdited = useCallback( + (params: CellEditedCallbackParams) => { + if (params.fieldName !== "sggNm") return; + const selected = sggOptions.find((s) => s.gugun === params.newValue); + if (!selected) return; + + const tabKey = SEWAGE_RESERVOIR_STATS_CD; + setRowDataMap((prev) => { + const next = new Map(prev); + const rows = [...(next.get(tabKey) ?? [])]; + const row = rows[params.itemIndex]; + if (!row) return prev; + rows[params.itemIndex] = { + ...row, + sggNm: selected.gugun, + admdstCd: selected.adstDstrCd, + admdstNm: selected.gugun, + }; + next.set(tabKey, rows); + return next; + }); + }, + [sggOptions], + ); + + const verificationCellStyle = ({ + fieldName, + row, + }: CellStyleCallbackParams): string | undefined => { + const verification = (row as GridRowWithVerification).verificationResult; + if (!verification) return; + const errors = verification[fieldName]; + if (Array.isArray(errors) && errors.length > 0) { + const hasImr = errors.some((e) => e.vrfcCd === "IMR" || e.vrfcCd === "HB"); + return hasImr ? "rg-cell-verify-error-imr" : "rg-cell-verify-error"; + } + }; + + const verificationCellTooltip = ({ + fieldName, + row, + }: CellTooltipCallbackParams): string | undefined => { + const verification = (row as GridRowWithVerification).verificationResult; + if (!verification) return; + const errors = verification[fieldName]; + if (Array.isArray(errors) && errors.length > 0) { + return errors + .map((e) => { + const prefix = + e.vrfcCd === "IMR" + ? "IMR검증 - " + : e.vrfcCd === "NV" + ? "일반검증 - " + : "HB검증 -"; + return `${prefix}${e.rsnExpln} +(검증 기준값: ${e.compVlCn} / 현재 입력값: ${e.nowVlCn})`; + }) + .filter(Boolean) + .join("\n"); + } + }; + + const verificationQueryContext = (() => { + const ctx = lastSearchedValues ?? searchValues; + const statsCd = tabStatsCdMap.get(activeTab); + if (!ctx?.year || !ctx?.sido || !ctx?.bsnmCd || !statsCd) return null; + return { + sewerBzmnCd: ctx.bsnmCd, + ctpvNm: ctx.sido, + sggNm: ctx.gugun === "ALL" ? "" : (ctx.gugun ?? ""), + statsYr: ctx.year, + statsCd, + }; + })(); + return ( <> - -
-
- - - - -
+
+ + {showActionToolbarCard && ( +
+
+ + {showEnterableToolbarButtons && ( + <> + void getLastYearDataFunc()} + /> + + + )} + {activeTabStatus?.laeSbmsnSttsCd === "2" && ( + + )} + {showSaveInToolbar && } +
+
+ )} + {showArticleToolbarCard && ( +
+
+ {showIsNotApplicable && ( + + )} + + +
+
+ )} + {isLoading && ( +
+
+ +
+
+ )} + void handleActiveTabChange(v)} + > + + {SewageReservoirPumpInpTabList.map((tab) => ( + + {tab.tabLabel || tab.tabKey} + + ))} + + {SewageReservoirPumpInpTabList.map((tab) => { + const tabKey = tab.tabKey ?? ""; + const tabEnterableForGrid = + computeTabEnterable( + tabStatusInfoMap.get(tabKey) ?? null, + inputPeriodEnterable, + ) || isAdminInfo; + const gugunOk = buildGugunOkForToolbar(lastSearchedValues); + const tabStatusInfo = tabStatusInfoMap.get(tabKey) ?? null; + const isReservoirTab = tabKey === SEWAGE_RESERVOIR_STATS_CD; + return ( + + { + setRowDataMap((prev) => { + const next = new Map(prev); + const cur = next.get(tabKey) ?? []; + const n = + typeof updater === "function" + ? (updater as ( + p: Record< + string, + string | number + >[], + ) => Record< + string, + string | number + >[])(cur) + : updater; + next.set(tabKey, n); + return next; + }); + }} + showHeaderCheckbox + statusInfo={tabStatusInfo} + isExcelImport={ + tabRowDataExists(rowDataMap, tabKey) && + tabEnterableForGrid && + gugunOk + } + cellStyleCallback={verificationCellStyle} + cellTooltipCallback={verificationCellTooltip} + onCellEdited={ + isReservoirTab && + tabEnterableForGrid && + sggOptions.length > 0 + ? handleSewageReservoirCellEdited + : undefined + } + /> + + ); + })} +
-
-
- - - -
-
- ); }; -export default SewageReservoirPumpInp; \ No newline at end of file +export default SewageReservoirPumpInp; diff --git a/apps/st/src/components/statistics/input/SewerageFinanceInp.tsx b/apps/st/src/components/statistics/input/SewerageFinanceInp.tsx index c1c2177de..3f3d57f4e 100644 --- a/apps/st/src/components/statistics/input/SewerageFinanceInp.tsx +++ b/apps/st/src/components/statistics/input/SewerageFinanceInp.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import SearchForm from "@/components/module/SearchForm"; -import { TabDataGrid } from '@rsone/components'; +import TabDataGrid from "@/components/module/TabDataGrid"; import SubmitButton from "@/components/button/SumitButton"; import SaveButton from "@/components/button/SaveButton"; import SubmitExceptionButton from "@/components/button/SubmitExceptionButton"; diff --git a/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx b/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx index 22b160656..6a31e84da 100644 --- a/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx +++ b/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx @@ -41,6 +41,7 @@ import VerificationResultButton from "@/components/button/VerificationResultButt // import { DataGrid } from "@rsone/components"; import DataGrid from "@/components/module/DataGrid"; import SearchForm from "@/components/module/SearchForm"; +import { openVerificationResultDialogIfNeeded } from "@/components/module/post-submit-verification-results"; import { VerificationResultDialog } from "@/components/module/VerificationResultDialog"; import { VerificationResultHistoryDialog } from "@/components/module/VerificationResultHistoryDialog"; import type { SearchFormValues } from "@/components/type/SearchForm.type"; @@ -253,7 +254,6 @@ const SeweragePenetrationRateInp = () => { ...d, ctpvNm: commonInfo.ctpvNm, spcCtpnRgnCn: d.spcCtpnRgnCn === '' || d.spcCtpnRgnCn === null ? '해당없음' : d.spcCtpnRgnCn, - swagePplPsplyRt: commonInfo.swagePplPsplyRt, })); setRowData( convertedData.map((d) => { @@ -348,9 +348,9 @@ const SeweragePenetrationRateInp = () => { statsCd: statsCd, }; - if (isAdminInfo) { - params.superAdmin = 'superAdmin'; - } + // if (isAdminInfo) { + // params.superAdmin = 'superAdmin'; + // } try { setIsLoading(true); @@ -525,11 +525,35 @@ const SeweragePenetrationRateInp = () => { } setGlobalProgress(100); await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true); - setVerificationResultDialogOpen(true); + await openVerificationResultDialogIfNeeded( + searchValues?.year && searchValues?.sido && searchValues?.bsnmCd && statsCd + ? { + sewerBzmnCd: searchValues.bsnmCd, + ctpvNm: searchValues.sido, + sggNm: searchValues.gugun === "ALL" ? "" : (searchValues.gugun ?? ""), + statsYr: searchValues.year, + statsCd, + } + : null, + setVerificationResultDialogOpen, + { notify: alert }, + ); } catch (e) { await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true); setIsLoading(false); - setVerificationResultDialogOpen(true); + await openVerificationResultDialogIfNeeded( + searchValues?.year && searchValues?.sido && searchValues?.bsnmCd && statsCd + ? { + sewerBzmnCd: searchValues.bsnmCd, + ctpvNm: searchValues.sido, + sggNm: searchValues.gugun === "ALL" ? "" : (searchValues.gugun ?? ""), + statsYr: searchValues.year, + statsCd, + } + : null, + setVerificationResultDialogOpen, + { notify: alert, notifySubmitComplete: false }, + ); const msg = e instanceof Error && e.message ? e.message @@ -540,7 +564,19 @@ const SeweragePenetrationRateInp = () => { } else { setGlobalProgress(100); await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), false); - setVerificationResultDialogOpen(true); + await openVerificationResultDialogIfNeeded( + searchValues?.year && searchValues?.sido && searchValues?.bsnmCd && statsCd + ? { + sewerBzmnCd: searchValues.bsnmCd, + ctpvNm: searchValues.sido, + sggNm: searchValues.gugun === "ALL" ? "" : (searchValues.gugun ?? ""), + statsYr: searchValues.year, + statsCd, + } + : null, + setVerificationResultDialogOpen, + { notify: alert }, + ); } } else { alert(response.message || '데이터 제출 중 오류가 발생했습니다.'); @@ -641,9 +677,9 @@ const SeweragePenetrationRateInp = () => { sewerBzmnFrgnrWholPpltnCnt: footerFromApi?.sewerBzmnFrgnrWholPpltnCnt // 행정참고자료 외국인 합계 }; - if (isAdminInfo) { - requestData.superAdmin = 'superAdmin'; - } + // if (isAdminInfo) { + // requestData.superAdmin = 'superAdmin'; + // } const response = await statisticsInputSewSupplyRateApi.saveSewerageSupplyRate(requestData); @@ -711,16 +747,21 @@ const SeweragePenetrationRateInp = () => { ? Boolean(lastSearchedValues?.gugun && lastSearchedValues.gugun !== 'ALL') : true; + const inputPeriodCurrent = isInputPeriodCurrent(periodCheckList); + const isSubmitted = statusInfo?.laeSbmsnSttsCd === '2'; const baseShowSaveInToolbar = (isEnterable || isAdminInfo) && gugunOkOrNotDoList; const baseShowEnterableToolbarButtons = isEnterable && gugunOkOrNotDoList; + const showSubmittedInInputPeriodToolbar = + isSubmitted && inputPeriodCurrent && gugunOkOrNotDoList; const showSaveInToolbar = baseShowSaveInToolbar && toolbarGroupAllowed; const showEnterableToolbarButtons = baseShowEnterableToolbarButtons && toolbarGroupAllowed; - /** 카드는 저장/제출줄/제출취소 중 하나라도 보일 때만 (관리자만으로 빈 카드 방지) */ const showActionToolbarCard = rowData.length > 0 && toolbarGroupAllowed && (baseShowSaveInToolbar || - (statusInfo?.laeSbmsnSttsCd === '2' && (isAdminInfo || (isEnterable && gugunOkOrNotDoList)))); + baseShowEnterableToolbarButtons || + showSubmittedInInputPeriodToolbar || + (isSubmitted && isAdminInfo)); return ( <> @@ -781,7 +822,7 @@ const SeweragePenetrationRateInp = () => { if (Array.isArray(errors) && errors.length > 0) { return errors .map((e) => { - const prefix = e.vrfcCd === 'IMR' ? 'IMR검증 - ' : (e.vrfcCd === 'NM' ? '일반검증 - ' : 'HB검증 -'); + const prefix = e.vrfcCd === 'IMR' ? 'IMR검증 - ' : (e.vrfcCd === 'NV' ? '일반검증 - ' : 'HB검증 -'); return `${prefix}${e.rsnExpln} (검증 기준값: ${e.compVlCn} / 현재 입력값: ${e.nowVlCn})`; }) diff --git a/apps/st/src/components/statistics/input/SewerageServiceLevelInp.tsx b/apps/st/src/components/statistics/input/SewerageServiceLevelInp.tsx index 7ce370bbb..f79b2e667 100644 --- a/apps/st/src/components/statistics/input/SewerageServiceLevelInp.tsx +++ b/apps/st/src/components/statistics/input/SewerageServiceLevelInp.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import SearchForm from "@/components/module/SearchForm"; -import { TabDataGrid } from '@rsone/components'; +import TabDataGrid from "@/components/module/TabDataGrid"; import SubmitButton from "@/components/button/SumitButton"; import SaveButton from "@/components/button/SaveButton"; import LastYearDataButton from "@/components/button/LastYearDataButton"; diff --git a/apps/st/src/components/statistics/management/StatisticsReviewHistory.tsx b/apps/st/src/components/statistics/input/StatisticsReviewHistory.tsx similarity index 100% rename from apps/st/src/components/statistics/management/StatisticsReviewHistory.tsx rename to apps/st/src/components/statistics/input/StatisticsReviewHistory.tsx diff --git a/apps/st/src/components/statistics/input/StatsInputStatusInp.tsx b/apps/st/src/components/statistics/input/StatsInputStatusInp.tsx index db04bc957..2cf6f2261 100644 --- a/apps/st/src/components/statistics/input/StatsInputStatusInp.tsx +++ b/apps/st/src/components/statistics/input/StatsInputStatusInp.tsx @@ -4,7 +4,7 @@ import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { RealGridReact } from "realgrid-react"; import useSWR, { useSWRConfig } from "swr"; -import { useAlertStore, useStatisticsInputStatusConfirmGroupGate } from "@/shared/lib"; +import { useActiveTabRegAuthorityAllowed, useAlertStore } from "@/shared/lib"; import { statisticsInputSewSupplyRateApi } from "@/entities/statistics/api/input/statistics-input-sew-supply-rate-service"; import { StatisticsInputStatusApi } from "@/entities/statistics/api/input/statistics-input-status-service"; import { statisticsApi } from "@/entities/statistics/api/statistics-service"; @@ -62,7 +62,7 @@ const StatsInputStatusInp = () => { ExceptionProcessingHistoryQueryContext | null >(null); const [isApproveLoading, setIsApproveLoading] = useState(false); - const confirmGroupToolbarAllowed = useStatisticsInputStatusConfirmGroupGate(); + const regAuthorityToolbarAllowed = useActiveTabRegAuthorityAllowed(); const swrKey = searchParams ? (["stats-input-status", searchParams] as const) @@ -214,6 +214,10 @@ const StatsInputStatusInp = () => { const openExceptionHistoryForRow = useCallback( (row: Record) => { + if (!regAuthorityToolbarAllowed) { + alert("권한이 없습니다."); + return; + } const ctx = lastSearchedValues; if (!ctx?.year || !ctx?.sido || !ctx?.bsnmCd) { alert("먼저 조회를 진행해주세요."); @@ -233,7 +237,7 @@ const StatsInputStatusInp = () => { }); setExceptionHistoryOpen(true); }, - [alert, lastSearchedValues], + [alert, lastSearchedValues, regAuthorityToolbarAllowed], ); const handleExceptionSubmitHistoryCellClick = useCallback( @@ -266,7 +270,7 @@ const StatsInputStatusInp = () => { gugunOkOrNotDoList && inputPeriodCurrentRef.current && !allRowsLaeSbmsnApproved && - confirmGroupToolbarAllowed; + regAuthorityToolbarAllowed; return ( <> diff --git a/apps/st/src/components/statistics/input/WaterReuseInp.tsx b/apps/st/src/components/statistics/input/WaterReuseInp.tsx index 2479ef030..26798d169 100644 --- a/apps/st/src/components/statistics/input/WaterReuseInp.tsx +++ b/apps/st/src/components/statistics/input/WaterReuseInp.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import SearchForm from "@/components/module/SearchForm"; -import { TabDataGrid } from '@rsone/components'; +import TabDataGrid from "@/components/module/TabDataGrid"; import SubmitButton from "@/components/button/SumitButton"; import SaveButton from "@/components/button/SaveButton"; import LastYearDataButton from "@/components/button/LastYearDataButton"; diff --git a/apps/st/src/components/statistics/management/LogHistoryDetailDialog.tsx b/apps/st/src/components/statistics/management/LogHistoryDetailDialog.tsx index 09667bc88..359eba00d 100644 --- a/apps/st/src/components/statistics/management/LogHistoryDetailDialog.tsx +++ b/apps/st/src/components/statistics/management/LogHistoryDetailDialog.tsx @@ -1,6 +1,7 @@ "use client"; -import { useEffect } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { GridFitStyle } from "realgrid"; import { Button, @@ -10,9 +11,20 @@ import { DialogFooter, DialogHeader, DialogTitle, + LoadingWithAnimation, } from "@common/components"; +import DataGrid from "@/components/module/DataGrid"; +import type { ColumnInfo } from "@/components/type/DataGrid.type"; +import { + alignLogDetailRowsToColumns, + buildLogHistoryDetailColumnInfo, + emptyLogHistoryDetailColumnInfo, +} from "@/components/util/column/management/LogHistoryDetailColumn"; +import { toGridRows } from "@/components/util/grid/gridFunction"; import { statisticsApi } from "@/entities/statistics/api/statistics-service"; +import type { StatisticsLogDetailRow } from "@/entities/statistics/model/statistics-interface"; +import { useAlertStore } from "@/shared/lib"; export interface LogHistoryDetailDialogProps { open: boolean; @@ -25,24 +37,115 @@ export function LogHistoryDetailDialog({ onOpenChange, row, }: LogHistoryDetailDialogProps) { + const { setMessage: alert } = useAlertStore((state) => state); + const [isLoading, setIsLoading] = useState(false); + const [rowData, setRowData] = useState[]>( + [], + ); + const [columnInfo, setColumnInfo] = useState( + emptyLogHistoryDetailColumnInfo, + ); + useEffect(() => { - if (!open || !row) return; + if (!open) { + setRowData([]); + setColumnInfo(emptyLogHistoryDetailColumnInfo); + setIsLoading(false); + return; + } + + if (!row) return; const sn = row.statsLogSn; + let cancelled = false; + void (async () => { - const response = await statisticsApi.getLogDetail( - sn !== undefined && sn !== null ? String(sn) : "", - ); - console.log(response.data); + setIsLoading(true); + setRowData([]); + + try { + const response = await statisticsApi.getLogDetail( + sn !== undefined && sn !== null ? String(sn) : "", + ); + + if (cancelled) return; + + if (response.status !== "success") { + alert( + response.message ?? + "로그 상세 정보를 불러오는데 실패했습니다.", + ); + return; + } + + const list = Array.isArray(response.data) + ? response.data + : []; + const nextColumnInfo = buildLogHistoryDetailColumnInfo(list); + const nextRows = alignLogDetailRowsToColumns( + toGridRows(list as StatisticsLogDetailRow[]), + nextColumnInfo, + ); + setColumnInfo(nextColumnInfo); + setRowData(nextRows); + } catch { + if (!cancelled) { + alert("로그 상세 정보를 불러오는데 실패했습니다."); + } + } finally { + if (!cancelled) setIsLoading(false); + } })(); - }, [open, row]); + + return () => { + cancelled = true; + }; + }, [open, row, alert]); + + const gridRemountKey = useMemo( + () => columnInfo.columns.map((c) => c.fieldName).join("\u0001"), + [columnInfo], + ); + + const showGrid = !isLoading && rowData.length > 0; return ( - + - + 로그 상세 정보 +
+ {isLoading && ( +
+
+ +
+
+ )} + {showGrid ? ( + + ) : ( + !isLoading && ( +

+ 표시할 상세 데이터가 없습니다. +

+ ) + )} +
diff --git a/apps/st/src/shared/lib/index.ts b/apps/st/src/shared/lib/index.ts index ec5104865..b2175d7c7 100644 --- a/apps/st/src/shared/lib/index.ts +++ b/apps/st/src/shared/lib/index.ts @@ -6,7 +6,8 @@ export { useGlobalProgressStore } from "./use-global-progress-store"; export { useMenuStore } from "./use-menu-store"; export { useUserStore } from "./use-user-store"; export { - useStatisticsInputStatusConfirmGroupGate, + isRegAuthorityAllowed, + useActiveTabRegAuthorityAllowed, useStatisticsInputToolbarGroupGate, } from "./use-statistics-input-toolbar-group-gate"; export { usePrompt } from "./use-prompt"; diff --git a/apps/st/src/shared/lib/statistics-tab-url.ts b/apps/st/src/shared/lib/statistics-tab-url.ts new file mode 100644 index 000000000..c3588254f --- /dev/null +++ b/apps/st/src/shared/lib/statistics-tab-url.ts @@ -0,0 +1,12 @@ +export const STATISTICS_ROUTE = "/statistics"; +export const STATISTICS_TAB_QUERY_KEY = "tab"; + +export function buildStatisticsTabUrl(tabId: string): string { + return `${STATISTICS_ROUTE}?${STATISTICS_TAB_QUERY_KEY}=${encodeURIComponent(tabId)}`; +} + +export function isStatisticsPathname( + pathname: string | null, +): pathname is typeof STATISTICS_ROUTE { + return pathname === STATISTICS_ROUTE; +} diff --git a/apps/st/src/shared/lib/stats-cd-input-navigation.ts b/apps/st/src/shared/lib/stats-cd-input-navigation.ts index 3acdf6f0b..6a00d33ae 100644 --- a/apps/st/src/shared/lib/stats-cd-input-navigation.ts +++ b/apps/st/src/shared/lib/stats-cd-input-navigation.ts @@ -37,8 +37,12 @@ export const STATISTICS_INPUT_URL_PATH = { SEWAGE_PIPE: "SewagePipeInp", SEWAGE_PIPE_MAINTAIN: "SewagePipeMaintainInp", SEWERAGE_PENETRATION_RATE: "SeweragePenetrationRateInp", + PUBLIC_SEWAGE_FACILITY: "PublicSewageFacilityInp", } as const; +/** `PublicSewageFacilityInp.tsx` — 공공하수처리시설 탭 */ +const PUBLIC_SEWAGE_FACILITY_INP_TAB_KEYS = ["ST00060001"] as const; + function normalizeSewagePipeTabKey(tabKey: string): string { if ( tabKey === "adminAreaConnectionInp" || @@ -59,6 +63,10 @@ const NAV_ROWS: { urlPath: string; tabKeys: readonly string[] }[] = [ urlPath: STATISTICS_INPUT_URL_PATH.SEWERAGE_PENETRATION_RATE, tabKeys: SEWERAGE_PENETRATION_RATE_INP_TAB_KEYS, }, + { + urlPath: STATISTICS_INPUT_URL_PATH.PUBLIC_SEWAGE_FACILITY, + tabKeys: PUBLIC_SEWAGE_FACILITY_INP_TAB_KEYS, + }, ]; /** diff --git a/apps/st/src/shared/lib/use-statistics-input-toolbar-group-gate.ts b/apps/st/src/shared/lib/use-statistics-input-toolbar-group-gate.ts index e8337f496..57f4a0782 100644 --- a/apps/st/src/shared/lib/use-statistics-input-toolbar-group-gate.ts +++ b/apps/st/src/shared/lib/use-statistics-input-toolbar-group-gate.ts @@ -1,68 +1,28 @@ "use client"; -import { useEffect, useState } from "react"; +import { useTabStore } from "@/store/tab-store"; -import { - checkStatisticsInputStatusConfirmGroupMembership, - checkStatisticsInputToolbarGroupMembership, -} from "@/entities/group-user/lib/check-statistics-input-toolbar-group-membership"; +export function isRegAuthorityAllowed( + pageMenuAuthority: { regAuthrtYn: string } | null | undefined, +): boolean { + return pageMenuAuthority?.regAuthrtYn === "Y"; +} -import { useUserStore } from "./use-user-store"; +/** + * 활성 탭의 `getPageMenuAuthority` 결과에서 `regAuthrtYn === "Y"`일 때만 true. + * 권한은 탭 추가·활성화 시 tab-store에서 조회합니다. + */ +export function useActiveTabRegAuthorityAllowed(): boolean { + const activeTabId = useTabStore((s) => s.activeTabId); + const pageMenuAuthority = useTabStore((s) => { + if (!activeTabId) return undefined; + return s.tabs.find((tab) => tab.id === activeTabId)?.pageMenuAuthority; + }); -/** 통계 입력 상단 툴바(저장·제출·검증결과 등) — 그룹 멤버십 만족 시에만 true */ + return isRegAuthorityAllowed(pageMenuAuthority); +} + +/** 통계 입력 화면 상단 툴바용 — {@link useActiveTabRegAuthorityAllowed}와 동일 */ export function useStatisticsInputToolbarGroupGate(): boolean { - const userInfo = useUserStore((s) => s.userInfo); - const [allowed, setAllowed] = useState(false); - - useEffect(() => { - let cancelled = false; - const name = userInfo?.userName?.trim() ?? ""; - const uid = userInfo?.userUnqId?.trim() ?? ""; - if (!name || !uid) { - setAllowed(false); - return; - } - void (async () => { - try { - const ok = await checkStatisticsInputToolbarGroupMembership(name, uid); - if (!cancelled) setAllowed(ok); - } catch { - if (!cancelled) setAllowed(false); - } - })(); - return () => { - cancelled = true; - }; - }, [userInfo?.userName, userInfo?.userUnqId]); - - return allowed; -} - -/** 통계 입력 현황 — `GRP_ST_CONFIRM` / `GRP_ADMIN` 멤버일 때만 true */ -export function useStatisticsInputStatusConfirmGroupGate(): boolean { - const userInfo = useUserStore((s) => s.userInfo); - const [allowed, setAllowed] = useState(false); - - useEffect(() => { - let cancelled = false; - const name = userInfo?.userName?.trim() ?? ""; - const uid = userInfo?.userUnqId?.trim() ?? ""; - if (!name || !uid) { - setAllowed(false); - return; - } - void (async () => { - try { - const ok = await checkStatisticsInputStatusConfirmGroupMembership(name, uid); - if (!cancelled) setAllowed(ok); - } catch { - if (!cancelled) setAllowed(false); - } - })(); - return () => { - cancelled = true; - }; - }, [userInfo?.userName, userInfo?.userUnqId]); - - return allowed; + return useActiveTabRegAuthorityAllowed(); } diff --git a/apps/st/src/shared/lib/use-statistics-tab-url-sync.ts b/apps/st/src/shared/lib/use-statistics-tab-url-sync.ts new file mode 100644 index 000000000..508e6e834 --- /dev/null +++ b/apps/st/src/shared/lib/use-statistics-tab-url-sync.ts @@ -0,0 +1,99 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; + +import type { MenuItem } from "@/entities/menu"; +import { findMenuItemByUrlPath } from "@/shared/lib/stats-cd-input-navigation"; +import { getTabStoreState } from "@/store/tab-store"; + +import { + STATISTICS_ROUTE, + STATISTICS_TAB_QUERY_KEY, + buildStatisticsTabUrl, + isStatisticsPathname, +} from "./statistics-tab-url"; + +type UseStatisticsTabUrlSyncParams = { + pathname: string | null; + menuList: MenuItem[] | null; + activeTabId: string | null; + addTab: (menuItem: MenuItem) => boolean; + addTabForGrid: (menuItem: MenuItem) => void; + setCurrentMenuInfo: (item: MenuItem) => void; +}; + +/** `/statistics?tab=` ↔ Zustand 활성 탭 동기화 */ +export function useStatisticsTabUrlSync({ + pathname, + menuList, + activeTabId, + addTab, + addTabForGrid, + setCurrentMenuInfo, +}: UseStatisticsTabUrlSyncParams): { + syncTabToUrl: (tabId: string) => void; +} { + const router = useRouter(); + const searchParams = useSearchParams(); + const tabParam = searchParams?.get(STATISTICS_TAB_QUERY_KEY) ?? null; + const skipUrlToStoreRef = useRef(false); + + const syncTabToUrl = (tabId: string) => { + if (!isStatisticsPathname(pathname)) { + router.push(buildStatisticsTabUrl(tabId)); + return; + } + if (tabParam === tabId) return; + skipUrlToStoreRef.current = true; + router.replace(buildStatisticsTabUrl(tabId), { scroll: false }); + }; + + // URL ?tab → 탭 스토어 + useEffect(() => { + if (!isStatisticsPathname(pathname) || !menuList) return; + if (skipUrlToStoreRef.current) { + skipUrlToStoreRef.current = false; + return; + } + + if (!tabParam) return; + if (getTabStoreState().activeTabId === tabParam) return; + + const menu = findMenuItemByUrlPath(menuList, tabParam); + if (!menu) return; + + setCurrentMenuInfo(menu); + const ok = addTab(menu); + if (ok) { + addTabForGrid(menu); + } + }, [ + pathname, + menuList, + tabParam, + addTab, + addTabForGrid, + setCurrentMenuInfo, + ]); + + // 활성 탭 → URL (탭 바 전환·persist 복원 포함) + useEffect(() => { + if (!isStatisticsPathname(pathname)) return; + + if (!activeTabId) { + if (tabParam) { + skipUrlToStoreRef.current = true; + router.replace(STATISTICS_ROUTE, { scroll: false }); + } + return; + } + + if (tabParam === activeTabId) return; + + skipUrlToStoreRef.current = true; + router.replace(buildStatisticsTabUrl(activeTabId), { scroll: false }); + }, [pathname, activeTabId, tabParam, router]); + + return { syncTabToUrl }; +} diff --git a/apps/st/src/store/tab-store.ts b/apps/st/src/store/tab-store.ts index 640cada97..6909ed26b 100644 --- a/apps/st/src/store/tab-store.ts +++ b/apps/st/src/store/tab-store.ts @@ -2,6 +2,8 @@ import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import type { MenuItem } from "@/entities/menu"; +import { MenuAuthorityApi } from "@/entities/menu-authority"; +import type { PageMenuAuthority } from "@/entities/menu-authority/model/menu-authority-interface"; export interface Tab { id: string; // 고유 ID (urlPath) @@ -10,6 +12,69 @@ export interface Tab { urlPath: string; // url 경로 parentEngNm: string; // 부모 menuEngNm menuItem: MenuItem; // 메뉴 정보 + /** undefined: 미조회, null: 조회했으나 없음/실패 */ + pageMenuAuthority?: PageMenuAuthority | null; +} + +async function fetchTabPageMenuAuthority( + menuCd: number, + apply: (authority: PageMenuAuthority | null) => void, +): Promise { + try { + const response = await MenuAuthorityApi.getPageMenuAuthority(menuCd); + apply(response?.[0] ?? null); + } catch { + apply(null); + } +} + +function scheduleTabPageMenuAuthorityFetch( + get: () => TabStore, + set: ( + partial: + | Partial + | ((state: TabStore) => Partial), + ) => void, + tabId: string, + menuCd: number, +): void { + void fetchTabPageMenuAuthority(menuCd, (authority) => { + const { tabs } = get(); + if (!tabs.some((tab) => tab.id === tabId)) return; + set((state) => ({ + tabs: state.tabs.map((tab) => + tab.id === tabId ? { ...tab, pageMenuAuthority: authority } : tab, + ), + })); + }); +} + +type TabStoreSetState = ( + partial: + | Partial + | ((state: TabStore) => Partial), +) => void; + +const tabStoreSetState: TabStoreSetState = (partial) => { + useTabStoreHook.setState(partial); +}; + +function ensureTabPageMenuAuthority( + get: () => TabStore, + set: TabStoreSetState, + tab: Tab, +): void { + if (tab.pageMenuAuthority !== undefined) return; + scheduleTabPageMenuAuthorityFetch(get, set, tab.id, tab.menuItem.menuCd); +} + +/** persist 복원·새로고침 후 활성 탭 메뉴 권한이 없으면 조회 */ +export function hydrateActiveTabPageMenuAuthority(): void { + const { tabs, activeTabId } = getTabStoreState(); + if (!activeTabId) return; + const tab = tabs.find((t) => t.id === activeTabId); + if (!tab) return; + ensureTabPageMenuAuthority(getTabStoreState, tabStoreSetState, tab); } interface TabStore { @@ -32,6 +97,7 @@ function tabForPersist(tab: Tab): Tab { return { ...tab, menuItem: { ...tab.menuItem, children: null }, + pageMenuAuthority: undefined, }; } @@ -71,6 +137,7 @@ const useTabStoreHook = create()( if (existingTab) { set({ activeTabId: tabId }); + ensureTabPageMenuAuthority(get, set, existingTab); return true; } @@ -85,6 +152,7 @@ const useTabStoreHook = create()( (m) => m.menuCd === menuItem.parentId, )?.menuEngNm || "", menuItem, + pageMenuAuthority: undefined, }; set({ @@ -92,6 +160,13 @@ const useTabStoreHook = create()( activeTabId: tabId, }); + scheduleTabPageMenuAuthorityFetch( + get, + set, + tabId, + menuItem.menuCd, + ); + return true; } return false; @@ -122,9 +197,15 @@ const useTabStoreHook = create()( tabs: newTabs, activeTabId: newActiveTabId, }); + if (newActiveTabId) { + const nextActive = get().tabs.find((t) => t.id === newActiveTabId); + if (nextActive) ensureTabPageMenuAuthority(get, set, nextActive); + } }, setActiveTab: (tabId: string) => { set({ activeTabId: tabId }); + const tab = get().tabs.find((t) => t.id === tabId); + if (tab) ensureTabPageMenuAuthority(get, set, tab); }, setMenuList: (menuList: MenuItem[] | null) => { set({ menuList }); @@ -173,6 +254,10 @@ const useTabStoreHook = create()( activeTabId, }; }, + onRehydrateStorage: () => (state, error) => { + if (error || !state) return; + queueMicrotask(() => hydrateActiveTabPageMenuAuthority()); + }, }, ), ); diff --git a/apps/wr/.env.local b/apps/wr/.env.local index 8f7fb329d..96b66b536 100644 --- a/apps/wr/.env.local +++ b/apps/wr/.env.local @@ -8,6 +8,7 @@ NEXT_PUBLIC_PROXY_HOST = http://localhost:3001 NEXT_PUBLIC_SUB_DOMAIN = http://localhost:13000 NEXT_PUBLIC_SERVER_API_URL = http://dev.onasys.co.kr:8000 NEXT_PUBLIC_FO_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1 +# NEXT_PUBLIC_FO_SERVER_API_URL = http://localhost:8883/operation-service/api/v1 NEXT_PUBLIC_CS_SERVER_API_URL = http://dev.onasys.co.kr:8000 NEXT_PUBLIC_CS_TUS_UPLOAD = /attach-service/api/v1/tus/upload NEXT_PUBLIC_GEOSERVER_URL = https://hasudo.geo.rsonesoft.com/geoserver diff --git a/apps/wr/app/(with-layout)/jwttest/page.tsx b/apps/wr/app/(with-layout)/jwttest/page.tsx index 25d47c1d2..40baa2034 100644 --- a/apps/wr/app/(with-layout)/jwttest/page.tsx +++ b/apps/wr/app/(with-layout)/jwttest/page.tsx @@ -5,7 +5,8 @@ */ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { LoginApi } from "@/src/entities/auth/api/login-service"; import { CLAIM_NAME } from "@/src/shared/config"; import { getCookie } from "@/src/shared/lib"; @@ -112,14 +113,51 @@ function readJwtState(): JwtState { export default function Page() { const [jwtState, setJwtState] = useState(() => readJwtState()); + const isRefreshingRef = useRef(false); + + const handleSilentRefresh = async () => { + try { + await LoginApi.silentRefresh(); + } catch (error) { + console.error("Manual silent refresh failed:", error); + } finally { + setJwtState(readJwtState()); + } + }; useEffect(() => { - const syncJwtState = () => { + const syncJwtState = async () => { + const nextState = readJwtState(); + + if ( + nextState.remainingMs !== null && + nextState.remainingMs > 0 && + nextState.remainingMs < 5 * 60 * 1000 && + !isRefreshingRef.current + ) { + isRefreshingRef.current = true; + try { + await LoginApi.silentRefresh(); + } catch (error) { + console.error("JWT test silent refresh failed:", error); + } + } + setJwtState(readJwtState()); + + if ( + nextState.remainingMs === null || + nextState.remainingMs >= 5 * 60 * 1000 || + nextState.remainingMs <= 0 + ) { + isRefreshingRef.current = false; + } }; - syncJwtState(); - const timer = window.setInterval(syncJwtState, 1000); + void syncJwtState(); + const timer = window.setInterval(() => { + void syncJwtState(); + }, 1000); return () => window.clearInterval(timer); }, []); @@ -128,6 +166,16 @@ export default function Page() {

JWT Exp Test

+ +
쿠키명
{CLAIM_NAME}
diff --git a/apps/wr/app/(with-layout)/layout.tsx b/apps/wr/app/(with-layout)/layout.tsx index 0367c77af..1e43f4c32 100644 --- a/apps/wr/app/(with-layout)/layout.tsx +++ b/apps/wr/app/(with-layout)/layout.tsx @@ -19,6 +19,7 @@ "use client"; import { BaseLayout } from "@common/components/ui"; +import { useQueryClient } from "@tanstack/react-query"; import { useWrStore } from "../store"; import { MainDiv } from "@common/business_components/ui"; import type { MenuItem } from "../store/common/type/menu"; @@ -35,6 +36,7 @@ export default function RootLayout({ const setCurrentMenuInfo = useMenuStore((state) => state.setCurrentMenuInfo); const router = useRouter(); const pathname = usePathname(); + const queryClient = useQueryClient(); const changeMenu = (path: MenuItem) => { setCurrentMenuInfo(path); @@ -46,7 +48,7 @@ export default function RootLayout({ } }; return ( - + {children} diff --git a/apps/wr/app/(with-layout)/ruseFcltMng/fcltReg/htdflReg/page.tsx b/apps/wr/app/(with-layout)/ruseFcltMng/fcltReg/htdflReg/page.tsx index d8f52297c..cc892b6d1 100644 --- a/apps/wr/app/(with-layout)/ruseFcltMng/fcltReg/htdflReg/page.tsx +++ b/apps/wr/app/(with-layout)/ruseFcltMng/fcltReg/htdflReg/page.tsx @@ -6,6 +6,7 @@ import type { customHtdflBdst } from "@/app/store/ruseFcltMng/fcltReg/htdflReg"; import { formatArchArea, tableData } from "@/components/lib/func/common"; import HtdflRegForm from "@/components/ui/form/fcltReg/htdflReg/htdflRegForm"; import { BdstReg } from "@/components/ui/modal/fcltReg/bdstRegModal/bdstRegModal"; +import { DEFAULT_LIST_SIZE } from "@/src/shared/config"; import { ContentsDiv, ScrollArea, @@ -136,12 +137,13 @@ export default function Home() {
-
- +
+ + { @@ -153,11 +155,12 @@ export default function Home() { index={htdfTableIndex} resetPageNo={resetPageNo} > +
- +
-
- - {/* */} {/* 높이 조정 */} +
+ + - {/* */} +
diff --git a/apps/wr/app/(with-layout)/ruseFcltMng/fcltReg/rainReg/page.tsx b/apps/wr/app/(with-layout)/ruseFcltMng/fcltReg/rainReg/page.tsx index 82344fa1f..45fb636c6 100644 --- a/apps/wr/app/(with-layout)/ruseFcltMng/fcltReg/rainReg/page.tsx +++ b/apps/wr/app/(with-layout)/ruseFcltMng/fcltReg/rainReg/page.tsx @@ -152,9 +152,9 @@ export default function Home() {
-
- - {/* */} +
+ + - {/* */} +
diff --git a/apps/wr/app/(with-layout)/ruseFcltMng/fcltReg/rswtReg/page.tsx b/apps/wr/app/(with-layout)/ruseFcltMng/fcltReg/rswtReg/page.tsx index 845ebf0e8..a4fa9b97b 100644 --- a/apps/wr/app/(with-layout)/ruseFcltMng/fcltReg/rswtReg/page.tsx +++ b/apps/wr/app/(with-layout)/ruseFcltMng/fcltReg/rswtReg/page.tsx @@ -158,8 +158,9 @@ export default function Home() {
-
- +
+ + +
diff --git a/apps/wr/app/(with-layout)/ruseFcltMng/fcltSttn/pbnstSttn/page.tsx b/apps/wr/app/(with-layout)/ruseFcltMng/fcltSttn/pbnstSttn/page.tsx index 2fd1c2d55..a481f66a1 100644 --- a/apps/wr/app/(with-layout)/ruseFcltMng/fcltSttn/pbnstSttn/page.tsx +++ b/apps/wr/app/(with-layout)/ruseFcltMng/fcltSttn/pbnstSttn/page.tsx @@ -10,7 +10,7 @@ "use client"; import { useWrStore } from "@/app/store"; import { btnClass } from "@/app/store/common/code"; -import { prfRegPageSize } from "@/app/store/prfmncReg/pbnstPrfmncReg"; +import { DEFAULT_LIST_SIZE } from "@/src/shared/config"; import type { customPbnst } from "@/app/store/ruseFcltMng/fcltReg/pbnstReg/type"; import type { WaterReuseFacility } from "@/app/store/ruseFcltMng/fcltSttn/pbnstSttn/type"; import { excelDownloadNumStyle, yearOptions } from "@/components/lib/func/common"; @@ -42,13 +42,13 @@ export default function PbnstSttn() { pbnstSttnSearch, pbnSttnInit, getPbnStnDetail, - getFlcts, // 하수재이용시설 조회 + getFlcts, // 하수재이용시설(tb_wr_pbnst_ruse_fclt) 조회 resetPbnstDetail, getLaeSeCdCheck, } = useWrStore((state) => state); const { pbnstSttnRes, fclts, pbnstSttnResCount } = pbnstSttn; - const { laeSeCd } = auth; + const { laeSeCd } = auth; // 지자체구분코드 const [isReset, setIsReset] = useState(false); const ref = useRef({} as ReturnType); @@ -66,15 +66,26 @@ export default function PbnstSttn() { const [pbnRuseNm, setPbnRuseNm] = useState(""); const { t } = useTranslation("base"); const alert = useAlertStore((state) => state.setMessage); - const isAllAreaUser = laeSeCd === "0"; + const isAllAreaUser = laeSeCd === "0"; // 모든 지자체 권한이 있는 경우 const fixedSidoValue = !isAllAreaUser && laeSeCd?.length ? laeSeCd.slice(0, 2) : ""; const fixedSigunValue = !isAllAreaUser && laeSeCd?.length === 5 ? laeSeCd : "0"; const getFixedSearchValue = useCallback( (baseValue: ReturnType): ReturnType => { + const cleanedValue = { ...baseValue }; + + // 유역(R) 탭 선택 시, 이전 행정구역 탭에서 선택된 시군구 값이 남아있어 + // 검색 결과가 시군구로 필터링되는 현상을 방지하기 위해 sigunValue를 '0'으로 초기화합니다. + // if (cleanedValue.partValue === "R"|| cleanedValue.partValue === "G") { + if (cleanedValue.partValue === "R") { + cleanedValue.sigunValue = "0"; + cleanedValue.sigunText = ""; + } + if (isAllAreaUser) { - return baseValue; + // console.log("isAllAreaUser is true"); + return cleanedValue; } const fixedSidoText = @@ -85,7 +96,7 @@ export default function PbnstSttn() { : ""; return { - ...baseValue, + ...cleanedValue, partValue: "A", sidoValue: fixedSidoValue, sigunValue: fixedSigunValue, @@ -133,7 +144,7 @@ export default function PbnstSttn() { useEffect(() => { if (useInitRef.current === true && laeSeCd !== undefined && fclts.length > 0) { - pbnSttnInit("1", "15", getFixedSearchValue(ref.current), searchYear, fclts); + pbnSttnInit("1", String(DEFAULT_LIST_SIZE), getFixedSearchValue(ref.current), searchYear, fclts); } }, [fclts, laeSeCd, searchYear, pbnSttnInit, getFixedSearchValue]); @@ -243,7 +254,7 @@ export default function PbnstSttn() { pbnstSttnSearch( false, "1", - String(prfRegPageSize), + String(DEFAULT_LIST_SIZE), fixedSearchValue, pbnRuseNm, searchYear, @@ -259,40 +270,40 @@ export default function PbnstSttn() { ref={ref} isAdmin={!isAllAreaUser} initSidoValue={fixedSidoValue} - initSigunValue={fixedSigunValue !== "0" ? fixedSigunValue : ""} + initSigunValue={fixedSigunValue !== "0" ? fixedSigunValue : "0"} />
-
-
- { - cellCLick(index); - }} - onPageChange={(page: number) => { - const fixedSearchValue = getFixedSearchValue(ref.current); - pbnstSttnSearch( - false, - String(page), - String(prfRegPageSize), - fixedSearchValue, - pbnRuseNm, - searchYear, - fclts - ); - setIsReset(false); - }} - resetPageNo={isReset} - widthFixed={true} - > +
+
+ { + cellCLick(index); + }} + onPageChange={(page: number) => { + const fixedSearchValue = getFixedSearchValue(ref.current); + pbnstSttnSearch( + false, + String(page), + String(DEFAULT_LIST_SIZE), + fixedSearchValue, + pbnRuseNm, + searchYear, + fclts + ); + setIsReset(false); + }} + resetPageNo={isReset} + widthFixed={true} + >
@@ -338,4 +349,3 @@ export default function PbnstSttn() { ); } - diff --git a/apps/wr/app/api/proxy/[...url]/route.ts b/apps/wr/app/api/proxy/[...url]/route.ts index cf6447b1a..e847e8ad2 100644 --- a/apps/wr/app/api/proxy/[...url]/route.ts +++ b/apps/wr/app/api/proxy/[...url]/route.ts @@ -15,9 +15,10 @@ import { NextResponse } from "next/server"; const ACCESS_TOKEN = process.env.NEXT_PUBLIC_ACCESS_TOKEN || "access-token"; const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id"; const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization"; +const COOKIE_DOMAIN = process.env.NEXT_PUBLIC_COOKIE_DOMAIN; const REFRESH_TOKEN = process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-token"; const SERVER_API_URL = process.env.NEXT_PUBLIC_SERVER_API_URL || ""; -const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "SN"; +const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "WR"; async function handler( request: Request, @@ -35,6 +36,7 @@ async function handler( // silent refresh if (isRefreshHandler) { const refreshToken = cookieStore.get(REFRESH_TOKEN); + console.log("refreshToken", refreshToken?.value); headers[CLAIM_NAME] = refreshToken?.value ?? ""; if (!headers[CLAIM_NAME]) { @@ -52,12 +54,11 @@ async function handler( headers, body: JSON.stringify(body), }); - +// console.log(result); if (!result.ok) { - console.log("Invalid Credentials "); return NextResponse.json( - { message: "Invalid Credentials" }, - { status: 401 }, + { message: "first Invalid Credentials" }, + { status: result.status }, ); } @@ -72,11 +73,13 @@ async function handler( }); if (refreshToken) { - cookieStore.set(REFRESH_TOKEN, refreshToken, { + response.cookies.set(REFRESH_TOKEN, refreshToken, { httpOnly: true, - sameSite: "lax", + sameSite: "strict", // 다른 사이트에서 들어오는 모든 요청에 대해 쿠키를 보내지 않는 가장 강력한 설정 + domain: getCookieDomain(), path: "/", }); + console.log("refresh cookie setting : " + refreshToken); } return response; @@ -109,3 +112,14 @@ export async function PUT( const params = await props.params; return handler(request, params, "PUT"); } + +function getCookieDomain(): string | undefined { + if (typeof window === "undefined") return undefined; + + if (COOKIE_DOMAIN) return COOKIE_DOMAIN; + + const { hostname } = window.location; + if (hostname === "localhost" || hostname === "127.0.0.1") return undefined; + + return hostname; +} diff --git a/apps/wr/app/layout.tsx b/apps/wr/app/layout.tsx index e26635d4f..edd57b02b 100644 --- a/apps/wr/app/layout.tsx +++ b/apps/wr/app/layout.tsx @@ -1,8 +1,6 @@ import type { Metadata } from "next"; import { App } from "../src/app"; -import { setCookie } from "../src/shared/lib/cookie"; -setCookie("site-id-key", process.env.NEXT_PUBLIC_SITE_ID || "WR"); export const metadata: Metadata = { title: "물재이용매칭시스템", description: "물재이용매칭 시스템입니다", diff --git a/apps/wr/app/store/ruseFcltMng/fcltReg/htdflReg/index.tsx b/apps/wr/app/store/ruseFcltMng/fcltReg/htdflReg/index.tsx index 25ceaee65..9f1901f07 100644 --- a/apps/wr/app/store/ruseFcltMng/fcltReg/htdflReg/index.tsx +++ b/apps/wr/app/store/ruseFcltMng/fcltReg/htdflReg/index.tsx @@ -1,5 +1,6 @@ import { getFetchWithCount, postFectch, putFectch, deleteFectch } from "@/components/lib/api"; +import { DEFAULT_LIST_SIZE } from "@/src/shared/config"; import type { TableUpperProps } from "@/app/store/common/type/header"; import { z } from "zod"; import { makeMdfr, makeRstr } from "../bdstReg"; @@ -121,7 +122,7 @@ export const htdflFacStateExport: htdflFacState = { const initFetch = async (code: string) => { - let url = `/drain/facilityList/1/11` + let url = `/drain/facilityList/1/${DEFAULT_LIST_SIZE}` url+=`/${code}` @@ -137,7 +138,7 @@ export const htdflFacActionsExport: (set: any) => htdflFacSActions = ( try { const htdflRes = await getFetchWithCount( "wr", - `/drain/facilityList/${pageNation}/11/${code}` + `/drain/facilityList/${pageNation}/${DEFAULT_LIST_SIZE}/${code}` ); return set((state: htdflFacState) => ({ ...state, @@ -181,7 +182,7 @@ export const htdflFacActionsExport: (set: any) => htdflFacSActions = ( } const htdflRes = await getFetchWithCount( "wr", - `/drain/facilityList/1/11/${code}` + `/drain/facilityList/1/${DEFAULT_LIST_SIZE}/${code}` ); set((state: htdflFacState) => ({ ...state, @@ -251,7 +252,7 @@ export const htdflFacActionsExport: (set: any) => htdflFacSActions = ( }, htdflNmSearch :async(code:string | number, fcltNm:string) =>{ try { - const htdflRes = await getFetchWithCount("wr", `/drain/facilityList/1/11/${code}/${fcltNm}`); + const htdflRes = await getFetchWithCount("wr", `/drain/facilityList/1/${DEFAULT_LIST_SIZE}/${code}/${fcltNm}`); set((state: htdflFacState) => { return { ...state, diff --git a/apps/wr/app/store/ruseFcltMng/fcltReg/pbnstReg/index.tsx b/apps/wr/app/store/ruseFcltMng/fcltReg/pbnstReg/index.tsx index 505d18557..6340f1082 100644 --- a/apps/wr/app/store/ruseFcltMng/fcltReg/pbnstReg/index.tsx +++ b/apps/wr/app/store/ruseFcltMng/fcltReg/pbnstReg/index.tsx @@ -6,7 +6,7 @@ import { postFectch, putFectch, } from "@/components/lib/api"; -import { SITE_ID } from "@/src/shared/config"; +import { DEFAULT_LIST_SIZE, SITE_ID } from "@/src/shared/config"; import type { chHeadType } from "@/app/store/prfmncReg/chkgdnReg/rainChkgdn/headList"; import { type customPbnst, @@ -113,7 +113,7 @@ const hasuListFetch = async ( ) => { const checkWrRegListRes = await checkWrRegList(); // 재사용시설에 대한 정보 조회 - let url = `/prcsFclt/ruseInfo/${page ? page : 1}/11/A/${sggOrCity ? sggOrCity : "11"}/0/1/SW`; + let url = `/prcsFclt/ruseInfo/${page ? page : 1}/${DEFAULT_LIST_SIZE}/A/${sggOrCity ? sggOrCity : "11"}/0/1/SW`; const data = checkWrRegListRes.filter((item: string) => item !== ""); if (searchTxt) { diff --git a/apps/wr/app/store/ruseFcltMng/fcltReg/rainReg/index.tsx b/apps/wr/app/store/ruseFcltMng/fcltReg/rainReg/index.tsx index f6a5f520e..ce6da6334 100644 --- a/apps/wr/app/store/ruseFcltMng/fcltReg/rainReg/index.tsx +++ b/apps/wr/app/store/ruseFcltMng/fcltReg/rainReg/index.tsx @@ -6,6 +6,7 @@ import { postFectch, putFectch, } from "@/components/lib/api"; +import { DEFAULT_LIST_SIZE } from "@/src/shared/config"; import { z } from "zod"; import { makeMdfr, makeRstr } from "../bdstReg"; import { @@ -14,7 +15,7 @@ import { validationMessages, } from "./type"; export const TEST_UUID = "2d4b151d-1828-41e0-91d9-4cd28ea1cc7e"; -export const rainRegSize = 15; +export const rainRegSize = DEFAULT_LIST_SIZE; /** * 밧물이용시설 등록 (스토어) diff --git a/apps/wr/app/store/ruseFcltMng/fcltReg/rswtReg/index.tsx b/apps/wr/app/store/ruseFcltMng/fcltReg/rswtReg/index.tsx index 3ef1af2e5..816d6ab52 100644 --- a/apps/wr/app/store/ruseFcltMng/fcltReg/rswtReg/index.tsx +++ b/apps/wr/app/store/ruseFcltMng/fcltReg/rswtReg/index.tsx @@ -1,6 +1,7 @@ import { deleteFectch, getFetchWithCount, postFectch, putFectch } from "@/components/lib/api"; import { upHeadList, type chgBuilding } from "../rainReg"; +import { DEFAULT_LIST_SIZE } from "@/src/shared/config"; import type { TableUpperProps } from "@/app/store/common/type/header"; import { z } from "zod"; import { makeMdfr, makeRstr } from "../bdstReg"; @@ -14,7 +15,7 @@ import { baseReuseBuildingSchema, chgBuildingSchema, validationMessages } from " * 재이용시설 등록 - 중수도시설 등록 * 중수도시설에서 사용하는 api 및 타입 등을 관리한다. * */ -export const rswtRegSize =15; +export const rswtRegSize = DEFAULT_LIST_SIZE; const rswtrObjSchema = z.object({ bdstRuseFcltSn: z.number().nullish(), orgwtKndCd: z.string({ required_error: validationMessages.requiredSelect("원수 종류") }).min(1, { message: validationMessages.requiredSelect("원수 종류") }), diff --git a/apps/wr/app/store/ruseFcltMng/fcltSttn/pbnstSttn/index.tsx b/apps/wr/app/store/ruseFcltMng/fcltSttn/pbnstSttn/index.tsx index ee94ada00..35a3fb3d7 100644 --- a/apps/wr/app/store/ruseFcltMng/fcltSttn/pbnstSttn/index.tsx +++ b/apps/wr/app/store/ruseFcltMng/fcltSttn/pbnstSttn/index.tsx @@ -120,77 +120,62 @@ export const hasuListFetch = async ( let url = ``; let filteredFclt = flcts; - const filterData = - reurnValue.sigunValue !== "0" - ? reurnValue.sigunValue - : reurnValue.sidoValue; - - if (filterData !== "0") - filteredFclt = flcts.filter((fclt: string) => { - return filterData.length == 2 - ? fclt.slice(0, 2) === filterData - : fclt.slice(0, 5) === filterData; - }); - if (reurnValue.partValue === "A") { - url += `${reurnValue.partValue}/${ - // reurnValue.sidoValue ? reurnValue.sidoValue : reurnValue.sigunValue - reurnValue.sigunValue !== "0" - ? reurnValue.sigunValue - : reurnValue.sidoValue - }/0/1/SW`; - - if (filterData !== "0") - filteredFclt = flcts.filter((fclt: string) => { - return filterData.length == 2 - ? fclt.slice(0, 2) === filterData - : fclt.slice(0, 5) === filterData; - }); - } else if (reurnValue.partValue === "R") { - url += `${reurnValue.partValue}/${ - reurnValue.regionValue - ? reurnValue.regionValue - : reurnValue.unitWaterShedValue - ? reurnValue.unitWaterShedValue - : reurnValue.middleRegionValue - }/0/1/SW`; - } else if (reurnValue.partValue === "G") { - url += `${reurnValue.envPartValue}/${reurnValue.sigunValue !== "0" ? reurnValue.sigunValue : reurnValue.sidoValue}/0/1/SW`; - if (filterData !== "0") - filteredFclt = flcts.filter((fclt: string) => { - return filterData.length == 2 - ? fclt.slice(0, 2) === filterData - : fclt.slice(0, 5) === filterData; - }); - } else { - url += `A/0/0/1/SW`; + let nameCd="0", partStr="A", sidogunValue="0"; + switch (reurnValue.partValue) { + case 'R': // 유역별 + if (reurnValue.regionValue !== '0') { + if(reurnValue.unitWaterShedValue==='0') nameCd = reurnValue.regionValue; + else if (reurnValue.middleRegionValue==='0') nameCd = reurnValue.unitWaterShedValue; + else nameCd = reurnValue.middleRegionValue; + } + partStr = reurnValue.partValue; + break; + default: + if(reurnValue.sidoValue && reurnValue.sidoValue !== '0') nameCd = reurnValue.sidoValue; + if(reurnValue.sigunValue &&reurnValue.sigunValue !== '0') nameCd = reurnValue.sigunValue; + sidogunValue = nameCd; + partStr = reurnValue.envPartValue !== '0' ? reurnValue.envPartValue:'A'; + break; } + if (sidogunValue && sidogunValue !== "0") { // 시도, 시군구 조회 조건이 있다면 + filteredFclt = flcts.filter((fclt: string) => { + return sidogunValue.length == 2 + ? fclt.slice(0, 2) === sidogunValue + : fclt.slice(0, 5) === sidogunValue; + }); + } + + url = `${partStr}/${nameCd}/0/1/SW`; + if (searchNm && searchNm?.length > 0) { url += "/" + searchNm; } + // console.log(url); // 시설 코드가 없으면 FO 조회를 호출하지 않고 빈 결과 반환 if (filteredFclt.length === 0) { return { data: [], count: 0 }; } + // fo의 하수처리시설을 조회 const pbnList = await postFectch( `/prcsFclt/ruseInfo/1/${filteredFclt.length}/` + url, filteredFclt, fetchType.COMMON // FO ); - const checkRuseFcltList = - searchNm && searchNm?.length > 0 - ? pbnList.data.map((item) => item.bscFctCd) - : filteredFclt; - + const checkRuseFcltList = pbnList.data.map((item) => item.bscFctCd); + // console.log(checkRuseFcltList); + + const getRuseList = await postFectch( - `/reuseFc/pubDetailList/${year}/${filterData}/${page}/${total}`, + `/reuseFc/pubDetailList/${year}/${sidogunValue}/${page}/${total}`, checkRuseFcltList, fetchType.WR ); const checkWrRegListRes = getRuseList.data; + // console.log(getRuseList); const data: WaterReuseFacility[] = checkWrRegListRes .map((item: FcPrfType) => { @@ -292,12 +277,13 @@ export const pbnstSttnActionsExport: (set: any) => pbnstSttnActions = ( isExcel: boolean, page: string, total: string, - reurnValue: ReturnType, + reurnValue: ReturnType, // 검색조건 searchNm: string | undefined, year: string, fclts: string[] ) => { try { + // console.log(reurnValue); const pbnstSttnRes = await hasuListFetch( page, total, diff --git a/apps/wr/middleware.ts b/apps/wr/middleware.ts index d1e96dacc..498e45fed 100644 --- a/apps/wr/middleware.ts +++ b/apps/wr/middleware.ts @@ -30,6 +30,7 @@ function buildAuthHeaders(req: NextRequest): Headers { const requestHeaders = new Headers(req.headers); const token = req.cookies.get(CLAIM_NAME)?.value; const userId = req.cookies.get(AUTH_USER_ID)?.value; + if (token) requestHeaders.set(CLAIM_NAME, token); if (userId) requestHeaders.set(AUTH_USER_ID, userId); return requestHeaders; @@ -38,7 +39,9 @@ function buildAuthHeaders(req: NextRequest): Headers { /** 공통: rewrite 응답 생성 (헤더 주입 여부 선택) */ function rewriteWith(url: string, req: NextRequest, withAuth = false): NextResponse { return withAuth - ? NextResponse.rewrite(new URL(url), { headers: buildAuthHeaders(req) }) + ? NextResponse.rewrite(new URL(url), { + headers: buildAuthHeaders(req), + }) : NextResponse.rewrite(new URL(url)); } diff --git a/apps/wr/package.json b/apps/wr/package.json index 881b4d149..662585887 100644 --- a/apps/wr/package.json +++ b/apps/wr/package.json @@ -30,7 +30,7 @@ "js-cookie": "^3.0.5", "lucide-react": "^0.474.0", "moment": "^2.30.1", - "next": "15.3.8", + "next": "15.5.18", "ol": "^7.3.0", "ol-ext": "^4.0.11", "proj4": "^2.9.0", diff --git a/apps/wr/src/app/providers/app-provider.tsx b/apps/wr/src/app/providers/app-provider.tsx index 88a10e46e..63df8349d 100644 --- a/apps/wr/src/app/providers/app-provider.tsx +++ b/apps/wr/src/app/providers/app-provider.tsx @@ -19,7 +19,9 @@ import koBase from "@/public/locales/ko/message.json"; import { StoreProvider as WrStoreProvider } from "@/app/store"; //import { LoginApi } from "@/src/entities/auth"; //import { useEffect } from "react"; +import { setCookie } from "@/src/shared/lib/cookie"; +setCookie("site-id-key", process.env.NEXT_PUBLIC_SITE_ID || "WR"); const i18n = i18next.createInstance(); i18n.init({ diff --git a/apps/wr/src/entities/auth/api/login-service.ts b/apps/wr/src/entities/auth/api/login-service.ts index 94be8b53f..1ad5f2cd0 100644 --- a/apps/wr/src/entities/auth/api/login-service.ts +++ b/apps/wr/src/entities/auth/api/login-service.ts @@ -1,7 +1,7 @@ import type { LoginService, ILogin } from "../model/login-interface"; import { tokenManager } from "../lib/token-manager"; -import {PROXY_HOST, ACCESS_TOKEN, AUTH_USER_ID,SITE_ID, CLAIM_NAME, ENDPOINT, JWT_EXPIRATION_TIME } from "../../../shared/config"; +import { ACCESS_TOKEN, AUTH_USER_ID, SITE_ID, CLAIM_NAME, ENDPOINT } from "../../../shared/config"; export class LoginApiService implements LoginService { private static instance: LoginApiService; @@ -18,31 +18,33 @@ export class LoginApiService implements LoginService { public async login(payload: ILogin): Promise { try { tokenManager.clearAuthToken(); - const response = await fetch( PROXY_HOST+ ENDPOINT.USER_SERVICE.LOGIN, { + const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, { method: "POST", headers: { "Content-Type": "application/json", - "X-Site-Id": SITE_ID, }, body: JSON.stringify({ userId: payload.userId, password: payload.password, }), }); - // const data = response.headers.get(ACCESS_TOKEN); - const accessToken = response.headers.get(ACCESS_TOKEN); - const tokenId = response.headers.get(AUTH_USER_ID); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data?.message ?? "Login failed"); + } + + const accessToken = data[ACCESS_TOKEN]; + const tokenId = data[AUTH_USER_ID]; if (!accessToken || !tokenId) { throw new Error("Missing required authentication tokens"); } - tokenManager.setToken(CLAIM_NAME, accessToken) - tokenManager.setToken(AUTH_USER_ID, tokenId) - tokenManager.setToken("site-id-key", SITE_ID) - tokenManager.setupRefreshTimer( - () => this.silentRefresh(), - JWT_EXPIRATION_TIME, - ); + tokenManager.setToken(CLAIM_NAME, accessToken); + tokenManager.setToken(AUTH_USER_ID, tokenId); + tokenManager.setToken("site-id-key", SITE_ID); + tokenManager.setupRefreshTimer(() => this.silentRefresh()); } catch (error) { throw new Error(`Login failed: ${error}`); @@ -54,17 +56,19 @@ export class LoginApiService implements LoginService { console.log(' silentRefresh ') try { - const response = await fetch(PROXY_HOST+ - `${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`, + const response = await fetch( + `/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`, { method: "PUT", - headers: { - "X-Site-Id": SITE_ID, - }, }, ); const data = await response.json(); + + if (!response.ok) { + throw new Error(data?.message ?? "Silent refresh failed"); + } + const accessToken = data[ACCESS_TOKEN]; const tokenId = data[AUTH_USER_ID]; @@ -74,6 +78,7 @@ export class LoginApiService implements LoginService { tokenManager.setToken(CLAIM_NAME, accessToken); tokenManager.setToken(AUTH_USER_ID, tokenId); + tokenManager.setupRefreshTimer(() => this.silentRefresh()); } catch (error) { tokenManager.clearAuthToken(); throw new Error(`Silent refresh failed: ${error}`); diff --git a/apps/wr/src/entities/auth/lib/auth-wrapper.tsx b/apps/wr/src/entities/auth/lib/auth-wrapper.tsx index 796cd44a9..91073c42c 100644 --- a/apps/wr/src/entities/auth/lib/auth-wrapper.tsx +++ b/apps/wr/src/entities/auth/lib/auth-wrapper.tsx @@ -2,10 +2,12 @@ import { useEffect } from "react"; import { usePathname } from "next/navigation"; +import { LoginApi } from "../api/login-service"; +import { tokenManager } from "./token-manager"; import { client } from "../../../shared/api"; import type { IUserInfo } from "../../../shared/store"; import { useUserStore } from "../../../shared/lib"; -import { ENDPOINT, AUTH_USER_ID } from "../../../shared/config"; +import { ENDPOINT, AUTH_USER_ID, CLAIM_NAME } from "../../../shared/config"; import { isAtLoginRedirectUrl } from "@common/network"; import { getCookie } from "../../../shared/lib"; @@ -17,22 +19,37 @@ const AuthWrapper = ({ children }: { children: React.ReactNode }) => { const { userInfo, setUserInfo } = useUserStore((state) => state); async function authCheck() { + // 현재 페이지가 로그인 페이지이면 authCheck(쿠키점검) 안함. if (isAtLoginRedirectUrl(LOGIN_PAGE_URL)) { return; } const userId = getCookie(AUTH_USER_ID); - if (!userId) { + if (!userId) { // token_id 가 없으면 로그인 페이지로 이동 throw new Error("Missing required authentication tokens"); } + if (userInfo) return; + const response = await client.get( `${ENDPOINT.USER_SERVICE.USERS}/info/me`, ); - if (!userInfo) setUserInfo(response.data); + setUserInfo(response.data); } useEffect(() => { + const token = getCookie(CLAIM_NAME); + const userId = getCookie(AUTH_USER_ID); + + if (!token || !userId) { + return; + } + + tokenManager.setupRefreshTimer(() => LoginApi.silentRefresh()); + }, []); + + useEffect(() => { + const isJwtTestPage = pathname?.startsWith("/jwttest"); (async () => { try { await authCheck(); @@ -40,7 +57,7 @@ const AuthWrapper = ({ children }: { children: React.ReactNode }) => { console.error(error); setUserInfo(null); - if (isAtLoginRedirectUrl(LOGIN_PAGE_URL)) return; + if (isAtLoginRedirectUrl(LOGIN_PAGE_URL) || isJwtTestPage) return; // const hostname = window.location.hostname; diff --git a/apps/wr/src/entities/auth/lib/token-manager.ts b/apps/wr/src/entities/auth/lib/token-manager.ts index 919386622..af6ecb1c3 100644 --- a/apps/wr/src/entities/auth/lib/token-manager.ts +++ b/apps/wr/src/entities/auth/lib/token-manager.ts @@ -1,4 +1,4 @@ -import { AUTH_USER_ID, CLAIM_NAME } from "@/src/shared/config"; +import { AUTH_USER_ID, CLAIM_NAME, REFRESH_TOKEN } from "@/src/shared/config"; import { getCookie, removeCookie, setCookie } from "@/src/shared/lib/cookie"; type JwtPayload = { @@ -11,6 +11,7 @@ type JwtPayload = { export class TokenManager { private static instance: TokenManager; private refreshTimer: NodeJS.Timeout | null = null; + private static readonly REFRESH_BUFFER_MS = 60000; // 60초 private constructor() {} @@ -79,20 +80,37 @@ export class TokenManager { removeCookie(key); } - public setupRefreshTimer( - callback: () => Promise, - expirationTime: number, - ): void { + private handleRefreshFailure(error: unknown, message: string): void { + console.error(message, error); + this.clearAuthToken(); + } + + public setupRefreshTimer(callback: () => Promise): void { if (this.refreshTimer) { clearTimeout(this.refreshTimer); + this.refreshTimer = null; } + const remainingMs = this.getRemainingMsFromToken(CLAIM_NAME); + if (remainingMs === null) { + return; + } + + if (remainingMs <= 0) { + this.clearAuthToken(); + return; + } + + const refreshDelay = Math.max( + remainingMs - TokenManager.REFRESH_BUFFER_MS, + 0, + ); + this.refreshTimer = setTimeout(() => { callback().catch((error) => { - console.error("Silent refresh failed:", error); - this.clearAuthToken(); + this.handleRefreshFailure(error, "Silent refresh failed:"); }); - }, expirationTime - 60000); + }, refreshDelay); } public clearAuthToken(): void { @@ -102,6 +120,7 @@ export class TokenManager { } this.removeToken(CLAIM_NAME); + this.removeToken(REFRESH_TOKEN); this.removeToken(AUTH_USER_ID); } } diff --git a/apps/wr/src/shared/config/api.ts b/apps/wr/src/shared/config/api.ts index eebb15c19..73bbab9cf 100644 --- a/apps/wr/src/shared/config/api.ts +++ b/apps/wr/src/shared/config/api.ts @@ -82,4 +82,4 @@ export const ENDPOINT = { }; -export const JWT_EXPIRATION_TIME = 1800000; +export const JWT_EXPIRATION_TIME = 3600000; diff --git a/apps/wr/src/shared/config/index.ts b/apps/wr/src/shared/config/index.ts index cd83447d5..04e4f2df5 100644 --- a/apps/wr/src/shared/config/index.ts +++ b/apps/wr/src/shared/config/index.ts @@ -1,2 +1,4 @@ export * from "./api"; export * from "./env"; + +export const DEFAULT_LIST_SIZE = 10; diff --git a/packages/common/business_components/README.md b/packages/common/business_components/README.md index 7b4765ce2..108752b6b 100644 --- a/packages/common/business_components/README.md +++ b/packages/common/business_components/README.md @@ -1,4 +1,5 @@ ## business_components 패키지 + ### 원앤올에서 제공받는 컴포넌트 외에 업무 파트에서 사용할 수 있는 별도 components 패키지 > 하수관로 운영관리 시스템 `sn` 작업에서는 이 패키지만 직접 수정 가능한 공통 비즈니스 컴포넌트 범위이다. `@common/components`, `@common/network`, `@common/auth`, `@common/config` 수정이 필요해 보이면 직접 수정하지 말고 원인과 영향 범위를 먼저 정리한다. @@ -6,6 +7,7 @@   1) 환경설정     (1) tsconfig.json 'path'에 '@common/business_components' 변수를 추가 + ```json "paths": { "@/*": ["./*"], @@ -16,16 +18,19 @@ "@common/business_components": ["../../packages/common/business_components/src/index"] } ``` +     (2) package.json 'dependencies'에 '@common/business_components' 추가 + ```json "dependencies": { "@common/business_components": "workspace:*", - "next": "15.3.8" + "next": "15.5.18" }, ```     (3) tailwind.config.ts 추가 -``` json + +```json content: [ "./app/**/*.{js,ts,jsx,tsx,mdx}", "../../packages/common/components/src/ui/**/*.{js,ts,jsx,tsx,mdx}", @@ -33,4 +38,3 @@ content: [ "../../packages/common/business_components/src/ui/**/*.{js,ts,jsx,tsx,mdx}", ], ``` - diff --git a/packages/common/business_components/src/ui/search_base_form/index.tsx b/packages/common/business_components/src/ui/search_base_form/index.tsx index e4fef1e1e..a42e6f548 100644 --- a/packages/common/business_components/src/ui/search_base_form/index.tsx +++ b/packages/common/business_components/src/ui/search_base_form/index.tsx @@ -10,9 +10,9 @@ type SearchBaseType = { rightChildren:React.ReactNode; selectSidoData?: SelectDataType[] | []; selectSigunData?: SelectDataType[] | []; - selectRegionData?: SelectDataType[] | []; - selectUnitWaterShedData?: SelectDataType[] | []; - selectMiddleRegionData?: SelectDataType[] | []; + selectRegionData?: SelectDataType[] | []; // 유역 코드 + selectUnitWaterShedData?: SelectDataType[] | []; // 단위유역 코드 + selectMiddleRegionData?: SelectDataType[] | []; // 중권역 코드 selectEnvPartData?: EnvPartSelectDataType[] | []; queryClick:(returnType:ReturnType) => void; excelName?:string; @@ -30,15 +30,15 @@ type SearchBaseType = { excelDisabled?: boolean; } export type ReturnType = { - partValue: string; - sidoValue: string; - sigunValue: string; - regionValue: string; - unitWaterShedValue: string; - middleRegionValue: string; - envPartValue: string; - sidoText: string; - sigunText: string; + partValue: string; // 조회 구분값(A: 행정, R: 유역, G: 환경청) + sidoValue: string; // 선택된 시도 코드 + sigunValue: string; // 선택된 시군구 코드 + regionValue: string; // 선택된 권역(유역) 코드 + unitWaterShedValue: string; // 선택된 단위유역 코드 + middleRegionValue: string; // 선택된 중권역 코드 + envPartValue: string; // 선택된 환경청 코드 + sidoText: string; // 선택된 시도명 + sigunText: string; // 선택된 시군구명 } function SearchBaseForm ({ diff --git a/packages/common/business_components/src/ui/search_form_left/index.tsx b/packages/common/business_components/src/ui/search_form_left/index.tsx index 0b04562b4..451b313b9 100644 --- a/packages/common/business_components/src/ui/search_form_left/index.tsx +++ b/packages/common/business_components/src/ui/search_form_left/index.tsx @@ -201,31 +201,51 @@ function SearchFormLeft({ isShowPart = false, children, selectSidoData = [], sel let selectedEnvPartSido; let selectedEnvPartSigun; + const resetSelectStateByPartChange = () => { + setSidoValue('0'); + setSigunValue('0'); + setSidoText(''); + setSigunText(''); + setRegionValue('0'); + setUnitWaterShedValue('0'); + setMiddleRegionValue('0'); + setEnvPartValue('0'); + + setFilteredSigunData([{ text: "전체", val: '0' }]); + setFilteredUnitWaterShedData([{ text: "전체", val: '0' }]); + setFilteredMiddleRegionData([{ text: "전체", val: '0' }]); + setFilteredEnvPartData([{ text: "전체", val: '0', adstDliStrCd:'' }]); + setFilteredEnvPartSidoData([{ text: "전체", val: '0', adstDliStrCd:'' }]); + setFilteredEnvPartSigunData([{ text: "전체", val: '0' }]); + setFilteredEnvPartSigunDataSelected([]); + + if (setRtnSidoValue) setRtnSidoValue('0'); + if (setRtnSigunValue) setRtnSigunValue('0'); + if (setRtnSidoText) setRtnSidoText(''); + if (setRtnSigunText) setRtnSigunText(''); + if (setRtnRegionValue) setRtnRegionValue('0'); + if (setRtnUnitWaterShedValue) setRtnUnitWaterShedValue('0'); + if (setRtnMiddleRegionValue) setRtnMiddleRegionValue('0'); + if (setRtnEnvPartValue) setRtnEnvPartValue('0'); + if (setRtnEnvPartSidoValue) setRtnEnvPartSidoValue('0'); + if (setRtnEnvPartSigunValue) setRtnEnvPartSigunValue('0'); + }; + switch(type) { case 'part': setPartValue(val); if (setRtnPartValue) { setRtnPartValue(val); } - // 구분 - if(val == 'R') { - initializeMiddleRegionData('0'); - initializeUnitWaterShedData('0'); - } else if(val == 'G') { + + resetSelectStateByPartChange(); + + if (val == 'G') { setFilteredEnvPartData([ { text: "전체", val: '0', adstDliStrCd:'' }, ...selectEnvPartData.filter((item: EnvPartSelectDataType) => item.val !== null && item.adstDliStrCd !== '') ]); - setEnvPartValue('0'); - initializeCityDataByEnvPartSido('0'); - } else { - initializeSigunData('0'); - onSelectValue(selectSigunData[0]?.val || '0', 'sigun'); } - - // 다른 selectbox 전체로 초기화 - onSelectValue(selectSidoData[0]?.val || '0', 'sido'); - onSelectValue(selectSigunData[0]?.val || '0', 'sigun'); break; case 'sido': setSidoValue(val); diff --git a/packages/common/business_components/src/ui/table/index.tsx b/packages/common/business_components/src/ui/table/index.tsx index 414efe5f7..ed05a7f19 100644 --- a/packages/common/business_components/src/ui/table/index.tsx +++ b/packages/common/business_components/src/ui/table/index.tsx @@ -152,7 +152,7 @@ export function UiTable>({ <> )}
- +
{widthFixed ? {leafCols.map(col => ( diff --git a/packages/rsone/components/type/computedFieldCallbacks.ts b/packages/rsone/components/type/computedFieldCallbacks.ts index 362d0da7f..1207696d9 100644 --- a/packages/rsone/components/type/computedFieldCallbacks.ts +++ b/packages/rsone/components/type/computedFieldCallbacks.ts @@ -8,6 +8,12 @@ function toNum(v: unknown): number { return isNaN(n) ? 0 : n; } +/** 소수점 첫째 자리 이후 버림 (반올림 없음) */ +function truncateToFirstDecimal(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.floor(value * 10) / 10; +} + /** 합계용 valueCallback 생성 */ export function createSumValueCallback(fieldNames: string[]): DataFieldValueCallback { return (_prod, _dataRow, _fieldName, fieldNamesArr, values) => { @@ -57,7 +63,7 @@ export function createCoverageRateValueCallback( }; } -// 하수도 설치율(%) valueCallback +// 하수도 설치율(%) valueCallback — 항목·중간·결과 모두 소수 첫째 자리 이후 버림(반올림 없음) export function createSwrgInstlrtRateValueCallback( numeratorFields: string[], denominatorFields: string[], @@ -68,18 +74,24 @@ export function createSwrgInstlrtRateValueCallback( let sum = 0; for (const f of fields) { const idx = fieldNamesArr.indexOf(f); - if (idx >= 0) sum += toNum(values[idx]); + if (idx >= 0) { + sum += truncateToFirstDecimal(toNum(values[idx])); + } } - return sum; + return truncateToFirstDecimal(sum); }; const sum1 = getSum(numeratorFields); const sum2 = getSum(denominatorFields); - const piplSupprt = toNum(values[fieldNamesArr.indexOf(thirdFields[0] || 'piplSupprt')]); + const piplSupprt = truncateToFirstDecimal( + toNum(values[fieldNamesArr.indexOf(thirdFields[0] || "piplSupprt")]), + ); - const total1 = (sum1 === 0 ? 0 : Math.round(sum2 / sum1 * 100)); + const ratioPercent = + sum1 === 0 ? 0 : truncateToFirstDecimal((sum2 / sum1) * 100); + const result = truncateToFirstDecimal((ratioPercent * piplSupprt) / 100); - return total1 * piplSupprt / 100; + return result; }; }