diff --git a/apps/fo/.env.local b/apps/fo/.env.local index 646d80c2c..d5e6751ed 100644 --- a/apps/fo/.env.local +++ b/apps/fo/.env.local @@ -17,7 +17,7 @@ NEXT_PUBLIC_SEWERAGE_API_URL = http://con.mind-one.co.kr:11083/sewerage-service 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_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login -NEXT_PUBLIC_CM_LOGIN_PAGE_URL = / +NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /auth/login #NEXT_PUBLIC_SERVER_API_URL = http://localhost:8883/operation-service diff --git a/apps/fo/.env.mindone b/apps/fo/.env.mindone index 2fd7e91c2..5b5ad726e 100644 --- a/apps/fo/.env.mindone +++ b/apps/fo/.env.mindone @@ -16,4 +16,4 @@ NEXT_PUBLIC_DC_URL = https://apigateway.apps.rsonesoft.com/dc-service NEXT_PUBLIC_SEWERAGE_API_URL = http://con.mind-one.co.kr:11083/sewerage-service 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_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/fo/.env.production b/apps/fo/.env.production index bd0d0f04b..d6940b0e9 100644 --- a/apps/fo/.env.production +++ b/apps/fo/.env.production @@ -17,4 +17,3 @@ NEXT_PUBLIC_DC_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/dc-s NEXT_PUBLIC_SEWERAGE_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/sewerage-service 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_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login \ No newline at end of file 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..2bf41d1d9 100644 --- a/apps/sn/.env.local +++ b/apps/sn/.env.local @@ -14,5 +14,5 @@ NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&ver NEXT_PUBLIC_SERVER_API_URL = http://dev.onasys.co.kr:8000 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)/map/components/popupComponent/Ssc.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/Ssc.tsx index f908fe98b..683fb9df0 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/Ssc.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/Ssc.tsx @@ -18,7 +18,7 @@ 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'; @@ -65,22 +65,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 +123,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) => { @@ -355,4 +325,4 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick }: { ) } -export default Ssc \ No newline at end of file +export default Ssc diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/smh.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/smh.tsx index 2a0b11682..cd59003ef 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/smh.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/smh.tsx @@ -15,40 +15,20 @@ import { VerticalTable } from '@/components/ui/verticalTable'; import { useEffect, useState } from 'react'; import GisApp from "@common/business_components/ui/map/lib/gis.core"; +import { getData } from '@/components/lib/api'; // 상류관로 보기 함수 (FeatureClickEventPopup에서 사용) export async function searchUpperPpls(gisMap: any, fcltMngNo: any) { - const BASE_URL = "/sewerage-service/api"; - const res = await fetch(`${BASE_URL}/v1/fcltmhl/upperPpls/${fcltMngNo}`, { - method: "GET", - }); - let data: any = null; - if (res.ok) { - try { - // 응답이 비어있는지 확인 - const contentLength = res.headers.get("content-length"); - if (contentLength === "0") { - alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`); - return; - } + try { + data = await getData(`v1/fcltmhl/upperPpls/${fcltMngNo}`); + } catch (error) { + console.error("JSON parsing error:", error); + alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`); + return; + } - // 응답 텍스트를 먼저 가져와서 빈 문자열인지 확인 - const text = await res.text(); - if (!text || text.trim() === "") { - alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`); - return; - } - - // JSON 파싱 시도 - const response = JSON.parse(text); - data = response.data; - } catch (error) { - console.error("JSON parsing error:", error); - alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`); - return; - } - } else { + if (!data) { alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`); return; } @@ -143,4 +123,4 @@ const Smh = ({ featureData, onSaveClick }: { featureData: any, onSaveClick?: () ) } -export default Smh; \ No newline at end of file +export default Smh; diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index 75b2d573e..6c684b642 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -18,7 +18,7 @@ "use client"; import Gis from "@common/business_components/ui/map/Gis"; -import { getData } from "../../components/lib/api"; +import { getRawJson } from "../../components/lib/api"; import { useCallback, useEffect, useRef, useState } from "react"; import GisApp from "@common/business_components/ui/map/lib/gis.core"; import SearchModal from "./searchModal/searchModal"; @@ -43,22 +43,7 @@ const Map = () => { const fetchData = async () => { try { - let data; - const res = await fetch("/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK", { - method: "GET", - }); - if (res.ok) { - // 응답이 비어있는지 확인 - const contentLength = res.headers.get('content-length'); - if (contentLength === '0') data = null; - - // 응답 텍스트를 먼저 가져와서 빈 문자열인지 확인 - const text = await res.text(); - if (!text || text.trim() === '') data = null; - - // JSON 파싱 시도 - data = JSON.parse(text); - } + const data = await getRawJson("/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK"); setTreeData(data); } catch (error) { console.error('JSON parsing error:', error); 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/tableModal.tsx b/apps/sn/app/(with-base-layout)/oper/floodPre/tableModal.tsx index 97e9d788d..f4acc2056 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,7 @@ const TableModal: React.FC = ({ fileNm, atflId }) => { return false } - const response = await fetch(`/attach-service/api/v1/download/${atflId}`); - if (!response.ok) { - throw new Error('파일을 가져오는 중 오류가 발생했습니다.'); - } - - 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' @@ -267,4 +263,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/tableModal.tsx b/apps/sn/app/(with-base-layout)/oper/oldPp/tableModal.tsx index 707f2d526..13921b2c1 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,7 @@ const TableModal: React.FC = ({ fileNm, atflId }) => { return false; } - const response = await fetch(`/attach-service/api/v1/download/${atflId}`); - if (!response.ok) { - throw new Error('파일을 가져오는 중 오류가 발생했습니다.'); - } - - 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' @@ -476,4 +472,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..73025226b 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,75 @@ 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) { + throw new Error('Failed to fetch data'); + } + + 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'); + } +}; + +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) { + throw new Error('Failed to fetch data'); + } + + 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 +213,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 +229,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 +264,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/html/(map-layout)/aged-pipe/page.tsx b/apps/sn/app/html/(map-layout)/aged-pipe/page.tsx index e3ebe4d3a..ab80b545b 100644 --- a/apps/sn/app/html/(map-layout)/aged-pipe/page.tsx +++ b/apps/sn/app/html/(map-layout)/aged-pipe/page.tsx @@ -7,7 +7,7 @@ import { downloadExcelWithKoreanHeaders } from "@common/business_components/ui"; import Stroke from "ol/style/Stroke.js"; import Style from "ol/style/Style.js"; import useStore from "@/store/useStore"; -import { getData } from "@/components/lib/api"; +import { getRawJson } from "@/components/lib/api"; import AgedPipeFilterPanel from "./AgedPipeFilterPanel"; import AgedPipeDetailPanel from "./AgedPipeDetailPanel"; import AgedPipeListModal from "./AgedPipeListModal"; @@ -102,19 +102,7 @@ const Map = () => { useEffect(() => { const fetchData = async () => { try { - let data; - const response = await fetch("/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK", { - method: "GET", - }); - if (response.ok) { - const contentLength = response.headers.get("content-length"); - if (contentLength === "0") data = null; - - const text = await response.text(); - if (!text || text.trim() === "") data = null; - else data = JSON.parse(text); - } - + const data = await getRawJson("/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK"); setTreeData(data); } catch (error) { console.error("노후관로 레이어 설정 조회 중 오류:", error); diff --git a/apps/sn/app/html/(map-layout)/map/components/popupComponent/Ssc.tsx b/apps/sn/app/html/(map-layout)/map/components/popupComponent/Ssc.tsx index f908fe98b..683fb9df0 100644 --- a/apps/sn/app/html/(map-layout)/map/components/popupComponent/Ssc.tsx +++ b/apps/sn/app/html/(map-layout)/map/components/popupComponent/Ssc.tsx @@ -18,7 +18,7 @@ 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'; @@ -65,22 +65,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 +123,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) => { @@ -355,4 +325,4 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick }: { ) } -export default Ssc \ No newline at end of file +export default Ssc diff --git a/apps/sn/app/html/(map-layout)/map/components/popupComponent/smh.tsx b/apps/sn/app/html/(map-layout)/map/components/popupComponent/smh.tsx index 2a0b11682..cd59003ef 100644 --- a/apps/sn/app/html/(map-layout)/map/components/popupComponent/smh.tsx +++ b/apps/sn/app/html/(map-layout)/map/components/popupComponent/smh.tsx @@ -15,40 +15,20 @@ import { VerticalTable } from '@/components/ui/verticalTable'; import { useEffect, useState } from 'react'; import GisApp from "@common/business_components/ui/map/lib/gis.core"; +import { getData } from '@/components/lib/api'; // 상류관로 보기 함수 (FeatureClickEventPopup에서 사용) export async function searchUpperPpls(gisMap: any, fcltMngNo: any) { - const BASE_URL = "/sewerage-service/api"; - const res = await fetch(`${BASE_URL}/v1/fcltmhl/upperPpls/${fcltMngNo}`, { - method: "GET", - }); - let data: any = null; - if (res.ok) { - try { - // 응답이 비어있는지 확인 - const contentLength = res.headers.get("content-length"); - if (contentLength === "0") { - alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`); - return; - } + try { + data = await getData(`v1/fcltmhl/upperPpls/${fcltMngNo}`); + } catch (error) { + console.error("JSON parsing error:", error); + alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`); + return; + } - // 응답 텍스트를 먼저 가져와서 빈 문자열인지 확인 - const text = await res.text(); - if (!text || text.trim() === "") { - alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`); - return; - } - - // JSON 파싱 시도 - const response = JSON.parse(text); - data = response.data; - } catch (error) { - console.error("JSON parsing error:", error); - alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`); - return; - } - } else { + if (!data) { alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`); return; } @@ -143,4 +123,4 @@ const Smh = ({ featureData, onSaveClick }: { featureData: any, onSaveClick?: () ) } -export default Smh; \ No newline at end of file +export default Smh; diff --git a/apps/sn/app/html/(map-layout)/map/page.tsx b/apps/sn/app/html/(map-layout)/map/page.tsx index 7f48a9243..cae8c372b 100644 --- a/apps/sn/app/html/(map-layout)/map/page.tsx +++ b/apps/sn/app/html/(map-layout)/map/page.tsx @@ -18,7 +18,7 @@ "use client"; import Gis from "@common/business_components/ui/map/Gis"; -import { getData } from "../../../components/lib/api"; +import { getRawJson } from "../../../components/lib/api"; import { useCallback, useEffect, useRef, useState } from "react"; import GisApp from "@common/business_components/ui/map/lib/gis.core"; import SearchModal from "./searchModal/searchModal"; @@ -43,22 +43,7 @@ const Map = () => { const fetchData = async () => { try { - let data; - const res = await fetch("/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK", { - method: "GET", - }); - if (res.ok) { - // 응답이 비어있는지 확인 - const contentLength = res.headers.get('content-length'); - if (contentLength === '0') data = null; - - // 응답 텍스트를 먼저 가져와서 빈 문자열인지 확인 - const text = await res.text(); - if (!text || text.trim() === '') data = null; - - // JSON 파싱 시도 - data = JSON.parse(text); - } + const data = await getRawJson("/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK"); setTreeData(data); } catch (error) { console.error('JSON parsing error:', error); 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/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 cbc9c508d..bae8ff421 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/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..8c8e0d75c 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, setRowData]); + 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,12 +1249,11 @@ const DataGrid = ({ )}
-
+
{ if (!grid) return; ( @@ -1176,33 +1265,33 @@ 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 +1435,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 ( + + + + 반려 사유 + +
+
+ +