diff --git a/apps/cs/.env.example b/apps/cs/.env.example index 9f786831b..19231af8a 100644 --- a/apps/cs/.env.example +++ b/apps/cs/.env.example @@ -1,7 +1,8 @@ # apps/cs/src/shared/config/env.ts 참고 NEXT_PUBLIC_PORT=3000 NEXT_PUBLIC_PROXY_HOST=http://localhost:3000 +SERVER_API_URL=http://192.168.0.102:8000 NEXT_PUBLIC_SITE_ID=CS NEXT_PUBLIC_SUB_DOMAIN=http://localhost:3000 -NEXT_PUBLIC_SERVER_API_URL=http://192.168.0.102:8000 -NEXT_PUBLIC_TINY_API_KEY=t9nc4z6inquvvd5pq1vea8wcp7lb01qmqseh6egms9vpaju2 \ No newline at end of file +NEXT_PUBLIC_TINY_API_KEY=t9nc4z6inquvvd5pq1vea8wcp7lb01qmqseh6egms9vpaju2 +NEXT_PUBLIC_TINY_BASE_URL= \ No newline at end of file diff --git a/apps/cs/middleware.ts b/apps/cs/middleware.ts index 4cc85b307..98b741325 100644 --- a/apps/cs/middleware.ts +++ b/apps/cs/middleware.ts @@ -1,21 +1,25 @@ import { NextRequest, NextResponse } from "next/server"; + import { - CLAIM_NAME, AUTH_USER_ID, + CLAIM_NAME, PORT, PROXY_HOST, SERVER_API_URL, SITE_ID, } from "./src/shared/config"; -const serverApiUrl = - process.env.NEXT_PUBLIC_SERVER_API_URL ?? "http://localhost:3000"; +const serverApiUrl = process.env.SERVER_API_URL ?? "http://localhost:3000"; -const linkBackendApiHost = process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST ?? "http://localhost:8080"; +const linkBackendApiHost = + process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST ?? "http://localhost:8080"; // CSP 헤더 설정 함수 (img-src만) function addSecurityHeaders(response: NextResponse): NextResponse { - response.headers.set("Content-Security-Policy", "img-src 'self' data: https: blob:;"); + response.headers.set( + "Content-Security-Policy", + "img-src 'self' data: https: blob:;", + ); return response; } @@ -66,7 +70,7 @@ export function middleware(req: NextRequest) { const requestHeaders = new Headers(req.headers); const token = req.cookies.get(CLAIM_NAME)?.value; const userId = req.cookies.get(AUTH_USER_ID)?.value; - + // /api/link 경로를 제거하고 백엔드 API 경로로 변환 const apiPath = pathname.replace("/api/link", ""); const queryParams = searchParams.toString(); @@ -94,11 +98,16 @@ export function middleware(req: NextRequest) { logMessages.push("\n--------------------------------\n"); // console.log(logMessages.join("\n")); - + const response = NextResponse.next(); return addSecurityHeaders(response); } export const config = { - matcher: ["/server/:path*", "/attach-service/:path*", "/api/link/:path*", "/"], + matcher: [ + "/server/:path*", + "/attach-service/:path*", + "/api/link/:path*", + "/", + ], }; diff --git a/apps/cs/src/entities/editor/model/editor-base-config.ts b/apps/cs/src/entities/editor/model/editor-base-config.ts index ee465b675..f0684c5c8 100644 --- a/apps/cs/src/entities/editor/model/editor-base-config.ts +++ b/apps/cs/src/entities/editor/model/editor-base-config.ts @@ -1,4 +1,4 @@ -import { SERVER_API_URL } from "@/shared/config"; +import { TINY_BASE_URL } from "@/shared/config"; export const editorBaseConfig = { // 폐쇄망 환경에서는 API 키 없이 자체 호스팅 모드 사용 @@ -7,7 +7,7 @@ export const editorBaseConfig = { licenseKey: "gpl", init: { language: "ko_KR", - language_url: '/tinymce/langs/ko_KR.js', // 직접 호스팅한 경우 경로 지정 + language_url: "/tinymce/langs/ko_KR.js", // 직접 호스팅한 경우 경로 지정 height: 500, // 폐쇄망 환경을 위한 설정 base_url: "/tinymce", @@ -38,7 +38,7 @@ export const editorBaseConfig = { "undo redo | blocks fontfamily fontsize | bold italic underline strikethrough forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist blockquote | table", "link image", ], - images_upload_base_path: SERVER_API_URL, + images_upload_base_path: TINY_BASE_URL, file_picker_types: "image", // 폐쇄망 환경을 위한 추가 설정 content_css: "/tinymce/skins/content/default/content.min.css", diff --git a/apps/cs/src/pages/groups/ui/groups-dialog.tsx b/apps/cs/src/pages/groups/ui/groups-dialog.tsx index 9fca4ebdb..ac7f92916 100644 --- a/apps/cs/src/pages/groups/ui/groups-dialog.tsx +++ b/apps/cs/src/pages/groups/ui/groups-dialog.tsx @@ -43,7 +43,7 @@ const GroupsDialog = memo( const { data: group } = useQuery({ queryKey: [ENDPOINT.CMS_SERVICE.GROUPS, groupId], queryFn: () => GroupApi.findGroupById(groupId!), - enabled: !!groupId && open, // open 시점 groupId가 있을 때만 상세 조회 API 호출 + enabled: !isRegistration, }); const { mutateAsync: createGroup } = useMutationWrapper({ @@ -92,8 +92,9 @@ const GroupsDialog = memo( }; useEffect(() => { - if (open) form.reset(); - }, [open]); + // 그룹 등록 및 취소 시 폼 초기화 + if (isRegistration && !open) form.reset(); + }, [groupId, open]); return ( diff --git a/apps/cs/src/pages/groups/ui/role-select.tsx b/apps/cs/src/pages/groups/ui/role-select.tsx index 539888591..bede34ed8 100644 --- a/apps/cs/src/pages/groups/ui/role-select.tsx +++ b/apps/cs/src/pages/groups/ui/role-select.tsx @@ -16,7 +16,13 @@ type RoleSelectProps = React.ComponentPropsWithoutRef & { function RoleSelect({ mngrYn = "N", ...props }: RoleSelectProps) { const { data: roles } = useQuery({ queryKey: [ENDPOINT.USER_SERVICE.ROLES], - queryFn: () => RoleApi.getRoles(), + queryFn: () => + RoleApi.getRoles({ + keywordType: "useYn", + keyword: "Y", + page: 0, + size: 100, + }), }); const options = roles?.content.filter((role) => role.mngrYn === mngrYn); diff --git a/apps/cs/src/pages/role/model/table-columns.tsx b/apps/cs/src/pages/role/model/table-columns.tsx index 71da16cfe..dcebbc736 100644 --- a/apps/cs/src/pages/role/model/table-columns.tsx +++ b/apps/cs/src/pages/role/model/table-columns.tsx @@ -3,13 +3,8 @@ import { useState } from "react"; import type { Role } from "@/entities/role"; import type { ColumnDef } from "@tanstack/react-table"; import { MappingRoleDialog } from "../ui/mapping-role-dialog"; -import { Button } from "@common/components"; -export const createColumns = ({ - deleteRole, -}: { - deleteRole: (roleId: string) => void; -}): ColumnDef[] => [ +export const createColumns = (): ColumnDef[] => [ { id: "no", header: "NO", @@ -44,18 +39,7 @@ export const createColumns = ({ { id: "action", header: "관리", - cell: ({ row }) => ( -
- - -
- ), + cell: ({ row }) => , }, ]; diff --git a/apps/cs/src/pages/role/ui/modify-page.tsx b/apps/cs/src/pages/role/ui/modify-page.tsx index 37f00836f..52c67f4b0 100644 --- a/apps/cs/src/pages/role/ui/modify-page.tsx +++ b/apps/cs/src/pages/role/ui/modify-page.tsx @@ -24,13 +24,6 @@ function ModifyPage({ roleId }: { roleId: string }) { }, }); - const { mutateAsync: deleteRole } = useMutationWrapper({ - mutationFn: () => RoleApi.deleteRole(roleId), - onSuccess: () => { - alert("삭제되었습니다.", () => router.push("/role")); - }, - }); - const form = useForm({ resolver: zodResolver(roleFormSchema), values: { @@ -47,12 +40,7 @@ function ModifyPage({ roleId }: { roleId: string }) {

권한 수정

- + ); } diff --git a/apps/cs/src/pages/role/ui/role-form.tsx b/apps/cs/src/pages/role/ui/role-form.tsx index bb67adb42..7a4653a9d 100644 --- a/apps/cs/src/pages/role/ui/role-form.tsx +++ b/apps/cs/src/pages/role/ui/role-form.tsx @@ -26,7 +26,6 @@ type RoleFormProps = { roleId?: string; form: UseFormReturn; handleSave: (data: RoleFormData) => Promise; - handleDelete?: () => Promise; }; function RoleForm({ roleId, form, handleSave, ...props }: RoleFormProps) { diff --git a/apps/cs/src/pages/role/ui/role-page.tsx b/apps/cs/src/pages/role/ui/role-page.tsx index 47d3d9a9e..b370ed338 100644 --- a/apps/cs/src/pages/role/ui/role-page.tsx +++ b/apps/cs/src/pages/role/ui/role-page.tsx @@ -7,7 +7,7 @@ import { getCoreRowModel, type PaginationState } from "@tanstack/react-table"; import { Button, Input, Label } from "@common/components"; import { RoleApi, type Role, type RoleParams } from "@/entities/role"; import { ENDPOINT } from "@/shared/config"; -import { useAlertStore, useDataTable, useMutationWrapper } from "@/shared/lib"; +import { useAlertStore, useDataTable } from "@/shared/lib"; import { createColumns } from "../model/table-columns"; function RolePage() { @@ -16,6 +16,7 @@ function RolePage() { const [keyword, setKeyword] = useState(""); const [searchParams, setSearchParams] = useState({ + keywordType: "roleName", keyword: "", sort: "regDt,desc", }); @@ -34,13 +35,6 @@ function RolePage() { }), }); - const { mutateAsync: deleteRole } = useMutationWrapper({ - mutationFn: (roleId: string) => RoleApi.deleteRole(roleId), - onSuccess: () => { - alert("삭제되었습니다.", query.refetch); - }, - }); - const handleRowClick = (row: Role) => { router.push(`/role/register/${row.roleId}`); }; @@ -48,9 +42,7 @@ function RolePage() { const { tableComponent } = useDataTable({ query, tableOptions: { - columns: createColumns({ - deleteRole, - }), + columns: createColumns(), state: { pagination, }, diff --git a/apps/cs/src/shared/config/env.ts b/apps/cs/src/shared/config/env.ts index 5e1392b4d..f352e1bd7 100644 --- a/apps/cs/src/shared/config/env.ts +++ b/apps/cs/src/shared/config/env.ts @@ -1,7 +1,7 @@ export const PORT = process.env.NEXT_PUBLIC_PORT || "3000"; export const PROXY_HOST = process.env.NEXT_PUBLIC_PROXY_HOST || `http://localhost:${PORT}`; -export const SERVER_API_URL = process.env.NEXT_PUBLIC_SERVER_API_URL; +export const SERVER_API_URL = process.env.SERVER_API_URL; export const AUTH_LOGIN_REDIRECT_URL = process.env.NEXT_PUBLIC_AUTH_LOGIN_REDIRECT_URL || "/auth/login"; export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "CS"; @@ -11,4 +11,5 @@ export const ACCESS_TOKEN = export const REFRESH_TOKEN = process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-token"; export const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id"; +export const TINY_BASE_URL = process.env.NEXT_PUBLIC_TINY_BASE_URL; export const COOKIE_DOMAIN = process.env.NEXT_PUBLIC_COOKIE_DOMAIN; diff --git a/apps/fo/app/auth/login/page.tsx b/apps/fo/app/auth/login/page.tsx new file mode 100644 index 000000000..0dbc42284 --- /dev/null +++ b/apps/fo/app/auth/login/page.tsx @@ -0,0 +1,2 @@ +import { LoginPage } from "@/src/features/auth/login"; +export default LoginPage; diff --git a/apps/fo/app/auth/login/route.ts b/apps/fo/app/auth/login/route-bak.ts similarity index 100% rename from apps/fo/app/auth/login/route.ts rename to apps/fo/app/auth/login/route-bak.ts diff --git a/apps/fo/app/components/api/index.ts b/apps/fo/app/components/api/index.ts index 6ed2eeb5e..542e72947 100644 --- a/apps/fo/app/components/api/index.ts +++ b/apps/fo/app/components/api/index.ts @@ -1,4 +1,28 @@ -import { DEV_URL_FO, DEV_URL_WR, LOCAL_URL } from "./config/env"; +import { CLAIM_NAME, SITE_ID } from "@/src/shared/config/env"; +import { tokenManager } from "@/src/entities/auth/lib/token-manager"; +import { DEV_URL_FO, DEV_URL_WR, LOCAL_URL } from "./config/env"; + +const LOGIN_PAGE_URL = + process.env.NEXT_PUBLIC_CM_LOGIN_PAGE_URL || "/auth/login"; + +const handleUnauthorizedResponse = () => { + if (typeof window === "undefined") return; + + const remainingMs = tokenManager.getRemainingMsFromToken(CLAIM_NAME); + const isExpired = remainingMs === null || remainingMs <= 0; + + if (!isExpired) return; + + tokenManager.clearAuthToken(); + + const isLoginPage = + window.location.pathname === "/auth/login" || + window.location.pathname.startsWith("/auth/login/"); + + if (!isLoginPage) { + window.location.assign(LOGIN_PAGE_URL); + } +}; export const getFetch = async (part : string, url: string) => { let reqUrl = ""; @@ -7,8 +31,12 @@ export const getFetch = async (part : string, url: string) => { method: "GET", headers: { accept: "application/json", + "X-Site-Id": SITE_ID, } }); + if (response.status === 401) { + handleUnauthorizedResponse(); + } if(!response.ok) { throw new Error(`오류 발생: ${response.status}`); } @@ -21,8 +49,12 @@ export const getFetchAreaList = async (url: string) => { method: "GET", headers: { accept: "application/json", + "X-Site-Id": SITE_ID, } }); + if (response.status === 401) { + handleUnauthorizedResponse(); + } if(!response.ok) { throw new Error(`오류 발생: ${response.status}`); } @@ -37,9 +69,13 @@ export const postFetch = async (part : string, url: string, params: unknown) => method: "POST", headers: { "Content-Type": "application/json", + "X-Site-Id": SITE_ID, }, body: JSON.stringify(params), }); + if (response.status === 401) { + handleUnauthorizedResponse(); + } if(!response.ok) { throw new Error(`오류 발생: ${response.status}`); } @@ -53,9 +89,13 @@ export const putFetch = async (part : string, url: string, params: unknown) => { method: "PUT", headers: { "Content-Type": "application/json", + "X-Site-Id": SITE_ID, }, body: JSON.stringify(params), }); + if (response.status === 401) { + handleUnauthorizedResponse(); + } if(!response.ok) { throw new Error(`오류 발생: ${response.status}`); } @@ -69,11 +109,15 @@ export const deleteFetch = async (part : string, url: string, params: unknown) = method: "DELETE", headers: { "Content-Type": "application/json", + "X-Site-Id": SITE_ID, }, body: JSON.stringify(params), }); + if (response.status === 401) { + handleUnauthorizedResponse(); + } if(!response.ok) { throw new Error(`오류 발생: ${response.status}`); } return response; -} \ No newline at end of file +} diff --git a/apps/fo/app/pages/(map-layout)/map/page.tsx b/apps/fo/app/pages/(map-layout)/map/page.tsx index 1e3789bfe..4fa553c97 100644 --- a/apps/fo/app/pages/(map-layout)/map/page.tsx +++ b/apps/fo/app/pages/(map-layout)/map/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { SITE_ID } from "@/src/shared/config/env"; import Gis from "@common/business_components/ui/map/Gis"; import GisApp from "@common/business_components/ui/map/lib/gis.core"; import { useEffect, useRef, useState } from "react"; @@ -59,7 +60,10 @@ const Map = () => { const reqUrl = `${API_URL}/lyrStngs/FO_NETWORK`; const response = await fetch(reqUrl, { method: "GET", - headers: { accept: "application/json" }, + headers: { + accept: "application/json", + "X-Site-Id": SITE_ID, + }, }); if (!response.ok) throw new Error(`HTTP 오류! 상태: ${response.status}`); const data = await response.json(); diff --git a/apps/fo/app/pages/(with-layout)/test/page.tsx b/apps/fo/app/pages/(with-layout)/test/page.tsx index 86689df10..7855cce20 100644 --- a/apps/fo/app/pages/(with-layout)/test/page.tsx +++ b/apps/fo/app/pages/(with-layout)/test/page.tsx @@ -1,4 +1,5 @@ "use client"; +import { SITE_ID } from "@/src/shared/config/env"; import React from "react"; export default function TestPage() { @@ -6,6 +7,9 @@ export default function TestPage() { const handleFindUser = () => { fetch('/operation-service/api/v1/fclt/flow/1/10/2024', { method: "GET", + headers: { + "X-Site-Id": SITE_ID, + }, }).then((data) => { console.log(data); }); @@ -19,4 +23,4 @@ export default function TestPage() { ); -} \ No newline at end of file +} diff --git a/apps/fo/public/images/bg_login.jpg b/apps/fo/public/images/bg_login.jpg new file mode 100644 index 000000000..b7793d16a Binary files /dev/null and b/apps/fo/public/images/bg_login.jpg differ diff --git a/apps/fo/src/entities/auth/lib/token-manager.ts b/apps/fo/src/entities/auth/lib/token-manager.ts index e86dba5f2..5908dd091 100644 --- a/apps/fo/src/entities/auth/lib/token-manager.ts +++ b/apps/fo/src/entities/auth/lib/token-manager.ts @@ -1,6 +1,13 @@ import { AUTH_USER_ID, CLAIM_NAME } from "@/src/shared/config"; import { getCookie, removeCookie, setCookie } from "@/src/shared/lib/cookie"; +type JwtPayload = { + sub?: string; + exp?: number; + iat?: number; + [key: string]: unknown; +}; + export class TokenManager { private static instance: TokenManager; private refreshTimer: NodeJS.Timeout | null = null; @@ -18,6 +25,49 @@ export class TokenManager { return getCookie(key); } + private parseJwt(token: string): JwtPayload { + const parts = token.split("."); + if (parts.length !== 3) { + throw new Error("유효한 JWT 형식이 아닙니다."); + } + + const base64Url = parts[1] ?? ""; + const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/"); + const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "="); + const jsonPayload = decodeURIComponent( + atob(padded) + .split("") + .map((char) => `%${char.charCodeAt(0).toString(16).padStart(2, "0")}`) + .join("") + ); + + return JSON.parse(jsonPayload) as JwtPayload; + } + + public getRemainingMsFromToken(key: string = CLAIM_NAME): number | null { + const token = this.getToken(key); + if (!token) { + return null; + } + + try { + const payload = this.parseJwt(token); + if (typeof payload.exp !== "number") { + return null; + } + + return payload.exp * 1000 - Date.now(); + } catch (error) { + console.error("JWT 파싱 오류:", error); + return null; + } + } + + public isExpiredToken(key: string = CLAIM_NAME): boolean { + const remainingMs = this.getRemainingMsFromToken(key); + return remainingMs === null || remainingMs <= 0; + } + public setToken(key: string, value: string): void { setCookie(key, value, { secure: location.protocol === "https:", diff --git a/apps/fo/src/features/auth/login/index.ts b/apps/fo/src/features/auth/login/index.ts new file mode 100644 index 000000000..2093c3d8d --- /dev/null +++ b/apps/fo/src/features/auth/login/index.ts @@ -0,0 +1 @@ +export { LoginPage } from "./ui/login-page"; diff --git a/apps/fo/src/features/auth/login/ui/login-page.tsx b/apps/fo/src/features/auth/login/ui/login-page.tsx new file mode 100644 index 000000000..fe14819f3 --- /dev/null +++ b/apps/fo/src/features/auth/login/ui/login-page.tsx @@ -0,0 +1,139 @@ +"use client"; + +import Image from "next/image"; +import { useEffect } from "react"; +import { images } from "@common/assets"; +import { LoginApi } from "@/src/entities/auth"; +import "./login.css"; + +function LoginPage() { + useEffect(() => { + const hostname = window.location.hostname; + if (hostname.includes("hasuon.or.kr")) { + window.location.href = + process.env.NEXT_PUBLIC_CM_LOGIN_PAGE_URL || "/auth/login"; + } + }, []); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + const formData = new FormData(e.target as HTMLFormElement); + const userId = formData.get("id") as string; + const password = formData.get("password") as string; + + await LoginApi.login({ userId, password }); + window.location.href = "/pages/dashboard"; + }; + + return ( +
+
+
+

+ 하수도정보시스템 +
+ CMS 통합관리시스템 +

+

하수도정보시스템 CMS 통합관리시스템에 오신 것을 환영합니다.

+
+
+ logo +
+
+
+
+
    +
  • +

    ID

    +
    + + +
    +
  • +
  • +

    PASSWORD

    +
    + + +
    +
  • +
+
+
+ + +
+ {/* 비밀번호 또는 아이디를 잃어버리셨나요? */} +
+ +
+
+

copyright © 2023 KECO. All rights reserved.

+
+ +

+ + 주소 : (22689) 인천광역시 서구 환경로 42(경서동 + 종합환경연구단지) + + + 전화 : (시스템) 032-590-4321, 3752, 4314 / (통 계) 032-590-3723, + 3722 + + 팩스 :032-590-3729 +

+
+
+ +
+ ); +} + +export { LoginPage }; + diff --git a/apps/fo/src/features/auth/login/ui/login.css b/apps/fo/src/features/auth/login/ui/login.css new file mode 100644 index 000000000..af73c9c68 --- /dev/null +++ b/apps/fo/src/features/auth/login/ui/login.css @@ -0,0 +1,310 @@ +/** +* OLYM Cascading Style Sheets +* File Name : login.css +* Description : 로그인 스타일시트 +* Author : Web business Team / Choi Hyun Mi +* Email : desingtj@olym.co.kr +* Date : 2023.07.20 +* Update : 2023.07.26 +* Copyright(c) 2023 OLYM Communications. All Rights Reserved. +*/ +html, +body { + background: #fff; +} +.fx { + display: flex; + -ms-display: flex; + -webkit-display: flex; + box-sizing: border-box; +} +.login { + width: 100%; + height: 100vh; +} +.login .left_box { + width: 100%; + background: url(/images/bg_login.jpg) no-repeat center/cover; + flex-direction: column; + justify-content: space-between; + padding: 7% 5% 5% 5%; +} +.login .left_box .text h1 { + font-size: 53px; + color: #fff; +} +.login .left_box .text h1 span { + font-weight: 800; +} +.login .left_box .text p { + margin-top: 40px; + font-size: 18px; + color: #fff; +} +.login .login_box { + width: 100%; + flex-direction: column; + justify-content: center; + padding: 4% 12% 4% 6%; +} +.login .login_box > ul { + flex-direction: column; + gap: 40px; +} +.login .login_box > ul > li h2 { + font-size: 22px; + font-weight: 600; + color: #2640a5; + margin-bottom: 14px; +} +.login .login_box > ul > li .input_st { + position: relative; +} +.login .login_box > ul > li .input_st .diveicon { + position: absolute; + top: 50%; + left: 28px; + transform: translateY(-50%); + font-family: "Xeicon"; + font-size: 25px; + color: #003288; +} +.login .login_box > ul > li input { + width: 100%; + padding: 20px 20px 20px 70px; + background: #f7f7f7; + box-sizing: border-box; + border: none; + border-radius: 5px; + font-size: 18px; + font-weight: 700; + color: #000; + height: auto; +} +.login .login_box > ul > li input:focus { + outline: 1px solid #3c5fe5; +} +.login .login_box > ul > li input::placeholder { + color: #afabab; + font-weight: 500; +} +.login .login_box .bottom_box { + justify-content: space-between; + gap: 20px; + padding: 50px 0 45px 0; +} +.login .login_box .bottom_box .checks { + display: flex; + align-items: center; + gap: 8px; +} +.login .login_box .bottom_box .checks input[type="checkbox"] { + width: 18px; + height: 18px; +} +.login .login_box .bottom_box .checks label { + font-size: 14px; +} +.login .login_box .bottom_box .pw_message { + align-items: center; + gap: 5px; + color: #636363; + font-weight: 500; + border-bottom: 1px solid #ccc; + padding-bottom: 5px; +} +.login .login_box .bottom_box .pw_message i { + color: #327af5; + font-size: 20px; +} +.login .login_box .login_btn { + position: relative; + border-radius: 5px; + padding: 24px 15px; + background: linear-gradient( + to left, + #1d3969 0%, + #264078 10%, + #172558 40%, + #162254 50%, + #111c43 60%, + #111c42 100% + ); + overflow: hidden; + justify-content: center; + align-items: center; + gap: 10px; + color: #fff; + cursor: pointer; +} +.login .login_box .login_btn span { + font-family: "Montserrat"; + font-size: 19px; + font-weight: 700; + position: relative; +} +.login .login_box .login_btn i { + font-size: 22px; + transform: translateY(-2px); +} +.login .login_box .login_btn:before { + display: block; + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0; + background: linear-gradient( + to right, + #1d3969 0%, + #264078 10%, + #172558 40%, + #162254 50%, + #111c43 60%, + #111c42 100% + ); + transition: opacity 0.5s; +} +.login .login_box .login_btn:hover::before { + opacity: 1; +} +.login .footer .copy { + margin-top: 36px; + justify-content: space-between; + align-items: center; + gap: 3%; +} +.login .footer .copy p { + font-size: 13px; + color: #d4d4d4; + width: 44%; + text-align: center; +} +.login .footer .copy::before, +.login .login_box .copy::after { + content: ""; + width: 20%; + height: 1px; + background: #d4d4d4; +} +.login .footer .fot_btn { + gap: 20px; + margin: 30px 0 10px 0; +} +.login .footer .fot_btn a { + color: #333; + font-weight: 700; + font-size: 13px; +} +.login .footer .fot_btn a:hover { + color: #003288; +} +.login .footer .addr { + color: #666; + font-size: 13px; + gap: 3px 20px; + flex-wrap: wrap; +} +/* 비밀번호 변경 팝업창 */ +.login .modal .modal_container { + width: 750px; +} +.login .pw_desc { + padding: 10px 10px 30px 10px; + color: #666; +} +.login .pw_desc p { + font-size: 1.25rem; + color: #000; + font-weight: 700; + margin-bottom: 10px; +} +.login .pw_desc span { + color: #f60; + font-weight: 700; +} +.login .modal .form_write { + border-top: 1px solid #ddd; + border-bottom: 1px solid #ddd; + padding: 20px 50px; +} +@media (max-width: 1024px) { + .login { + flex-direction: column; + height: auto; + } + .login .left_box { + padding: 30px; + flex-direction: column-reverse; + gap: 20px; + } + .login .left_box .text h1 { + font-size: 30px; + line-height: 1.2em; + } + .login .left_box .text p { + margin-top: 20px; + font-size: 14px; + } + .login .left_box .img img { + height: 40px; + } + .login .login_box { + padding: 30px; + } + .login .login_box > ul { + gap: 20px; + } + .login .login_box > ul > li h2 { + margin-bottom: 7px; + } + .login .login_box > ul > li .input_st::after { + left: 15px; + font-size: 20px; + } + .login .login_box > ul > li input { + padding: 15px 10px 15px 50px; + font-size: 16px; + } + .login .login_box .bottom_box { + flex-direction: column; + align-items: flex-start; + padding: 30px 0; + } + .login .login_box .bottom_box .id_save label { + font-size: 14px; + } + .login .login_box .login_btn { + padding: 15px; + } + .login .login_box .login_btn span { + font-size: 16px; + } + .login .login_box .login_btn i { + font-size: 18px; + transform: translateY(-1px); + } + .login .login_box .copy p { + width: 100%; + } + .login .login_box .copy::before, + .login .login_box .copy::after { + display: none; + } + .login .footer .addr { + font-size: 11px; + gap: 3px 10px; + } + .login .modal .modal_container { + width: 90%; + } + .login .pw_desc { + padding: 0 0 20px 0; + } + .login .modal .form_write { + padding: 10px; + } +} +/* // login */ diff --git a/apps/sn/.env.local b/apps/sn/.env.local index 951f1030b..33b8ef842 100644 --- a/apps/sn/.env.local +++ b/apps/sn/.env.local @@ -12,7 +12,8 @@ NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver 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_SERVER_API_URL = http://dev.onasys.co.kr:8000 +NEXT_PUBLIC_SEWERAGE_API_URL = http://localhost:8883 NEXT_PUBLIC_DC_URL = https://apigateway.apps.rsonesoft.com # NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login -NEXT_PUBLIC_CM_LOGIN_PAGE_URL = / -NEXT_PUBLIC_SEWERAGE_API_URL=http://localhost:8883 +NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /auth/login +# NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.66:8883 diff --git a/apps/sn/.env.mindone b/apps/sn/.env.mindone index 847103c97..dc567fea8 100644 --- a/apps/sn/.env.mindone +++ b/apps/sn/.env.mindone @@ -13,4 +13,4 @@ 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_SEWERAGE_API_URL = http://con.mind-one.co.kr:11083 NEXT_PUBLIC_DC_URL = https://apigateway.apps.rsonesoft.com -NEXT_PUBLIC_CM_LOGIN_PAGE_URL = / \ No newline at end of file +NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /auth/login \ No newline at end of file diff --git a/apps/sn/.env.production b/apps/sn/.env.production index 4a7c15edc..0b8189a78 100644 --- a/apps/sn/.env.production +++ b/apps/sn/.env.production @@ -15,4 +15,3 @@ 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_SEWERAGE_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000 NEXT_PUBLIC_DC_URL = http://apigateway.iipbs-was.svc.cluster.local:8000 -NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login diff --git a/apps/sn/app/(map-layout)/layout.tsx b/apps/sn/app/(map-layout)/layout.tsx index f58e67489..f840fa131 100644 --- a/apps/sn/app/(map-layout)/layout.tsx +++ b/apps/sn/app/(map-layout)/layout.tsx @@ -34,6 +34,14 @@ export default function MapLayout({ const { fetchCtpv, fetchSgg, fetchBIZ_SE_CD, fetchBIZ_TY_CD } = useStore(); + useEffect(() => { + document.body.classList.add("sn-map-page-lock"); + + return () => { + document.body.classList.remove("sn-map-page-lock"); + }; + }, []); + useEffect(() => { fetchCtpv(); fetchSgg(); diff --git a/apps/sn/app/(map-layout)/map/agedPipePopup/page.tsx b/apps/sn/app/(map-layout)/map/agedPipePopup/page.tsx new file mode 100644 index 000000000..22c9fa407 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/agedPipePopup/page.tsx @@ -0,0 +1,30 @@ +import { redirect } from "next/navigation"; + +type LegacyAgedPipePopupPageProps = { + searchParams: Promise>; +}; + +export default async function LegacyAgedPipePopupPage({ + searchParams, +}: LegacyAgedPipePopupPageProps) { + const resolvedSearchParams = await searchParams; + const nextSearchParams = new URLSearchParams(); + + Object.entries(resolvedSearchParams).forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach((item) => nextSearchParams.append(key, item)); + return; + } + + if (typeof value === "string") { + nextSearchParams.set(key, value); + } + }); + + const queryString = nextSearchParams.toString(); + redirect( + queryString + ? `/popup/agedPipePopup?${queryString}` + : "/popup/agedPipePopup", + ); +} diff --git a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipeLayerSettingPanel.tsx b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipeLayerSettingPanel.tsx new file mode 100644 index 000000000..9162074f4 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipeLayerSettingPanel.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { AgedPipeFilters } from "./agedPipeUtils"; + +type Props = { + filters: AgedPipeFilters; + onFiltersChange: (filters: AgedPipeFilters) => void; + onApply: (filters?: AgedPipeFilters) => void; +}; + +export default function AgedPipeLayerSettingPanel({ + filters, + onFiltersChange, + onApply, +}: Props) { + const [isCollapsed, setIsCollapsed] = useState(false); + const [elapsedYearsText, setElapsedYearsText] = useState(String(filters.elapsedYears ?? "")); + + useEffect(() => { + setElapsedYearsText(String(filters.elapsedYears ?? "")); + }, [filters.elapsedYears]); + + const commitElapsedYears = () => { + const nextFilters = { + ...filters, + elapsedYears: elapsedYearsText === "" ? ("" as unknown as number) : Number(elapsedYearsText), + }; + onFiltersChange(nextFilters); + onApply(nextFilters); + }; + + return ( +
+
+ + 노후관로 설정 +
+ + {!isCollapsed && ( +
+
+ + event.currentTarget.select()} + onChange={(event) => { + const digits = event.target.value.replace(/[^0-9]/g, ""); + setElapsedYearsText(digits); + }} + onKeyDown={(event) => { + if (event.key === "Enter") commitElapsedYears(); + }} + /> + +
+
+ )} +
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx new file mode 100644 index 000000000..efea0197c --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx @@ -0,0 +1,304 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { Button, Input } from "@common/components"; +import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui"; +import { getData } from "@/components/lib/api"; +import useStore from "@/store/useStore"; +import LoadingOverlay from "@/components/ui/LoadingBar"; +import { + AGED_PIPE_DEFAULT_FILTERS, + AGED_PIPE_EXCEL_HEADER, + AGED_PIPE_LAYER_TYPE, + AGED_PIPE_LIST_HEADLIST, + AGED_PIPE_PAGE_SIZE, + buildAgedPipeCqlFilters, + normalizeAgedPipeFilters, + toDisplayPipeRow, + validateElapsedYears, + type AgedPipeFilters, + type AgedPipeRow, +} from "./agedPipeUtils"; + +const POPUP_ROW_SELECT_MESSAGE = "SN_AGED_PIPE_ROW_SELECT"; +const POPUP_MINIMIZE_MESSAGE = "SN_AGED_PIPE_POPUP_MINIMIZE"; +const POPUP_FILTER_SYNC_MESSAGE = "SN_AGED_PIPE_FILTER_SYNC"; + +export default function AgedPipePopupPage() { + const searchParams = useSearchParams(); + const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore(); + const [filters, setFilters] = useState(() => + normalizeAgedPipeFilters({ + elapsedYears: Number(searchParams.get("elapsedYears") ?? AGED_PIPE_DEFAULT_FILTERS.elapsedYears), + ctpvCd: searchParams.get("ctpvCd") ?? AGED_PIPE_DEFAULT_FILTERS.ctpvCd, + sggCd: searchParams.get("sggCd") ?? AGED_PIPE_DEFAULT_FILTERS.sggCd, + }), + ); + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + const [currentPage, setCurrentPage] = useState(1); + const [selectedIndex, setSelectedIndex] = useState(-1); + const [isLoading, setIsLoading] = useState(false); + + const sggOptions = useMemo( + () => + filters.ctpvCd === "0" + ? sgg + : sgg.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2))), + [filters.ctpvCd, sgg], + ); + + useEffect(() => { + if (ctpv.length === 0) fetchCtpv(); + if (sgg.length === 0) fetchSgg(); + }, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]); + + const fetchRows = useCallback( + async (page: number, nextFilters: AgedPipeFilters, size: number) => { + const normalized = normalizeAgedPipeFilters(nextFilters); + const queryParams = new URLSearchParams({ + lyrTypeNm: AGED_PIPE_LAYER_TYPE, + page: String(page - 1), + size: String(size), + sort: "string", + }); + const cqlFilters = buildAgedPipeCqlFilters(normalized); + if (cqlFilters.length > 0) { + queryParams.append("cqlFiter", cqlFilters.join(" and ")); + } + + const response = await getData(`v1/fclts?${queryParams.toString()}`); + const responseRows = Array.isArray(response?.data) ? response.data : []; + + return { + rows: responseRows.map((item: Record, index: number) => + toDisplayPipeRow(item, index, page, ctpv, sgg), + ), + total: Number(response?.count ?? 0), + }; + }, + [ctpv, sgg], + ); + + const loadRows = useCallback( + async (page = 1, nextFilters = filters) => { + setIsLoading(true); + try { + const response = await fetchRows(page, nextFilters, AGED_PIPE_PAGE_SIZE); + setRows(response.rows); + setTotal(response.total); + setCurrentPage(page); + } catch (error) { + console.error("노후관로 목록 조회 중 오류:", error); + setRows([]); + setTotal(0); + } finally { + setIsLoading(false); + } + }, + [fetchRows, filters], + ); + + useEffect(() => { + loadRows(1, filters); + // 최초 로딩만 수행한다. 이후 조회는 버튼/페이지 변경으로 처리한다. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + const handleFilterSyncMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + if (event.data?.type !== POPUP_FILTER_SYNC_MESSAGE) return; + if (!event.data?.filters) return; + + const normalized = normalizeAgedPipeFilters(event.data.filters as AgedPipeFilters); + setFilters(normalized); + setSelectedIndex(-1); + loadRows(1, normalized); + }; + + window.addEventListener("message", handleFilterSyncMessage); + return () => window.removeEventListener("message", handleFilterSyncMessage); + }, [loadRows]); + + const handleSearch = (page = 1) => { + if (!validateElapsedYears(Number(filters.elapsedYears))) { + alert("경과년도는 1년~100년 범위 내에서 입력해주세요."); + return; + } + + const normalized = normalizeAgedPipeFilters(filters); + setFilters(normalized); + loadRows(page, normalized); + }; + + const handleRowClick = (index?: number) => { + if (typeof index !== "number") return; + const row = rows[index]; + if (!row) return; + + setSelectedIndex(index); + window.opener?.postMessage( + { + type: POPUP_ROW_SELECT_MESSAGE, + row, + }, + window.location.origin, + ); + }; + + const handleDownload = async () => { + if (total === 0) { + alert("다운로드할 데이터가 없습니다."); + return; + } + + try { + setIsLoading(true); + const response = await fetchRows(1, filters, 10000); + await downloadExcelWithKoreanHeaders({ + data: response.rows as JSON[], + fileName: `노후관로_현황_${new Date().toISOString().slice(0, 10)}`, + sheetNameArray: ["노후관로 현황"], + headerRange: "A1:J1", + headerMapping: AGED_PIPE_EXCEL_HEADER, + }); + } catch (error) { + console.error("노후관로 목록 다운로드 중 오류:", error); + alert("노후관로 목록 다운로드 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + const handleMinimize = () => { + window.opener?.postMessage( + { + type: POPUP_MINIMIZE_MESSAGE, + filters: normalizeAgedPipeFilters(filters), + total, + }, + window.location.origin, + ); + window.close(); + }; + + return ( +
+ +
+

노후관로 현황

+
+ +
+
+ +
+
+ + + + + + +
+ + +
+
+ + + headName={<>노후관로 조회 결과 총 {total.toLocaleString()}건} + tableData={rows} + headlist={AGED_PIPE_LIST_HEADLIST} + pageSize={10} + total={total} + cellClick={(index) => handleRowClick(index)} + onPageChange={(page) => handleSearch(page)} + curPage={currentPage} + index={selectedIndex} + widthFixed={true} + /> +
+
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts b/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts new file mode 100644 index 000000000..62bccc416 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts @@ -0,0 +1,236 @@ +"use client"; + +import type { SelectDataType } from "@common/business_components"; +import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue"; +import { + MS_SWAGE_PPL_CODES, + MS_SWAGE_PPL_USE_CODES, +} from "../common/table"; + +export const AGED_PIPE_LAYER_TYPE = "tb_ms_swage_ppl"; +export const AGED_PIPE_DEFAULT_ELAPSED_YEARS = 30; +export const AGED_PIPE_PAGE_SIZE = 10; +export const AGED_PIPE_MIN_ZOOM = 14; +export const AGED_PIPE_INVALID_INSTALL_YMD_PREFIXES = ["19000101", "19990101"]; + +export type AgedPipeFilters = { + elapsedYears: number; + ctpvCd: string; + sggCd: string; +}; + +export type AgedPipeRow = { + fid: string; + lyrTypeNm: string; + rowNumber: number; + fcltMngNo: string; + instlYmd: string; + pdmtVl: string | number; + prlgVl: string | number; + usgCd: string; + mqltCd: string; + elapsedYears: number | string; + ctpvNm?: string; + sggNm?: string; + [key: string]: unknown; +}; + +type PipeSource = Record; + +const useMap = new Map(MS_SWAGE_PPL_USE_CODES.map((item) => [item.val.trim(), item.text])); +const materialMap = new Map(MS_SWAGE_PPL_CODES.map((item) => [item.val.trim(), item.text])); + +export const AGED_PIPE_DEFAULT_FILTERS: AgedPipeFilters = { + elapsedYears: AGED_PIPE_DEFAULT_ELAPSED_YEARS, + ctpvCd: "0", + sggCd: "0", +}; + +export const AGED_PIPE_LIST_HEADLIST = [ + { id: "rowNumber", title: "NO", widthPercent: 6 }, + { id: "fcltMngNo", title: "시설관리번호", widthPercent: 18 }, + { id: "instlYmd", title: "설치일자", widthPercent: 12 }, + { id: "pdmtVl", title: "관경(mm)", widthPercent: 10 }, + { id: "prlgVl", title: "연장(m)", widthPercent: 10 }, + { id: "usgCd", title: "용도", widthPercent: 14 }, + { id: "mqltCd", title: "재질", widthPercent: 18 }, + { id: "elapsedYears", title: "경과년도", widthPercent: 12, align: "right" }, +]; + +export const AGED_PIPE_EXCEL_HEADER = { + rowNumber: "NO", + fcltMngNo: "시설관리번호", + instlYmd: "설치일자", + pdmtVl: "관경(mm)", + prlgVl: "연장(m)", + usgCd: "용도", + mqltCd: "재질", + elapsedYears: "경과년도", + ctpvNm: "시도", + sggNm: "시군구", +}; + +const pad2 = (value: number) => String(value).padStart(2, "0"); + +const normalizeYmd = (value: unknown) => + String(value ?? "").replace(/[^0-9]/g, "").slice(0, 8); + +const getToday = () => new Date(); + +export const normalizeAgedPipeFilters = (filters: AgedPipeFilters): AgedPipeFilters => { + const elapsedYears = Math.trunc(Number(filters.elapsedYears)); + + return { + ...filters, + elapsedYears: + Number.isFinite(elapsedYears) && elapsedYears >= 1 && elapsedYears <= 100 + ? elapsedYears + : AGED_PIPE_DEFAULT_ELAPSED_YEARS, + }; +}; + +export const validateElapsedYears = (value: number) => + Number.isInteger(value) && value >= 1 && value <= 100; + +export const getCutoffYmd = (elapsedYears: number) => { + const today = getToday(); + const cutoff = new Date(today.getFullYear() - elapsedYears, today.getMonth(), today.getDate()); + return `${cutoff.getFullYear()}${pad2(cutoff.getMonth() + 1)}${pad2(cutoff.getDate())}`; +}; + +export const getElapsedYears = (value: unknown) => { + const normalized = normalizeYmd(value); + if (normalized.length < 4 || isInvalidInstallYmd(value)) return null; + + const year = Number(normalized.slice(0, 4)); + const month = Number(normalized.slice(4, 6) || "1"); + const day = Number(normalized.slice(6, 8) || "1"); + if (!year) return null; + + const today = getToday(); + let elapsed = today.getFullYear() - year; + if ( + today.getMonth() + 1 < month || + (today.getMonth() + 1 === month && today.getDate() < day) + ) { + elapsed -= 1; + } + + return Math.max(elapsed, 0); +}; + +export const isInvalidInstallYmd = (value: unknown) => { + const normalized = normalizeYmd(value); + return ( + normalized.length < 8 || + AGED_PIPE_INVALID_INSTALL_YMD_PREFIXES.some((prefix) => normalized.startsWith(prefix)) + ); +}; + +export const getElapsedYearsDisplayText = (value: unknown) => { + const elapsedYears = getElapsedYears(value); + return elapsedYears == null ? "확인불가" : `${elapsedYears}년`; +}; + +export const buildAgedPipeCqlFilters = (filters: AgedPipeFilters) => { + const normalized = normalizeAgedPipeFilters(filters); + const cqlFilters = [ + "instl_ymd IS NOT NULL", + ...AGED_PIPE_INVALID_INSTALL_YMD_PREFIXES.map((prefix) => `instl_ymd NOT LIKE '${prefix}%'`), + `instl_ymd <= '${getCutoffYmd(normalized.elapsedYears)}'`, + ]; + + if (normalized.sggCd !== "0") { + cqlFilters.push(`pbadms_emd_cd LIKE '${normalized.sggCd}%'`); + } else if (normalized.ctpvCd !== "0") { + cqlFilters.push(`pbadms_emd_cd LIKE '${normalized.ctpvCd}%'`); + } + + return cqlFilters; +}; + +const getRegionCode = (source: PipeSource) => + String(source.pbadmsEmdCd ?? source.pbadms_emd_cd ?? "").trim(); + +export const matchesAgedPipeFilters = (source: PipeSource, filters: AgedPipeFilters) => { + const normalized = normalizeAgedPipeFilters(filters); + const instlYmd = normalizeYmd(source.instl_ymd ?? source.instlYmd); + if (!instlYmd || isInvalidInstallYmd(instlYmd) || instlYmd > getCutoffYmd(normalized.elapsedYears)) return false; + + const regionCode = getRegionCode(source); + if (normalized.sggCd !== "0") return regionCode.startsWith(normalized.sggCd); + if (normalized.ctpvCd !== "0") return regionCode.startsWith(normalized.ctpvCd); + return true; +}; + +const resolveRegionName = ( + code: string, + directName: unknown, + options: SelectDataType[], + prefixLength: number, +) => { + const direct = String(directName ?? "").trim(); + if (direct) return direct; + + const prefix = code.slice(0, prefixLength); + return options.find((item) => String(item.val).trim() === prefix)?.text ?? ""; +}; + +export const toDisplayPipeRow = ( + source: PipeSource, + index: number, + page: number, + ctpvOptions: SelectDataType[], + sggOptions: SelectDataType[], +): AgedPipeRow => { + const formatted = formatDisplayData(source) as PipeSource; + const codePrefix = getRegionCode(source); + const instlYmd = formatted.instlYmd ?? source.instlYmd ?? formatted.instl_ymd ?? source.instl_ymd; + const invalidInstallYmd = isInvalidInstallYmd(source.instlYmd ?? source.instl_ymd ?? instlYmd); + const trimmedUse = String(source.usgCd ?? source.usg_cd ?? "").trim(); + const trimmedMaterial = String(source.mqltCd ?? source.mqlt_cd ?? "").trim(); + const pdmtVl = formatted.pdmtVl ?? source.pdmtVl ?? source.pdmt_vl ?? "-"; + const prlgVl = formatted.prlgVl ?? source.prlgVl ?? source.prlg_vl ?? "-"; + const fcltMngNo = String(formatted.fcltMngNo ?? source.fcltMngNo ?? source.fclt_mng_no ?? "-"); + + return { + ...source, + ...formatted, + fid: String(source.fid ?? ""), + lyrTypeNm: String(source.lyrTypeNm ?? AGED_PIPE_LAYER_TYPE), + rowNumber: (page - 1) * AGED_PIPE_PAGE_SIZE + index + 1, + fcltMngNo, + swtrmtFcltCd: source.swtrmtFcltCd ?? fcltMngNo, + instlYmd: invalidInstallYmd ? "확인불가" : String(instlYmd ?? "-"), + pdmtVl: typeof pdmtVl === "number" ? pdmtVl : String(pdmtVl), + prlgVl: typeof prlgVl === "number" ? prlgVl : String(prlgVl), + usgCd: useMap.get(trimmedUse) ?? String(formatted.usgCd ?? source.usgCd ?? source.usg_cd ?? "-"), + mqltCd: + materialMap.get(trimmedMaterial) ?? + String(formatted.mqltCd ?? source.mqltCd ?? source.mqlt_cd ?? "-"), + elapsedYears: getElapsedYears(source.instlYmd ?? source.instl_ymd) ?? "확인불가", + ctpvNm: resolveRegionName( + codePrefix, + formatted.ctpvNm ?? source.ctpvNm ?? source.ctpv_nm, + ctpvOptions, + 2, + ), + sggNm: resolveRegionName( + codePrefix, + formatted.sggNm ?? source.sggNm ?? source.sgg_nm, + sggOptions, + 5, + ), + }; +}; + +export const isAgedPipeLayerNode = (node: any) => { + const data = node?.data ?? node; + const layerName = String(data?.lyrNm ?? node?.text ?? ""); + const layerTypeName = String(data?.lyrTyNm ?? ""); + + return ( + layerTypeName === "tb_old_pipe" || + layerName.includes("노후관로") + ); +}; diff --git a/apps/sn/app/(map-layout)/map/components/common/table.ts b/apps/sn/app/(map-layout)/map/components/common/table.ts index 4e501fb20..81e14a83f 100644 --- a/apps/sn/app/(map-layout)/map/components/common/table.ts +++ b/apps/sn/app/(map-layout)/map/components/common/table.ts @@ -3,6 +3,7 @@ export const MsSwagePplCol = [ { id: 'fclt_mng_no', title: '시설관리번호' }, { id: 'inst_mng_no', title: '기관관리번호' }, { id: 'instl_ymd', title: '설치일자' }, + { id: 'elapsedYears', title: '경과년도' }, { id: 'mapsht_no', title: '도엽번호' }, { id: 'pdmt_vl', title: '관경(mm)' }, { id: 'prlg_vl', title: '연장(m)' }, diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/Ssc.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/Ssc.tsx index f908fe98b..6a11df513 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/Ssc.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/Ssc.tsx @@ -13,29 +13,30 @@ * - 위경도로 강우시 미처리 하수 목록 조회: GET /api/v1/onsemiro/getSwgRainsByGeomety (sewerage-service) * - 강우시 월류 하수 관리 모니터링 조회: GET /api/v1/onsemiro/swgRain/{adstDstrCd}/{manageNo}/mntrngs (sewerage-service) */ -import { UiModal, UiTable } from '@common/business_components/ui'; +'use client'; +import { UiTable } from '@common/business_components/ui'; import { Button, TableHead, TableRow } from '@common/components/ui'; import React, { useState, useEffect } from 'react'; import { untrtSwgRainCol3, untrtSwgRainCol4, untrtSwgRainCol5 } from '../common/table'; import { VerticalTable } from '@/components/ui/verticalTable'; -import { getData } from "@/components/lib/api"; +import { getRawJson } from "@/components/lib/api"; import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue'; import { Label } from '@radix-ui/react-label'; +import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup'; const inputColumn = [ { id: "prrtymngYn", title: "중점여부", type: "select", options: ["우수토실", "중점우수토실"] }, { id: "swtrmtZoneNm", title: "처리구역", type: "input" }, ]; -const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick }: { +const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick, renderMode = 'inline' }: { featureData: any, isMainSsc?: boolean, chmbrXcnts?: string, chmbrYdnts?: string, - onSaveClick?: () => void + onSaveClick?: () => void, + renderMode?: 'inline' | 'popup' }) => { - const [isModalOpen, setIsModalOpen] = useState(false); - const [isDataLoaded, setIsDataLoaded] = useState(false); const [formData, setFormData] = useState({}); const [rainMeasureData, setRainMeasureData] = useState({}); // 강우, 측정 정보 const [mntrngsData, setMntrngsData] = useState([]); // 수량/수질측정 정보 @@ -65,22 +66,7 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick }: { size: '3', }); - let result; - const res = await fetch(`/sewerage-service/api/v1/onsemiro/getSwgRainsByGeomety?${queryParams.toString()}`, { - method: "GET", - }); - if (res.ok) { - // 응답이 비어있는지 확인 - const contentLength = res.headers.get('content-length'); - if (contentLength === '0') result = null; - - // 응답 텍스트를 먼저 가져와서 빈 문자열인지 확인 - const text = await res.text(); - if (!text || text.trim() === '') result = null; - - // JSON 파싱 시도 - result = JSON.parse(text); - } + const result = await getRawJson(`/sewerage-service/api/v1/onsemiro/getSwgRainsByGeomety?${queryParams.toString()}`); // helper: "YYYY-MM-DD HH:MM:SS" -> "MM-DD HH:MM" const formatNoYearNoSec = (val: any) => { @@ -138,22 +124,7 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick }: { size: '3', }); - let result; - const res = await fetch(`/sewerage-service/api/v1/onsemiro/swgRain/${adstDstrCd}/${manageNo}/mntrngs?${queryParams.toString()}`, { - method: "GET", - }); - if (res.ok) { - // 응답이 비어있는지 확인 - const contentLength = res.headers.get('content-length'); - if (contentLength === '0') result = null; - - // 응답 텍스트를 먼저 가져와서 빈 문자열인지 확인 - const text = await res.text(); - if (!text || text.trim() === '') result = null; - - // JSON 파싱 시도 - result = JSON.parse(text); - } + const result = await getRawJson(`/sewerage-service/api/v1/onsemiro/swgRain/${adstDstrCd}/${manageNo}/mntrngs?${queryParams.toString()}`); // 각 아이템 전처리 const processedDataArray = result.content.map((data: any) => { @@ -181,14 +152,15 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick }: { } }, [chmbrXcnts, chmbrYdnts]); - const openModal = () => { - setIsModalOpen(true); - }; + const openDetailPopup = () => { + const popupWindow = openWindowPopup("sscDetail", { + chmbrXcnts, + chmbrYdnts, + sourcePath: window.location.pathname, + }); - const closeModal = () => { - setIsModalOpen(false); - if (!isDataLoaded) { - setTimeout(() => setIsDataLoaded(true), 100); + if (!popupWindow) { + alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.'); } }; @@ -322,37 +294,28 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick }: { /> )} - {isMainSsc && ( + {isMainSsc && renderMode !== 'popup' && (

중점우수토실 운영정보

+
+ )} - + {isMainSsc && renderMode === 'popup' && ( +
+

중점우수토실 운영정보

+ {renderModalBody()}
)} ) } -export default Ssc \ No newline at end of file +export default Ssc diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtFclt.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtFclt.tsx index 6516c0cd3..78e082414 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtFclt.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtFclt.tsx @@ -9,28 +9,37 @@ * - 조회 버튼을 통한 모달 표시 및 ReportTab 연동 */ 'use client' -import React, { useState } from 'react' +import React from 'react' import { VerticalTable } from '@/components/ui/verticalTable'; -import { UiModal } from '@common/business_components/ui'; import { Button } from '@common/components/ui'; -import ReportTab from '../../searchModal/reportTab'; +import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup'; // 하수처리시설 const headList = [ { id: "title", title: "하수도정비 기본계획보고서" }, ]; -const SwtrmtFclt = ({ gisObject }: { gisObject?: any }) => { - const [isModalOpen, setIsModalOpen] = useState(false); - +const SwtrmtFclt = ({ gisObject }: { gisObject: any }) => { const ctpvNm = gisObject?.ctpv_nm; const sggNm = gisObject?.sgg_nm; + const handleOpenReportPopup = () => { + const popupWindow = openWindowPopup("reportTab", { + ctpvNm, + sggNm, + sourcePath: window.location.pathname, + }); + + if (!popupWindow) { + alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.'); + } + }; + return (
setIsModalOpen(true)}>조회 + title: }} headName='디지털 자료관 연계정보' tableData={[]} @@ -43,29 +52,6 @@ const SwtrmtFclt = ({ gisObject }: { gisObject?: any }) => { throw new Error("Function not implemented."); }} /> - - } - useDirectRender={true} - />
) } diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/chart/infilChart.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/chart/infilChart.tsx index 3836b87f6..91495f412 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/chart/infilChart.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/chart/infilChart.tsx @@ -7,14 +7,12 @@ import { CHART_FORM, type ChartConfig, MainChart, - UiModal, } from '@common/business_components'; import { SelectBox } from '@common/business_components/ui'; import { Button, Input, Label } from '@common/components'; import type { TInftwtAnls } from './aplcnYms'; import * as fn from './aplcnYms'; -import { useInfilChartModalStore } from '../chartModalStore'; type ModalOpenProp = { pplMngNo?: string; // 관로관리번호 @@ -28,7 +26,6 @@ type DataType = { const INITIAL_ANALYSIS_OPTIONS = [{ val: 0, text: '선택' }]; const InfilChart = ({ pplMngNo = '' }: ModalOpenProp) => { - const { isOpen: isModalOpen, setIsOpen: setIsModalOpen } = useInfilChartModalStore(); const [selectedAplcnYm, setSelectedAplcnYm] = useState(''); const [inftwtAnlss, setInftwtAnlss] = useState([]); @@ -66,12 +63,6 @@ const InfilChart = ({ pplMngNo = '' }: ModalOpenProp) => { setInftwtQty('0'); }; - useEffect(() => { - if (!isModalOpen) { - resetState(); // 모달이 닫힐 때 리셋 - } - }, [isModalOpen]); - const handleAplcnYmChange = async (val: string | number) => { const selectedValue = String(val); @@ -276,21 +267,10 @@ const InfilChart = ({ pplMngNo = '' }: ModalOpenProp) => { ); return ( - +
+

침입수 분석

+ {renderBody()} +
); }; diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx index aa622be1b..ec1cec8b1 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx @@ -7,14 +7,12 @@ import { CHART_FORM, type ChartConfig, MainChart, - UiModal, } from '@common/business_components'; import { SelectBox } from '@common/business_components/ui'; import { Button, Input, Label } from '@common/components'; import type { TRdiiAnls } from './aplcnYms'; import * as fn from './aplcnYms'; -import { useRdiiChartModalStore } from '../chartModalStore'; type ModalOpenProp = { pplMngNo?: string; // 관로관리번호 @@ -29,7 +27,6 @@ type LineBarChartData = { const INITIAL_ANALYSIS_OPTIONS = [{ val: 0, text: '선택' }]; const RdiiChart = ({ pplMngNo = '' }: ModalOpenProp) => { - const { isOpen: isModalOpen, setIsOpen: setIsModalOpen } = useRdiiChartModalStore(); const [aplcnYm, setAplcnYm] = useState(''); const [rdiiAnlss, setRdiiAnlss] = useState([]); @@ -72,12 +69,6 @@ const RdiiChart = ({ pplMngNo = '' }: ModalOpenProp) => { setTotalVolume('0');// 총체적 m³/mm 초기화 }; - useEffect(() => { - if (!isModalOpen) { - resetState(); - } - }, [isModalOpen]); - const handleAplcnYmChange = async (val: string | number) => { const selectedValue = String(val); @@ -336,21 +327,10 @@ const RdiiChart = ({ pplMngNo = '' }: ModalOpenProp) => { ); return ( - +
+

RDII 분석

+ {renderBody()} +
); }; diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/chartModalStore.ts b/apps/sn/app/(map-layout)/map/components/popupComponent/chartModalStore.ts deleted file mode 100644 index c84fa48a7..000000000 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/chartModalStore.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { create } from 'zustand'; - -interface ModalStore { - isOpen: boolean; - setIsOpen: (open: boolean) => void; -} - -export const useInfilChartModalStore = create((set) => ({ - isOpen: false, - setIsOpen: (open: boolean) => set({ isOpen: open }), -})); - -export const useRdiiChartModalStore = create((set) => ({ - isOpen: false, - setIsOpen: (open: boolean) => set({ isOpen: open }), -})); \ No newline at end of file diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx index 6394ef64f..36cbb46f6 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx @@ -23,7 +23,6 @@ */ 'use client' import { getData, postData } from "@/components/lib/api"; -import Modal from "@/components/ui/modal"; import { VerticalTable } from "@/components/ui/verticalTable"; import { useGisContext } from "@common/business_components/ui/map/Gis"; import { Button } from "@common/components/ui"; @@ -34,13 +33,12 @@ import Stroke from "ol/style/Stroke.js"; import CircleStyle from "ol/style/Circle.js"; import { getLayerContentComponent } from "../common"; import { SwtrmtFcltCol, SwtrmtFcltCol2, SwtrmtPrcsZoneCol, SwtrmtPrcsZoneCol2, untrtSwgRainCol1, untrtSwgRainCol2, MsSwagePplCol, MsSwagePplCol2, ExclnSewagePumpCol, ExclnSewagePumpCol2, DrtwSewagePumpCol, DrtwSewagePumpCol2, MsSwageMhlCol, MsSwageMhlCol2, stofcbrCol, stofcbrCol2, MS_SWAGE_MHL_CODES, DRTW_SEWAGE_PUMP_USE_CODES } from "../common/table"; -import InfilChart from './chart/infilChart'; -import RdiiChart from './chart/rdiiChart'; -import { useInfilChartModalStore, useRdiiChartModalStore } from "./chartModalStore"; import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue'; import { codeMapper } from "../common/codeMapper"; import LoadingOverlay from '@/components/ui/LoadingBar'; import { searchUpperPpls } from "./smh"; +import { getElapsedYearsDisplayText, isInvalidInstallYmd } from "../agedPipe/agedPipeUtils"; +import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup'; const linkedFacilityHighlightStyle = new Style({ image: new CircleStyle({ @@ -63,7 +61,6 @@ const linkedFacilityHighlightStyle = new Style({ zIndex: 9999, }); - const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, featureData: any }) => { const { gisMap } = useGisContext() as unknown as { checkedLayerIds: React.MutableRefObject; treeData: { lyrSn: number }[] | null; gisCore: any; gisMap: any; }; const [layerNm, setLayerNm] = useState(""); // 레이어 이름 상태 @@ -74,12 +71,9 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, const [buttonChk, setButtonChk] = useState(true) const [fetchData, setFetchData] = useState(); // api 호출여부 const contentRef = useRef(null); - const [isModalOpen, setIsModalOpen] = useState(false); // 모달 열기 상태 const [isMainSsc, setIsMainSsc] = useState(false); // 중점우수토실 여부 const [isDrtwSewagePump, setIsDrtwSewagePump] = useState(false); // 오수펌프장 여부 // 🎯 InfilChart 및 RdiiChart 관련 상태 추가 - const { setIsOpen: setIsModalInfil } = useInfilChartModalStore(); - const { setIsOpen: setIsModalRdii } = useRdiiChartModalStore(); const [isClicked, setIsClicked] = useState(true); // 클릭 여부 상태 const [isLoading, setIsLoading] = useState(false); // 로딩 상태 const [chmbrXcnts, setChmbrXcnts] = useState(''); // 로딩 상태 @@ -88,7 +82,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, const linkedFacilityOriginalStyleRef = useRef(null); // 🎯 관로관리번호 추출 - const pplMngNo = featureData?.values_?.fclt_mng_no || ""; + const pplMngNo = featureData?.values_?.fclt_mng_no || featureData?.fcltMngNo || ""; const featureId = typeof featureData.getId === 'function' ? featureData.getId() @@ -258,7 +252,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, case 'tb_ms_swage_ppl': { result = '하수관로'; setHeadlist(MsSwagePplCol2) - setLayerSpecificNm(featureData.swtrmtFcltCd); + setLayerSpecificNm(featureData.fcltMngNo ?? featureData.swtrmtFcltCd); break; } case 'tb_ms_swage_mhl': { @@ -341,6 +335,14 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, // 🎯 기본 데이터 포맷팅 const formattedData = formatDisplayData(data); + if (selectedLayerName === '하수관로') { + const installYmd = data.instl_ymd ?? data.instlYmd; + formattedData.elapsedYears = getElapsedYearsDisplayText(installYmd); + if (isInvalidInstallYmd(installYmd)) { + formattedData.instl_ymd = "확인불가"; + formattedData.instlYmd = "확인불가"; + } + } setFetchData(formattedData); setTableData(formattedData); @@ -407,8 +409,8 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, // 자체입력정보 API 엔드포인트 선택 let apiEndpoint = ''; - const fcltMngNo = featureData?.values_?.fclt_mng_no || null; - const mngInstCd = featureData?.values_?.mng_inst_cd || null; + const fcltMngNo = featureData?.values_?.fclt_mng_no || featureData?.fcltMngNo || null; + const mngInstCd = featureData?.values_?.mng_inst_cd || featureData?.mngInstCd || null; if (selectedLayerName === '하수관로') apiEndpoint = `v1/fcltppl/${fcltMngNo}/${mngInstCd}`; else if (selectedLayerName === '우수토실') apiEndpoint = `v1/fcltstofcbr/${fcltMngNo}/${mngInstCd}`; else if (selectedLayerName === '하수펌프장') apiEndpoint = `v1/fcltpump/${fcltMngNo}/${mngInstCd}`; @@ -461,16 +463,9 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, // 저장 시 모달 띄우기 const handleSaveClick = () => { - setIsModalOpen(true); // 모달 열기 - }; - - const handleModalSave = () => { - setIsModalOpen(false); // 모달 닫기 - btnClickEvent(); // 저장 이벤트 실행 - }; - - const handleModalClose = () => { - setIsModalOpen(false); // 모달 닫기 + const shouldSave = window.confirm('정보를 저장하시겠습니까?'); + if (!shouldSave) return; + btnClickEvent(); }; // 저장 버튼 이벤트 @@ -557,14 +552,26 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, {/* 하수관로일 때만 Infil/RDII 버튼 표시 */} {layerNm === '하수관로' && ( <> - - + + + )} ); diff --git a/apps/sn/app/(map-layout)/map/searchModal/reportFilesModal.tsx b/apps/sn/app/(map-layout)/map/searchModal/reportFilesModal.tsx deleted file mode 100644 index 2b7218332..000000000 --- a/apps/sn/app/(map-layout)/map/searchModal/reportFilesModal.tsx +++ /dev/null @@ -1,108 +0,0 @@ -/** - * ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 하수도정비기본계획보고서 탭 (셀 클릭) - * - * 📄 Report Files Modal - * Next.js App Router (app/) 기반 보고서 파일 모달 컴포넌트 - * - * - 하수도정비 기본계획 보고서 파일 목록 표시 - * - 파일명 및 등록일자 정보 테이블 출력 - * - 페이지네이션 및 로딩 오버레이 지원 - * - 파일 클릭 이벤트 처리 및 상세 조회 - * - Zustand를 통한 모달 상태 및 데이터 관리 - * - 모달 열림/닫힘 시 데이터 초기화 기능 - */ -'use client' -import React, { useEffect } from 'react'; -import LoadingOverlay from '@/components/ui/LoadingBar'; -import useReportTab from './useReportTab'; -import { useReportTabStore } from '@/store/useReportTabStore'; -import { UiTable } from '@common/business_components/ui'; -import { UiModal } from "@common/business_components"; -import useSearchModalStore from '@/store/useSearchModalStore'; - -type ModalOpenProp = { clsfSystmSn: string } // 자료관 기본계획 일련번호 -const ReportFilesModal: React.FC = ({ clsfSystmSn }: ModalOpenProp) => { - // 하수도정비 기본계획 보고서 파일목록 - const REPORT_FILES_HEADLIST = [ - { id: "no", title: "NO" }, - { id: "fileTitle", title: "파일명" }, - { id: "regDt", title: "등록일자" }, - ]; - - const { - curPage, setCurPage, - isLoading, - isResetPage, - isResetSelection, - setIsResetPage, - setIsResetSelection - } = useSearchModalStore(); - const { - isOpen: isModalOpen, - files, - dcFiles: filesOrigin, - setIsOpen: setIsModalOpen, - setFiles, - fetchDcFiles - } = useReportTabStore(); - const { getFiles, handleCellClick } = useReportTab(); - - - useEffect(() => { - fetchDcFiles(clsfSystmSn); - }, [clsfSystmSn]); - - useEffect(() => { - if (isModalOpen === true) { - getFiles(); // 파일 목록 조회 - } else { - setFiles([]); // 모달이 닫힐 때 파일 목록 초기화 - setCurPage(1); // 페이지를 1로 초기화 - setIsResetPage(true); // 페이지를 초기화 - setIsResetSelection(true); // 선택 초기화 - } - }, [filesOrigin, isModalOpen, curPage]); - - const renderBody = () => ( - <> -
- - { - return (<>조회 결과 ({filesOrigin.length.toLocaleString('ko-KR')})건); - })() - } - tableData={files} - headlist={REPORT_FILES_HEADLIST} - pageSize={10} - total={filesOrigin.length} - cellClick={handleCellClick} - onPageChange={setCurPage} - resetPageNo={isResetPage} - resetSelection={isResetSelection} - /> -
- - ); - - - return ( - - ); -}; - -export default React.memo(ReportFilesModal); \ No newline at end of file diff --git a/apps/sn/app/(map-layout)/map/searchModal/reportTab.tsx b/apps/sn/app/(map-layout)/map/searchModal/reportTab.tsx index ad57c0a5f..f054a9ed1 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/reportTab.tsx +++ b/apps/sn/app/(map-layout)/map/searchModal/reportTab.tsx @@ -22,7 +22,7 @@ import useStore from '@/store/useStore'; import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox'; import useSearchModalStore from '@/store/useSearchModalStore'; import { useReportTabStore } from '@/store/useReportTabStore'; -import ReportFilesModal from './reportFilesModal'; // 파일목록모달 +import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup'; // 하수도정비 기본계획 보고서 @@ -56,7 +56,6 @@ const ReportTab: React.FC = ({ open, isSearch = true, ctpvNm, sg const [ctpvCd, setCtpvCd] = useState('0'); const [sggCd, setSggCd] = useState('0'); const [reportGubun, setReportGubun] = useState("0"); - const [clsfSystmSn, setClsfSystmSn] = useState(''); // 자료관 기본계획 일련번호 const { tableData, @@ -72,8 +71,6 @@ const ReportTab: React.FC = ({ open, isSearch = true, ctpvNm, sg } = useSearchModalStore(); const { ctpv, sgg } = useStore(); const { - isOpen: isReportFilesModalOpen, - setIsOpen: setIsReportFilesModalOpen, dcTree: reportOriginData, fetchDcTree } = useReportTabStore(); // 모달 상태 관리 스토어 @@ -139,8 +136,14 @@ const ReportTab: React.FC = ({ open, isSearch = true, ctpvNm, sg if (typeof index === "number" && tableData[index]) { const reportData = tableData[index]; if (reportData.fileSn !== "") { - setClsfSystmSn(reportData.clsfSystmSn); // 자료관 기본계획 일련번호 설정 - setIsReportFilesModalOpen(true); // 모달 열기 + const popupWindow = openWindowPopup("reportFiles", { + clsfSystmSn: reportData.clsfSystmSn, + sourcePath: window.location.pathname, + }); + + if (!popupWindow) { + alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.'); + } } } }; @@ -263,10 +266,8 @@ const ReportTab: React.FC = ({ open, isSearch = true, ctpvNm, sg /> - {/* 모달 컴포넌트 */} - {isReportFilesModalOpen && } ); }; -export default React.memo(ReportTab); \ No newline at end of file +export default React.memo(ReportTab); diff --git a/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx b/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx index 3ce334518..809647bf6 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx +++ b/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx @@ -11,21 +11,26 @@ * - Zustand를 통한 검색 모달 상태 관리 * - 로딩 오버레이 및 선택 초기화 지원 */ +'use client'; import LoadingOverlay from '@/components/ui/LoadingBar'; -import { UiModal, UITab, UiTable } from '@common/business_components/ui'; +import { UITab, UiTable } from '@common/business_components/ui'; import { Button } from '@common/components/ui'; -import React, { useEffect, useMemo } from 'react'; +import React, { useEffect, useMemo, useRef } from 'react'; import { Toaster } from "react-hot-toast"; import useSearchModalStore from '@/store/useSearchModalStore'; import { ExclnSewagePumpCol2, MsSwageMhlCol2, MsSwagePplCol2, stofcbrCol2, SwtrmtFcltCol2, SwtrmtPrcsZoneCol2 } from '../components/common/table'; import BasicTab, { BASIC_TYPES } from './basicTab'; import OperationTab from './operationTab'; -import ReportTab from './reportTab'; import useSearchModal from './useSearchModal'; import useBasicTab from './useBasicTab'; import useOperationTab from './useOperationTab'; +import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup'; -const SearchModal: React.FC = () => { +type SearchModalProps = { + renderMode?: "launcher" | "popup"; +}; + +const SearchModal: React.FC = ({ renderMode = "launcher" }) => { const { showTable, isSearchModalOpen, setIsSearchModalOpen, @@ -59,6 +64,7 @@ const SearchModal: React.FC = () => { operationResultExcelDownload, handleOperationPageNation } = useOperationTab(); + const hasAutoOpenedReportRef = useRef(false); const dynamicHeadlist: { id: string, title: string }[] = useMemo(() => { @@ -87,6 +93,42 @@ const SearchModal: React.FC = () => { return []; }, [activeTab, workType]); + useEffect(() => { + if (renderMode !== "launcher" || !isSearchModalOpen) return; + + const popupWindow = openWindowPopup("mapSearch", { + sourcePath: window.location.pathname, + }); + + if (!popupWindow) { + alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.'); + } + + setIsSearchModalOpen(false); + }, [renderMode, isSearchModalOpen]); + + const openReportTabPopup = () => { + const popupWindow = openWindowPopup("reportTab", { + sourcePath: window.location.pathname, + }); + + if (!popupWindow) { + alert('?앹뾽??李⑤떒?섏뿀?듬땲?? 釉뚮씪?곗? ?앹뾽 ?덉슜 ?ㅼ젙???뺤씤?댁<?몄슂.'); + } + }; + + useEffect(() => { + if (activeTab !== 'report') { + hasAutoOpenedReportRef.current = false; + return; + } + + if (hasAutoOpenedReportRef.current) return; + + openReportTabPopup(); + hasAutoOpenedReportRef.current = true; + }, [activeTab]); + useEffect(() => { resetFilters(); }, [activeTab, basicType, workType]); @@ -119,7 +161,23 @@ const SearchModal: React.FC = () => { /> {activeTab === 'basic' && ()} {/* 기본정보 탭 */} {activeTab === 'operation' && ()} {/* 유지관리 정보 탭 */} - {activeTab === 'report' && ()} {/* 하수도정비기본계획보고서 탭 */} + {activeTab === 'report' && ( +
+
+

+ 하수도정비 기본계획보고서 +

+

+ 보고서 상세는 모달 내부 탭 대신 +
+ 전용 윈도우 팝업으로 열리도록 전환되었습니다. +

+ +
+
+ )} {/* 하수도정비기본계획보고서 탭 */} {/* 📋 결과 리스트 */} @@ -214,22 +272,16 @@ const SearchModal: React.FC = () => { ); + if (renderMode === "launcher") { + return null; + } + return ( -
- +
+

관망도 검색

+
+ {renderBody()} +
); diff --git a/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts b/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts index 9184286ed..d914d2dbb 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts +++ b/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts @@ -31,6 +31,13 @@ import { swagePrcsZoneCqlFilters } from './swagePrcsZone'; import { swageMhlCqlFilters } from './swageMhl'; import { swagePmstnCqlFilters } from './swagePmstn'; import { stofcbrCqlFilters } from './stofcbr'; +import { postPopupMessageAndClose } from '@/features/windowPopup/lib/popupMessage'; +import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types'; + +const isPopupWindow = () => + typeof window !== "undefined" && + !!window.opener && + window.location.pathname.startsWith("/popup/"); export default function useBasicTab() { @@ -115,9 +122,21 @@ export default function useBasicTab() { // 기본정보 테이블 cellClick 핸들러 const handleBasicCellClick = async (index?: number | undefined, headId?: String) => { if (typeof index === "number") { + const row = tableData[index]; + + if (isPopupWindow()) { + postPopupMessageAndClose( + WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SELECT_FEATURE, + { + layerNm: row.lyrTypeNm, + fid: String(row.fid), + }, + ); + return; + } + setIsLoading(true); - const row = tableData[index]; const layerNm = row.lyrTypeNm; const fid = row.fid; const core = GisApp.Module.layer as any; diff --git a/apps/sn/app/(map-layout)/map/searchModal/useOperationTab.ts b/apps/sn/app/(map-layout)/map/searchModal/useOperationTab.ts index 669dd4cb5..3d80b54d9 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/useOperationTab.ts +++ b/apps/sn/app/(map-layout)/map/searchModal/useOperationTab.ts @@ -23,6 +23,13 @@ import { confirmToast } from '@/components/ui/toast' import { toast } from "react-hot-toast"; import useSearchModalStore from '@/store/useSearchModalStore'; import useStore from '@/store/useStore'; +import { postPopupMessageAndClose } from '@/features/windowPopup/lib/popupMessage'; +import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types'; + +const isPopupWindow = () => + typeof window !== "undefined" && + !!window.opener && + window.location.pathname.startsWith("/popup/"); export default function useOperationTab() { @@ -200,9 +207,21 @@ export default function useOperationTab() { // 유지관리 정보 디테일 테이블 cellClick 핸들러 const handleOperationResultCellClick = async (index?: number | undefined, headId?: String) => { if (typeof index === "number") { + const row = detailTableData[index]; + + if (isPopupWindow()) { + postPopupMessageAndClose( + WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SELECT_FEATURE, + { + layerNm: row.lyrTypeNm, + fid: String(row.fid), + }, + ); + return; + } + setIsLoading(true); - const row = detailTableData[index]; const layerNm = row.lyrTypeNm; const fid = row.fid; const core = GisApp.Module.layer as any; @@ -434,6 +453,20 @@ export default function useOperationTab() { } }; + const emitMultipleFeaturesToMap = () => { + if (detailTableData.length === 0 || !isPopupWindow()) return false; + + const row = detailTableData[0]; + postPopupMessageAndClose( + WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_MULTIPLE, + { + layerNm: row.lyrTypeNm, + fids: JSON.stringify(detailTableData.map((item) => item.fid)), + }, + ); + return true; + }; + return { WORK_TYPES, @@ -446,7 +479,8 @@ export default function useOperationTab() { handleOperationCellClick, handleOperationResultCellClick, handleOperationPageNation, + emitMultipleFeaturesToMap, operationExcelDownload, operationResultExcelDownload }; -}; \ No newline at end of file +}; diff --git a/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts b/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts index 1d317e3d4..585885521 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts +++ b/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts @@ -19,6 +19,13 @@ import GisApp from "@common/business_components/ui/map/lib/gis.core"; import useSearchModalStore from '../../../store/useSearchModalStore'; import useBasicTab from './useBasicTab'; import useOperationTab from './useOperationTab'; +import { postPopupMessageAndClose } from '@/features/windowPopup/lib/popupMessage'; +import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types'; + +const isPopupWindow = () => + typeof window !== "undefined" && + !!window.opener && + window.location.pathname.startsWith("/popup/"); export default function useSearchModal() { const { @@ -132,6 +139,18 @@ export default function useSearchModal() { const viewMultipleFeature = async () => { if (activeTab === 'basic') { if (!(tableData.length === 0)) { + if (isPopupWindow()) { + const row = tableData[0]; + postPopupMessageAndClose( + WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_MULTIPLE, + { + layerNm: row.lyrTypeNm, + fids: JSON.stringify(tableData.map((item) => item.fid)), + }, + ); + return; + } + setIsLoading(true); const row = tableData[0]; @@ -144,6 +163,18 @@ export default function useSearchModal() { } } else if (activeTab === 'operation') { if (!(detailTableData.length === 0)) { + if (isPopupWindow()) { + const row = detailTableData[0]; + postPopupMessageAndClose( + WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_MULTIPLE, + { + layerNm: row.lyrTypeNm, + fids: JSON.stringify(detailTableData.map((item) => item.fid)), + }, + ); + return; + } + setIsLoading(true); const row = detailTableData[0]; diff --git a/apps/sn/app/(popupLayout)/layout.tsx b/apps/sn/app/(popupLayout)/layout.tsx new file mode 100644 index 000000000..d02ca99ac --- /dev/null +++ b/apps/sn/app/(popupLayout)/layout.tsx @@ -0,0 +1,11 @@ +export default function PopupLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/apps/sn/app/(popupLayout)/popup/[popupKey]/page.tsx b/apps/sn/app/(popupLayout)/popup/[popupKey]/page.tsx new file mode 100644 index 000000000..8231f4f4d --- /dev/null +++ b/apps/sn/app/(popupLayout)/popup/[popupKey]/page.tsx @@ -0,0 +1,30 @@ +import { isWindowPopupKey } from "@/features/windowPopup/model/popupDefinitions"; +import { windowPopupRegistry } from "@/features/windowPopup/registry"; + +type PopupPageProps = { + params: Promise<{ + popupKey: string; + }>; + searchParams: Promise>; +}; + +export default async function PopupPage({ + params, + searchParams, +}: PopupPageProps) { + const { popupKey } = await params; + const resolvedSearchParams = await searchParams; + + if (!isWindowPopupKey(popupKey)) { + return ( +
+

등록되지 않은 팝업입니다.

+

popupKey: {popupKey}

+
+ ); + } + + const PopupComponent = windowPopupRegistry[popupKey].component; + + return ; +} diff --git a/apps/sn/app/(with-base-layout)/ii/fileupload/page.tsx b/apps/sn/app/(with-base-layout)/ii/fileupload/page.tsx index 9f90d5ead..1d30922c1 100644 --- a/apps/sn/app/(with-base-layout)/ii/fileupload/page.tsx +++ b/apps/sn/app/(with-base-layout)/ii/fileupload/page.tsx @@ -21,6 +21,7 @@ // FileUpload.js import React, { useState } from 'react'; import axios from 'axios'; +import { SITE_ID } from '@/shared/config'; const SEWERAGE_URL = "/sewerage-service/api"; function FileUpload() { @@ -49,6 +50,7 @@ function FileUpload() { method: "POST", headers: { "Content-Type": "application/json", + "X-Site-Id": SITE_ID, }, body: JSON.stringify({authToken: auth}) }).then((data) => { @@ -58,7 +60,11 @@ function FileUpload() { const handleFindUserOna = () => { const auth = getCookie("Authorization"); if (auth) console.log("쿠키 auth : " + auth); - fetch("/server/user-service/api/v1/users/65a00f65-8460-49af-98ec-042977e56f4b").then((data) => { + fetch("/server/user-service/api/v1/users/65a00f65-8460-49af-98ec-042977e56f4b", { + headers: { + "X-Site-Id": SITE_ID, + }, + }).then((data) => { console.log(data); }); }; @@ -86,7 +92,11 @@ function FileUpload() { formData.append('inpFile', file); try { - const response = await axios.post('http://localhost:8883/sewerage-service/api/v1/rdiiAnls/savewithfile', formData); + const response = await axios.post('http://localhost:8883/sewerage-service/api/v1/rdiiAnls/savewithfile', formData, { + headers: { + "X-Site-Id": SITE_ID, + }, + }); alert('업로드 성공: ' + response.data.message); } catch (err) { diff --git a/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx b/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx index 4b1db105b..3df6a1541 100644 --- a/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx @@ -17,22 +17,20 @@ * - 첨부파일 삭제및 Soft Delete 삭제: DELETE /api/v1/atfl/attachments/{atflId} (attach-service) */ 'use client' -import { deleteData, postData, useCookieAuth } from '@/components/lib/api' +import { deleteData, useCookieAuth } from '@/components/lib/api' import { searchData } from '@/components/lib/common/apiHelpers' import { formatDate, handleDownload } from '@/components/lib/utils' import LoadingOverlay from '@/components/ui/LoadingBar' -import { SearchDiv, SearchFormLeft, SearchFormRight, UiTable, UiModal } from '@common/business_components/ui' +import { SearchDiv, SearchFormLeft, SearchFormRight, UiTable } from '@common/business_components/ui' import ListDiv from '@common/business_components/ui/list_div' import { Button, DatePicker, Input, Label } from '@common/components/ui' import { useEffect, useState } from 'react' import { tableHeadlist } from '..' -import OperFileUploadForm from '../../../components/ui/operFileUploadForm' -import { tokenManager } from '@/entities/auth/lib/token-manager'; -import { AUTH_USER_ID } from "@/shared/config"; import { useQuery } from "@tanstack/react-query"; import { ENDPOINT } from "@common/components/config"; import { getUserInfo } from "@common/components/api"; -import TableModal from './tableModal'; +import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup'; +import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types'; const Page = () => { @@ -51,8 +49,6 @@ const Page = () => { const [curPage, setCurPage] = useState(1) //현재 페이지 const [totalData, setTotalData] = useState(0) //총 데이터 수 const [isLoading, setIsLoading] = useState(true) //로딩 상태 - const [isUploadModalOpen, setIsUploadModalOpen] = useState(false) // 등록 모달 상태 - const [isTableModalOpen, setIsTableModalOpen] = useState(false) // 테이블 모달 상태 const [resetSelection, setResetSelection] = useState(false); // 선택 초기화 상태 const [isResetPage, setIsResetPage] = useState(false); @@ -67,6 +63,17 @@ const Page = () => { fetchData() }, []) + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + if (event.data?.type !== WINDOW_POPUP_MESSAGE_TYPE.FLOOD_PRE_UPLOAD_COMPLETED) return; + handleSearch(); + }; + + window.addEventListener("message", handleMessage); + return () => window.removeEventListener("message", handleMessage); + }, [fromDate, toDate, pjNm, curPage]); + // 페이지 변경 시마다 데이터 fetch useEffect(() => { setIsResetPage(false); @@ -140,27 +147,24 @@ const Page = () => { } } - // 현황 등록 - const registFile = async (fileName: string, year: string, quater: string, cd: string, id: string) => { - const json = { - mngYr: year, - mngQu: quater, - atflId: id, - atflCd: cd, - rgtr: tokenManager.getToken(AUTH_USER_ID) || null, - sbmrgPrevntBizNm: fileName - } - const data = await postData('v1/sbmrgPrevnt', json) - if (data) { - alert('파일이 등록되었습니다.'); - setIsUploadModalOpen(false); - handleSearch(); + // 등록 버튼 클릭 핸들러 + const handleRegisterClick = () => { + const popupWindow = openWindowPopup("floodPreUpload"); + if (!popupWindow) { + alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.'); } } - // 등록 버튼 클릭 핸들러 - const handleRegisterClick = () => { - setIsUploadModalOpen(true); + const handleTablePopupOpen = (fileName: string, id: string) => { + const popupWindow = openWindowPopup("floodPreTable", { + fileNm: fileName, + atflId: id, + sourcePath: window.location.pathname, + }); + + if (!popupWindow) { + alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.'); + } } @@ -246,7 +250,7 @@ const Page = () => { setFileNm(fileName); setAtflId(id); setSbmrgPrevntBizSn(tableData[index]?.sbmrgPrevntBizSn); - setIsTableModalOpen(true); + handleTablePopupOpen(fileName, id); } }} onPageChange={setCurPage} @@ -254,40 +258,8 @@ const Page = () => { resetPageNo={isResetPage} /> - {/* 파일 업로드 모달 */} - { - await registFile(file, year, quater, cd, id) - }} - /> - } - /> - {/* 엑셀 데이터 테이블 모달 */} - } - useDirectRender={true} - /> ) } -export default Page \ No newline at end of file +export default Page diff --git a/apps/sn/app/(with-base-layout)/oper/floodPre/tableModal.tsx b/apps/sn/app/(with-base-layout)/oper/floodPre/tableModal.tsx index 97e9d788d..4b35069af 100644 --- a/apps/sn/app/(with-base-layout)/oper/floodPre/tableModal.tsx +++ b/apps/sn/app/(with-base-layout)/oper/floodPre/tableModal.tsx @@ -17,6 +17,7 @@ import { useState, useEffect } from 'react' import LoadingOverlay from '@/components/ui/LoadingBar' import { UITab, UiTable } from '@common/business_components/ui' import * as XLSX from 'xlsx'; +import { getBlobData } from '@/components/lib/api'; // import { Table, TableBody, TableHeader } from "@common/components/ui"; // import { HeadMakeFloodPre } from './headMakeFloodPre'; // import { InputTableBody } from './inputTableBody'; @@ -54,12 +55,13 @@ const TableModal: React.FC = ({ fileNm, atflId }) => { return false } - const response = await fetch(`/attach-service/api/v1/download/${atflId}`); - if (!response.ok) { - throw new Error('파일을 가져오는 중 오류가 발생했습니다.'); + if (atflId === '' || atflId === undefined) { + alert('첨부파일 ID가 없어 파일을 불러올 수 없습니다.'); + setIsLoading(false); + return false; } - const blob = await response.blob(); + const blob = await getBlobData(`/attach-service/api/v1/download/${atflId}`); const file = new File([blob], fileNm, { type: blob.type || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' @@ -114,6 +116,8 @@ const TableModal: React.FC = ({ fileNm, atflId }) => { const filteredResultData = filterEmptyRows(resultData); setResultTableData(filteredResultData); } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('FloodPre attachment fetch details:', { atflId, fileNm, message }); console.error('파일 처리 중 오류 발생:', error); alert('파일 처리 중 오류가 발생했습니다.'); } finally { @@ -267,4 +271,4 @@ const TableModal: React.FC = ({ fileNm, atflId }) => { ) }; -export default TableModal; \ No newline at end of file +export default TableModal; diff --git a/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx b/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx index a078a9795..86d5d4853 100644 --- a/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx @@ -17,22 +17,20 @@ * - 첨부파일 삭제및 Soft Delete 삭제: DELETE /api/v1/atfl/attachments/{atflId} (attach-service) */ 'use client' -import { deleteData, postData, useCookieAuth } from '@/components/lib/api' +import { deleteData, useCookieAuth } from '@/components/lib/api' import { searchData } from '@/components/lib/common/apiHelpers' import { formatDate, handleDownload } from '@/components/lib/utils' import LoadingOverlay from '@/components/ui/LoadingBar' -import { SearchDiv, SearchFormLeft, SearchFormRight, UiTable, UiModal } from '@common/business_components/ui' +import { SearchDiv, SearchFormLeft, SearchFormRight, UiTable } from '@common/business_components/ui' import ListDiv from '@common/business_components/ui/list_div' import { Button, DatePicker, Input, Label } from '@common/components/ui' import { useEffect, useState } from 'react' import { tableHeadlistOldPp } from '..' -import OperFileUploadForm from '../../../components/ui/operFileUploadForm' -import { tokenManager } from '@/entities/auth/lib/token-manager'; -import { AUTH_USER_ID } from "@/shared/config"; import { useQuery } from "@tanstack/react-query"; import { ENDPOINT } from "@common/components/config"; import { getUserInfo } from "@common/components/api"; -import TableModal from './tableModal'; +import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup'; +import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types'; const Page = () => { @@ -49,8 +47,6 @@ const Page = () => { const [curPage, setCurPage] = useState(1) const [totalData, setTotalData] = useState(0) //총 데이터 수 const [isLoading, setIsLoading] = useState(true) //로딩 상태 - const [isModalOpen, setIsModalOpen] = useState(false) // 등록 모달 상태 - const [isTableModalOpen, setIsTableModalOpen] = useState(false) // 테이블 모달 상태 const [resetSelection, setResetSelection] = useState(false); // 선택 초기화 상태 const [isResetPage, setIsResetPage] = useState(false); @@ -65,6 +61,17 @@ const Page = () => { fetchData() }, []) + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + if (event.data?.type !== WINDOW_POPUP_MESSAGE_TYPE.OLD_PP_UPLOAD_COMPLETED) return; + handleSearch(); + }; + + window.addEventListener("message", handleMessage); + return () => window.removeEventListener("message", handleMessage); + }, [fromDate, toDate, pjNm, curPage]); + // 페이지 변경 시마다 데이터 fetch useEffect(() => { setIsResetPage(false); @@ -137,27 +144,25 @@ const Page = () => { } } - // 파일 등록 - const registFile = async (fileNm: string, year: string, quater: string, cd: string, id: string) => { - const json = { - mngYr: year, - mngQu: quater, - atflCd: cd, - atflId: id, - rgtr: tokenManager.getToken(AUTH_USER_ID) || null, - dcrpPplMtncBizNm: fileNm - } - const data = await postData('v1/dcrpPpl', json); - if (data) { - alert('파일이 등록되었습니다.'); - setIsModalOpen(false); - handleSearch(); + // 등록 버튼 클릭 핸들러 + const handleRegisterClick = () => { + const popupWindow = openWindowPopup("oldPpUpload"); + if (!popupWindow) { + alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.'); } } - // 등록 버튼 클릭 핸들러 - const handleRegisterClick = () => { - setIsModalOpen(true); + // 엑셀 데이터는 브라우저 윈도우 팝업에서 독립 조회 + const handleTablePopupOpen = (fileName: string, id: string) => { + const popupWindow = openWindowPopup("oldPpTable", { + fileNm: fileName, + atflId: id, + sourcePath: window.location.pathname, + }); + + if (!popupWindow) { + alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.'); + } } @@ -242,47 +247,15 @@ const Page = () => { setFileNm(fileName); setAtflId(id); setDcrpPplMtncBizSn(tableData[index]?.dcrpPplMtncBizSn); - setIsTableModalOpen(true); + handleTablePopupOpen(fileName, id); } }} onPageChange={setCurPage} resetSelection={resetSelection} /> - {/* 파일 업로드 모달 */} - { - await registFile(file, year, quater, cd, id) - }} - /> - } - /> - {/* 엑셀 데이터 테이블 모달 */} - } - useDirectRender={true} - /> ) } -export default Page \ No newline at end of file +export default Page diff --git a/apps/sn/app/(with-base-layout)/oper/oldPp/tableModal.tsx b/apps/sn/app/(with-base-layout)/oper/oldPp/tableModal.tsx index 707f2d526..f22df820c 100644 --- a/apps/sn/app/(with-base-layout)/oper/oldPp/tableModal.tsx +++ b/apps/sn/app/(with-base-layout)/oper/oldPp/tableModal.tsx @@ -19,6 +19,7 @@ import { UITab, UiTable } from '@common/business_components/ui' import * as XLSX from 'xlsx'; import { HeadMakeColSpan } from '@common/business_components/ui/table/HeadMakeColSpan'; import { oldPpTable2HeadList, oldPpTable2NodeList, oldPpTable6HeadList, oldPpTable6NodeList } from './headList'; +import { getBlobData } from '@/components/lib/api'; const TABS = [ { val: "1", text: "raw data" }, @@ -63,12 +64,13 @@ const TableModal: React.FC = ({ fileNm, atflId }) => { return false; } - const response = await fetch(`/attach-service/api/v1/download/${atflId}`); - if (!response.ok) { - throw new Error('파일을 가져오는 중 오류가 발생했습니다.'); + if (atflId === '' || atflId === undefined) { + alert('첨부파일 ID가 없어 파일을 불러올 수 없습니다.'); + setIsLoading(false); + return false; } - const blob = await response.blob(); + const blob = await getBlobData(`/attach-service/api/v1/download/${atflId}`); const file = new File([blob], fileNm, { type: blob.type || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' @@ -246,6 +248,8 @@ const TableModal: React.FC = ({ fileNm, atflId }) => { })); setTableData6(filteredData6); } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('OldPp attachment fetch details:', { atflId, fileNm, message }); console.error('파일 처리 중 오류 발생:', error); alert('파일 처리 중 오류가 발생했습니다.'); } finally { @@ -476,4 +480,4 @@ const TableModal: React.FC = ({ fileNm, atflId }) => { ) }; -export default TableModal; \ No newline at end of file +export default TableModal; diff --git a/apps/sn/app/auth/login/page.tsx b/apps/sn/app/auth/login/page.tsx new file mode 100644 index 000000000..7a35b0ddd --- /dev/null +++ b/apps/sn/app/auth/login/page.tsx @@ -0,0 +1,2 @@ +import { LoginPage } from "@/features/auth/login"; +export default LoginPage; diff --git a/apps/sn/app/auth/login/route.ts b/apps/sn/app/auth/login/route-bak.ts similarity index 83% rename from apps/sn/app/auth/login/route.ts rename to apps/sn/app/auth/login/route-bak.ts index 65bbce15e..796537cc3 100644 --- a/apps/sn/app/auth/login/route.ts +++ b/apps/sn/app/auth/login/route-bak.ts @@ -1,10 +1,12 @@ import { NextRequest, NextResponse } from "next/server"; +import { tokenManager } from "@/entities/auth/lib/token-manager"; export async function GET(request: NextRequest) { const hostname = request.nextUrl.hostname; const redirectUrl = process.env.NEXT_PUBLIC_CM_LOGIN_PAGE_URL || "https://portal.hasuon.or.kr"; if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "con.mind-one.co.kr") { + tokenManager.clearAuthToken(); return NextResponse.redirect(new URL("/", request.url)); // 로컬 및 개발 환경 } else { return NextResponse.redirect(new URL(redirectUrl)); // 공단 환경 diff --git a/apps/sn/app/components/lib/api/index.ts b/apps/sn/app/components/lib/api/index.ts index d567eec05..aba7a3092 100644 --- a/apps/sn/app/components/lib/api/index.ts +++ b/apps/sn/app/components/lib/api/index.ts @@ -11,13 +11,51 @@ */ import { useEffect, useState } from "react"; import axios from 'axios'; +import { CLAIM_NAME, SITE_ID } from "@/shared/config"; +import { tokenManager } from "@/entities/auth/lib/token-manager"; const BASE_URL = '/sewerage-service/api'; +const DEFAULT_HEADERS = { + "X-Site-Id": SITE_ID, +}; +const LOGIN_PAGE_URL = + process.env.NEXT_PUBLIC_CM_LOGIN_PAGE_URL || "/auth/login"; + +const withSiteIdHeaders = (headers: HeadersInit = {}) => ({ + ...headers, + ...DEFAULT_HEADERS, +}); + +const resolveApiUrl = (url: string) => + url.startsWith('/') ? `${url}` : `${BASE_URL}/${url}`; + +const handleUnauthorizedResponse = () => { + if (typeof window === "undefined") return; + + const remainingMs = tokenManager.getRemainingMsFromToken(CLAIM_NAME); + const isExpired = remainingMs === null || remainingMs <= 0; + + if (!isExpired) return; + + tokenManager.clearAuthToken(); + + const isLoginPage = + window.location.pathname === "/auth/login" || + window.location.pathname.startsWith("/auth/login/"); + + if (!isLoginPage) { + window.location.assign(LOGIN_PAGE_URL); + } +}; export const getData = async (url: string): Promise => { - const res = await fetch(url.startsWith('/') ? `${url}` : `${BASE_URL}/${url}`, { + const res = await fetch(resolveApiUrl(url), { method: "GET", + headers: withSiteIdHeaders(), }); + if (res.status === 401) { + handleUnauthorizedResponse(); + } if (res.ok) { try { // 응답이 비어있는지 확인 @@ -47,12 +85,15 @@ export const getData = async (url: string): Promise => { export const postData = async (url: string, data: object): Promise => { const options: RequestInit = { method: "POST", - headers: { + headers: withSiteIdHeaders({ "Content-Type": "application/json", - }, + }), body: JSON.stringify(data), }; const res = await fetch(`${BASE_URL}/${url}`, options); + if (res.status === 401) { + handleUnauthorizedResponse(); + } if (res.ok) { const resData = await res.json(); console.log('postData response:', resData); @@ -62,6 +103,7 @@ export const postData = async (url: string, data: object): Promise => { export const postMutiPart = async (url: string, data: FormData): Promise => { const res = await axios.post(`${BASE_URL}/${url}`, data, { + headers: DEFAULT_HEADERS, timeout: 600000, // 10분 }); console.log('postMultiPart response:', res); @@ -71,12 +113,15 @@ export const postMutiPart = async (url: string, data: FormData): Promise => export const putData = async (url: string, data: object): Promise => { const options: RequestInit = { method: "PUT", - headers: { + headers: withSiteIdHeaders({ "Content-Type": "application/json", - }, + }), body: JSON.stringify(data), }; const res = await fetch(`${BASE_URL}/${url}`, options); + if (res.status === 401) { + handleUnauthorizedResponse(); + } const resData = await res.json(); console.log('putData response:', resData); return resData.data; @@ -85,21 +130,81 @@ export const putData = async (url: string, data: object): Promise => { export const deleteData = async (url: string, data?: object): Promise => { const options: RequestInit = { method: "DELETE", - headers: { + headers: withSiteIdHeaders({ "Content-Type": "application/json", - }, + }), body: data ? JSON.stringify(data) : undefined, }; - const res = await fetch(url.startsWith('/') ? `${url}` : `${BASE_URL}/${url}`, options); + const res = await fetch(resolveApiUrl(url), options); + if (res.status === 401) { + handleUnauthorizedResponse(); + } if (res.status === 204) return true return res; }; +export const getRawJson = async (url: string): Promise => { + const res = await fetch(resolveApiUrl(url), { + method: "GET", + headers: withSiteIdHeaders(), + }); + if (res.status === 401) { + handleUnauthorizedResponse(); + } + + if (!res.ok) { + const errorText = await res.text().catch(() => ""); + throw new Error( + `Failed to fetch data: ${res.status} ${res.statusText}${errorText ? ` - ${errorText.slice(0, 200)}` : ""}`, + ); + } + + const contentLength = res.headers.get('content-length'); + if (contentLength === '0') { + return null; + } + + const text = await res.text(); + if (!text || text.trim() === '') { + return null; + } + + try { + return JSON.parse(text); + } catch (error) { + console.error('JSON parsing error:', error); + throw new Error(`Invalid JSON response from server: ${url}`); + } +}; + +export const getBlobData = async (url: string): Promise => { + const res = await fetch(resolveApiUrl(url), { + method: "GET", + headers: withSiteIdHeaders(), + }); + if (res.status === 401) { + handleUnauthorizedResponse(); + } + + if (!res.ok) { + const errorText = await res.text().catch(() => ""); + throw new Error( + `Failed to fetch blob: ${res.status} ${res.statusText}${errorText ? ` - ${errorText.slice(0, 200)}` : ""} (${url})`, + ); + } + + return res.blob(); +}; + // DC API 호출 export const getDcData = async (url: string, resType: string = "json"): Promise => { const res = await fetch('/dc-service' + url, { method: "GET", + headers: withSiteIdHeaders(), }); + if (res.status === 401) { + handleUnauthorizedResponse(); + } if (res.ok) { let data; if (resType === "json") data = await res.json(); @@ -114,7 +219,11 @@ export const getDcData = async (url: string, resType: string = "json"): Promise< export const getCmsData = async (url: string, data?: object): Promise => { const res = await fetch('/cms-service' + url, { method: "GET", + headers: withSiteIdHeaders(), }); + if (res.status === 401) { + handleUnauthorizedResponse(); + } if (res.ok) { const data = await res.json(); return data; @@ -126,7 +235,11 @@ export const getCmsData = async (url: string, data?: object): Promise => { export const getGeoData = async (url: string, data?: object): Promise => { const res = await fetch('/geoserver' + url, { method: "GET", + headers: withSiteIdHeaders(), }); + if (res.status === 401) { + handleUnauthorizedResponse(); + } if (res.ok) { const data = await res.json(); return data; @@ -157,4 +270,4 @@ export const useCookieAuth = () => { }, []); return data; -}; \ No newline at end of file +}; diff --git a/apps/sn/app/components/lib/utils/fileDeleteUtil.tsx b/apps/sn/app/components/lib/utils/fileDeleteUtil.tsx index 768c3ef9c..d9525f028 100644 --- a/apps/sn/app/components/lib/utils/fileDeleteUtil.tsx +++ b/apps/sn/app/components/lib/utils/fileDeleteUtil.tsx @@ -13,6 +13,7 @@ * - 첨부파일 삭제및 Soft Delete 삭제(첨부파일코드로 삭제): DELETE /api/v1/atfl/attachments/atflCd/{atflCd} (attach-service) */ import { getCookie } from "@/shared/lib"; +import { SITE_ID } from "@/shared/config"; import { ENDPOINT } from "../api/attachment/attachment-service"; const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id"; @@ -43,6 +44,7 @@ export const handleDelete = async ( method: "DELETE", headers: { "Content-Type": "application/json", + "X-Site-Id": SITE_ID, [CLAIM_NAME]: getCookie(CLAIM_NAME) ?? "", [AUTH_USER_ID]: getCookie(AUTH_USER_ID) ?? "", }, @@ -118,4 +120,4 @@ export const handleDelete = async ( // options?.handleDeleteError?.(deleteError); // throw deleteError; // } -// }; \ No newline at end of file +// }; diff --git a/apps/sn/app/components/lib/utils/fileDownload.ts b/apps/sn/app/components/lib/utils/fileDownload.ts index 855488692..093bc46bf 100644 --- a/apps/sn/app/components/lib/utils/fileDownload.ts +++ b/apps/sn/app/components/lib/utils/fileDownload.ts @@ -10,10 +10,15 @@ * - Fetch 기반 HTTP 요청 처리 */ +import { SITE_ID } from "@/shared/config"; + export const handleDownload = async (api: string, fileNm: string) => { try { const response = await fetch(api, { method: 'GET', + headers: { + "X-Site-Id": SITE_ID, + }, }); if (!response.ok) throw new Error('파일 다운로드 실패'); @@ -34,4 +39,4 @@ export const fileDownload = (data: Blob, fileName: string) => { // Blob데이터 // 메모리 해제 link.remove(); window.URL.revokeObjectURL(url); -} \ No newline at end of file +} diff --git a/apps/sn/app/components/ui/modal.tsx b/apps/sn/app/components/ui/modal.tsx deleted file mode 100644 index dce0ee2b5..000000000 --- a/apps/sn/app/components/ui/modal.tsx +++ /dev/null @@ -1,45 +0,0 @@ -/** - * 📄 Modal Component - * 모달 컴포넌트 - * - * - 모달 열기/닫기 상태 관리 - * - 제목 및 내용 표시 - * - 저장 및 닫기 버튼 지원 - * - 반응형 크기 조정 (최대/최소 너비 및 높이) - * - 오버레이 및 z-index를 통한 최상위 레이어 배치 - * - 접근성 지원 (aria-describedby) - */ -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogOverlay } from '@common/components/ui' -import React from 'react' - -interface ModalProps { - isModalOpen: boolean; - setIsModalOpen: (open: boolean) => void; - title?: string; - children: React.ReactNode; - closeModal: () => void; - saveModal?: () => void; - description?: string; -} - -const Modal: React.FC = ({ isModalOpen, setIsModalOpen, title, children, closeModal, saveModal }) => { - return ( - - - - {title ? - {title} - : null} - {children} - -
- {saveModal ? : null} - -
-
-
-
- ) -} - -export default Modal \ No newline at end of file diff --git a/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeDetailPanel.tsx b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeDetailPanel.tsx index cf6db8572..88b5ddbe8 100644 --- a/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeDetailPanel.tsx +++ b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeDetailPanel.tsx @@ -1,6 +1,7 @@ "use client"; import { Button } from "@common/components"; +import { VerticalTable } from "@/components/ui/verticalTable"; import { AGED_PIPE_DETAIL_FIELDS, getElapsedYears, @@ -16,39 +17,48 @@ export default function AgedPipeDetailPanel({ featureData, onClose }: Props) { const source = featureData?.values_ ?? featureData ?? {}; const facilityNo = String(getPipeDisplayValue(source, "fclt_mng_no") ?? "-"); const elapsedYears = getElapsedYears(source.instl_ymd ?? source.instlYmd); + const detailFields = [ + ...AGED_PIPE_DETAIL_FIELDS, + { id: "elapsedYears", title: "경과년도" }, + ]; + const tableData = detailFields.reduce>((acc, field) => { + acc[field.id] = + field.id === "elapsedYears" + ? elapsedYears === null + ? "확인불가" + : `${elapsedYears}년` + : String(getPipeDisplayValue(source, field.id) ?? "-"); + return acc; + }, {}); return ( -
-
+
+
-
노후관로 상세
-
시설관리번호 {facilityNo}
-
- 경과년도 {elapsedYears ?? "-"}년 +
+ 하수관로 정보
관리번호 : {facilityNo}
-
-
- {AGED_PIPE_DETAIL_FIELDS.map((field) => ( -
-
{field.title}
-
- {String(getPipeDisplayValue(source, field.id) ?? "-")} -
-
- ))} -
+
+ undefined} + />
); diff --git a/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeFilterPanel.tsx b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeFilterPanel.tsx index 5a94014a2..a80ced9c9 100644 --- a/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeFilterPanel.tsx +++ b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeFilterPanel.tsx @@ -1,7 +1,6 @@ "use client"; -import { Button, Input } from "@common/components"; -import useStore from "@/store/useStore"; +import { useState } from "react"; import type { AgedPipeFilters } from "./agedPipeUtils"; type OverlayProps = { @@ -13,89 +12,62 @@ type OverlayProps = { }; type Props = OverlayProps & { + title?: string; filters: AgedPipeFilters; onFiltersChange: (filters: AgedPipeFilters) => void; onApply: () => void; }; export default function AgedPipeFilterPanel({ + title = "지반침하정보", filters, onFiltersChange, onApply, }: Props) { - const { ctpv, sgg } = useStore(); - const sggOptions = filters.ctpvCd === "0" - ? sgg - : sgg.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2))); + const [isCollapsed, setIsCollapsed] = useState(false); return ( -
-
-
노후관로 기준
-
- 경과년수 이상 관로만 지도와 목록에 반영합니다. +
+
+ + {title} +
+ + {!isCollapsed && ( +
+
+ + { + const next = Number(event.target.value.replace(/[^0-9]/g, "") || "0"); + onFiltersChange({ ...filters, elapsedYears: next }); + }} + onKeyDown={(event) => { + if (event.key === "Enter") onApply(); + }} + /> + +
-
- -
- - - - - -
- - + )}
); } diff --git a/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeListModal.tsx b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeListModal.tsx index bf6a8fb02..a5c90e184 100644 --- a/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeListModal.tsx +++ b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeListModal.tsx @@ -64,14 +64,11 @@ export default function AgedPipeListModal({ return (
-
+
-
+
노후관로 목록
-
- 지도 위에 띄워지는 윈도우형 팝업 미리보기 -
+
+
HASUON
+
디지털하수관로 시스템
+
+
+ +
+ + +
+ + +
+ + +
+
+
+
+
+
+ + 하수처리시설 + 왕숙천 + 진건로 + +
+
+
+
+ + + + + + + + +
+ + + + + + + +
+
+
+ +
+ +
+ +
+ + + + diff --git a/apps/sn/app/layout.tsx b/apps/sn/app/layout.tsx index 9363fe185..b8b4897c8 100644 --- a/apps/sn/app/layout.tsx +++ b/apps/sn/app/layout.tsx @@ -12,7 +12,11 @@ * - 앱 전체의 공통 스타일 및 스크립트 적용 */ import type { Metadata } from "next"; -import { App } from "@/app"; +import "@fontsource-variable/noto-sans-kr"; +import { cookies } from "next/headers"; +import { AppProvider } from "./providers"; +import "../src/app/styles/base.css"; +import "../src/app/styles/components.css"; export const metadata: Metadata = { title: "디지털 하수관로", @@ -24,4 +28,16 @@ export const metadata: Metadata = { } }; -export default App; \ No newline at end of file +export default async function RootLayout({ + children, +}: Readonly<{ children: React.ReactNode }>) { + const theme = (await cookies()).get("site-theme-key"); + + return ( + + + {children} + + + ); +} diff --git a/apps/sn/app/providers.tsx b/apps/sn/app/providers.tsx new file mode 100644 index 000000000..4bbe9f864 --- /dev/null +++ b/apps/sn/app/providers.tsx @@ -0,0 +1 @@ +export { AppProvider } from "../src/app/providers"; diff --git a/apps/sn/public/images/bg_login.jpg b/apps/sn/public/images/bg_login.jpg new file mode 100644 index 000000000..b7793d16a Binary files /dev/null and b/apps/sn/public/images/bg_login.jpg differ diff --git a/apps/sn/public/images/map/layericon/Layer_Select_BTN.png b/apps/sn/public/images/map/layericon/Layer_Select_BTN.png new file mode 100644 index 000000000..1e810feaf Binary files /dev/null and b/apps/sn/public/images/map/layericon/Layer_Select_BTN.png differ diff --git a/apps/sn/src/app/index.tsx b/apps/sn/src/app/index.tsx index 692b55ef0..29d73b03f 100644 --- a/apps/sn/src/app/index.tsx +++ b/apps/sn/src/app/index.tsx @@ -1,7 +1,3 @@ -import "./styles/base.css"; -import "./styles/components.css"; -import "@fontsource-variable/noto-sans-kr"; - import { AppProvider } from "./providers"; import { cookies } from "next/headers"; diff --git a/apps/sn/src/app/styles/base.css b/apps/sn/src/app/styles/base.css index 8453ff9e8..6cc507bb9 100644 --- a/apps/sn/src/app/styles/base.css +++ b/apps/sn/src/app/styles/base.css @@ -10,3 +10,7 @@ body { font-family: "Noto Sans KR", sans-serif; font-size: 10px; } + +body.sn-map-page-lock { + overflow: hidden; +} diff --git a/apps/sn/src/entities/auth/api/login-service.ts b/apps/sn/src/entities/auth/api/login-service.ts index c1968fe36..4d7a7cd8d 100644 --- a/apps/sn/src/entities/auth/api/login-service.ts +++ b/apps/sn/src/entities/auth/api/login-service.ts @@ -5,6 +5,7 @@ import { ACCESS_TOKEN, AUTH_USER_ID, CLAIM_NAME, + SITE_ID, } from "@/shared/config"; import type { LoginService, ILogin } from "../model/login-interface"; import { tokenManager } from "../lib/token-manager"; @@ -23,8 +24,13 @@ export class LoginApiService implements LoginService { public async login(payload: ILogin): Promise { try { + tokenManager.clearAuthToken(); 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, @@ -57,7 +63,12 @@ export class LoginApiService implements LoginService { try { const response = await fetch( `/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`, - { method: "PUT" }, + { + method: "PUT", + headers: { + "X-Site-Id": SITE_ID, + }, + }, ); const data = await response.json(); diff --git a/apps/sn/src/entities/auth/lib/auth-wrapper.tsx b/apps/sn/src/entities/auth/lib/auth-wrapper.tsx index bc4a82aab..f37ff4041 100644 --- a/apps/sn/src/entities/auth/lib/auth-wrapper.tsx +++ b/apps/sn/src/entities/auth/lib/auth-wrapper.tsx @@ -8,6 +8,7 @@ import { useUserStore } from "@/shared/lib"; import { ENDPOINT, AUTH_USER_ID } from "@/shared/config"; import { isAtLoginRedirectUrl } from "@common/network"; import { getCookie } from "@/shared/lib"; +import { tokenManager } from "./token-manager"; const LOGIN_PAGE_URL = process.env.NEXT_PUBLIC_CM_LOGIN_PAGE_URL || "/auth/login"; @@ -38,6 +39,7 @@ const AuthWrapper = ({ children }: { children: React.ReactNode }) => { await authCheck(); } catch (error) { console.error(error); + setUserInfo(null); if (isAtLoginRedirectUrl(LOGIN_PAGE_URL)) return; diff --git a/apps/sn/src/entities/auth/lib/token-manager.ts b/apps/sn/src/entities/auth/lib/token-manager.ts index 8c3292794..5b24c8bd3 100644 --- a/apps/sn/src/entities/auth/lib/token-manager.ts +++ b/apps/sn/src/entities/auth/lib/token-manager.ts @@ -1,6 +1,13 @@ import { AUTH_USER_ID, CLAIM_NAME } from "@/shared/config"; import { getCookie, setCookie, removeCookie } from "@/shared/lib";; +type JwtPayload = { + sub?: string; + exp?: number; + iat?: number; + [key: string]: unknown; +}; + export class TokenManager { private static instance: TokenManager; private refreshTimer: NodeJS.Timeout | null = null; @@ -18,6 +25,49 @@ export class TokenManager { return getCookie(key); } + private parseJwt(token: string): JwtPayload { + const parts = token.split("."); + if (parts.length !== 3) { + throw new Error("유효한 JWT 형식이 아닙니다."); + } + + const base64Url = parts[1] ?? ""; + const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/"); + const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "="); + const jsonPayload = decodeURIComponent( + atob(padded) + .split("") + .map((char) => `%${char.charCodeAt(0).toString(16).padStart(2, "0")}`) + .join("") + ); + + return JSON.parse(jsonPayload) as JwtPayload; + } + + public getRemainingMsFromToken(key: string = CLAIM_NAME): number | null { + const token = this.getToken(key); + if (!token) { + return null; + } + + try { + const payload = this.parseJwt(token); + if (typeof payload.exp !== "number") { + return null; + } + + return payload.exp * 1000 - Date.now(); + } catch (error) { + console.error("JWT 파싱 오류:", error); + return null; + } + } + + public isExpiredToken(key: string = CLAIM_NAME): boolean { + const remainingMs = this.getRemainingMsFromToken(key); + return remainingMs === null || remainingMs <= 0; + } + public setToken(key: string, value: string): void { // 환경에 따른 도메인 설정 //const domain = this.getCookieDomain(); diff --git a/apps/sn/src/features/auth/login/index.ts b/apps/sn/src/features/auth/login/index.ts new file mode 100644 index 000000000..2093c3d8d --- /dev/null +++ b/apps/sn/src/features/auth/login/index.ts @@ -0,0 +1 @@ +export { LoginPage } from "./ui/login-page"; diff --git a/apps/sn/src/features/auth/login/ui/login-page.tsx b/apps/sn/src/features/auth/login/ui/login-page.tsx new file mode 100644 index 000000000..dea3c90f1 --- /dev/null +++ b/apps/sn/src/features/auth/login/ui/login-page.tsx @@ -0,0 +1,139 @@ +"use client"; + +import Image from "next/image"; +import { useEffect } from "react"; +import { images } from "@common/assets"; +import { LoginApi } from "@/entities/auth"; +import "./login.css"; + +function LoginPage() { + useEffect(() => { + const hostname = window.location.hostname; + if (hostname.includes("hasuon.or.kr")) { + window.location.href = + process.env.NEXT_PUBLIC_CM_LOGIN_PAGE_URL || "/auth/login"; + } + }, []); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + const formData = new FormData(e.target as HTMLFormElement); + const userId = formData.get("id") as string; + const password = formData.get("password") as string; + + await LoginApi.login({ userId, password }); + window.location.href = "/map"; + }; + + return ( +
+
+
+

+ 하수도정보시스템 +
+ CMS 통합관리시스템 +

+

하수도정보시스템 CMS 통합관리시스템에 오신 것을 환영합니다.

+
+
+ logo +
+
+
+
+
    +
  • +

    ID

    +
    + + +
    +
  • +
  • +

    PASSWORD

    +
    + + +
    +
  • +
+
+
+ + +
+ {/* 비밀번호 또는 아이디를 잃어버리셨나요? */} +
+ +
+
+

copyright © 2023 KECO. All rights reserved.

+
+ +

+ + 주소 : (22689) 인천광역시 서구 환경로 42(경서동 + 종합환경연구단지) + + + 전화 : (시스템) 032-590-4321, 3752, 4314 / (통 계) 032-590-3723, + 3722 + + 팩스 :032-590-3729 +

+
+
+ +
+ ); +} + +export { LoginPage }; + diff --git a/apps/sn/src/features/auth/login/ui/login.css b/apps/sn/src/features/auth/login/ui/login.css new file mode 100644 index 000000000..af73c9c68 --- /dev/null +++ b/apps/sn/src/features/auth/login/ui/login.css @@ -0,0 +1,310 @@ +/** +* OLYM Cascading Style Sheets +* File Name : login.css +* Description : 로그인 스타일시트 +* Author : Web business Team / Choi Hyun Mi +* Email : desingtj@olym.co.kr +* Date : 2023.07.20 +* Update : 2023.07.26 +* Copyright(c) 2023 OLYM Communications. All Rights Reserved. +*/ +html, +body { + background: #fff; +} +.fx { + display: flex; + -ms-display: flex; + -webkit-display: flex; + box-sizing: border-box; +} +.login { + width: 100%; + height: 100vh; +} +.login .left_box { + width: 100%; + background: url(/images/bg_login.jpg) no-repeat center/cover; + flex-direction: column; + justify-content: space-between; + padding: 7% 5% 5% 5%; +} +.login .left_box .text h1 { + font-size: 53px; + color: #fff; +} +.login .left_box .text h1 span { + font-weight: 800; +} +.login .left_box .text p { + margin-top: 40px; + font-size: 18px; + color: #fff; +} +.login .login_box { + width: 100%; + flex-direction: column; + justify-content: center; + padding: 4% 12% 4% 6%; +} +.login .login_box > ul { + flex-direction: column; + gap: 40px; +} +.login .login_box > ul > li h2 { + font-size: 22px; + font-weight: 600; + color: #2640a5; + margin-bottom: 14px; +} +.login .login_box > ul > li .input_st { + position: relative; +} +.login .login_box > ul > li .input_st .diveicon { + position: absolute; + top: 50%; + left: 28px; + transform: translateY(-50%); + font-family: "Xeicon"; + font-size: 25px; + color: #003288; +} +.login .login_box > ul > li input { + width: 100%; + padding: 20px 20px 20px 70px; + background: #f7f7f7; + box-sizing: border-box; + border: none; + border-radius: 5px; + font-size: 18px; + font-weight: 700; + color: #000; + height: auto; +} +.login .login_box > ul > li input:focus { + outline: 1px solid #3c5fe5; +} +.login .login_box > ul > li input::placeholder { + color: #afabab; + font-weight: 500; +} +.login .login_box .bottom_box { + justify-content: space-between; + gap: 20px; + padding: 50px 0 45px 0; +} +.login .login_box .bottom_box .checks { + display: flex; + align-items: center; + gap: 8px; +} +.login .login_box .bottom_box .checks input[type="checkbox"] { + width: 18px; + height: 18px; +} +.login .login_box .bottom_box .checks label { + font-size: 14px; +} +.login .login_box .bottom_box .pw_message { + align-items: center; + gap: 5px; + color: #636363; + font-weight: 500; + border-bottom: 1px solid #ccc; + padding-bottom: 5px; +} +.login .login_box .bottom_box .pw_message i { + color: #327af5; + font-size: 20px; +} +.login .login_box .login_btn { + position: relative; + border-radius: 5px; + padding: 24px 15px; + background: linear-gradient( + to left, + #1d3969 0%, + #264078 10%, + #172558 40%, + #162254 50%, + #111c43 60%, + #111c42 100% + ); + overflow: hidden; + justify-content: center; + align-items: center; + gap: 10px; + color: #fff; + cursor: pointer; +} +.login .login_box .login_btn span { + font-family: "Montserrat"; + font-size: 19px; + font-weight: 700; + position: relative; +} +.login .login_box .login_btn i { + font-size: 22px; + transform: translateY(-2px); +} +.login .login_box .login_btn:before { + display: block; + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0; + background: linear-gradient( + to right, + #1d3969 0%, + #264078 10%, + #172558 40%, + #162254 50%, + #111c43 60%, + #111c42 100% + ); + transition: opacity 0.5s; +} +.login .login_box .login_btn:hover::before { + opacity: 1; +} +.login .footer .copy { + margin-top: 36px; + justify-content: space-between; + align-items: center; + gap: 3%; +} +.login .footer .copy p { + font-size: 13px; + color: #d4d4d4; + width: 44%; + text-align: center; +} +.login .footer .copy::before, +.login .login_box .copy::after { + content: ""; + width: 20%; + height: 1px; + background: #d4d4d4; +} +.login .footer .fot_btn { + gap: 20px; + margin: 30px 0 10px 0; +} +.login .footer .fot_btn a { + color: #333; + font-weight: 700; + font-size: 13px; +} +.login .footer .fot_btn a:hover { + color: #003288; +} +.login .footer .addr { + color: #666; + font-size: 13px; + gap: 3px 20px; + flex-wrap: wrap; +} +/* 비밀번호 변경 팝업창 */ +.login .modal .modal_container { + width: 750px; +} +.login .pw_desc { + padding: 10px 10px 30px 10px; + color: #666; +} +.login .pw_desc p { + font-size: 1.25rem; + color: #000; + font-weight: 700; + margin-bottom: 10px; +} +.login .pw_desc span { + color: #f60; + font-weight: 700; +} +.login .modal .form_write { + border-top: 1px solid #ddd; + border-bottom: 1px solid #ddd; + padding: 20px 50px; +} +@media (max-width: 1024px) { + .login { + flex-direction: column; + height: auto; + } + .login .left_box { + padding: 30px; + flex-direction: column-reverse; + gap: 20px; + } + .login .left_box .text h1 { + font-size: 30px; + line-height: 1.2em; + } + .login .left_box .text p { + margin-top: 20px; + font-size: 14px; + } + .login .left_box .img img { + height: 40px; + } + .login .login_box { + padding: 30px; + } + .login .login_box > ul { + gap: 20px; + } + .login .login_box > ul > li h2 { + margin-bottom: 7px; + } + .login .login_box > ul > li .input_st::after { + left: 15px; + font-size: 20px; + } + .login .login_box > ul > li input { + padding: 15px 10px 15px 50px; + font-size: 16px; + } + .login .login_box .bottom_box { + flex-direction: column; + align-items: flex-start; + padding: 30px 0; + } + .login .login_box .bottom_box .id_save label { + font-size: 14px; + } + .login .login_box .login_btn { + padding: 15px; + } + .login .login_box .login_btn span { + font-size: 16px; + } + .login .login_box .login_btn i { + font-size: 18px; + transform: translateY(-1px); + } + .login .login_box .copy p { + width: 100%; + } + .login .login_box .copy::before, + .login .login_box .copy::after { + display: none; + } + .login .footer .addr { + font-size: 11px; + gap: 3px 10px; + } + .login .modal .modal_container { + width: 90%; + } + .login .pw_desc { + padding: 0 0 20px 0; + } + .login .modal .form_write { + padding: 10px; + } +} +/* // login */ diff --git a/apps/sn/src/features/windowPopup/index.ts b/apps/sn/src/features/windowPopup/index.ts new file mode 100644 index 000000000..8424dcc1d --- /dev/null +++ b/apps/sn/src/features/windowPopup/index.ts @@ -0,0 +1,14 @@ +export { openWindowPopup } from "./lib/openWindowPopup"; +export { + isWindowPopupKey, + windowPopupDefinitions, + type WindowPopupKey, +} from "./model/popupDefinitions"; +export { windowPopupRegistry } from "./registry"; +export type { + OpenWindowPopupParams, + WindowPopupContentProps, + WindowPopupMessageType, + WindowPopupSearchParams, +} from "./model/types"; +export { WINDOW_POPUP_MESSAGE_TYPE } from "./model/types"; diff --git a/apps/sn/src/features/windowPopup/lib/openWindowPopup.ts b/apps/sn/src/features/windowPopup/lib/openWindowPopup.ts new file mode 100644 index 000000000..dbb90794a --- /dev/null +++ b/apps/sn/src/features/windowPopup/lib/openWindowPopup.ts @@ -0,0 +1,61 @@ +import { + isWindowPopupKey, + windowPopupDefinitions, + type WindowPopupKey, +} from "../model/popupDefinitions"; +import type { OpenWindowPopupParams } from "../model/types"; + +const buildQueryString = (params: OpenWindowPopupParams = {}) => { + const searchParams = new URLSearchParams(); + + Object.entries(params).forEach(([key, value]) => { + if (value === null || value === undefined || value === "") return; + searchParams.set(key, String(value)); + }); + + const queryString = searchParams.toString(); + return queryString ? `?${queryString}` : ""; +}; + +const buildPopupFeatures = (width: number, height: number) => { + const screenLeft = window.screenX ?? window.screenLeft ?? 0; + const screenTop = window.screenY ?? window.screenTop ?? 0; + const outerWidth = window.outerWidth || window.screen.width; + const outerHeight = window.outerHeight || window.screen.height; + const left = Math.max(0, screenLeft + Math.round((outerWidth - width) / 2)); + const top = Math.max(0, screenTop + Math.round((outerHeight - height) / 2)); + + return [ + `width=${width}`, + `height=${height}`, + `left=${left}`, + `top=${top}`, + "resizable=yes", + "scrollbars=yes", + "status=no", + "location=no", + "menubar=no", + "toolbar=no", + ].join(","); +}; + +export const openWindowPopup = ( + popupKey: WindowPopupKey, + params?: OpenWindowPopupParams, +) => { + if (typeof window === "undefined") return null; + if (!isWindowPopupKey(popupKey)) return null; + + const definition = windowPopupDefinitions[popupKey]; + const popupUrl = `/popup/${popupKey}${buildQueryString(params)}`; + const popupFeatures = buildPopupFeatures(definition.width, definition.height); + const popupWindow = window.open( + popupUrl, + `sn-popup-${popupKey}`, + popupFeatures, + ); + + popupWindow?.focus(); + + return popupWindow; +}; diff --git a/apps/sn/src/features/windowPopup/lib/popupMessage.ts b/apps/sn/src/features/windowPopup/lib/popupMessage.ts new file mode 100644 index 000000000..6ae7ad7f9 --- /dev/null +++ b/apps/sn/src/features/windowPopup/lib/popupMessage.ts @@ -0,0 +1,24 @@ +import type { WindowPopupMessageType } from "../model/types"; + +export const postPopupMessage = ( + type: WindowPopupMessageType, + payload?: Record, +) => { + if (typeof window === "undefined" || !window.opener) return; + + window.opener.postMessage( + { + type, + payload, + }, + window.location.origin, + ); +}; + +export const postPopupMessageAndClose = ( + type: WindowPopupMessageType, + payload?: Record, +) => { + postPopupMessage(type, payload); + window.close(); +}; diff --git a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts new file mode 100644 index 000000000..b288fb969 --- /dev/null +++ b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts @@ -0,0 +1,82 @@ +import type { WindowPopupDefinition } from "./types"; + +export const windowPopupDefinitions = { + oldPpUpload: { + key: "oldPpUpload", + title: "노후 하수관로 정비사업 등록", + width: 860, + height: 360, + }, + oldPpTable: { + key: "oldPpTable", + title: "노후 하수관로 정비사업 상세", + width: 1400, + height: 900, + requiredParams: ["fileNm", "atflId"], + }, + floodPreUpload: { + key: "floodPreUpload", + title: "도시침수 예방사업 등록", + width: 860, + height: 360, + }, + floodPreTable: { + key: "floodPreTable", + title: "도시침수 예방사업 상세", + width: 1400, + height: 900, + requiredParams: ["fileNm", "atflId"], + }, + reportTab: { + key: "reportTab", + title: "하수도정비 기본계획보고서", + width: 1280, + height: 920, + }, + reportFiles: { + key: "reportFiles", + title: "보고서 파일 목록", + width: 860, + height: 720, + requiredParams: ["clsfSystmSn"], + }, + mapSearch: { + key: "mapSearch", + title: "관망도 검색", + width: 1280, + height: 980, + }, + infilChart: { + key: "infilChart", + title: "침입수 분석", + width: 1360, + height: 980, + requiredParams: ["pplMngNo"], + }, + rdiiChart: { + key: "rdiiChart", + title: "RDII 분석", + width: 1360, + height: 980, + requiredParams: ["pplMngNo"], + }, + sscDetail: { + key: "sscDetail", + title: "중점우수토실 운영정보", + width: 1180, + height: 980, + requiredParams: ["chmbrXcnts", "chmbrYdnts"], + }, + agedPipePopup: { + key: "agedPipePopup", + title: "노후 하수관로 관리 ", + width: 1180, + height: 760, + }, +} as const satisfies Record; + +export type WindowPopupKey = keyof typeof windowPopupDefinitions; + +export const isWindowPopupKey = (key: string): key is WindowPopupKey => { + return key in windowPopupDefinitions; +}; diff --git a/apps/sn/src/features/windowPopup/model/types.ts b/apps/sn/src/features/windowPopup/model/types.ts new file mode 100644 index 000000000..8387e03c1 --- /dev/null +++ b/apps/sn/src/features/windowPopup/model/types.ts @@ -0,0 +1,37 @@ +import type { ComponentType } from "react"; + +export type WindowPopupSearchParams = Record< + string, + string | string[] | undefined +>; + +export type WindowPopupContentProps = { + searchParams: WindowPopupSearchParams; +}; + +export type WindowPopupDefinition = { + key: string; + title: string; + width: number; + height: number; + requiredParams?: string[]; +}; + +export type WindowPopupRegistryItem = WindowPopupDefinition & { + component: ComponentType; +}; + +export type OpenWindowPopupParams = Record< + string, + string | number | boolean | null | undefined +>; + +export const WINDOW_POPUP_MESSAGE_TYPE = { + OLD_PP_UPLOAD_COMPLETED: "sn:popup:oldPpUploadCompleted", + FLOOD_PRE_UPLOAD_COMPLETED: "sn:popup:floodPreUploadCompleted", + MAP_SEARCH_SELECT_FEATURE: "sn:popup:mapSearchSelectFeature", + MAP_SEARCH_SHOW_MULTIPLE: "sn:popup:mapSearchShowMultiple", +} as const; + +export type WindowPopupMessageType = + (typeof WINDOW_POPUP_MESSAGE_TYPE)[keyof typeof WINDOW_POPUP_MESSAGE_TYPE]; diff --git a/apps/sn/src/features/windowPopup/registry.tsx b/apps/sn/src/features/windowPopup/registry.tsx new file mode 100644 index 000000000..7343e26b7 --- /dev/null +++ b/apps/sn/src/features/windowPopup/registry.tsx @@ -0,0 +1,60 @@ +import { windowPopupDefinitions, type WindowPopupKey } from "./model/popupDefinitions"; +import type { WindowPopupRegistryItem } from "./model/types"; +import AgedPipePopupContent from "./ui/AgedPipePopupContent"; +import FloodPreTablePopupContent from "./ui/FloodPreTablePopupContent"; +import FloodPreUploadPopupContent from "./ui/FloodPreUploadPopupContent"; +import InfilChartPopupContent from "./ui/InfilChartPopupContent"; +import OldPpUploadPopupContent from "./ui/OldPpUploadPopupContent"; +import OldPpTablePopupContent from "./ui/OldPpTablePopupContent"; +import RdiiChartPopupContent from "./ui/RdiiChartPopupContent"; +import ReportFilesPopupContent from "./ui/ReportFilesPopupContent"; +import ReportTabPopupContent from "./ui/ReportTabPopupContent"; +import SearchModalPopupContent from "./ui/SearchModalPopupContent"; +import SscDetailPopupContent from "./ui/SscDetailPopupContent"; + +export const windowPopupRegistry = { + oldPpUpload: { + ...windowPopupDefinitions.oldPpUpload, + component: OldPpUploadPopupContent, + }, + oldPpTable: { + ...windowPopupDefinitions.oldPpTable, + component: OldPpTablePopupContent, + }, + floodPreUpload: { + ...windowPopupDefinitions.floodPreUpload, + component: FloodPreUploadPopupContent, + }, + floodPreTable: { + ...windowPopupDefinitions.floodPreTable, + component: FloodPreTablePopupContent, + }, + reportTab: { + ...windowPopupDefinitions.reportTab, + component: ReportTabPopupContent, + }, + reportFiles: { + ...windowPopupDefinitions.reportFiles, + component: ReportFilesPopupContent, + }, + mapSearch: { + ...windowPopupDefinitions.mapSearch, + component: SearchModalPopupContent, + }, + infilChart: { + ...windowPopupDefinitions.infilChart, + component: InfilChartPopupContent, + }, + rdiiChart: { + ...windowPopupDefinitions.rdiiChart, + component: RdiiChartPopupContent, + }, + sscDetail: { + ...windowPopupDefinitions.sscDetail, + component: SscDetailPopupContent, + }, + agedPipePopup: { + ...windowPopupDefinitions.agedPipePopup, + component: AgedPipePopupContent, + }, +} as const satisfies Record; diff --git a/apps/sn/src/features/windowPopup/ui/AgedPipePopupContent.tsx b/apps/sn/src/features/windowPopup/ui/AgedPipePopupContent.tsx new file mode 100644 index 000000000..9ff897803 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/AgedPipePopupContent.tsx @@ -0,0 +1,10 @@ +import { Suspense } from "react"; +import AgedPipePopupPage from "@/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage"; + +export default function AgedPipePopupContent() { + return ( + + + + ); +} diff --git a/apps/sn/src/features/windowPopup/ui/FloodPreTablePopupContent.tsx b/apps/sn/src/features/windowPopup/ui/FloodPreTablePopupContent.tsx new file mode 100644 index 000000000..bbc342b70 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/FloodPreTablePopupContent.tsx @@ -0,0 +1,36 @@ +import TableModal from "@/app/(with-base-layout)/oper/floodPre/tableModal"; +import { windowPopupDefinitions } from "../model/popupDefinitions"; +import type { WindowPopupContentProps } from "../model/types"; +import PopupMissingParams from "./PopupMissingParams"; +import { getPopupParamValue } from "./popupContentUtils"; + +export default function FloodPreTablePopupContent({ + searchParams, +}: WindowPopupContentProps) { + const fileNm = getPopupParamValue(searchParams.fileNm); + const atflId = getPopupParamValue(searchParams.atflId); + const missingParams = [ + !fileNm ? "fileNm" : "", + !atflId ? "atflId" : "", + ].filter(Boolean); + + if (missingParams.length > 0) { + return ( + + ); + } + + return ( +
+

+ {windowPopupDefinitions.floodPreTable.title} +

+
+ +
+
+ ); +} diff --git a/apps/sn/src/features/windowPopup/ui/FloodPreUploadPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/FloodPreUploadPopupContent.tsx new file mode 100644 index 000000000..629c6be53 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/FloodPreUploadPopupContent.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { postData } from "@/components/lib/api"; +import OperFileUploadForm from "@/components/ui/operFileUploadForm"; +import { tokenManager } from "@/entities/auth/lib/token-manager"; +import { AUTH_USER_ID } from "@/shared/config"; +import { postPopupMessageAndClose } from "../lib/popupMessage"; +import { windowPopupDefinitions } from "../model/popupDefinitions"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "../model/types"; + +export default function FloodPreUploadPopupContent() { + const handleSubmit = async ( + fileNm: string, + year: string, + quater: string, + cd: string, + id: string, + ) => { + const data = await postData("v1/sbmrgPrevnt", { + mngYr: year, + mngQu: quater, + atflId: id, + atflCd: cd, + rgtr: tokenManager.getToken(AUTH_USER_ID) || null, + sbmrgPrevntBizNm: fileNm, + }); + + if (data) { + alert("파일이 등록되었습니다."); + postPopupMessageAndClose(WINDOW_POPUP_MESSAGE_TYPE.FLOOD_PRE_UPLOAD_COMPLETED); + } + }; + + return ( +
+ +
+ ); +} diff --git a/apps/sn/src/features/windowPopup/ui/InfilChartPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/InfilChartPopupContent.tsx new file mode 100644 index 000000000..f6a739f53 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/InfilChartPopupContent.tsx @@ -0,0 +1,22 @@ +import InfilChart from "@/app/(map-layout)/map/components/popupComponent/chart/infilChart"; +import { windowPopupDefinitions } from "../model/popupDefinitions"; +import type { WindowPopupContentProps } from "../model/types"; +import PopupMissingParams from "./PopupMissingParams"; +import { getPopupParamValue } from "./popupContentUtils"; + +export default function InfilChartPopupContent({ + searchParams, +}: WindowPopupContentProps) { + const pplMngNo = getPopupParamValue(searchParams.pplMngNo); + + if (!pplMngNo) { + return ( + + ); + } + + return ; +} diff --git a/apps/sn/src/features/windowPopup/ui/OldPpTablePopupContent.tsx b/apps/sn/src/features/windowPopup/ui/OldPpTablePopupContent.tsx new file mode 100644 index 000000000..3d3439d9c --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/OldPpTablePopupContent.tsx @@ -0,0 +1,36 @@ +import TableModal from "@/app/(with-base-layout)/oper/oldPp/tableModal"; +import { windowPopupDefinitions } from "../model/popupDefinitions"; +import type { WindowPopupContentProps } from "../model/types"; +import PopupMissingParams from "./PopupMissingParams"; +import { getPopupParamValue } from "./popupContentUtils"; + +export default function OldPpTablePopupContent({ + searchParams, +}: WindowPopupContentProps) { + const fileNm = getPopupParamValue(searchParams.fileNm); + const atflId = getPopupParamValue(searchParams.atflId); + const missingParams = [ + !fileNm ? "fileNm" : "", + !atflId ? "atflId" : "", + ].filter(Boolean); + + if (missingParams.length > 0) { + return ( + + ); + } + + return ( +
+

+ {windowPopupDefinitions.oldPpTable.title} +

+
+ +
+
+ ); +} diff --git a/apps/sn/src/features/windowPopup/ui/OldPpUploadPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/OldPpUploadPopupContent.tsx new file mode 100644 index 000000000..e8e1b1e52 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/OldPpUploadPopupContent.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { postData } from "@/components/lib/api"; +import OperFileUploadForm from "@/components/ui/operFileUploadForm"; +import { tokenManager } from "@/entities/auth/lib/token-manager"; +import { AUTH_USER_ID } from "@/shared/config"; +import { postPopupMessageAndClose } from "../lib/popupMessage"; +import { windowPopupDefinitions } from "../model/popupDefinitions"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "../model/types"; + +export default function OldPpUploadPopupContent() { + const handleSubmit = async ( + fileNm: string, + year: string, + quater: string, + cd: string, + id: string, + ) => { + const data = await postData("v1/dcrpPpl", { + mngYr: year, + mngQu: quater, + atflCd: cd, + atflId: id, + rgtr: tokenManager.getToken(AUTH_USER_ID) || null, + dcrpPplMtncBizNm: fileNm, + }); + + if (data) { + alert("파일이 등록되었습니다."); + postPopupMessageAndClose(WINDOW_POPUP_MESSAGE_TYPE.OLD_PP_UPLOAD_COMPLETED); + } + }; + + return ( +
+ +
+ ); +} diff --git a/apps/sn/src/features/windowPopup/ui/PopupMissingParams.tsx b/apps/sn/src/features/windowPopup/ui/PopupMissingParams.tsx new file mode 100644 index 000000000..32b8bb088 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/PopupMissingParams.tsx @@ -0,0 +1,19 @@ +export default function PopupMissingParams({ + title, + missingParams, +}: { + title: string; + missingParams: string[]; +}) { + return ( +
+

{title}

+

+ 팝업을 여는 데 필요한 값이 누락되었습니다. +

+

+ 누락된 값: {missingParams.join(", ")} +

+
+ ); +} diff --git a/apps/sn/src/features/windowPopup/ui/RdiiChartPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/RdiiChartPopupContent.tsx new file mode 100644 index 000000000..8a591e004 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/RdiiChartPopupContent.tsx @@ -0,0 +1,22 @@ +import RdiiChart from "@/app/(map-layout)/map/components/popupComponent/chart/rdiiChart"; +import { windowPopupDefinitions } from "../model/popupDefinitions"; +import type { WindowPopupContentProps } from "../model/types"; +import PopupMissingParams from "./PopupMissingParams"; +import { getPopupParamValue } from "./popupContentUtils"; + +export default function RdiiChartPopupContent({ + searchParams, +}: WindowPopupContentProps) { + const pplMngNo = getPopupParamValue(searchParams.pplMngNo); + + if (!pplMngNo) { + return ( + + ); + } + + return ; +} diff --git a/apps/sn/src/features/windowPopup/ui/ReportFilesPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/ReportFilesPopupContent.tsx new file mode 100644 index 000000000..de3064383 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/ReportFilesPopupContent.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { useEffect, useState } from "react"; +import LoadingOverlay from "@/components/ui/LoadingBar"; +import { getDcData } from "@/components/lib/api"; +import { UiTable } from "@common/business_components/ui"; +import { windowPopupDefinitions } from "../model/popupDefinitions"; +import type { WindowPopupContentProps } from "../model/types"; +import PopupMissingParams from "./PopupMissingParams"; +import { getPopupParamValue } from "./popupContentUtils"; + +type ReportDataFile = { + no?: number; + fileTitle: string; + regDt: string; + fileSn: string; + filePath: string; +}; + +const REPORT_FILES_HEADLIST = [ + { id: "no", title: "NO" }, + { id: "fileTitle", title: "파일명" }, + { id: "regDt", title: "등록일자" }, +]; + +export default function ReportFilesPopupContent({ + searchParams, +}: WindowPopupContentProps) { + const clsfSystmSn = getPopupParamValue(searchParams.clsfSystmSn); + const [files, setFiles] = useState([]); + const [allFiles, setAllFiles] = useState([]); + const [curPage, setCurPage] = useState(1); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (!clsfSystmSn) return; + + const fetchFiles = async () => { + setIsLoading(true); + try { + const result = await getDcData(`/api/v1/file?clsfSystmSn=${clsfSystmSn}`); + const normalized = (result ?? []).map((item: ReportDataFile, index: number) => ({ + ...item, + no: index + 1, + })); + setAllFiles(normalized); + } catch (error) { + console.error("보고서 파일 목록 조회 실패:", error); + setAllFiles([]); + } finally { + setIsLoading(false); + } + }; + + fetchFiles(); + }, [clsfSystmSn]); + + useEffect(() => { + const paged = allFiles.slice((curPage - 1) * 10, curPage * 10); + setFiles(paged); + }, [allFiles, curPage]); + + const handleDownload = async (fileSn: string, fileTitle: string, filePath: string) => { + if (!filePath) { + alert("filePath 정보가 없으므로 다운로드가 불가합니다."); + return; + } + + try { + const responseBlob = await getDcData( + `/api/v1/fileDownload?filePath=${encodeURIComponent(filePath)}`, + "blob", + ); + + const objectUrl = URL.createObjectURL(responseBlob); + const anchor = document.createElement("a"); + anchor.href = objectUrl; + anchor.download = fileTitle; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(objectUrl); + } catch (error) { + console.error("보고서 파일 다운로드 실패:", error); + alert("파일 다운로드 중 오류가 발생했습니다."); + } + }; + + if (!clsfSystmSn) { + return ( + + ); + } + + return ( +
+

+ {windowPopupDefinitions.reportFiles.title} +

+
+ + { + if (typeof index !== "number" || !files[index]) return; + const target = files[index]; + handleDownload(target.fileSn, target.fileTitle, target.filePath); + }} + /> +
+
+ ); +} diff --git a/apps/sn/src/features/windowPopup/ui/ReportTabPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/ReportTabPopupContent.tsx new file mode 100644 index 000000000..d2913df8e --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/ReportTabPopupContent.tsx @@ -0,0 +1,27 @@ +import ReportTab from "@/app/(map-layout)/map/searchModal/reportTab"; +import { windowPopupDefinitions } from "../model/popupDefinitions"; +import type { WindowPopupContentProps } from "../model/types"; +import { getPopupParamValue } from "./popupContentUtils"; + +export default function ReportTabPopupContent({ + searchParams, +}: WindowPopupContentProps) { + const ctpvNm = getPopupParamValue(searchParams.ctpvNm); + const sggNm = getPopupParamValue(searchParams.sggNm); + + return ( +
+

+ {windowPopupDefinitions.reportTab.title} +

+
+ +
+
+ ); +} diff --git a/apps/sn/src/features/windowPopup/ui/SearchModalPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/SearchModalPopupContent.tsx new file mode 100644 index 000000000..0bbb50bbe --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/SearchModalPopupContent.tsx @@ -0,0 +1,5 @@ +import SearchModal from "@/app/(map-layout)/map/searchModal/searchModal"; + +export default function SearchModalPopupContent() { + return ; +} diff --git a/apps/sn/src/features/windowPopup/ui/SscDetailPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/SscDetailPopupContent.tsx new file mode 100644 index 000000000..9e0ff6464 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/SscDetailPopupContent.tsx @@ -0,0 +1,36 @@ +import Ssc from "@/app/(map-layout)/map/components/popupComponent/Ssc"; +import { windowPopupDefinitions } from "../model/popupDefinitions"; +import type { WindowPopupContentProps } from "../model/types"; +import PopupMissingParams from "./PopupMissingParams"; +import { getPopupParamValue } from "./popupContentUtils"; + +export default function SscDetailPopupContent({ + searchParams, +}: WindowPopupContentProps) { + const chmbrXcnts = getPopupParamValue(searchParams.chmbrXcnts); + const chmbrYdnts = getPopupParamValue(searchParams.chmbrYdnts); + + const missingParams = [ + !chmbrXcnts ? "chmbrXcnts" : "", + !chmbrYdnts ? "chmbrYdnts" : "", + ].filter(Boolean); + + if (missingParams.length > 0) { + return ( + + ); + } + + return ( + + ); +} diff --git a/apps/sn/src/features/windowPopup/ui/popupContentUtils.ts b/apps/sn/src/features/windowPopup/ui/popupContentUtils.ts new file mode 100644 index 000000000..5b2c1152f --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/popupContentUtils.ts @@ -0,0 +1,6 @@ +export const getPopupParamValue = ( + value: string | string[] | undefined, +): string => { + if (Array.isArray(value)) return value[0] ?? ""; + return value ?? ""; +}; diff --git a/apps/sn/src/shared/providers/prompt-provider.tsx b/apps/sn/src/shared/providers/prompt-provider.tsx index 10338c338..7724d0326 100644 --- a/apps/sn/src/shared/providers/prompt-provider.tsx +++ b/apps/sn/src/shared/providers/prompt-provider.tsx @@ -1,3 +1,4 @@ +"use client"; import { createContext, useState } from "react"; interface PromptContext { diff --git a/apps/st/package.json b/apps/st/package.json index 184138657..5cf4bf67d 100644 --- a/apps/st/package.json +++ b/apps/st/package.json @@ -1,76 +1,76 @@ { - "name": "st", - "version": "0.1.0", - "private": true, - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "lint": "next lint" - }, - "dependencies": { - "@common/assets": "workspace:*", - "@common/auth": "workspace:*", - "@common/components": "workspace:*", - "@common/config": "workspace:*", - "@common/network": "workspace:*", - "@rsone/components": "workspace:*", - "@faker-js/faker": "^9.9.0", - "@fontsource-variable/noto-sans-kr": "^5.2.5", - "@frontend-opensource/use-react-hooks": "^1.1.4", - "@fullcalendar/core": "^6.1.17", - "@fullcalendar/daygrid": "^6.1.17", - "@fullcalendar/interaction": "^6.1.17", - "@fullcalendar/react": "^6.1.17", - "@fullcalendar/timegrid": "^6.1.17", - "@hookform/resolvers": "^4.1.3", - "@minoru/react-dnd-treeview": "3.5.1-alpha.1", - "@radix-ui/react-icons": "^1.3.2", - "@radix-ui/react-label": "^2.1.2", - "@radix-ui/react-slot": "^1.1.2", - "@radix-ui/react-tooltip": "^1.1.8", - "@tanstack/react-query": "^5.67.1", - "@tanstack/react-query-devtools": "^5.75.5", - "@tanstack/react-table": "^8.21.2", - "@tinymce/tinymce-react": "^5.1.1", - "@types/xlsx": "^0.0.36", - "axios": "^1.8.1", - "chart.js": "^4.4.8", - "chartjs-plugin-zoom": "^2.2.0", - "dayjs": "^1.11.13", - "js-cookie": "^3.0.5", - "jszip": "^3.10.1", - "lucide-react": "^0.474.0", - "next": "15.3.8", - "next-qrcode": "^2.5.1", - "next-themes": "^0.4.6", - "react-chartjs-2": "^5.3.0", - "react-dnd": "^16.0.1", - "react-hook-form": "^7.54.2", - "realgrid": "^2.9.5", - "realgrid-react": "^0.9.7", - "remixicon": "^4.7.0", - "sonner": "^2.0.1", - "swr": "^2.3.7", - "tailwindcss-animate": "^1.0.7", - "tus-js-client": "^4.3.1", - "xlsx": "^0.18.5", - "zod": "^3.24.2", - "zustand": "^5.0.3" - }, - "devDependencies": { - "@eslint/eslintrc": "^3", - "@types/js-cookie": "^3.0.6", - "@types/node": "^22.13.1", - "@types/webpack": "^5.28.5", - "eslint": "^9", - "eslint-config-next": "15.3.8", - "eslint-plugin-react-hooks": "^5.0.0", - "postcss": "^8", - "prettier": "^3.4.2", - "prettier-plugin-tailwindcss": "^0.6.11", - "steiger": "^0.5.5", - "tailwindcss": "^3.4.1", - "typescript": "^5" - } + "name": "st", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@common/assets": "workspace:*", + "@common/auth": "workspace:*", + "@common/components": "workspace:*", + "@common/config": "workspace:*", + "@common/network": "workspace:*", + "@rsone/components": "workspace:*", + "@faker-js/faker": "^9.9.0", + "@fontsource-variable/noto-sans-kr": "^5.2.5", + "@frontend-opensource/use-react-hooks": "^1.1.4", + "@fullcalendar/core": "^6.1.17", + "@fullcalendar/daygrid": "^6.1.17", + "@fullcalendar/interaction": "^6.1.17", + "@fullcalendar/react": "^6.1.17", + "@fullcalendar/timegrid": "^6.1.17", + "@hookform/resolvers": "^4.1.3", + "@minoru/react-dnd-treeview": "3.5.1-alpha.1", + "@radix-ui/react-icons": "^1.3.2", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-tooltip": "^1.1.8", + "@tanstack/react-query": "^5.67.1", + "@tanstack/react-query-devtools": "^5.75.5", + "@tanstack/react-table": "^8.21.2", + "@tinymce/tinymce-react": "^5.1.1", + "@types/xlsx": "^0.0.36", + "axios": "^1.8.1", + "chart.js": "^4.4.8", + "chartjs-plugin-zoom": "^2.2.0", + "dayjs": "^1.11.13", + "js-cookie": "^3.0.5", + "jszip": "^3.10.1", + "lucide-react": "^0.474.0", + "next": "15.3.8", + "next-qrcode": "^2.5.1", + "next-themes": "^0.4.6", + "react-chartjs-2": "^5.3.0", + "react-dnd": "^16.0.1", + "react-hook-form": "^7.54.2", + "realgrid": "^2.9.5", + "realgrid-react": "^0.9.7", + "remixicon": "^4.7.0", + "sonner": "^2.0.1", + "swr": "^2.3.7", + "tailwindcss-animate": "^1.0.7", + "tus-js-client": "^4.3.1", + "xlsx": "^0.18.5", + "zod": "^3.24.2", + "zustand": "^5.0.3" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@types/js-cookie": "^3.0.6", + "@types/node": "^22.13.1", + "@types/webpack": "^5.28.5", + "eslint": "^9", + "eslint-config-next": "15.3.8", + "eslint-plugin-react-hooks": "^5.0.0", + "postcss": "^8", + "prettier": "^3.4.2", + "prettier-plugin-tailwindcss": "^0.6.11", + "steiger": "^0.5.5", + "tailwindcss": "^3.4.1", + "typescript": "^5" + } } diff --git a/apps/st/src/app/providers/app-provider.tsx b/apps/st/src/app/providers/app-provider.tsx index 70e69eee8..4e64d241c 100644 --- a/apps/st/src/app/providers/app-provider.tsx +++ b/apps/st/src/app/providers/app-provider.tsx @@ -12,6 +12,7 @@ import { AlertDialog } from "@/shared/ui/alert-dialog"; import { PromptDialog } from "@/shared/ui/prompt-dialog"; import { ConfirmDialog } from "@/shared/ui/confirm-dialog"; import { SelectPromptDialog } from "@/shared/ui/select-prompt-dialog"; +import { GlobalProgressOverlay } from "@/shared/ui/global-progress-overlay"; import { PromptProvider, StoreProvider } from "@/shared/providers"; import { AlertDialog as RsoneAlertDialog } from "@rsone/components"; @@ -36,6 +37,7 @@ const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => { + diff --git a/apps/st/src/components/button/ReviewOpinionButton.tsx b/apps/st/src/components/button/ReviewOpinionButton.tsx index 50df8fcb4..fbcdbd754 100644 --- a/apps/st/src/components/button/ReviewOpinionButton.tsx +++ b/apps/st/src/components/button/ReviewOpinionButton.tsx @@ -1,7 +1,7 @@ import { Button } from "@common/components"; interface ReviewOpinionButtonProps { - reviewOpinionFunc: () => void; + reviewOpinionFunc: () => void | Promise; } const ReviewOpinionButton = ({ reviewOpinionFunc }: ReviewOpinionButtonProps) => { diff --git a/apps/st/src/components/button/VerificationResultButton.tsx b/apps/st/src/components/button/VerificationResultButton.tsx new file mode 100644 index 000000000..580f6d0d0 --- /dev/null +++ b/apps/st/src/components/button/VerificationResultButton.tsx @@ -0,0 +1,16 @@ +import { Button } from "@common/components"; + +interface VerificationResultButtonProps { + VerificationResultButtonFunc: () => void; +} + +const VerificationResultButton = ({ VerificationResultButtonFunc }: VerificationResultButtonProps) => { + return ( + + ); +}; + +export default VerificationResultButton; \ No newline at end of file diff --git a/apps/st/src/components/css/DataGrid.css b/apps/st/src/components/css/DataGrid.css index 8d56c48f8..c1b346ad5 100644 --- a/apps/st/src/components/css/DataGrid.css +++ b/apps/st/src/components/css/DataGrid.css @@ -16,12 +16,6 @@ @apply !bg-[#5987bf] !text-[#fff]; } - .rg-grid .rg-header .rg-table tr td, - .rg-grid .rg-head .rg-table tr td { - border: none; - border-bottom: 1px solid #e5e7eb; - } - .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell { @apply !bg-white; } @@ -138,4 +132,10 @@ .rg-grid .rg-body .rg-table tr.rg-alternate-row td, .rg-grid .rg-body .rg-table tr.rg-alternate-row .rg-data-cell { background-color: #ffffff !important; +} + +.rg-grid .rg-header .rg-table tr td, +.rg-grid .rg-head .rg-table tr td { + border: none; + border-bottom: 1px solid #e5e7eb; } \ No newline at end of file diff --git a/apps/st/src/components/module/DataGrid.tsx b/apps/st/src/components/module/DataGrid.tsx index d85693a53..93e5fccc5 100644 --- a/apps/st/src/components/module/DataGrid.tsx +++ b/apps/st/src/components/module/DataGrid.tsx @@ -311,6 +311,7 @@ function buildFieldsFromColumns( const DataGrid = ({ columnInfo, rowStartNum, + excelExportFileSuffix, isExcelImport = true, isExcelExport = true, fixedColumns, @@ -318,13 +319,19 @@ const DataGrid = ({ rowData, setRowData, getGridDataRef, + getGridRef, cellStyleCallback, cellTooltipCallback, + onCellEdited, onRowClick, + onItemChecked, + onItemAllChecked, + onScrollToBottom, statusInfo, highlightedRowId, idFieldName = "id", enableSort = false, + gridHeight = 65, // vh 단위 }: DataGridProps) => { const { columns, columnLayout } = columnInfo; const gridRef = useRef(null); @@ -340,9 +347,20 @@ const DataGrid = ({ const cellTooltipCallbackRef = useRef( cellTooltipCallback, ); + const onCellEditedRef = useRef(onCellEdited); + const onScrollToBottomRef = useRef<(() => void) | undefined>( + onScrollToBottom, + ); + const onItemCheckedRef = useRef(onItemChecked); + const onItemAllCheckedRef = + useRef(onItemAllChecked); rowDataRef.current = rowData; cellStyleCallbackRef.current = cellStyleCallback; cellTooltipCallbackRef.current = cellTooltipCallback; + onScrollToBottomRef.current = onScrollToBottom; + onItemCheckedRef.current = onItemChecked; + onItemAllCheckedRef.current = onItemAllChecked; + onCellEditedRef.current = onCellEdited; // highlightedRowId가 변경될 때 해당 행으로 스크롤 및 선택 useEffect(() => { @@ -470,10 +488,14 @@ const DataGrid = ({ gridView.setColumnLayout?.(normalizedLayout); } + const exportBaseName = excelExportFileSuffix + ? `${menuName}_${excelExportFileSuffix}` + : menuName; + gridView.exportGrid({ type: "excel", target: "local", - fileName: `${menuName}.xlsx`, + fileName: `${exportBaseName}.xlsx`, footer: "hidden", allColumns: true, // 숨김(visible: false) 컬럼도 엑셀에 포함 done: () => { @@ -558,10 +580,10 @@ const DataGrid = ({ ) { const cell = worksheet[ - XLSX.utils.encode_cell({ - r: rowIndex, - c: col, - }) + XLSX.utils.encode_cell({ + r: rowIndex, + c: col, + }) ]; if ( cell && @@ -637,7 +659,7 @@ const DataGrid = ({ const col = range.s.c + colIndex; const cell = worksheet[ - XLSX.utils.encode_cell({ r: row, c: col }) + XLSX.utils.encode_cell({ r: row, c: col }) ]; const headerName = headers[colIndex] || `Column${colIndex + 1}`; @@ -761,13 +783,13 @@ const DataGrid = ({ onSorting?: (g: unknown, f: unknown[], d: unknown[]) => boolean; } ).onSorting = enableSort - ? createOnSortingHandler( - columns, - setRowData, - sortDirRef, - originalRowsRef, - ) - : () => false; + ? createOnSortingHandler( + columns, + setRowData, + sortDirRef, + originalRowsRef, + ) + : () => false; (gridView.displayOptions as DisplayOptions).fitStyle = GridFitStyle.EVEN; @@ -937,7 +959,7 @@ const DataGrid = ({ ) { ( column.footer[ - index + index ] as FooterItemWithCallback ).valueCallback = footerItem.valueCallback; @@ -990,11 +1012,11 @@ const DataGrid = ({ getGridDataRef.current = () => { const gv = gridRef.current?.gridView as | { - getDataSource?: () => { - getRowCount(): number; - getValue(r: number, f: string): unknown; - }; - } + getDataSource?: () => { + getRowCount(): number; + getValue(r: number, f: string): unknown; + }; + } | undefined; if (!gv?.getDataSource) return []; const ds = gv.getDataSource(); @@ -1090,6 +1112,74 @@ const DataGrid = ({ }; }, [onRowClick, cellTooltipCallback, columns]); + useEffect(() => { + const gridView = gridRef.current?.gridView; + if (!gridView || !onScrollToBottom) return; + + gridView.onScrollToBottom = () => { + onScrollToBottomRef.current?.(); + }; + + return () => { + if (gridView.onScrollToBottom) + gridView.onScrollToBottom = undefined; + }; + }, [onScrollToBottom]); + + useEffect(() => { + const gridView = gridRef.current?.gridView; + if (!gridView) return; + + if (onItemChecked) { + gridView.onItemChecked = (_grid, itemIndex, checked) => { + onItemCheckedRef.current?.(itemIndex, checked); + }; + } else { + gridView.onItemChecked = undefined; + } + + if (onItemAllChecked) { + gridView.onItemAllChecked = (_grid, checked) => { + onItemAllCheckedRef.current?.(checked); + }; + } else { + gridView.onItemAllChecked = undefined; + } + + return () => { + if (gridView.onItemChecked) gridView.onItemChecked = undefined; + if (gridView.onItemAllChecked) + gridView.onItemAllChecked = undefined; + }; + }, [onItemChecked, onItemAllChecked]); + + useEffect(() => { + const gridView = gridRef.current?.gridView; + if (!gridView) return; + + gridView.onCellEdited = (grid, itemIndex, _row, field) => { + const fieldName = columns[field]?.fieldName; + if (!fieldName) return; + + const newValue = grid.getValue(itemIndex, fieldName); + const prevRow = rowDataRef.current[itemIndex]; + const oldValue = prevRow?.[fieldName] ?? ""; + + onCellEditedRef.current?.({ + itemIndex, + fieldName, + oldValue, + newValue, + }); + }; + + return () => { + if (gridView.onCellEdited) { + gridView.onCellEdited = undefined; + } + }; + }, [columns]); + return (
진행상황:{" "} {statusInfo.laeSbmsnSttsCd === null || - statusInfo.laeSbmsnSttsCd === "0" + statusInfo.laeSbmsnSttsCd === "0" ? "미작성" : statusInfo.laeSbmsnSttsCd === "1" - ? "작성중" - : statusInfo.laeSbmsnSttsCd === "2" - ? "제출" - : statusInfo.laeSbmsnSttsCd === "3" - ? "승인" - : statusInfo.laeSbmsnSttsCd === "4" - ? "반려" - : "검증중"} + ? "작성중" + : statusInfo.laeSbmsnSttsCd === "2" + ? "제출" + : statusInfo.laeSbmsnSttsCd === "3" + ? "승인" + : statusInfo.laeSbmsnSttsCd === "4" + ? "반려" + : "검증중"} 제출일:{" "} {statusInfo.laeSbmsnYmd ? statusInfo.laeSbmsnYmd.length === 8 && - /^\d{8}$/.test(statusInfo.laeSbmsnYmd) + /^\d{8}$/.test(statusInfo.laeSbmsnYmd) ? `${statusInfo.laeSbmsnYmd.slice(0, 4)}-${statusInfo.laeSbmsnYmd.slice(4, 6)}-${statusInfo.laeSbmsnYmd.slice(6, 8)}` : statusInfo.laeSbmsnYmd : "미제출"} @@ -1159,7 +1249,7 @@ const DataGrid = ({ )}
-
+
boolean; } ).onSorting = enableSort - ? createOnSortingHandler( - columns, - setRowData, - sortDirRef, - originalRowsRef, - ) - : () => false; + ? createOnSortingHandler( + columns, + setRowData, + sortDirRef, + originalRowsRef, + ) + : () => false; const dataProvider = gridRef.current ?.dataProvider as unknown as | { - setFields?(f: unknown[]): void; - setRows?( - r: Record[], - ): void; - getJsonRow?( - r: number, - ): Record; - } + setFields?(f: unknown[]): void; + setRows?( + r: Record[], + ): void; + getJsonRow?( + r: number, + ): Record; + } | undefined; if (dataProvider?.setFields) { dataProvider.setFields( buildFieldsFromColumns(columns), ); } - if (dataProvider?.setRows && rowData?.length) { + if (dataProvider?.setRows) dataProvider.setRows(rowData); - } + if (getGridRef) getGridRef.current = gridRef.current; const gridView = gridRef.current?.gridView ?? grid; if (!gridView?.columnByName) return; setTimeout(() => { @@ -1346,14 +1436,14 @@ const DataGrid = ({ })} footer={ column.footer && - !Array.isArray(column.footer) + !Array.isArray(column.footer) ? { - text: column.footer.text, - expression: - column.footer.expression, - numberFormat: - column.footer.numberFormat, - } + text: column.footer.text, + expression: + column.footer.expression, + numberFormat: + column.footer.numberFormat, + } : undefined } mergeRule={ diff --git a/apps/st/src/components/module/SearchForm.tsx b/apps/st/src/components/module/SearchForm.tsx index c3d7e4fc2..b5cdb06a4 100644 --- a/apps/st/src/components/module/SearchForm.tsx +++ b/apps/st/src/components/module/SearchForm.tsx @@ -1,52 +1,66 @@ "use client"; -import { useState, useEffect } from "react"; +import { useEffect, useState } from "react"; + import useSWR from "swr"; -import { - Button, - Input, - Label, - Select, - SelectTrigger, - SelectContent, - SelectItem, - SelectValue, - Checkbox, -} from "@common/components"; - -import { useAlertStore } from "@/shared/lib"; -import { - type SearchFormProps, - type SearchFormValues, - defaultSearchFormProps, -} from "../type/SearchForm.type"; -import { statisticsAPi } from "@/entities/statistics/api/statistics-service"; +import { useAlertStore, useUserStore } from "@/shared/lib"; +import { statisticsApi } from "@/entities/statistics/api/statistics-service"; import type { ApiResponse, SearchCondition, Sgg, } from "@/entities/statistics/model/statistics-interface"; +import { + Button, + Checkbox, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@common/components"; -const getSidoListFetcher = async (): Promise> => { - return statisticsAPi.getSidoList(); +import { + defaultSearchFormProps, + type SearchFormProps, + type SearchFormValues, +} from "../type/SearchForm.type"; +import { doList } from "../util/column/input/DoList"; + +const getSidoListFetcher = async ( + key: [string, string], +): Promise> => { + return statisticsApi.getSidoList({ instSeCd: key[1] }); }; const getSggListFetcher = async ( - key: [string, string], + key: [string, string, string, string], ): Promise> => { - return statisticsAPi.getSggList(key[1]); + const gugunSkipYn = doList.includes(key[3]) ? "N" : "Y"; + return statisticsApi.getSggAllList(key[1], { + instSeCd: key[2], + gugunSkipYn, + }); }; const SearchForm = (props: SearchFormProps = {}) => { const { setMessage: alert } = useAlertStore((state) => state); + const userInfo = useUserStore((state) => state.userInfo); const mergedProps = { ...defaultSearchFormProps, ...props }; + const instSeCd = userInfo?.instSeCd ?? ""; + const sidoSelectText = + props.sidoSelectText && props.sidoSelectText.trim() !== "" + ? props.sidoSelectText + : "선택"; const currentYear = new Date().getFullYear(); const defaultYear = currentYear - 1; const { data: sidoList } = useSWR>( - "sido-list", + ["sido-list", instSeCd], getSidoListFetcher, { revalidateOnFocus: false, @@ -77,8 +91,10 @@ const SearchForm = (props: SearchFormProps = {}) => { isDetail: false, }); - const sggKey: [string, string] | null = - values.sido && values.sido !== "ALL" ? ["sgg-list", values.sido] : null; + const sggKey: [string, string, string, string] | null = + values.sido && values.sido !== "ALL" + ? ["sgg-list", values.sido, instSeCd, values.bsnmCd || ""] + : null; const { data: sggList } = useSWR>( sggKey, getSggListFetcher, @@ -102,7 +118,7 @@ const SearchForm = (props: SearchFormProps = {}) => { ); const sggOptions = sggList?.data ?? []; - const handleSubmit = (e: React.SubmitEvent) => { + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); props.onSubmit?.(values); }; @@ -163,10 +179,14 @@ const SearchForm = (props: SearchFormProps = {}) => { onValueChange={handleSidoChange} > - + - 선택 + + {sidoSelectText} + {sidoList?.data?.sidoList?.map((sido) => ( > => { - return statisticsAPi.getSidoList(); + return statisticsApi.getSidoList(); }; const getSggAllListFetcher = async ( key: [string, string], ): Promise> => { - return statisticsAPi.getSggAllList(key[1]); + return statisticsApi.getSggAllList(key[1]); }; type SidoSggFacilityNameSearchFormProps = { diff --git a/apps/st/src/components/module/VerificationResultDialog.tsx b/apps/st/src/components/module/VerificationResultDialog.tsx new file mode 100644 index 000000000..5e11dd6ac --- /dev/null +++ b/apps/st/src/components/module/VerificationResultDialog.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { + Button, + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@common/components"; + +export interface VerificationResultDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function VerificationResultDialog({ + open, + onOpenChange, +}: VerificationResultDialogProps) { + return ( + + + + 검증 결과 + +
+ + + + + + +
+ ); +} + diff --git a/apps/st/src/components/module/VerificationResultHistoryDialog.tsx b/apps/st/src/components/module/VerificationResultHistoryDialog.tsx new file mode 100644 index 000000000..3be4a6a9e --- /dev/null +++ b/apps/st/src/components/module/VerificationResultHistoryDialog.tsx @@ -0,0 +1,276 @@ +"use client"; + +import { type Dispatch, type SetStateAction, useCallback, useEffect, useMemo, useState } from "react"; +import { + Button, + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + LoadingWithAnimation, +} from "@common/components"; +import DataGrid from "@/components/module/DataGrid"; +import { VerificationResultHistoryPagination } from "@/components/module/VerificationResultHistoryPagination"; +import type { ColumnInfo } from "@/components/type/DataGrid.type"; +import { statisticsApi } from "@/entities/statistics/api/statistics-service"; +import type { VerificationResultListRequest, VerificationResultReportItem } from "@/entities/statistics/model/statistics-interface"; + +type VerificationResultGridRow = Record; + +const PRCS_YN_LABEL: Record = { + "0": "미통과", + "1": "통과", + "2": "예외처리", + "3": "반려", +}; + +const verificationResultColumnInfo: ColumnInfo = { + columns: [ + { name: "ctpvNm", fieldName: "ctpvNm", header: { text: "시도" }, editable: false }, + { name: "sggNm", fieldName: "sggNm", header: { text: "시군구" }, editable: false }, + { name: "vrfcTrgtNm", fieldName: "vrfcTrgtNm", header: { text: "검증대상" }, editable: false }, + { name: "rsnExpln", fieldName: "rsnExpln", header: { text: "사유" }, editable: false }, + { name: "nowVlCn", fieldName: "nowVlCn", header: { text: "입력값" }, editable: false }, + { name: "compVlCn", fieldName: "compVlCn", header: { text: "기준값" }, editable: false }, + { name: "prcsYn", fieldName: "prcsYn", 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 }, + ], + columnLayout: ["ctpvNm", "sggNm", "vrfcTrgtNm", "rsnExpln", "nowVlCn", "compVlCn", "prcsYn", "pbcpAprvYn", "regDt", "mdfcnDt"], +}; + +export interface VerificationResultHistoryDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + queryContext?: { + sewerBzmnCd: string; + ctpvNm: string; + sggNm: string; + statsYr: string; + statsCd: string; + } | null; + title?: string; + emptyMessage?: string; +} + +export function VerificationResultHistoryDialog({ + open, + onOpenChange, + queryContext, + title = "검증 결과 내역", + emptyMessage = "표시할 검증 결과가 없습니다.", +}: VerificationResultHistoryDialogProps) { + const [normalEntries, setNormalEntries] = useState([]); + const [imrEntries, setImrEntries] = useState([]); + 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 [errorMessage, setErrorMessage] = useState(null); + + const resetState = useCallback(() => { + setNormalEntries([]); + setImrEntries([]); + setNormalPageIndex(0); + setImrPageIndex(0); + setNormalPageCount(1); + setImrPageCount(1); + setNormalLoading(false); + setImrLoading(false); + setErrorMessage(null); + }, []); + + const fetchVerificationResultPage = useCallback( + async (vrfcMthdCd: "NV" | "IMR", pageIndex: number) => { + if (!queryContext) return null; + const param: VerificationResultListRequest = { + sewerBzmnCd: queryContext.sewerBzmnCd, + ctpvNm: queryContext.ctpvNm, + sggNm: queryContext.sggNm, + statsYr: queryContext.statsYr, + statsCd: queryContext.statsCd, + pbcpAprvYn: 'N', + vrfcMthdCd, + prcsYnList: "0,2,3", + page: pageIndex + 1, + size: 30, + }; + return statisticsApi.getVerificationResultList(param); + }, + [queryContext], + ); + + const loadInitial = useCallback(async () => { + if (!queryContext) { + 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), + ]); + setNormalEntries(nvRes?.data?.verifyResultList ?? []); + setImrEntries(imrRes?.data?.verifyResultList ?? []); + setNormalPageCount(nvRes?.data?.pageInfo?.totalPages ?? 1); + setImrPageCount(imrRes?.data?.pageInfo?.totalPages ?? 1); + } catch { + setNormalEntries([]); + setImrEntries([]); + setNormalPageCount(1); + setImrPageCount(1); + setErrorMessage("검증 결과 내역 조회 중 오류가 발생했습니다."); + } finally { + setNormalLoading(false); + setImrLoading(false); + } + }, [fetchVerificationResultPage, queryContext]); + + 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); + } + }, + [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], + ); + + useEffect(() => { + if (!open) { + resetState(); + return; + } + 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 ?? "-", + prcsYn: PRCS_YN_LABEL[item.prcsYn] ?? item.prcsYn, + pbcpAprvYn: item.pbcpAprvYn, + regDt: (item.regDt ?? "").slice(0, 16), + mdfcnDt: (item.mdfcnDt ?? "").slice(0, 16), + })); + + const normalRows = useMemo(() => mapRows(normalEntries), [normalEntries]); + const imrRows = useMemo(() => mapRows(imrEntries), [imrEntries]); + + const noopSetRows = ((_: SetStateAction[]>) => {}) as Dispatch< + SetStateAction[]> + >; + + const renderGrid = ( + rows: VerificationResultGridRow[], + pageIndex: number, + pageCount: number, + onPageIndexChange: (page: number) => void, + loading: boolean, + ) => { + if (!loading && rows.length === 0) { + return

{emptyMessage}

; + } + + return ( +
+ + + {loading && ( +
+
+ +
+
+ )} +
+ ); + }; + + return ( + + + + {`${title} - ${queryContext?.statsYr} ${queryContext?.ctpvNm} ${queryContext?.sggNm}`} + +
+ {errorMessage && ( +

+ {errorMessage} +

+ )} +
+

일반검증 내역

+ {renderGrid(normalRows, normalPageIndex, normalPageCount, handleNormalPageChange, normalLoading)} +
+
+

I-MR 검증내역

+ {renderGrid(imrRows, imrPageIndex, imrPageCount, handleImrPageChange, imrLoading)} +
+
+ + + + + +
+
+ ); +} diff --git a/apps/st/src/components/module/VerificationResultHistoryPagination.tsx b/apps/st/src/components/module/VerificationResultHistoryPagination.tsx new file mode 100644 index 000000000..e3fa59851 --- /dev/null +++ b/apps/st/src/components/module/VerificationResultHistoryPagination.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { + ChevronLeftIcon, + ChevronRightIcon, + DoubleArrowLeftIcon, + DoubleArrowRightIcon, +} from "@radix-ui/react-icons"; +import { Button, cn } from "@common/components"; + +export type VerificationResultHistoryPaginationProps = { + pageCount: number; + pageIndex: number; + onPageIndexChange: (pageIndex: number) => void; + className?: string; +}; + +export function VerificationResultHistoryPagination({ + pageCount, + pageIndex: currentPage, + onPageIndexChange, + className, +}: VerificationResultHistoryPaginationProps) { + const maxVisiblePages = 10; + let startPage = Math.max(0, currentPage - maxVisiblePages / 2); + const endPage = Math.min(pageCount, startPage + maxVisiblePages); + + if (endPage - startPage < maxVisiblePages) { + startPage = Math.max(0, endPage - maxVisiblePages); + } + + const pageNumbers = Array(endPage - startPage) + .fill(0) + .map((_, i) => startPage + i); + + const canPrevious = currentPage > 0; + const canNext = pageCount > 0 && currentPage < pageCount - 1; + + return ( +
+ + +
+ {pageNumbers.map((pageNumber) => ( + + ))} +
+ + +
+ ); +} diff --git a/apps/st/src/components/statistics/input/SewagePipeInp.tsx b/apps/st/src/components/statistics/input/SewagePipeInp.tsx index 42fc8cfd2..91eaaacc3 100644 --- a/apps/st/src/components/statistics/input/SewagePipeInp.tsx +++ b/apps/st/src/components/statistics/input/SewagePipeInp.tsx @@ -1,14 +1,16 @@ import { useState, useMemo, useRef } from "react"; import SearchForm from "@/components/module/SearchForm"; -import TabDataGrid from "@/components/module/TabDataGrid"; +import DataGrid from "@/components/module/DataGrid"; +import SplitDataGrid from "@/components/module/SplitDataGrid"; +import GridTopActionButton from "@/components/module/GridTopActionButton"; 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 { SewagePipeInpColumnInfoList } from "@/components/util/column/input/sewagePipe/SewagePipeInpColumn"; -import type { ColumnInfoList, ColumnConfig } from "@/components/type/DataGrid.type"; +import type { ColumnInfo, ColumnInfoList, ColumnConfig } from "@/components/type/DataGrid.type"; import type { SearchFormValues } from "@/components/type/SearchForm.type"; import { useAlertStore } from "@/shared/lib"; import { useAdminStore } from "@/store/admin-store"; @@ -17,20 +19,13 @@ import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { doList } from "@/components/util/column/input/DoList"; import { statisticsInputSewPipeApiService } from "@/entities/statistics/api/input/statistics-sewage-pipe-input-service"; import type { - SeweragePipeInputAdminAreaConnectionItem, - SeweragePipeInputFacilityConnectionItem, - SeweragePipeInputFactInstallStatusItem, - SeweragePipeInputManholeStatusItem, - SeweragePipeInputPipeDiameterStatusItem, - SeweragePipeInputPipeTypeStatusItem, - SeweragePipeInputRegionalFactStatusItem, SeweragePipeInputRegionalFactStatusSaveRequest, SeweragePipeInputStatusInfo, SeweragePipeRequestParams, } from "@/entities/statistics/model/input/statistics-sewage-pipe-input-interface"; import { RegionalFactStatusInpColumn } from "@/components/util/column/input/sewagePipe/RegionalFactStatusInpColumn"; import { toGridRows } from "@/components/util/grid/gridFunction"; -import { LoadingWithAnimation } from "@common/components"; +import { LoadingWithAnimation, Tabs, TabsContent, TabsList, TabsTrigger } from "@common/components"; import { getUserInfo } from "../../../../../../packages/common/components/src/api"; const applyEnterableFromStatus = ( @@ -62,6 +57,23 @@ function buildSewagePipeSearchParam( }; } +/** ST00020006(연결)은 행이 좌·우 그리드 키에만 있음 */ +function tabRowDataExists( + m: Map[]>, + tabKey: string, +): boolean { + if (tabKey === "ST00020006") { + const left = m.get("treatmentPlantConnectionInp"); + const right = m.get("adminAreaConnectionInp"); + return ( + (Array.isArray(left) && left.length > 0) || + (Array.isArray(right) && right.length > 0) + ); + } + const rows = m.get(tabKey); + return Array.isArray(rows) && rows.length > 0; +} + // 하수도 통계 관리 > 02. 하수관로 const SewagePipeInp = () => { const [rowDataMap, setRowDataMap] = useState[]>>(new Map()); @@ -128,7 +140,7 @@ const SewagePipeInp = () => { }); }, [isEnterable, isAdminInfo]); - /** mergeIntoExistingMap: true면 지난연도(PRE) 등 — 현재 탭만 갱신, 나머지 탭 캐시 유지 */ + /** mergeIntoExistingMap: true면 기존 맵에 병합(다른 탭 rowData·검색 후 다른 탭 캐시 유지). false면 맵 전체 교체 */ const loadSewagePipeActiveTabData = async ( tabKey: string, searchParam: SeweragePipeRequestParams, @@ -290,7 +302,6 @@ const SewagePipeInp = () => { const checkEnterable = periodResponse.data; periodEnterableRef.current = checkEnterable; - setRowDataMap(new Map()); lazyTabLoggedRef.current.clear(); setIsLoading(true); @@ -301,7 +312,7 @@ const SewagePipeInp = () => { setHasSearched(true); setLastSearchedYear(values.year ?? null); - await loadSewagePipeActiveTabData(activeTab, searchParam, values, checkEnterable, false, true); + await loadSewagePipeActiveTabData(activeTab, searchParam, values, checkEnterable, true, true); } catch { alert('데이터 조회 중 오류가 발생했습니다.'); } finally { @@ -312,8 +323,7 @@ const SewagePipeInp = () => { const handleActiveTabChange = async (tabKey: string) => { setActiveTab(tabKey); - const activeTabData = rowDataMap.get(tabKey); - const hasActiveTabData = Array.isArray(activeTabData) && activeTabData.length > 0; + const hasActiveTabData = tabRowDataExists(rowDataMap, tabKey); const canLazyLoad = searchParamRef.current && @@ -324,86 +334,20 @@ const SewagePipeInp = () => { if (!canLazyLoad) return; const searchParam = searchParamRef.current; - if (!searchParam) return; + const values = lastSearchedValues ?? searchValues; + if (!searchParam || !values) return; try { setIsLoading(true); - - let response; - - if (tabKey === 'ST00020001') { - response = await statisticsInputSewPipeApiService.getRegionalFactStatus(searchParam); - } else if (tabKey === 'ST00020002') { - response = await statisticsInputSewPipeApiService.getFactInstallStatus(searchParam); - } else if (tabKey === 'ST00020003') { - response = await statisticsInputSewPipeApiService.getPipeTypeStatus(searchParam); - } else if (tabKey === 'ST00020004') { - response = await statisticsInputSewPipeApiService.getPipeDiameterStatus(searchParam); - } else if (tabKey === 'ST00020005') { - response = await statisticsInputSewPipeApiService.getManholeStatus(searchParam); - } else if (tabKey === 'ST00020006') { - response = await statisticsInputSewPipeApiService.getConnectionStatus(searchParam); - } - lazyTabLoggedRef.current.add(tabKey); - - if (response?.status === 'success') { - setStatusInfo(response.data.statusInfo); - setStatsCd(response.data.commonInfo.statsCd); - applyEnterableFromStatus(response.data.statusInfo, periodEnterableRef.current, setIsEnterable); - setRowDataMap(prev => { - const next = new Map(prev); - if (tabKey === 'ST00020001') { - const commonInfo = response.data.commonInfo; - const convertedData = (response.data.regionalFactStatusList ?? []).map((d: SeweragePipeInputRegionalFactStatusItem) => ({ - ctpvNm: commonInfo.ctpvNm, - ...d - })); - next.set(tabKey, toGridRows(convertedData)); - } else if (tabKey === 'ST00020002') { - const commonInfo = response.data.commonInfo; - const convertedData = (response.data.factInstallStatusList ?? []).map((d: SeweragePipeInputFactInstallStatusItem) => ({ - ctpvNm: commonInfo.ctpvNm, - ...d - })); - next.set(tabKey, toGridRows(convertedData)); - } else if (tabKey === 'ST00020003') { - const commonInfo = response.data.commonInfo; - const convertedData = (response.data.pipeTypeStatusList ?? []).map((d: SeweragePipeInputPipeTypeStatusItem) => ({ - ctpvNm: commonInfo.ctpvNm, - ...d - })); - next.set(tabKey, toGridRows(convertedData)); - } else if (tabKey === 'ST00020004') { - const commonInfo = response.data.commonInfo; - const convertedData = (response.data.pipeDiameterStatusList ?? []).map((d: SeweragePipeInputPipeDiameterStatusItem) => ({ - ctpvNm: commonInfo.ctpvNm, - ...d - })); - next.set(tabKey, toGridRows(convertedData)); - } else if (tabKey === 'ST00020005') { - const commonInfo = response.data.commonInfo; - const convertedData = (response.data.manholeStatusList ?? []).map((d: SeweragePipeInputManholeStatusItem) => ({ - ctpvNm: commonInfo.ctpvNm, - ...d - })); - next.set(tabKey, toGridRows(convertedData)); - } else if (tabKey === 'ST00020006') { - const commonInfo = response.data.commonInfo; - const facilityConvertedData = (response.data.facilityConnectionList ?? []).map((d: SeweragePipeInputFacilityConnectionItem) => ({ - ...d - })); - const adminConvertedData = (response.data.adminAreaConnectionList ?? []).map((d: SeweragePipeInputAdminAreaConnectionItem) => ({ - ...d, - ctpvNm: d.ctpvNm || commonInfo.ctpvNm, - })); - next.set('treatmentPlantConnectionInp', toGridRows(facilityConvertedData)); - next.set('adminAreaConnectionInp', toGridRows(adminConvertedData)); - } - return next; - }); - } else { - alert(response?.message || '데이터 조회 중 오류가 발생했습니다.'); - } + const ok = await loadSewagePipeActiveTabData( + tabKey, + searchParam, + values, + periodEnterableRef.current, + true, + false, + ); + if (ok) lazyTabLoggedRef.current.add(tabKey); } catch { alert('데이터 조회 중 오류가 발생했습니다.'); } finally { @@ -543,8 +487,9 @@ const SewagePipeInp = () => { const showSaveInToolbar = (isEnterable || isAdminInfo) && gugunOkOrNotDoList; const showEnterableToolbarButtons = isEnterable && gugunOkOrNotDoList; - /** TabDataGrid는 rowDataMap 사용 — 행이 하나라도 있을 때만 툴바 카드 의미 있음 */ + /** rowDataMap — 행이 하나라도 있을 때만 툴바·엑셀 의미 있음 */ const hasRowData = Array.from(rowDataMap.values()).some((rows) => rows.length > 0); + const excelToolbarEnabled = hasRowData && isEnterable && gugunOkOrNotDoList; const showActionToolbarCard = (hasRowData || hasSearched) && @@ -573,16 +518,132 @@ const SewagePipeInp = () => {
} - +
+ void handleActiveTabChange(v)} + className="w-full" + variant="default" + > + + {columnInfo.map((info) => ( + + {info.tabLabel || info.tabKey} + + ))} + + {columnInfo.map((info) => { + const tabKey = info.tabKey; + + if (info.isSplitGrid && info.left && info.right) { + const leftKey = info.left.tabKey || `${tabKey}_left`; + const rightKey = info.right.tabKey || `${tabKey}_right`; + const leftRowData = rowDataMap.get(leftKey) ?? []; + const rightRowData = rowDataMap.get(rightKey) ?? []; + const splitTopButton = info.right.topButton; + const showSplitTopButton = + Boolean(splitTopButton?.text) && + (splitTopButton?.visible ?? true) && + excelToolbarEnabled; + const leftColumnInfo: ColumnInfo = { + columns: info.left.columns ?? [], + columnLayout: info.left.columnLayout ?? [], + }; + const rightColumnInfo: ColumnInfo = { + columns: info.right.columns ?? [], + columnLayout: info.right.columnLayout ?? [], + }; + + return ( + +
+
+ {showSplitTopButton && ( + + )} +
+ { + setRowDataMap((prev) => { + const next = new Map(prev); + const cur = next.get(leftKey) ?? []; + const n = + typeof updater === "function" + ? updater(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(cur) + : updater; + next.set(rightKey, n); + return next; + }); + }} + /> +
+
+ ); + } + + if (!info.columns || !info.columnLayout) { + return null; + } + + const rowData = rowDataMap.get(tabKey) ?? []; + const gridColumnInfo: ColumnInfo = { + columns: info.columns, + columnLayout: info.columnLayout, + }; + + 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; + }); + }} + isExcelImport={excelToolbarEnabled} + isExcelExport + showHeaderCheckbox={false} + fixedColumns={info.fixedColumns} + enableSort={false} + getGridDataRef={tabKey === "ST00020001" ? getGridDataRef : undefined} + /> + + ); + })} +
+
); }; diff --git a/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx b/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx index e8fec3870..c37700dae 100644 --- a/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx +++ b/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx @@ -6,16 +6,18 @@ import DataGrid from "@/components/module/DataGrid"; 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 CancelSubmitButton from "@/components/button/CancelSubmitButton"; +import VerificationResultButton from "@/components/button/VerificationResultButton"; +import { VerificationResultHistoryDialog } from "@/components/module/VerificationResultHistoryDialog"; +import { VerificationResultDialog } from "@/components/module/VerificationResultDialog"; import { LoadingWithAnimation } from "@common/components"; import { ConfirmDialog } from "@/shared/ui/confirm-dialog"; import SubmitDailog from "./SubmitDailog"; -import { useAlertStore } from "@/shared/lib"; +import { useAlertStore, useGlobalProgressStore } from "@/shared/lib"; import { useAdminStore } from "@/store/admin-store"; import { statisticsInputSewSupplyRateApi } from "@/entities/statistics/api/input/statistics-input-sew-supply-rate-service"; -import { statisticsAPi } from "@/entities/statistics/api/statistics-service"; +import { statisticsApi } from "@/entities/statistics/api/statistics-service"; import { toGridRows } from "@/components/util/grid/gridFunction"; import { getFooterSummary } from "@/components/util/grid/footerSummary"; import { getUserInfo } from "../../../../../../packages/common/components/src/api"; @@ -30,7 +32,11 @@ import type { SewerageSupplyRateSaveRequest, SewerageSupplyRateStatusInfo, } from "@/entities/statistics/model/input/statistics-sewage-supply-rate-input-interface"; -import type { cancelSubmitRequest, SubmitExceptionRequest } from "@/entities/statistics/model/statistics-interface"; +import type { + cancelSubmitRequest, + ImrStatusResponse, + SubmitExceptionRequest, +} from "@/entities/statistics/model/statistics-interface"; import type { StatisticsInputSubmitInfoFormData } from "@/entities/statistics/model/statistics-input-submit-info-schema"; import { SeweragePenetrationRateInpColumn } from "@/components/util/column/input/SeweragePenetrationRateInpColumn"; import { doList } from "@/components/util/column/input/DoList"; @@ -45,6 +51,32 @@ const hasVerificationErrors = (verification?: SewerageSupplyRateVerificationResu return Object.values(verification).some((errors) => Array.isArray(errors) && errors.length > 0); }; +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** IMR 상태가 COMPLETED 이고 job_prog 가 100일 때까지 폴링 */ +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 getSewerageSupplyRateParams( values: SearchFormValues, overrides?: { yr?: string; demoYr?: string; mode?: string } @@ -69,11 +101,17 @@ const SeweragePenetrationRateInp = () => { const [lastSearchedValues, setLastSearchedValues] = useState(null); const [statusInfo, setStatusInfo] = useState(null); const [showDailog, setShowDailog] = useState(false); + const [verificationHistoryOpen, setVerificationHistoryOpen] = useState(false); + const [verificationResultDialogOpen, setVerificationResultDialogOpen] = useState(false); /** 예외제출 버튼으로 연 다이얼로그인지 (확인 시 executeSubmit 대신 SubmitExceptionFunc 경로) */ const [isExceptionSubmitFlow, setIsExceptionSubmitFlow] = useState(false); const [statsCd, setStatsCd] = useState(''); const [footerFromApi, setFooterFromApi] = useState<{ sewerBzmnWholPpltnCnt?: number; sewerBzmnDomplWholPpltnCnt?: number; sewerBzmnFrgnrWholPpltnCnt?: number; sewerBzmnArea?: number } | null>(null); const { confirm } = useConfirm(); + const showGlobalProgress = useGlobalProgressStore((state) => state.show); + const setGlobalProgress = useGlobalProgressStore((state) => state.setProgress); + const hideGlobalProgress = useGlobalProgressStore((state) => state.hide); + const globalProgressVisible = useGlobalProgressStore((state) => state.visible); const { isAdminInfo } = useAdminStore(); @@ -88,6 +126,7 @@ const SeweragePenetrationRateInp = () => { }); // 최상위 관리자일땐 모든 상황에서 edit 가능하게 수정 필요. + const columnInfo = useMemo(() => { const { columns, columnLayout } = SeweragePenetrationRateInpColumn; const base = (isEnterable || isAdminInfo) ? columns : columns.map((col) => ({ ...col, editable: false as const })); @@ -212,7 +251,7 @@ const SeweragePenetrationRateInp = () => { try { setIsLoading(true); - const response = await statisticsAPi.submitException(params); + const response = await statisticsApi.submitException(params); if (response.status === 'success') { await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), false); @@ -271,7 +310,7 @@ const SeweragePenetrationRateInp = () => { const executeSubmit = async (submitInfo: StatisticsInputSubmitInfoFormData) => { try { - setIsLoading(true); + showGlobalProgress({ label: "제출 진행 중...", progress: 0 }); const userInfo = await getUserInfo(); const currentData = getGridDataRef.current?.(); @@ -348,39 +387,62 @@ const SeweragePenetrationRateInp = () => { const response = await statisticsInputSewSupplyRateApi.submitSewerageSupplyRate(requestData); if (response.status === 'success') { - const imrParam = { - user_id: userInfo.userUnqId, - sewer_bzmn_cd: searchValues?.bsnmCd ?? '', - stats_cd: statsCd, - stats_yr: searchValues?.year ?? '', - lae_sbmsn_stts_cd: (statusInfo?.laeSbmsnSttsCd === '4' ? '4' : '2'), - }; + setGlobalProgress(50); + + if (response.data.finalStatus === '5') { + const imrParam = { + user_id: userInfo.userUnqId, + sewer_bzmn_cd: searchValues?.bsnmCd ?? '', + stats_cd: statsCd, + stats_yr: searchValues?.year ?? '', + lae_sbmsn_stts_cd: (statusInfo?.laeSbmsnSttsCd === '4' ? '4' : '2'), + gen_verify_stts_cd: response.data.nvpassed ? '1' : '2', + }; - try { - await statisticsAPi.callImrService(imrParam); - } catch { - await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true); - setIsLoading(false); - alert('IMR 검증 중 오류가 발생했습니다.\n관리자에게 문의하세요.'); - return; + 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 fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true); + setVerificationResultDialogOpen(true); + } catch (e) { + await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true); + setIsLoading(false); + const msg = + e instanceof Error && e.message + ? e.message + : 'IMR 검증 중 오류가 발생했습니다.\n관리자에게 문의하세요.'; + alert(msg); + return; + } + } else { + setGlobalProgress(100); + await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), false); + alert('제출되었습니다.'); } - - await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), false); - alert('제출되었습니다.'); } else { alert(response.message || '데이터 제출 중 오류가 발생했습니다.'); } } catch { alert('데이터 제출 중 오류가 발생했습니다.'); } finally { - setIsLoading(false); + hideGlobalProgress(); } }; const saveFunc = async () => { try { if (await confirm('저장 하시겠습니까?')) { - setIsLoading(true); + showGlobalProgress({ label: "저장 진행 중...", progress: 0 }); const userInfo = await getUserInfo(); const currentData = getGridDataRef.current?.(); @@ -465,23 +527,8 @@ const SeweragePenetrationRateInp = () => { const response = await statisticsInputSewSupplyRateApi.saveSewerageSupplyRate(requestData); if (response.status === 'success') { - const imrParam = { - user_id: userInfo.userUnqId, - sewer_bzmn_cd: searchValues?.bsnmCd ?? '', - stats_cd: statsCd, - stats_yr: searchValues?.year ?? '', - lae_sbmsn_stts_cd: (statusInfo?.laeSbmsnSttsCd === '4' ? '3' : '1'), - }; - - try { - await statisticsAPi.callImrService(imrParam); - } catch { - await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true); - setIsLoading(false); - alert('IMR 검증 중 오류가 발생했습니다.\n관리자에게 문의하세요.'); - return; - } - + setGlobalProgress(50); + setGlobalProgress(100); await fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true); alert('저장되었습니다.'); } else { @@ -493,7 +540,7 @@ const SeweragePenetrationRateInp = () => { } catch { alert('데이터 저장 중 오류가 발생했습니다.'); } finally { - setIsLoading(false); + hideGlobalProgress(); } }; @@ -512,7 +559,7 @@ const SeweragePenetrationRateInp = () => { statsCd: statsCd, }; - const response = await statisticsAPi.cancelSubmit(requestData); + const response = await statisticsApi.cancelSubmit(requestData); if (response.status === 'success') { fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}), true); @@ -526,6 +573,14 @@ const SeweragePenetrationRateInp = () => { } }; + const openVerificationHistoryDialog = () => { + if (!searchValues?.year || !searchValues?.sido || !searchValues?.bsnmCd || !statsCd) { + alert("먼저 조회를 진행해주세요."); + return; + } + setVerificationHistoryOpen(true); + }; + const gugunOkOrNotDoList = doList.includes(lastSearchedValues?.bsnmCd || '') ? Boolean(lastSearchedValues?.gugun && lastSearchedValues.gugun !== 'ALL') : true; @@ -547,12 +602,11 @@ const SeweragePenetrationRateInp = () => {
{showEnterableToolbarButtons && ( <> - {isAdminInfo && statusInfo && statusInfo.laeSbmsnSttsCd !== '0' && ( - submitFunc(true)} /> - )} + getLastYearDataFunc(searchValues ?? {})} /> submitFunc(false)} /> - )} {statusInfo?.laeSbmsnSttsCd === '2' && } @@ -560,7 +614,7 @@ const SeweragePenetrationRateInp = () => {
)} - {isLoading &&
+ {isLoading && !globalProgressVisible &&
@@ -593,7 +647,7 @@ const SeweragePenetrationRateInp = () => { return errors .map((e) => { const prefix = e.vrfcCd === 'IMR' ? 'IMR검증 - ' : '일반검증 - '; - return `${prefix}${e.rsnExpln}`; + return `${prefix}${e.rsnExpln}(검증 기준값: ${e.compVlCn} / 현재 입력값: ${e.nowVlCn})`; }) .filter(Boolean) .join('\n'); @@ -614,6 +668,25 @@ const SeweragePenetrationRateInp = () => { submitMode={isExceptionSubmitFlow ? 'exception' : 'normal'} isLoading={isLoading} /> + + ); }; diff --git a/apps/st/src/components/statistics/input/StatsInputStatusInp.tsx b/apps/st/src/components/statistics/input/StatsInputStatusInp.tsx index 919231fde..5bbf6dd7a 100644 --- a/apps/st/src/components/statistics/input/StatsInputStatusInp.tsx +++ b/apps/st/src/components/statistics/input/StatsInputStatusInp.tsx @@ -1,22 +1,25 @@ -import { useState, useEffect } from "react"; +import { useEffect, useState } from "react"; + import useSWR, { useSWRConfig } from "swr"; -import SearchForm from "@/components/module/SearchForm"; -import { useTabStore } from "@/store/tab-store"; -import DataGrid from "@/components/module/DataGrid"; -import ApproveButton from "@/components/button/ApproveButton"; -import RejectButton from "@/components/button/RejectButton"; -import { LoadingWithAnimation } from "@common/components"; - -import { StatsInputStatusInpColumn } from "@/components/util/column/input/StatsInputStatusInpColumn"; +import { useAlertStore } from "@/shared/lib"; import { StatisticsInputStatusApi } from "@/entities/statistics/api/input/statistics-input-status-service"; import type { StatsInputStatusListRequestParams } from "@/entities/statistics/model/input/statistics-input-status-interface"; -import type { SearchFormValues } from "@/components/type/SearchForm.type"; -import { useAlertStore } from "@/shared/lib"; -import { toGridRows } from "@/components/util/grid/gridFunction"; import type { StatsInputStatusListApiResponse } from "@/entities/statistics/model/input/statistics-input-status-interface"; +import { LoadingWithAnimation } from "@common/components"; -const getStatsInputStatusFetcher = async (key: [string, StatsInputStatusListRequestParams]): Promise => { +import ApproveButton from "@/components/button/ApproveButton"; +import RejectButton from "@/components/button/RejectButton"; +import DataGrid from "@/components/module/DataGrid"; +import SearchForm from "@/components/module/SearchForm"; +import type { SearchFormValues } from "@/components/type/SearchForm.type"; +import { StatsInputStatusInpColumn } from "@/components/util/column/input/StatsInputStatusInpColumn"; +import { toGridRows } from "@/components/util/grid/gridFunction"; +import { useTabStore } from "@/store/tab-store"; + +const getStatsInputStatusFetcher = async ( + key: [string, StatsInputStatusListRequestParams], +): Promise => { return StatisticsInputStatusApi.getStatisticsInputStatusList(key[1]); }; @@ -24,10 +27,15 @@ const getStatsInputStatusFetcher = async (key: [string, StatsInputStatusListRequ const StatsInputStatusInp = () => { const { setMessage: alert } = useAlertStore((state) => state); const activeTabId = useTabStore((state) => state.activeTabId); - const [rowData, setRowData] = useState[]>([]); - const [searchParams, setSearchParams] = useState(null); + const [rowData, setRowData] = useState[]>( + [], + ); + const [searchParams, setSearchParams] = + useState(null); - const swrKey = searchParams ? (["stats-input-status", searchParams] as const) : null; + const swrKey = searchParams + ? (["stats-input-status", searchParams] as const) + : null; const { mutate } = useSWRConfig(); const { isLoading } = useSWR( swrKey, @@ -49,7 +57,7 @@ const StatsInputStatusInp = () => { ); useEffect(() => { - if (activeTabId === 'StatsInputStatusInp' && swrKey) { + if (activeTabId === "StatsInputStatusInp" && swrKey) { mutate(swrKey); } }, [activeTabId]); @@ -63,7 +71,7 @@ const StatsInputStatusInp = () => { ctpvNm: values.sido, sewerBzmnCd: values.bsnmCd, sggNm: values.gugun === "ALL" ? "" : values.gugun, - statsYr: values.year, + statsYr: values.year ?? "", }; setSearchParams(params); mutate(["stats-input-status", params]); @@ -87,22 +95,24 @@ const StatsInputStatusInp = () => {
-
+
- {isLoading &&
-
- + {isLoading && ( +
+
+ +
-
} - @@ -111,4 +121,4 @@ const StatsInputStatusInp = () => { ); }; -export default StatsInputStatusInp; \ No newline at end of file +export default StatsInputStatusInp; diff --git a/apps/st/src/components/statistics/management/LogHistory.tsx b/apps/st/src/components/statistics/management/LogHistory.tsx index 8614b0b5d..e2abaf60d 100644 --- a/apps/st/src/components/statistics/management/LogHistory.tsx +++ b/apps/st/src/components/statistics/management/LogHistory.tsx @@ -2,13 +2,30 @@ import { useCallback, useEffect, useState } from "react"; +import dayjs from "dayjs"; +import useSWR from "swr"; + +import { useAlertStore } from "@/shared/lib"; import { CodeSelectWithAll } from "@/entities/code"; -import { statisticsAPi } from "@/entities/statistics/api/statistics-service"; +import { statisticsApi } from "@/entities/statistics/api/statistics-service"; import type { + ApiResponse, + SearchCondition, + Sgg, StatisticsLog, StatisticsLogRequest, } from "@/entities/statistics/model/statistics-interface"; -import { Button, Label, LoadingWithAnimation } from "@common/components"; +import { + Button, + InputDatePicker, + Label, + LoadingWithAnimation, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@common/components"; import DataGrid from "@/components/module/DataGrid"; import { LogHistoryDetailDialog } from "@/components/statistics/management/LogHistoryDetailDialog"; @@ -18,9 +35,23 @@ import { toGridRows } from "@/components/util/grid/gridFunction"; const PAGE_SIZE = 100; +const getSidoListFetcher = async (): Promise> => { + return statisticsApi.getSidoList(); +}; + +const getSggListFetcher = async ( + key: [string, string], +): Promise> => { + return statisticsApi.getSggList(key[1]); +}; + const LogHistory = () => { - // const { setMessage: alert } = useAlertStore((state) => state); + const { setMessage: alert } = useAlertStore((state) => state); const [statsCd, setStatsCd] = useState(""); + const [sido, setSido] = useState("ALL"); + const [gugun, setGugun] = useState("ALL"); + const [bsnmCd, setBsnmCd] = useState(""); + const [statsDataChgComCd, setStatsDataChgComCd] = useState("ALL"); const [isLoading, setIsLoading] = useState(false); const [rowData, setRowData] = useState[]>( [], @@ -32,49 +63,148 @@ const LogHistory = () => { string, string | number > | null>(null); + const [startDate, setStartDate] = useState(() => + dayjs().subtract(1, "month").startOf("day").toDate(), + ); + const [endDate, setEndDate] = useState(() => + dayjs().endOf("day").toDate(), + ); + + const { data: sidoList } = useSWR>( + "log-history-sido-list", + getSidoListFetcher, + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + revalidateIfStale: false, + dedupingInterval: 2000, + shouldRetryOnError: false, + onSuccess: (data) => { + if (data.status !== "success") { + alert(`시도 목록을 불러오는데 실패했습니다. + 새로고침 후 다시 시도해주세요.`); + } + }, + onError: () => { + alert(`시도 목록을 불러오는데 실패했습니다. +새로고침 후 다시 시도해주세요.`); + }, + }, + ); + + const sggKey: [string, string] | null = + sido && sido !== "ALL" ? ["log-history-sgg-list", sido] : null; + const { data: sggList } = useSWR>( + sggKey, + getSggListFetcher, + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + revalidateIfStale: false, + dedupingInterval: 2000, + shouldRetryOnError: false, + onSuccess: (data) => { + if (data.status !== "success") { + alert(`시군구 목록을 불러오는데 실패했습니다. +새로고침 후 다시 시도해주세요.`); + } + }, + onError: () => { + alert(`시군구 목록을 불러오는데 실패했습니다. +새로고침 후 다시 시도해주세요.`); + }, + }, + ); + const sggOptions = sggList?.data ?? []; + + const handleSidoChange = (value: string) => { + const selectedBsnm = + sidoList?.data.sidoList.find((v) => v.sido === value)?.bsnmCd ?? ""; + setSido(value); + setGugun("ALL"); + setBsnmCd(selectedBsnm); + }; + + const handleSggChange = (value: string) => { + const listForBsnm = sggList?.data ?? []; + const selectedBsnm = + value === "ALL" + ? (sidoList?.data.sidoList.find((v) => v.sido === sido) + ?.bsnmCd ?? "") + : (listForBsnm.find((v) => v.gugun === value)?.bsnmCd ?? ""); + setGugun(value); + setBsnmCd(selectedBsnm); + }; const loadLogs = useCallback( async (page1Based: number) => { const param: StatisticsLogRequest = { - statsCd: statsCd === "" || statsCd === "ALL" ? "" : statsCd, page: page1Based, size: PAGE_SIZE, + startDate: startDate + ? dayjs(startDate).format("YYYY-MM-DD") + : "", + endDate: endDate ? dayjs(endDate).format("YYYY-MM-DD") : "", + ...(!(statsCd === "" || statsCd === "ALL") && { statsCd }), + ...(sido && sido !== "ALL" && { ctpvNm: sido }), + ...(gugun && gugun !== "ALL" && { sggNm: gugun }), + ...(bsnmCd && { sewerBzmnCd: bsnmCd }), + ...(statsDataChgComCd && + statsDataChgComCd !== "ALL" && { + statsDataChgComCd, + }), }; - setIsLoading(true); + try { + setIsLoading(true); - const response = await statisticsAPi.getLogList(param); - if (response.status === "success") { - const lostList = response.data?.statsLogList ?? []; - const convertedData: StatisticsLog[] = lostList.map( - (item: StatisticsLog) => ({ - ...item, - dmndFwkNm: - item.dmndFwkNm === "save" - ? "저장" - : item.dmndFwkNm === "submit" - ? "제출" - : "예외 제출", - }), - ); + const response = await statisticsApi.getLogList(param); - setRowData(toGridRows(convertedData)); - setTotalPages(response.data?.pageInfo?.totalPages ?? 0); - setPageIndex(Math.max(0, page1Based - 1)); - } else { - alert( - response.message || "데이터 조회 중 오류가 발생했습니다.", - ); + if (response.status === "success") { + const lostList = response.data?.statsLogList ?? []; + + setRowData(toGridRows(lostList)); + setTotalPages(response.data?.pageInfo?.totalPages ?? 0); + setPageIndex(Math.max(0, page1Based - 1)); + } else { + alert( + response.message || + "데이터 조회 중 오류가 발생했습니다.", + ); + } + } catch { + alert("데이터 조회 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); } }, - [statsCd], + [ + statsCd, + startDate, + endDate, + sido, + gugun, + bsnmCd, + statsDataChgComCd, + alert, + ], ); - const handleSearch = async (e: React.SubmitEvent) => { + const handleSearch = async (e: React.FormEvent) => { e.preventDefault(); await loadLogs(1); }; + const handleReset = () => { + setStatsCd(""); + setSido("ALL"); + setGugun("ALL"); + setBsnmCd(""); + setStatsDataChgComCd("ALL"); + setStartDate(dayjs().subtract(1, "month").startOf("day").toDate()); + setEndDate(dayjs().endOf("day").toDate()); + }; + const handleRowClick = (row: Record) => { setSelectedLogRow(row); setDetailDialogOpen(true); @@ -86,14 +216,58 @@ const LogHistory = () => { }; useEffect(() => { - loadLogs(1); + void loadLogs(1); }, []); return (
-
+
+
+ + +
+
+ + +
{ className="w-[16rem]" />
+
+ + +
+
+ + + + +
diff --git a/apps/st/src/components/statistics/management/LogHistoryDetailDialog.tsx b/apps/st/src/components/statistics/management/LogHistoryDetailDialog.tsx index b451b3a14..09667bc88 100644 --- a/apps/st/src/components/statistics/management/LogHistoryDetailDialog.tsx +++ b/apps/st/src/components/statistics/management/LogHistoryDetailDialog.tsx @@ -12,7 +12,7 @@ import { DialogTitle, } from "@common/components"; -import { statisticsAPi } from "@/entities/statistics/api/statistics-service"; +import { statisticsApi } from "@/entities/statistics/api/statistics-service"; export interface LogHistoryDetailDialogProps { open: boolean; @@ -30,7 +30,7 @@ export function LogHistoryDetailDialog({ const sn = row.statsLogSn; void (async () => { - const response = await statisticsAPi.getLogDetail( + const response = await statisticsApi.getLogDetail( sn !== undefined && sn !== null ? String(sn) : "", ); console.log(response.data); diff --git a/apps/st/src/components/statistics/review/ReturnReasonDialog.tsx b/apps/st/src/components/statistics/review/ReturnReasonDialog.tsx new file mode 100644 index 000000000..b2955bc9a --- /dev/null +++ b/apps/st/src/components/statistics/review/ReturnReasonDialog.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { + Button, + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + Label, + Textarea, +} from "@common/components"; + +export interface ReturnReasonDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSave?: (reason: string) => void | Promise; +} + +export function ReturnReasonDialog({ + open, + onOpenChange, + onSave, +}: ReturnReasonDialogProps) { + const [reason, setReason] = useState(""); + const [isSaving, setIsSaving] = useState(false); + const [reasonError, setReasonError] = useState(false); + + useEffect(() => { + if (!open) return; + setReason(""); + setReasonError(false); + }, [open]); + + const handleSave = async () => { + if (reason.trim() === "") { + setReasonError(true); + return; + } + + try { + setIsSaving(true); + await onSave?.(reason); + onOpenChange(false); + } finally { + setIsSaving(false); + } + }; + + return ( + + + + 반려 사유 + +
+
+ +