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/wr/.env.production b/apps/wr/.env.production index a159d1aa4..934f1e6a5 100644 --- a/apps/wr/.env.production +++ b/apps/wr/.env.production @@ -20,5 +20,4 @@ NEXT_PUBLIC_NGII_PARAM = BDE2BEF39DAFEC293AB6730150F222AC&service=WMTS&request=G NEXT_PUBLIC_HASUDOINFO = https://www.hasudoinfo.or.kr NEXT_PUBLIC_HASUDOINFO_IN = http://10.101.163.102:8889 NEXT_PUBLIC_FINDADRESS_URL=https://business.juso.go.kr/addrlink/addrLinkApi.do -NEXT_PRIVATE_WR_ADDRESS_KEY = U01TXOFVVEgyMDI1MDgyODE0MTAxNzExNjEyMjA= -NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login \ No newline at end of file +NEXT_PRIVATE_WR_ADDRESS_KEY = U01TXOFVVEgyMDI1MDgyODE0MTAxNzExNjEyMjA= \ No newline at end of file diff --git a/apps/wr/app/(map-layout)/map/mainMap/page.tsx b/apps/wr/app/(map-layout)/map/mainMap/page.tsx index a2addf109..13798ff4f 100644 --- a/apps/wr/app/(map-layout)/map/mainMap/page.tsx +++ b/apps/wr/app/(map-layout)/map/mainMap/page.tsx @@ -21,7 +21,8 @@ import WrOverLay from "@/components/ui/map/wrOverLay/wrOverLay"; const Map = () => { - const { code, comMap, rainMapDetile, rswtMapDetile, getRuseMaplListReturn, pbnMapDetaile, ldrmntMapDetile, htdfMapDetile, coSttnMapDetail } = useWrStore((state) => state); + const { code, comMap, rainMapDetile, rswtMapDetile, getRuseMaplListReturn + , pbnMapDetaile, ldrmntMapDetile, htdfMapDetile, coSttnMapDetail } = useWrStore((state) => state); const { isFeatured } = comMap; const [treeData, setTreeData] = useState([]); // 초기값을 객체 또는 null로 설정 const gisMaps = useRef(null); diff --git a/apps/wr/app/(map-layout)/service/map.ts b/apps/wr/app/(map-layout)/service/map.ts index 326f10c75..0a5293d35 100644 --- a/apps/wr/app/(map-layout)/service/map.ts +++ b/apps/wr/app/(map-layout)/service/map.ts @@ -2,6 +2,8 @@ /** * 관망도 API */ +import { SITE_ID } from "@/src/shared/config"; + const API_URL = `/sewerage-service/api/v1/map` //const API_URL = "http://con.mind-one.co.kr:11083/sewerage-service/api/v1/map"; @@ -35,6 +37,7 @@ export const mapService = { method: 'GET', headers: { 'Accept': 'application/json', + 'X-Site-Id': SITE_ID, }, }); diff --git a/apps/wr/app/(popUp-layout)/splyPopup/service/map.ts b/apps/wr/app/(popUp-layout)/splyPopup/service/map.ts index 605bae620..11d556468 100644 --- a/apps/wr/app/(popUp-layout)/splyPopup/service/map.ts +++ b/apps/wr/app/(popUp-layout)/splyPopup/service/map.ts @@ -3,6 +3,7 @@ * 관망도 API */ import { DEV_URL_WR, DEV_URL_SN } from "@/components/lib/config"; +import { SITE_ID } from "@/src/shared/config"; export type LyrStngProps = { lyr_sn: string @@ -34,6 +35,7 @@ export const mapService = { method: 'GET', headers: { 'Accept': 'application/json', + 'X-Site-Id': SITE_ID, }, }); @@ -48,6 +50,7 @@ export const mapService = { method: 'GET', headers: { 'Accept': 'application/json', + 'X-Site-Id': SITE_ID, }, }); diff --git a/apps/wr/app/(with-layout)/jwttest/page.tsx b/apps/wr/app/(with-layout)/jwttest/page.tsx new file mode 100644 index 000000000..25d47c1d2 --- /dev/null +++ b/apps/wr/app/(with-layout)/jwttest/page.tsx @@ -0,0 +1,164 @@ +/** + * JWT를 decoding하면 만료시간을 읽을 수 있다. + * JWT는 3부분으로 구성 : Header.Payload.Signature + * Payload 부분에 만료시간(exp)이 있다. + */ +"use client"; + +import { useEffect, useState } from "react"; +import { CLAIM_NAME } from "@/src/shared/config"; +import { getCookie } from "@/src/shared/lib"; + +interface JwtPayload { + sub?: string; // 토큰 subject. 일반적으로 사용자 식별자나 주체 정보 + exp?: number; // 만료 시각(Unix timestamp, 초 단위) + iat?: number; // 토큰 발급 시각(Unix timestamp, 초 단위) + [key: string]: unknown; // 그 외 JWT payload에 포함될 수 있는 추가 클레임 +} + +interface JwtState { + token: string; // 쿠키에서 읽어온 JWT 원본 문자열 + payload: JwtPayload | null; // JWT를 파싱한 payload 객체 + exp: number | null; // payload에서 추출한 exp 값 + expDateLocal: string | null; // exp를 로컬 시간 문자열로 변환한 값 + remainingMs: number | null; // 현재 시각 기준 남은 시간(ms) + error: string | null; // 쿠키 없음, 파싱 실패 등 오류 메시지 +} + + +function parseJwt(token: string): JwtPayload { + const parts = token.split("."); + if (parts.length !== 3) { + throw new Error("유효한 JWT 형식이 아닙니다."); + } + + const base64Url = parts[1] ?? ""; // Payload를 가져온다. + 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; +} + +function formatRemainingMs(remainingMs: number | null): string { + if (remainingMs === null) return "-"; + if (remainingMs <= 0) return "만료됨"; + + const totalSeconds = Math.floor(remainingMs / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + return `${hours}시간 ${minutes}분 ${seconds}초`; +} + +function readJwtState(): JwtState { + const token = getCookie(CLAIM_NAME) ?? ""; + + if (!token) { + return { + token: "", + payload: null, + exp: null, + expDateLocal: null, + remainingMs: null, + error: `${CLAIM_NAME} 쿠키가 없습니다.`, + }; + } + + try { + const payload = parseJwt(token); + + if (typeof payload.exp !== "number") { + return { + token, + payload, + exp: null, + expDateLocal: null, + remainingMs: null, + error: "JWT payload에 exp 값이 없습니다.", + }; + } + + const expMs = payload.exp * 1000; + + return { + token, + payload, + exp: payload.exp, + expDateLocal: new Date(expMs).toLocaleString(), + remainingMs: Math.max(expMs - Date.now(), 0), + error: null, + }; + } catch (error) { + return { + token, + payload: null, + exp: null, + expDateLocal: null, + remainingMs: null, + error: + error instanceof Error + ? error.message + : "JWT 파싱 중 오류가 발생했습니다.", + }; + } +} + +export default function Page() { + const [jwtState, setJwtState] = useState(() => readJwtState()); + + useEffect(() => { + const syncJwtState = () => { + setJwtState(readJwtState()); + }; + + syncJwtState(); + const timer = window.setInterval(syncJwtState, 1000); + + return () => window.clearInterval(timer); + }, []); + + return ( +
+

JWT Exp Test

+ +
+
쿠키명
+
{CLAIM_NAME}
+
+ +
+
exp
+
{jwtState.exp ?? "-"}
+
+ +
+
만료시각
+
{jwtState.expDateLocal ?? "-"}
+
+ +
+
남은시간
+
{formatRemainingMs(jwtState.remainingMs)}
+
+ +
+
상태
+
{jwtState.error ?? "정상"}
+
+ +
+
payload
+
+          {jwtState.payload ? JSON.stringify(jwtState.payload, null, 2) : "-"}
+        
+
+
+ ); +} diff --git a/apps/wr/app/(with-layout)/ruseFcltMng/fcltReg/pbnstReg/page.tsx b/apps/wr/app/(with-layout)/ruseFcltMng/fcltReg/pbnstReg/page.tsx index 0a243a41f..5cf8fc0d5 100644 --- a/apps/wr/app/(with-layout)/ruseFcltMng/fcltReg/pbnstReg/page.tsx +++ b/apps/wr/app/(with-layout)/ruseFcltMng/fcltReg/pbnstReg/page.tsx @@ -287,7 +287,7 @@ export default function PublicReuseFaciltyInsert() { }, [pbnstRes, append, selectRow]); useEffect(() => { - pubHasuInit(String(pageNation), laeSeCd); + pubHasuInit(String(pageNation), laeSeCd); // 해당 지자체의 하수처리시설 조회 isResetPage(false); setSelectRow(undefined); setSelectedRowIndex(-1); diff --git a/apps/wr/app/(with-layout)/ruseFcltMng/fcltSttn/htdflSttn/page.tsx b/apps/wr/app/(with-layout)/ruseFcltMng/fcltSttn/htdflSttn/page.tsx index 2f31e616c..5705faabc 100644 --- a/apps/wr/app/(with-layout)/ruseFcltMng/fcltSttn/htdflSttn/page.tsx +++ b/apps/wr/app/(with-layout)/ruseFcltMng/fcltSttn/htdflSttn/page.tsx @@ -37,17 +37,20 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useAlertStore } from "@/src/shared/lib"; import { useTranslation } from "react-i18next"; +const defaultSigunOptions: SelectDataType[] = [{ text: "전체", val: "0" }]; + export default function HtdflSttn() { - const { auth, code, htdflSttn, htdflSttnSearch, initHtdfl } = useWrStore((state) => state); + const { auth, code, htdflSttn, htdflSttnSearch, initHtdfl, getLaeSeCdCheck } = useWrStore((state) => state); const { laeSeCd } = auth; // 지자체구분코드 const { htdflSttnRes, htdflSttnResCount } = htdflSttn; const isDownloadTriggered = useRef(false); + const skipNextSearchRef = useRef(true); const { t } = useTranslation('base'); const [pageNation, setPageNation] = useState(1); const [isChHead, setIsChHead] = useState(false); const [resetPage, isResetPage] = useState(false); const [bdstTable, setBdstTable] = useState(); - const [selectSgg, setSelectSgg] = useState() + const [selectSgg, setSelectSgg] = useState(defaultSigunOptions) const [objSearch, setObjSearch] = useState({ sgg: "", city: "" }); const [detailModal, setDetailModal] = useState<{ isOpen: boolean; @@ -59,22 +62,23 @@ export default function HtdflSttn() { // }>({ isOpen: false, chBuilding: [] }); const [fcltNm, setFcltNm] = useState(""); const alert = useAlertStore((state) => state.setMessage); - - console.log("laeSeCd : ", laeSeCd); - useEffect(() => { - htdflSttnSearch(false, pageNation, htdflSttnSize, String(getConValue()), fcltNm, code.cityCd, code.sggCd); - isResetPage(false) - }, [pageNation]); + const isAllAreaUser = laeSeCd === "0"; + const isLaeSeCdReady = + laeSeCd === "0" || laeSeCd.length === 2 || laeSeCd.length === 5; + const fixedSidoValue = + !isAllAreaUser && laeSeCd?.length ? laeSeCd.slice(0, 2) : ""; + const fixedSigunValue = + !isAllAreaUser && laeSeCd?.length === 5 ? laeSeCd : "0"; const getSelCity = (val: string | number) => { const cityValue = String(val); if (cityValue === "0") { - setSelectSgg([]); + setSelectSgg(defaultSigunOptions); setObjSearch({ city: cityValue, sgg: "" }); } else { const data = getCity(cityValue, code.sggCd); - setSelectSgg(data); + setSelectSgg(data.length > 0 ? data : defaultSigunOptions); const firstSggVal = data.find(item => item.val === "0")?.val || (data[0]?.val ?? ""); setObjSearch({ city: cityValue, sgg: firstSggVal }); @@ -85,16 +89,24 @@ export default function HtdflSttn() { setObjSearch({ ...objSearch, sgg: String(val) }) }; - const getConValue = () => { + const getConValue = (): string => { if (!objSearch.city || objSearch.city === "0") { return "0"; } if (objSearch.sgg && objSearch.sgg !== "0") { - return objSearch.sgg; + return String(objSearch.sgg); } - return objSearch.city; + return String(objSearch.city); }; + const getFixedConValue = useCallback((): string => { + if (laeSeCd === undefined || laeSeCd === "0") { + return getConValue(); + } + + return laeSeCd.length === 5 ? laeSeCd : laeSeCd.slice(0, 2); + }, [laeSeCd, objSearch]); + const cellClick = (index: number | undefined, headId: string) => { if ( headId !== "chBuildingYn" && @@ -106,9 +118,37 @@ export default function HtdflSttn() { }; const excelClick = useCallback(() => { + if (!isLaeSeCdReady) { + return; + } + isDownloadTriggered.current = true; - htdflSttnSearch(true, 1, htdflSttnResCount, String(getConValue()), fcltNm, code.cityCd, code.sggCd); - }, [htdflSttnSearch, objSearch, code.cityCd, code.sggCd, fcltNm, htdflSttnResCount]); + htdflSttnSearch(true, 1, htdflSttnResCount, String(getFixedConValue()), fcltNm, code.cityCd, code.sggCd); + }, [htdflSttnSearch, fcltNm, htdflSttnResCount, code.cityCd, code.sggCd, getFixedConValue, isLaeSeCdReady]); + + useEffect(() => { + getLaeSeCdCheck(); + }, [getLaeSeCdCheck]); + + useEffect(() => { + if (!isLaeSeCdReady) { + return; + } + + if (laeSeCd === "0") { + setSelectSgg(defaultSigunOptions); + setObjSearch({ city: "0", sgg: "" }); + return; + } + + const cityValue = laeSeCd.slice(0, 2); + const sigunOptions = getCity(cityValue, code.sggCd); + setSelectSgg(sigunOptions.length > 0 ? sigunOptions : defaultSigunOptions); + setObjSearch({ + city: cityValue, + sgg: laeSeCd.length === 5 ? laeSeCd : "0", + }); + }, [laeSeCd, code.sggCd, isLaeSeCdReady]); useEffect(() => { if (!isDownloadTriggered.current) { @@ -135,10 +175,27 @@ export default function HtdflSttn() { }, [htdflSttn.excelList, code]); useEffect(() => { - if (!htdflSttn.isInit) { - initHtdfl(false, 1, htdflSttnSize, "0", fcltNm, code.cityCd, code.sggCd); + if (!isLaeSeCdReady || htdflSttn.isInit) { + return; } - }, [htdflSttn.isInit, initHtdfl]); + + skipNextSearchRef.current = true; + initHtdfl(false, 1, htdflSttnSize, String(getFixedConValue()), fcltNm, code.cityCd, code.sggCd); + }, [htdflSttn.isInit, initHtdfl, fcltNm, code.cityCd, code.sggCd, getFixedConValue, isLaeSeCdReady]); + + useEffect(() => { + if (!isLaeSeCdReady) { + return; + } + + if (skipNextSearchRef.current) { + skipNextSearchRef.current = false; + return; + } + + htdflSttnSearch(false, pageNation, htdflSttnSize, String(getFixedConValue()), fcltNm, code.cityCd, code.sggCd); + isResetPage(false) + }, [pageNation, htdflSttnSearch, fcltNm, code.cityCd, code.sggCd, getFixedConValue, isLaeSeCdReady]); useEffect(() => { const data = tableData(htdflSttnRes, { code }); @@ -171,18 +228,27 @@ export default function HtdflSttn() { className="col-span-2 w-1/6 h-full text-2xl" label="시도" selectClass={""} - selectArray={code.cityCd} + selectArray={ + !isAllAreaUser && fixedSidoValue + ? code.cityCd.filter((item) => item.val === fixedSidoValue) + : code.cityCd + } + value={objSearch.city || "0"} onSelectValue={getSelCity} /> item.val === "0" || item.val === fixedSigunValue) + : selectSgg || defaultSigunOptions + } + value={objSearch.sgg || "0"} onSelectValue={getSgg} /> -
+
@@ -201,7 +267,10 @@ export default function HtdflSttn() {
diff --git a/apps/wr/app/(with-layout)/ruseFcltMng/prfmncSttn/chkgdnSttn/page.tsx b/apps/wr/app/(with-layout)/ruseFcltMng/prfmncSttn/chkgdnSttn/page.tsx index 4e675954a..ee2e776ba 100644 --- a/apps/wr/app/(with-layout)/ruseFcltMng/prfmncSttn/chkgdnSttn/page.tsx +++ b/apps/wr/app/(with-layout)/ruseFcltMng/prfmncSttn/chkgdnSttn/page.tsx @@ -4,7 +4,7 @@ import { useWrStore } from "@/app/store"; import { btnClass } from "@/app/store/common/code"; import { rainPageSize } from "@/app/store/prfmncReg/chkgdnReg/rainChkgdn"; import { rswtPageSize } from "@/app/store/prfmncReg/chkgdnReg/rswrtChkgdn"; -import { excelDownloadNumStyle, yearOptions } from "@/components/lib/func/common"; +import { excelDownloadNumStyle, getCity, yearOptions } from "@/components/lib/func/common"; import { type SheetParameter } from "@common/business_components/ui"; import ChkgdModal from "@/components/ui/modal/chkgd/chkgdModal/chkgdModal"; import RainChkgdTable from "@/components/ui/table/chkgdTable/rainChkgd/rainChkgdTable"; @@ -17,16 +17,20 @@ import { SearchInput, SelectBox, UiModal, + type SelectDataType, } from "@common/business_components/ui"; import ListDiv from "@common/business_components/ui/list_div"; import { Button, Label } from "@common/components/ui"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useAlertStore } from "@/src/shared/lib"; import { useTranslation } from "react-i18next"; +const defaultSigunOptions: SelectDataType[] = [{ text: "전체", val: "0" }]; + export default function Page() { const { auth, + code, rainChkSttn, rswtChkSttn, rainChkSttnInit, @@ -36,15 +40,28 @@ export default function Page() { isRainSttn, isRswtSttn, rswtChkPolInit, + getLaeSeCdCheck, } = useWrStore((state) => { return state; }); const { laeSeCd } = auth; + const isAllAreaUser = laeSeCd === "0"; + const isLaeSeCdReady = + laeSeCd === "0" || laeSeCd.length === 2 || laeSeCd.length === 5; + const fixedSidoValue = + !isAllAreaUser && laeSeCd?.length ? laeSeCd.slice(0, 2) : ""; + const fixedSigunValue = + !isAllAreaUser && laeSeCd?.length === 5 ? laeSeCd : "0"; const [selFcltDiv, setSelFcltDiv] = useState("RNB"); const [rainPageNation, setRainPageNation] = useState(1); const [rswtPageNation, setRswtPageNation] = useState(1); const [fcltNm, setFcltNm] = useState(""); + const [selectSgg, setSelectSgg] = useState(defaultSigunOptions); + const [objSearch, setObjSearch] = useState<{ city: string; sgg: string }>({ + city: "", + sgg: "", + }); const [isModal, setIsModal] = useState({ open: false, data: {}, @@ -52,7 +69,51 @@ export default function Page() { const [searchYear, setSearchYear] = useState( String(new Date().getFullYear()) ); + const useInitRef = useRef(false); + const skipNextSearchRef = useRef(true); + + const getSelCity = (val: string | number) => { + const cityValue = String(val); + if (cityValue === "0") { + setSelectSgg(defaultSigunOptions); + setObjSearch({ city: cityValue, sgg: "" }); + } else { + const data = getCity(cityValue, code.sggCd); + setSelectSgg(data.length > 0 ? data : defaultSigunOptions); + const firstSggVal = + data.find((item) => item.val === "0")?.val || (data[0]?.val ?? ""); + setObjSearch({ city: cityValue, sgg: String(firstSggVal) }); + } + }; + + const getSgg = (val: string | number) => { + setObjSearch((prev) => ({ ...prev, sgg: String(val) })); + }; + + const getConValue = (): string => { + if (!objSearch.city || objSearch.city === "0") { + return "0"; + } + if (objSearch.sgg && objSearch.sgg !== "0") { + return objSearch.sgg; + } + return objSearch.city; + }; + + const getFixedConValue = useCallback((): string => { + if (laeSeCd === undefined || laeSeCd === "0") { + return getConValue(); + } + + return laeSeCd.length === 5 ? laeSeCd : laeSeCd.slice(0, 2); + }, [laeSeCd, objSearch]); + const cellClick = () => { + if (!isLaeSeCdReady) { + return; + } + + const conValue = getFixedConValue(); if (searchYear) { if (selFcltDiv === "RNB") { if (fcltNm) { @@ -60,11 +121,11 @@ export default function Page() { rainPageNation, rainPageSize, searchYear, - laeSeCd, + conValue, fcltNm ); } else { - rainChkSttnInit(rainPageNation, rainPageSize, searchYear, laeSeCd); + rainChkSttnInit(rainPageNation, rainPageSize, searchYear, conValue); } } else { if (fcltNm) { @@ -72,63 +133,108 @@ export default function Page() { rswtPageNation, rswtPageSize, searchYear, - laeSeCd, + conValue, fcltNm ); } else { - rswtChkSttnInit(rswtPageNation, rswtPageSize, searchYear, laeSeCd); + rswtChkSttnInit(rswtPageNation, rswtPageSize, searchYear, conValue); } } } }; const rainPageChange = (pageNo: number) => { + if (!isLaeSeCdReady) { + return; + } + + const conValue = getFixedConValue(); if (pageNo > 0) { setRainPageNation(pageNo); if (fcltNm) { - rainChkSttnNmSearch(pageNo, rainPageSize, searchYear, laeSeCd, fcltNm); + rainChkSttnNmSearch(pageNo, rainPageSize, searchYear, conValue, fcltNm); } else { - rainChkSttnInit(pageNo, rainPageSize, searchYear, laeSeCd); + rainChkSttnInit(pageNo, rainPageSize, searchYear, conValue); } } }; const rswtPageChange = (pageNo: number) => { + if (!isLaeSeCdReady) { + return; + } + + const conValue = getFixedConValue(); if (pageNo > 0) { setRswtPageNation(pageNo); if (fcltNm) { - rswtChkNmSttnSearch(pageNo, rainPageSize, searchYear, laeSeCd, fcltNm); + rswtChkNmSttnSearch(pageNo, rainPageSize, searchYear, conValue, fcltNm); } else { - rswtChkSttnInit(pageNo, rswtPageSize, searchYear, laeSeCd); + rswtChkSttnInit(pageNo, rswtPageSize, searchYear, conValue); } } }; useEffect(() => { + getLaeSeCdCheck(); + useInitRef.current = true; + }, [getLaeSeCdCheck]); + + useEffect(() => { + if (!isLaeSeCdReady) { + return; + } + + if (laeSeCd === "0") { + setSelectSgg(defaultSigunOptions); + setObjSearch({ city: "0", sgg: "" }); + return; + } + + const cityValue = laeSeCd.slice(0, 2); + const sigunOptions = getCity(cityValue, code.sggCd); + setSelectSgg(sigunOptions.length > 0 ? sigunOptions : defaultSigunOptions); + setObjSearch({ + city: cityValue, + sgg: laeSeCd.length === 5 ? laeSeCd : "0", + }); + }, [laeSeCd, code.sggCd, isLaeSeCdReady]); + + useEffect(() => { + if (!useInitRef.current || !isLaeSeCdReady || rainChkSttn.isInit) { + return; + } + + skipNextSearchRef.current = true; if (!rainChkSttn.isInit) { isRainSttn(true); rainChkSttnInit( 1, rainPageSize, new Date().getFullYear().toString(), - laeSeCd + getFixedConValue() ); setIsModal({ data: {}, open: false }); } - }, [rainChkSttn.isInit, rainChkSttnInit, rainPageSize]); + }, [rainChkSttn.isInit, rainChkSttnInit, rainPageSize, isLaeSeCdReady, getFixedConValue, isRainSttn]); useEffect(() => { + if (!useInitRef.current || !isLaeSeCdReady || rswtChkSttn.isInit) { + return; + } + + skipNextSearchRef.current = true; if (!rswtChkSttn.isInit) { setIsModal({ data: {}, open: false }); rswtChkSttnInit( 1, rswtPageSize, new Date().getFullYear().toString(), - laeSeCd + getFixedConValue() ); isRswtSttn(true); } - }, [rswtChkSttn.isInit, rswtChkSttnInit, rswtPageSize]); + }, [rswtChkSttn.isInit, rswtChkSttnInit, rswtPageSize, isLaeSeCdReady, getFixedConValue, isRswtSttn]); const handleClick = useCallback( (data: any) => { @@ -191,6 +297,42 @@ export default function Page() {
+
+ + item.val === fixedSidoValue) + : code.cityCd + } + value={objSearch.city || "0"} + onSelectValue={getSelCity} + /> +
+
+ + item.val === "0" || item.val === fixedSigunValue + ) + : selectSgg || defaultSigunOptions + } + value={objSearch.sgg || "0"} + onSelectValue={getSgg} + /> +
diff --git a/apps/wr/app/(with-layout)/stats/rswtStat/page.tsx b/apps/wr/app/(with-layout)/stats/rswtStat/page.tsx index 3e3e26dcf..b8c7d735c 100644 --- a/apps/wr/app/(with-layout)/stats/rswtStat/page.tsx +++ b/apps/wr/app/(with-layout)/stats/rswtStat/page.tsx @@ -41,7 +41,7 @@ export default function RswtStat() { "개소": item.ctpvCnt, // "건축연면적(m³)": item.archArea, "처리용량(m³)": item.filtrTrtmfctCpct, - "물사용량(m³/년)": item.wtrUsqty, + // "물사용량(m³/년)": item.wtrUsqty, "중수도+대체수이용량(m³/년)": item.rswtAltSum, "중수도이용량(m³/년)": item.rswtrUsqty, "대체수이용량(m³/년)": item.altUswtrUtztnQnt, @@ -59,8 +59,8 @@ export default function RswtStat() { data: (data as unknown as JSON[]) ?? [], fileName: "중수도재이용통계", sheetNameArray: ["Sheet1"], - headerRange: "A1:N1", - decimalArray0: [1,2,3,4,5,6,7,8,9,10,11,12,13], // 0부터 시작 + headerRange: "A1:M1", + decimalArray0: [1,2,3,4,5,6,7,8,9,10,11,12], // 0부터 시작 }) } else { alert(t("excelDownErrMsg")) @@ -174,15 +174,9 @@ export default function RswtStat() { } else if (i == 12) { const value = Number(item[12]) || 0; sidoData[i] = (Number(sidoData[i]) + value) as string | number; - } else if (i == 13) { - const value = Number(item[13]) || 0; - sidoData[i] = (Number(sidoData[i]) + value) as string | number; } else if (i == 14) { const value = Number(item[14]) || 0; sidoData[i] = (Number(sidoData[i]) + value) as string | number; - } else if (i == 16) { - const value = Number(item[16]) || 0; - sidoData[i] = (Number(sidoData[i]) + value) as string | number; } else { sidoData[i] = ''; } @@ -209,7 +203,7 @@ export default function RswtStat() { }); const sidoSummaryList = Array.from(sidoDataMap.values()); - const sidoTotalSummary = [['전국', '', '합계', 0, '', '', 0, '', '', 0, 0, 0, 0, 0, 0, '']]; + const sidoTotalSummary = [['전국', '', '합계', 0, '', '', 0, '', '', 0, 0, 0, 0, '', 0]]; sidoSummaryList.forEach((item: (string | number)[]) => { item.forEach((value: string | number, index: number) => { if (typeof value === 'number') { @@ -226,7 +220,7 @@ export default function RswtStat() { const sheetArray: SheetParameter[] = [{ data: excelData, headerNum: 3, - headerRange: "A1:P3", + headerRange: "A1:O3", sheetName: 'sheet1', startCol: 'A', decimalArray0: [1,2,3,4,5,6,7,8,9,10,11,12], // 0부터 시작 diff --git a/apps/wr/app/store/common/auth/index.tsx b/apps/wr/app/store/common/auth/index.tsx index e213bafa3..feca57c80 100644 --- a/apps/wr/app/store/common/auth/index.tsx +++ b/apps/wr/app/store/common/auth/index.tsx @@ -12,7 +12,7 @@ export type authState = { mdfcnAuthrtYn: string; delAuthrtYn: string; laeSeCd: string; // 지자체구분코드 - myUUID?: string; + myUUID?: string; }; }; @@ -67,17 +67,18 @@ export const authActionsExport: (set: any, get: any) => authActions = ( redirect("/"); } }, + // 로그인 사용자 지역코드 laeSeCd를 조회 후 상태 update getLaeSeCdCheck: async () => { try { const current = get().auth.laeSeCd; if (current && current !== "") { return; } - const infoMe = await getUserInfo(); - //console.log(infoMe); + const infoMe = await getUserInfo(); // 로그인 사용자의 정보 조회 + // console.log(infoMe); const isLaeSeCd = - infoMe.laeSeCd.slice(0, 2) === "G3" ? infoMe.laeSeCd.slice(2) : "0"; - + infoMe.laeSeCd.slice(0, 2) === "G3" ? infoMe.laeSeCd.slice(2) : "0"; //G341830 + // console.log("isLaeSeCd",isLaeSeCd); set((state: authState) => ({ ...state, auth: { diff --git a/apps/wr/app/store/common/code/index.ts b/apps/wr/app/store/common/code/index.ts index fb400de2c..adcff57f2 100644 --- a/apps/wr/app/store/common/code/index.ts +++ b/apps/wr/app/store/common/code/index.ts @@ -40,7 +40,6 @@ const codeMApping = { }; export const getCommonUpCd = async (set: any) => { try { - let lwCdCode: Record = {}; const upCd = await getFetchWithCount( fetchType.CS, "/cms-service/api/v1/up-codes?keyword=&upCd=&cdSe=&useYn=Y&siteId=WR&page=0&size=100&sort=string" @@ -50,24 +49,9 @@ export const getCommonUpCd = async (set: any) => { .filter((item: any) => item.upCd.slice(0, 2) === "WR") .map((cd: any) => cd.upCd); - await Promise.all( - upWrCode.map(async (cd) => { - const mapping = codeMApping[cd as keyof typeof codeMApping]; - if (!mapping) return; + await fetchCommonCodesByKeys(upWrCode); - const lwCd = await getFetchWithCount( - fetchType.CS, - `/cms-service/api/v1/lwpo-cds/up-cd/${cd}` - ); - - const list = Array.isArray(lwCd) ? lwCd : (lwCd.content ?? []); - lwCds[mapping] = list.map((it: any) => ({ - text: it.lwpoCdNm, - val: it.lwpoCd, - })); - }) - ); - lwCdCode = lwCds; + const lwCdCode = lwCds; set((state: comCodeState) => ({ code: { ...state.code, @@ -78,6 +62,8 @@ export const getCommonUpCd = async (set: any) => { pplLenght: lwCdCode["pplLenght"] ?? [], dmdUszCd: lwCdCode["dmdUszCd"] ?? [], sewageOutFclttUse: lwCdCode["sewageOutFclttUse"] ?? [], + rainRswtMng: lwCdCode["rainRswtMng"] ?? [], + rainFcltFlowRate: lwCdCode["rainFcltFlowRate"] ?? [], ruseMngMtd: lwCdCode["ruseMngMtd"] ?? [], rusePrcsMthdCd: lwCdCode["rusePrcsMthdCd"] ?? [], erctUswtrPrcsMeth: lwCdCode["erctUswtrPrcsMeth"] ?? [], @@ -93,6 +79,30 @@ export const getCommonUpCd = async (set: any) => { console.log("com code error ", e); } }; + +const fetchCommonCodesByKeys = async (keys: string[]) => { + const targets = [...new Set(keys)].filter( + (cd) => Boolean(codeMApping[cd as keyof typeof codeMApping]) + ); + + await Promise.all( + targets.map(async (cd) => { + const mapping = codeMApping[cd as keyof typeof codeMApping]; + if (!mapping) return; + + const lwCd = await getFetchWithCount( + fetchType.CS, + `/cms-service/api/v1/lwpo-cds/up-cd/${cd}` + ); + + const list = Array.isArray(lwCd) ? lwCd : (lwCd.content ?? []); + lwCds[mapping] = list.map((it: any) => ({ + text: it.lwpoCdNm, + val: it.lwpoCd, + })); + }) + ); +}; //물재이용 주소 코드: devU01TX0FVVEgyMDI1MDgyNTEwMzkwNDExNjEwNTM= export const buildingType = { RST: "RST", @@ -219,41 +229,42 @@ export const getEnvPartCd = async (set: any) => { export type comCodeState = { code: { - isInit: boolean; - chBuCd: SelectDataType[]; - rainFcltCd: SelectDataType[]; - bdstUsgCd: SelectDataType[]; - cityCd: SelectDataType[]; - sggCd: SelectDataType[]; - rvbsnCd: SelectDataType[]; - mdmbsnCd: SelectDataType[]; - lgbsnCd: SelectDataType[]; - lclgvrnmntCd: EnvPartSelectDataType[]; - orgwtKndCd: SelectDataType[]; - yn: SelectDataType[]; - pplQulity: SelectDataType[]; - pplLenght: SelectDataType[]; - dmdUszCd: SelectDataType[]; - sewageOutFclttUse: SelectDataType[]; + isInit: boolean; // 초기화 여부 + chBuCd: SelectDataType[]; // 변경사업코드 + rainFcltCd: SelectDataType[]; // 빗물시설코드 + bdstUsgCd: SelectDataType[]; // 건물용도코드 + cityCd: SelectDataType[]; // 시도코드 + sggCd: SelectDataType[]; // 시군구코드 + rvbsnCd: SelectDataType[]; // 대권역코드 + mdmbsnCd: SelectDataType[]; // 중권역코드 + lgbsnCd: SelectDataType[]; // 단위유역(대영역)코드 + lclgvrnmntCd: EnvPartSelectDataType[]; // 지방환경청코드 + orgwtKndCd: SelectDataType[]; // 원수종류코드 + yn: SelectDataType[]; // 여부코드 + pplQulity: SelectDataType[]; // 관저질 + pplLenght: SelectDataType[]; // 관연장 + dmdUszCd: SelectDataType[]; // 용도별코드 + sewageOutFclttUse: SelectDataType[]; // 하수재이용 장외코드 + rainRswtMng: SelectDataType[]; // 운영관리형태코드 + rainFcltFlowRate: SelectDataType[]; // 유량측정방법코드 buildingType: { - RST: string; - RNB: string; - HTB: string; - }; - ruseMngMtd: SelectDataType[]; - ruseSeCd: SelectDataType[]; - ruseBizMthdCd: SelectDataType[]; - rusePrcsMthdCd: SelectDataType[]; - erctUswtrPrcsMeth: SelectDataType[]; - bdstFilterYn: SelectDataType[]; - bdstIncenYn: SelectDataType[]; - bdstGrntchClass: SelectDataType[]; - // rswtPrscCdMthdCd: SelectDataType[]; - htdPowerMthdCd: SelectDataType[]; - htdUtztnUsgCd: SelectDataType[]; - rswtBdstType: SelectDataType[]; - bdstTotalUsgType: SelectDataType[]; - userSiSggCd: string; + RST: string; // 중수도시설 구분 + RNB: string; // 빗물이용시설 구분 + HTB: string; // 온배수시설 구분 + }; // 시설구분 + ruseMngMtd: SelectDataType[]; // 재이용관리방식코드 + ruseSeCd: SelectDataType[]; // 재이용구분코드 + ruseBizMthdCd: SelectDataType[]; // 재이용사업방식코드 + rusePrcsMthdCd: SelectDataType[]; // 재이용처리공법코드 + erctUswtrPrcsMeth: SelectDataType[]; // 하수처리공법코드 + bdstFilterYn: SelectDataType[]; // 여과시설여부코드 + bdstIncenYn: SelectDataType[]; // 인센티브여부코드 + bdstGrntchClass: SelectDataType[]; // 보조금지원대상분류코드 + htdPowerMthdCd: SelectDataType[]; // 온배수발전방식코드 + htdUtztnUsgCd: SelectDataType[]; // 온배수이용용도코드 + rswtBdstType: SelectDataType[]; // 중수도건물유형코드 + bdstTotalUsgType: SelectDataType[]; // 전체건물용도유형코드 + userSiSggCd: string; // 사용자 시군구코드 }; }; @@ -277,6 +288,8 @@ export const comCodeStateExport: comCodeState = { yn: yn, dmdUszCd: lwCds["dmdUszCd"] ?? [], sewageOutFclttUse: lwCds["sewageOutFclttUse"] ?? [], + rainRswtMng: lwCds["rainRswtMng"] ?? [], + rainFcltFlowRate: lwCds["rainFcltFlowRate"] ?? [], buildingType: buildingType, ruseMngMtd: lwCds["ruseMngMtd"] ?? [], ruseSeCd: ruseSeCd, @@ -298,10 +311,51 @@ export const comCodeStateExport: comCodeState = { export type comCodeActions = { returnCodeText: (val: string, codeName: string) => string; returnTextCode: (text: string, codeName: string) => string; + getCommonCodesByKeys: (keys: string[]) => Promise; }; export const codeActionExport: (set: any, get:any) => comCodeActions = (set: any, get:any) => { return { + getCommonCodesByKeys: async (keys: string[]) => { + try { + await fetchCommonCodesByKeys(keys); + const lwCdCode = lwCds; + + set((state: comCodeState) => ({ + code: { + ...state.code, + rainFcltCd: lwCdCode["rainFcltCd"] ?? state.code.rainFcltCd, + bdstUsgCd: lwCdCode["bdstUsgCd"] ?? state.code.bdstUsgCd, + orgwtKndCd: lwCdCode["orgwtKndCd"] ?? state.code.orgwtKndCd, + pplQulity: lwCdCode["pplQulity"] ?? state.code.pplQulity, + pplLenght: lwCdCode["pplLenght"] ?? state.code.pplLenght, + dmdUszCd: lwCdCode["dmdUszCd"] ?? state.code.dmdUszCd, + sewageOutFclttUse: + lwCdCode["sewageOutFclttUse"] ?? state.code.sewageOutFclttUse, + rainRswtMng: lwCdCode["rainRswtMng"] ?? state.code.rainRswtMng, + rainFcltFlowRate: + lwCdCode["rainFcltFlowRate"] ?? state.code.rainFcltFlowRate, + ruseMngMtd: lwCdCode["ruseMngMtd"] ?? state.code.ruseMngMtd, + rusePrcsMthdCd: + lwCdCode["rusePrcsMthdCd"] ?? state.code.rusePrcsMthdCd, + erctUswtrPrcsMeth: + lwCdCode["erctUswtrPrcsMeth"] ?? state.code.erctUswtrPrcsMeth, + bdstGrntchClass: + lwCdCode["bdstGrntchClass"] ?? state.code.bdstGrntchClass, + htdPowerMthdCd: + lwCdCode["htdPowerMthdCd"] ?? state.code.htdPowerMthdCd, + htdUtztnUsgCd: + lwCdCode["htdUtztnUsgCd"] ?? state.code.htdUtztnUsgCd, + rswtBdstType: lwCdCode["rswtBdstType"] ?? state.code.rswtBdstType, + bdstTotalUsgType: + lwCdCode["bdstUsgCd"]?.concat(lwCdCode["rswtBdstType"] || []) ?? + state.code.bdstTotalUsgType, + }, + })); + } catch (e) { + console.log("selected common code error ", e); + } + }, returnCodeText: (val: string, codeName: string) => { const codeList = get().code[codeName] || []; diff --git a/apps/wr/app/store/map/splyPop/index.tsx b/apps/wr/app/store/map/splyPop/index.tsx index a3fcba03b..7ea586e63 100644 --- a/apps/wr/app/store/map/splyPop/index.tsx +++ b/apps/wr/app/store/map/splyPop/index.tsx @@ -1,6 +1,7 @@ import { getFetch } from "@/components/lib/api"; import type { FacilityInfo, SubsInfo } from "./type"; import { DEV_URL_FO } from "@/components/lib/config"; +import { SITE_ID } from "@/src/shared/config"; const checkWrRegList = async () => { const wrRegList = await getFetch("wr", `/reuseFc/pubFc`); @@ -26,6 +27,7 @@ const getRuseFclt = async ( method: "POST", headers: { "Content-Type": "application/json", + "X-Site-Id": SITE_ID, }, body: JSON.stringify(ruseList), }); diff --git a/apps/wr/app/store/prfmncReg/chkgdnReg/rswrtChkgdn/excel.tsx b/apps/wr/app/store/prfmncReg/chkgdnReg/rswrtChkgdn/excel.tsx index 6e1114781..cc5109000 100644 --- a/apps/wr/app/store/prfmncReg/chkgdnReg/rswrtChkgdn/excel.tsx +++ b/apps/wr/app/store/prfmncReg/chkgdnReg/rswrtChkgdn/excel.tsx @@ -270,35 +270,25 @@ export const rswtExcelDataCheck = ( issues: string[] ) => { // const chkMngType = { "01": "위탁", "02": "자체" }; - + //rainRswtMng: SelectDataType[]; // 운영관리형태코드 + // console.log(rswtChkgdData); rswtChkgdData.forEach((rswt: any) => { const defaultMessage = `*중수도 수질 [NO: ${rswt.bdstRuseFcltSn}, 점검일: ${rswt.chckYmd}, 이용용도: ${codeToText(rswt.utztnUsgCd ,"dmdUszCd")??rswt.utztnUsgCd}] 항목에서 `; if (rswt.chckYmd && isInvalidDate(rswt.chckYmd)) { issues.push(defaultMessage + `점검일: 날짜 형식 아님 (${rswt.chckYmd})`); } - // if (rswt.operMngCd?.length > 1 && !chkMngType[rswt.operMngCd]) { - // issues.push( - // defaultMessage + - // `운영관리형태: 위탁 혹은 자체만 허용 (${rswt.operMngCd})` - // ); - // } - // if (rswt.utztnUsgCd?.length > 2 && !codeToText(rswt.utztnUsgCd, "dmdUszCd")) { - // issues.push( - // defaultMessage + `이용용도: 허용되지 않은 값 (${rswt.utztnUsgCd})` - // ); - // } + if (rswt.operMngCd?.length > 1 && !codeToText(rswt.operMngCd, "rainRswtMng")) { + issues.push( defaultMessage + `운영관리형태: 위탁 혹은 자체만 허용 (${rswt.operMngCd})` ); + } + if (rswt.utztnUsgCd?.length > 2 && !codeToText(rswt.utztnUsgCd, "dmdUszCd")) { + issues.push( defaultMessage + `이용용도: 허용되지 않은 값 (${rswt.utztnUsgCd})` ); + } if (rswt.anlOperCst.length > 2 && isInvalidNumber(rswt.anlOperCst)) { - - issues.push( - defaultMessage + - ` 연간운영비(원/년): 숫자 형식 아님 (${rswt.anlOperCst})` - ); + issues.push( defaultMessage + ` 연간운영비(원/년): 숫자 형식 아님 (${rswt.anlOperCst})` ); } if (rswt.prcsCpct.length > 2 && isInvalidNumber(rswt.prcsCpct)) { - issues.push( - defaultMessage + ` 처리용량(m³): 숫자 형식 아님 (${rswt.prcsCpct})` - ); + issues.push( defaultMessage + ` 처리용량(m³): 숫자 형식 아님 (${rswt.prcsCpct})` ); } // if ( // rswt.spwtPositn.length > 1 && @@ -313,55 +303,31 @@ export const rswtExcelDataCheck = ( if (rswt?.rswtrPollutions && rswt?.rswtrPollutions.length > 0) { rswt.rswtrPollutions.forEach((pol) => { if (isInvalidNumber(pol.grssClfgUswtr)) { - issues.push( - defaultMessage + - `${pol.node}분기_총대장균군수: 숫자 형식 아님 (${pol.grssClfgUswtr})` - ); + issues.push( defaultMessage + `${pol.node}분기_총대장균군수: 숫자 형식 아님 (${pol.grssClfgUswtr})` ); } if (isInvalidNumber(pol.combResdch)) { - issues.push( - defaultMessage + - `${pol.node}분기_결합잔류염소: 숫자 형식 아님 (${pol.combResdch})` - ); + issues.push( defaultMessage + `${pol.node}분기_결합잔류염소: 숫자 형식 아님 (${pol.combResdch})` ); } if (isInvalidNumber(pol.trbt)) { - issues.push( - defaultMessage + - `${pol.node}분기_탁도: 숫자 형식 아님 (${pol.trbt})` - ); + issues.push( defaultMessage + `${pol.node}분기_탁도: 숫자 형식 아님 (${pol.trbt})` ); } if (isInvalidNumber(pol.bod)) { - issues.push( - defaultMessage + `${pol.node}분기_BOD: 숫자 형식 아님 (${pol.bod})` - ); + issues.push( defaultMessage + `${pol.node}분기_BOD: 숫자 형식 아님 (${pol.bod})` ); } if (isInvalidNumber(pol.crmty)) { - issues.push( - defaultMessage + - `${pol.node}분기_색도: 숫자 형식 아님 (${pol.crmty})` - ); + issues.push( defaultMessage + `${pol.node}분기_색도: 숫자 형식 아님 (${pol.crmty})` ); } if (isInvalidNumber(pol.tph)) { - issues.push( - defaultMessage + `${pol.node}분기_T-P: 숫자 형식 아님 (${pol.tph})` - ); + issues.push( defaultMessage + `${pol.node}분기_T-P: 숫자 형식 아님 (${pol.tph})` ); } if (isInvalidNumber(pol.tn)) { - issues.push( - defaultMessage + `${pol.node}분기_T-N: 숫자 형식 아님 (${pol.tn})` - ); + issues.push( defaultMessage + `${pol.node}분기_T-N: 숫자 형식 아님 (${pol.tn})` ); } - if (pol.ph?.length > 1 && isInvalidNumber(pol.ph)) { - issues.push( - defaultMessage + `${pol.node}분기_PH: 숫자 형식 아님 (${pol.ph})` - ); + if (isInvalidNumber(pol.ph)) { + issues.push( defaultMessage + `${pol.node}분기_PH: 숫자 형식 아님 (${pol.ph})` ); } - - if (isInvalidNumber(pol.clIonQnt)) { - issues.push( - defaultMessage + - `${pol.node}분기_염소이온량: 숫자 형식 아님 (${pol.clIonQnt})` - ); + if (isInvalidNumber(pol.clIonQnt)) { + issues.push( defaultMessage + `${pol.node}분기_염소이온량: 숫자 형식 아님 (${pol.clIonQnt})` ); } }); } @@ -369,19 +335,43 @@ export const rswtExcelDataCheck = ( if (rswt?.rswtrDetails && rswt?.rswtrDetails.length > 0) { rswt?.rswtrDetails.forEach((key) => { if (key.levyAmt?.length > 1 && isInvalidNumber(key.levyAmt)) { - issues.push( - defaultMessage + - `과태료부과금액(천원): 숫자 형식 아님 (${key.levyAmt})` - ); + issues.push( defaultMessage + `과태료부과금액(천원): 숫자 형식 아님 (${key.levyAmt})` ); } if (key.vltnDt?.length > 2 && isInvalidDate(key.vltnDt)) { - issues.push( - defaultMessage + - `과태료 부과일(위반일): 날짜 형식 아님 (${key.vltnDt})` - ); + issues.push( defaultMessage + `과태료 부과일(위반일): 날짜 형식 아님 (${key.vltnDt})` ); } }); } + + const polutionUseCodes = new Set( + (rswt?.rswtrPollutions ?? []) + .map((pol) => pol.utztnUsgCd) + .filter((code): code is string => Boolean(code)) + ); + const guideDetailCodes = new Set( + (rswt?.guideDetails ?? []) + .map((detail) => detail.utztnUsgCd) + .filter((code): code is string => Boolean(code)) + ); + + polutionUseCodes.forEach((code) => { + if (!guideDetailCodes.has(code)) { + issues.push( + defaultMessage + + `중수도_점검결과_수질 sheet의 이용용도에 대응하는 중수도_점검결과_이용량현황sheet의 이용용도가 없습니다.` + ); + } + }); + + guideDetailCodes.forEach((code) => { + if (!polutionUseCodes.has(code)) { + issues.push( + defaultMessage + + `중수도_점검결과_이용량현황sheet의 이용용도에 대응하는 중수도_점검결과_수질sheet의 이용용도가 없습니다.` + ); + } + }); + }); }; diff --git a/apps/wr/app/store/prfmncReg/chkgdnReg/rswrtChkgdn/index.tsx b/apps/wr/app/store/prfmncReg/chkgdnReg/rswrtChkgdn/index.tsx index 3ce6cc2ff..71ae80eda 100644 --- a/apps/wr/app/store/prfmncReg/chkgdnReg/rswrtChkgdn/index.tsx +++ b/apps/wr/app/store/prfmncReg/chkgdnReg/rswrtChkgdn/index.tsx @@ -1,4 +1,7 @@ -import { buildingType } from "@/app/store/common/code"; +/** + * 중수도 지도점검 store + */ +import { buildingType } from "@/app/store/common/code"; import type { TableUpperProps } from "@/app/store/common/type/header"; import { TEST_UUID, @@ -53,29 +56,14 @@ export type rswtChkgdnState = { isInit: boolean; // 초기 목록 조회 완료 여부 isSttn: boolean; // 테이블 선택/상세 상태 제어 플래그 isLoading:boolean; // 등록/업로드 등 비동기 처리 진행 여부 - rswtChkCount: number; // 메인 목록 전체 건수 + rswtChkCount: number; // 메인 목록 전체 건수 : 시설의 건수임 }; }; type rswtChkgdnActions = { - rswtChkgdnInit: ( - pageNo: number, - cnt: number, - year: string, - sggCd: string - ) => void; - rswtChkNmSearch: ( - pageNo: number, - cnt: number, - year: string, - sggCd: string, - txt: string - ) => void; - rswtrChkExcelDownload( - sidoCode: SelectDataType[], - sggCode: SelectDataType[], - sggCd: string, - ): Promise; + rswtChkgdnInit: ( pageNo: number, cnt: number, year: string, sggCd: string ) => void; + rswtChkNmSearch: ( pageNo: number, cnt: number, year: string, sggCd: string, txt: string ) => void; + rswtrChkExcelDownload( sidoCode: SelectDataType[], sggCode: SelectDataType[], sggCd: string, ): Promise; rswtChkExceDataUpload: (excelData: rswtChkZodType[],laeSeCd:string) => Promise; rswtChkDelete: (chkgdnSns: number[] ,year:string) => Promise; rswtChkDetilsUpdate: (rswtChkDetails: rswtrChkDetailsZodType[] ,year:string) => Promise; @@ -189,8 +177,7 @@ export const rswtChkgdnActionsExport: (set: any) => rswtChkgdnActions = ( // set((state: rswtChkgdnType) => ({ rswtChkgdn: { ...state.rswtChkgdn, isLoading: true } })); try { const rswtChkRes = await initFetch(pageNo, cnt, year, sggCd); - - + // console.log(rswtChkRes); set((state: rswtChkgdnState) => ({ ...state, rswtChkgdn: { @@ -246,7 +233,7 @@ export const rswtChkgdnActionsExport: (set: any) => rswtChkgdnActions = ( rswtChkExceDataUpload: async (excelData: rswtChkZodType[],laeSeCd:string) => { const successMessage = "지도점검 등록이 완료되었습니다."; const failMessage = "지도점검 등록이 실패되었습니다."; - console.log(excelData); + // console.log(excelData); try { set((state: rswtChkgdnType) => ( { rswtChkgdn: { ...state.rswtChkgdn, isLoading: true } })); diff --git a/apps/wr/app/store/ruseFcltMng/fcltReg/pbnstReg/index.tsx b/apps/wr/app/store/ruseFcltMng/fcltReg/pbnstReg/index.tsx index 35458abd5..505d18557 100644 --- a/apps/wr/app/store/ruseFcltMng/fcltReg/pbnstReg/index.tsx +++ b/apps/wr/app/store/ruseFcltMng/fcltReg/pbnstReg/index.tsx @@ -6,6 +6,7 @@ import { postFectch, putFectch, } from "@/components/lib/api"; +import { SITE_ID } from "@/src/shared/config"; import type { chHeadType } from "@/app/store/prfmncReg/chkgdnReg/rainChkgdn/headList"; import { type customPbnst, @@ -99,18 +100,18 @@ export const pubFacStateExport: pubFacState = { pbnstHasuResCount: 1, }, }; - +// 재사용시설에 대한 정보 조회 export const checkWrRegList = async () => { const wrRegList = await getFetch("wr", `/reuseFc/pubFc`); return wrRegList; }; - +// 하수처리시설 정보 조회 const hasuListFetch = async ( page: string, - sggOrCity: string, + sggOrCity: string, // 해당 지자체의 하수처리시설 조회 searchTxt?: string ) => { - const checkWrRegListRes = await checkWrRegList(); + const checkWrRegListRes = await checkWrRegList(); // 재사용시설에 대한 정보 조회 let url = `/prcsFclt/ruseInfo/${page ? page : 1}/11/A/${sggOrCity ? sggOrCity : "11"}/0/1/SW`; @@ -307,6 +308,7 @@ export const pubFacActionsExport: (set: any) => pubFacActions = (set: any) => { method: "GET", headers: { accept: "application/octet-stream", + "X-Site-Id": SITE_ID, }, } ); diff --git a/apps/wr/app/store/ruseFcltMng/fcltSttn/pbnstSttn/index.tsx b/apps/wr/app/store/ruseFcltMng/fcltSttn/pbnstSttn/index.tsx index 9e2c47a04..ee94ada00 100644 --- a/apps/wr/app/store/ruseFcltMng/fcltSttn/pbnstSttn/index.tsx +++ b/apps/wr/app/store/ruseFcltMng/fcltSttn/pbnstSttn/index.tsx @@ -112,6 +112,12 @@ export const hasuListFetch = async ( year: string, flcts: string[] ) => { + + // 시설 코드가 없으면 FO 조회를 호출하지 않고 빈 결과 반환 + if (flcts.length === 0) { + return { data: [], count: 0 }; + } + let url = ``; let filteredFclt = flcts; const filterData = @@ -163,6 +169,11 @@ export const hasuListFetch = async ( url += "/" + searchNm; } + // 시설 코드가 없으면 FO 조회를 호출하지 않고 빈 결과 반환 + if (filteredFclt.length === 0) { + return { data: [], count: 0 }; + } + const pbnList = await postFectch( `/prcsFclt/ruseInfo/1/${filteredFclt.length}/` + url, filteredFclt, diff --git a/apps/wr/app/store/stats/rainStat/excel.tsx b/apps/wr/app/store/stats/rainStat/excel.tsx index db2099d9a..2405872bb 100644 --- a/apps/wr/app/store/stats/rainStat/excel.tsx +++ b/apps/wr/app/store/stats/rainStat/excel.tsx @@ -1,41 +1,51 @@ -import type { RainStatProps } from "."; +import { codeToText } from "@/components/lib/func/common"; +import type { RainStatProps } from "."; -export const excelRainStat = (excelList:RainStatProps[]) : (string | number)[][] => { +const toUseTypeText = (val: string) => + val + .split(",") + .map((code) => code.trim()) + .filter(Boolean) + .map((code) => codeToText(code, "dmdUszCd") ?? code) + .join(", "); + +export const excelRainStat = ( + excelList: RainStatProps[] +): (string | number)[][] => { const excelData: (string | number)[][] = []; - excelList.forEach((item) => { + excelList.forEach((item) => { excelData.push([ //시도 item.ctpvCd ?? "", //시군구 item.sggCd ?? "", - //행정구역명 - String(item.areaNm) ?? "", + //행정구역명 + String(item.areaNm) ?? "", //시설명 String(item.bdstNm) ?? "", //건축물용도코드 (값 없음) - //String(item.bdstUsgCd) ?? "", - "", + codeToText(String(item.bdstUsgCd ?? ""), "bdstUsgCd") ?? String(item.bdstUsgCd ?? ""), //주소 String(item.daddr) ?? "", //법적의무대상여부 - String(item.lawTrgtYn)==="Y" ? "대상" : "미대상", + String(item.lawTrgtYn) === "Y" ? "대상" : "미대상", //설치완료일자 String(item.instlCmptnDt).substring(0, 10) ?? "", //설치비용 item.instlCst ?? "", - //접수면종류 - String(item.pntfColKnd) ?? "", + //집수면종류 + codeToText(String(item.pntfColKnd ?? ""), "rainFcltCd") ?? String(item.pntfColKnd ?? ""), //여과등처리시설유무 String(item.filtrTrtmfctYn) ?? "", - //처리용량(저류조용량) - item.dtfcltCpct ?? "", - //연간사용량 - item.wtrUsqty ?? "", - //이용용도코드 - item.utztnUsgCd ?? "", - //연간운영비 - item.anlOperCst ?? "", - ]); - }); + //처리용량(저류조용량) + item.dtfcltCpct ?? "", + //연간사용량 + item.wtrUsqty ?? "", + //이용용도코드 + toUseTypeText(item.utztnUsgCd ?? ""), + //연간운영비 + item.anlOperCst ?? "", + ]); + }); return excelData; -} \ No newline at end of file +}; diff --git a/apps/wr/app/store/stats/rainStat/headList.tsx b/apps/wr/app/store/stats/rainStat/headList.tsx index 7601919d5..47737635b 100644 --- a/apps/wr/app/store/stats/rainStat/headList.tsx +++ b/apps/wr/app/store/stats/rainStat/headList.tsx @@ -1,158 +1,34 @@ import type { TableUpperProps } from "../../common/type/header"; export const rainStatHeadListExport: TableUpperProps[] = [ - { - id: "ctpvCd", - title: "시도", - row:0, - rowSpan:2 - }, - { - id: "tmp", - title: "개소", - row:0, - rowSpan:2 - }, - { - id: "instlCst", - title: "설치비(백만원)", - row:0, - rowSpan:2 - }, - { - id: "dtfcltCpct", - title: "저류조용량(m³/년)", - row:0, - rowSpan:2 - }, - { - id: "wtrUsqty", - title: "연간사용량(㎥/년)", - row:0, - rowSpan:2 - }, - { - id: "a", - title: "이용용도", - row:0, - colSpan:7 - }, - { - id: "lndwtr", - title: "조경용수", - commonCode : "01", - row:1, - }, - { - id: "cleanToilUswtr", - title: "청소화장실용수", - commonCode : "02", - row:1, - }, - { - id:"resn", - title:"세척살수용수", - commonCode : "03", - row:1, - }, - { - id: "farmUswtr", - title: "농업용수", - commonCode : "04", - row:1, - }, - { - id: "indstUswtr", - title: "공업용수", - commonCode : "05", - row:1, - }, - { - id: "hydUswtr", - title: "친수용수", - commonCode : "06", - row:1, - }, - { - id: "ithlEtcUswtr", - title: "기타", - commonCode : "07", - row:1, - }, - { - id: "anlOperCst", - title: "연간운영비(백만원/년)", - row:0, - rowSpan:2 - }, + { id: "ctpvCd", title: "시도", row:0, rowSpan:2 }, + { id: "tmp", title: "개소", row:0, rowSpan:2 }, + { id: "instlCst", title: "설치비(백만원)", row:0, rowSpan:2 }, + { id: "dtfcltCpct", title: "저류조용량(m³/년)", row:0, rowSpan:2 }, + { id: "wtrUsqty", title: "연간사용량(㎥/년)", row:0, rowSpan:2 }, + { id: "a", title: "이용용도", row:0, colSpan:7 }, + { id: "lndwtr", title: "조경용수", commonCode : "01", row:1 }, + { id: "cleanToilUswtr", title: "청소화장실용수", commonCode : "02", row:1 }, + { id: "resn", title: "세척살수용수", commonCode : "03", row:1 }, + { id: "farmUswtr", title: "농업용수", commonCode : "04", row:1 }, + { id: "indstUswtr", title: "공업용수", commonCode : "05", row:1 }, + { id: "hydUswtr", title: "친수용수", commonCode : "06", row:1 }, + { id: "ithlEtcUswtr", title: "기타", commonCode : "07", row:1 }, + { id: "anlOperCst", title: "연간운영비(백만원/년)", row:0, rowSpan:2 }, ]; export const rainStatDetailHeadListExport: TableUpperProps[] = [ - { - id: "ctpvCd", - title: "시도", - row:0, - }, - { - id: "sggCd", - title: "시군구", - row:0, - }, - { - id: "areaNm", - title: "행정구역명", - row:0, - }, - { - id: "", - title: "시설명", - row:0, - }, - { - id: "이용용도", - title: "이용용도", - row:0, - colSpan:7 - }, - { - id: "조경용수", - title: "조경용수", - row:1, - }, - { - id: "청소화장실용수", - title: "청소화장실용수", - row:1, - }, - { - id:"세척살수용수", - title:"세척살수용수", - row:1, - }, - { - id: "농업용수", - title: "농업용수", - row:1, - }, - { - id: "공업용수", - title: "공업용수", - row:1, - }, - { - id: "친수용수", - title: "친수용수", - row:1, - }, - { - id: "기타", - title: "기타", - row:1, - }, - { - id: "연간운영비", - title: "연간운영비(백만원/년)", - row:0, - rowSpan:2 - }, + { id: "ctpvCd", title: "시도", row:0 }, + { id: "sggCd", title: "시군구", row:0 }, + { id: "areaNm", title: "행정구역명", row:0 }, + { id: "", title: "시설명", row:0 }, + { id: "이용용도", title: "이용용도", row:0, colSpan:7 }, + { id: "조경용수", title: "조경용수", row:1 }, + { id: "청소화장실용수", title: "청소화장실용수", row:1 }, + { id: "세척살수용수", title: "세척살수용수", row:1 }, + { id: "농업용수", title: "농업용수", row:1 }, + { id: "공업용수", title: "공업용수", row:1 }, + { id: "친수용수", title: "친수용수", row:1 }, + { id: "기타", title: "기타", row:1 }, + { id: "연간운영비", title: "연간운영비(백만원/년)", row:0, rowSpan:2 }, ]; \ No newline at end of file diff --git a/apps/wr/app/store/stats/rainStat/index.tsx b/apps/wr/app/store/stats/rainStat/index.tsx index d08db404d..084d6e938 100644 --- a/apps/wr/app/store/stats/rainStat/index.tsx +++ b/apps/wr/app/store/stats/rainStat/index.tsx @@ -89,6 +89,7 @@ export const rainStatActionsExport = ( searchRainStatList: async (year: string, city: SelectDataType[]) => { const res = await fetchRainStatList(year); const cityArr: any[] = []; + excelDownList.length = 0; res.filter((resFilter: RainStatProps) => city.some((cityCd) => @@ -104,7 +105,6 @@ export const rainStatActionsExport = ( if (obj) { obj.instlCst += item.instlCst; obj.dtfcltCpct += item.dtfcltCpct; - obj.wtrUsqty += item.wtrUsqty; obj.anlOperCst += item.anlOperCst; obj.tmp += 1; } else { @@ -113,7 +113,7 @@ export const rainStatActionsExport = ( obj.instlCst = item.instlCst; obj.tmp = 1; obj.dtfcltCpct = item.dtfcltCpct; - obj.wtrUsqty = item.wtrUsqty; + obj.wtrUsqty = 0; obj.anlOperCst = item.anlOperCst; obj.lndwtr = 0; //조경용수 obj.cleanToilUswtr = 0; //청소화장실용수 @@ -138,6 +138,15 @@ export const rainStatActionsExport = ( }); }); } + + obj.wtrUsqty = + obj.lndwtr + + obj.cleanToilUswtr + + obj.resn + + obj.farmUswtr + + obj.indstUswtr + + obj.hydUswtr + + obj.ithlEtcUswtr; }); const totalObj = { @@ -145,7 +154,7 @@ export const rainStatActionsExport = ( tmp: 0, instlCst: 0, //설치비 dtfcltCpct: 0, //저류조용량 - bdstUsgCd: 0, //연간사용량 + wtrUsqty: 0, //연간사용량 lndwtr: 0, //조경용수 cleanToilUswtr: 0, //청소화장실용수 resn: 0, //세척살수용수 @@ -165,8 +174,8 @@ export const rainStatActionsExport = ( totalObj.dtfcltCpct += !isNaN(Number(item.dtfcltCpct)) ? Number(item.dtfcltCpct) : 0; - totalObj.bdstUsgCd += !isNaN(Number(item.bdstUsgCd)) - ? Number(item.bdstUsgCd) + totalObj.wtrUsqty += !isNaN(Number(item.wtrUsqty)) + ? Number(item.wtrUsqty) : 0; totalObj.lndwtr += !isNaN(Number(item.lndwtr)) ? Number(item.lndwtr) @@ -196,7 +205,7 @@ export const rainStatActionsExport = ( tmp: item.tmp, instlCst: item.instlCst, //설치비 dtfcltCpct: item.dtfcltCpct, //저류조용량 - bdstUsgCd: item.bdstUsgCd, //연간사용량 + wtrUsqty: item.wtrUsqty, //연간사용량 lndwtr: item.lndwtr, //조경용수 cleanToilUswtr: item.cleanToilUswtr, //청소화장실용수 resn: item.resn, //세척살수용수 @@ -208,17 +217,30 @@ export const rainStatActionsExport = ( }); }); + cityArr.forEach((item) => { + item.instlCst = Math.round(Number(item.instlCst) / 1000000); + item.anlOperCst = Math.round(Number(item.anlOperCst) / 1000000); + }); + + totalObj.instlCst = Math.round(Number(totalObj.instlCst) / 1000000); + totalObj.anlOperCst = Math.round(Number(totalObj.anlOperCst) / 1000000); + //상세 const rainDetailList: RainStatProps[] = []; res.forEach((item: RainStatProps) => { const utztnUsgArray = new Array(); + let wtrUsqty = 0; item.guideStats.forEach((guideStat) => { utztnUsgArray.push(guideStat.utztnUsgCd); + wtrUsqty += Number(guideStat.ruseFcltUtztnQnt) || 0; }); rainDetailList.push({ ...item, + instlCst: Math.round(Number(item.instlCst) / 1000000), + wtrUsqty, utztnUsgCd: utztnUsgArray.join(", "), + anlOperCst: Math.round(Number(item.anlOperCst) / 1000000), }); }); diff --git a/apps/wr/app/store/stats/rswtStat/excel.tsx b/apps/wr/app/store/stats/rswtStat/excel.tsx index 4bd698ad0..eb9eba6da 100644 --- a/apps/wr/app/store/stats/rswtStat/excel.tsx +++ b/apps/wr/app/store/stats/rswtStat/excel.tsx @@ -1,50 +1,53 @@ -import type { SelectDataType } from "@common/business_components/ui"; +import { codeToText } from "@/components/lib/func/common"; import type { RswtStatProps } from "."; -export const excelRswtStat = (excelList: RswtStatProps[], rswtBdstType : SelectDataType[] ) : (string | number)[][] => { +const toUseTypeText = (val: string) => + val + .split(",") + .map((code) => code.trim()) + .filter(Boolean) + .map((code) => codeToText(code, "dmdUszCd") ?? code) + .join(", "); - - console.log('bdstUsgCd ', rswtBdstType) +export const excelRswtStat = ( + excelList: RswtStatProps[] +): (string | number)[][] => { const excelData: (string | number)[][] = []; - excelList.forEach((item) => { - excelData.push([ - //시도 - String(item.ctpvCd) ?? "", - //시군구 - String(item.sggCd) ?? "", - //행정구역명 - String(item.areaNm) ?? "", - //건축물명 - String(item.bdstNm) ?? "", - //건축물용도코드(빈칸) - //String(item.bdstUsgCd) ?? "", - rswtBdstType?.find((cd)=> cd.val === item.bdstUsgCd.toString())?.text ?? "", //건축물용도 - //주소 - String(item.daddr) ?? "", - //법적의무대상여부 - String(item.lawTrgtYn)==="Y" ? "대상" : "미대상", - //설치완료일자 - item.instlCmptnDt ? String(item.instlCmptnDt).substring(0, 10) : "", - //가동개시일(빈칸) - //String(item.instlCst) ?? "", - "", - //건축물면적 - // item.archArea ?? "", - //처리용량 - item.filtrTrtmfctCpct ?? "", - //물사용량 - item.wtrUsqty ?? "", - //중수도+대체수이용량 - item.rswtAltSum ?? "", - //중수도이용량 - item.rswtrUsqty ?? "", - //대체수이용량 - item.altUswtrUtztnQnt ?? "", - //중수도용도 - String(item.utztnUsgCd) ?? "", - //연간운영비 - item.anlOperCst ?? "", - ]); - }); + excelList.forEach((item) => { + excelData.push([ + //시도 + String(item.ctpvCd) ?? "", + //시군구 + String(item.sggCd) ?? "", + //행정구역명 + String(item.areaNm) ?? "", + //건축물명 + String(item.bdstNm) ?? "", + //건축물용도코드 + codeToText(String(item.bdstUsgCd ?? ""), "rswtBdstType") ?? String(item.bdstUsgCd ?? ""), + //주소 + String(item.daddr) ?? "", + //법적의무대상여부 + String(item.lawTrgtYn) === "Y" ? "대상" : "미대상", + //설치완료일자 + item.instlCmptnDt ? String(item.instlCmptnDt).substring(0, 10) : "", + //가동개시일(빈칸) + "", + //처리용량 + item.filtrTrtmfctCpct ?? "", + //물사용량 + // item.wtrUsqty ?? "", + //중수도+대체수이용량 + item.rswtAltSum ?? "", + //중수도이용량 + item.rswtrUsqty ?? "", + //대체수이용량 + item.altUswtrUtztnQnt ?? "", + //중수도용도 + toUseTypeText(String(item.utztnUsgCd ?? "")), + //연간운영비 + item.anlOperCst ?? "", + ]); + }); return excelData; -} \ No newline at end of file +}; diff --git a/apps/wr/app/store/stats/rswtStat/headList.tsx b/apps/wr/app/store/stats/rswtStat/headList.tsx index 47aeca57b..609a155cd 100644 --- a/apps/wr/app/store/stats/rswtStat/headList.tsx +++ b/apps/wr/app/store/stats/rswtStat/headList.tsx @@ -30,12 +30,12 @@ export const rswtStatHeadListExport :TableUpperProps[]=[ row:0, rowSpan:2 }, - { - id: "wtrUsqty", - title: "물사용량(m³/년)", - row:0, - rowSpan:2 - }, + // { + // id: "wtrUsqty", + // title: "물사용량(m³/년)", + // row:0, + // rowSpan:2 + // }, { id: "rswtAltSum", title: "중수도+대체수이용량(m³/년)", @@ -105,4 +105,4 @@ export const rswtStatHeadListExport :TableUpperProps[]=[ }, -] \ No newline at end of file +] diff --git a/apps/wr/app/store/stats/rswtStat/index.tsx b/apps/wr/app/store/stats/rswtStat/index.tsx index fa183f1a2..9be8ddcad 100644 --- a/apps/wr/app/store/stats/rswtStat/index.tsx +++ b/apps/wr/app/store/stats/rswtStat/index.tsx @@ -35,6 +35,7 @@ export type RswtStatProps = { export type GuideStatProps = { utztnUsgCd: string + splyUswtrUtztnQnt: number ruseFcltUtztnQnt: number } @@ -73,14 +74,22 @@ export const rswtStatActionsExport: (set: any) => rswtStatActions = ( set: any ) => { return { - searchRswtStatList: async (year: string, city: SelectDataType[] , rswtBdstType:SelectDataType[]) => { + searchRswtStatList: async (year: string, city: SelectDataType[]) => { const res = await getFetch(fetchType.WR, `/reuseChk/reuseWater/stat/${year}`) const data = res.filter(item => { const code = item.ctpvCd.trim(); return city.some(el => el.val === code); }); const cityArr: any[] = []; + excelDownList.length = 0; data.forEach((item: RswtStatProps) => { + const itemAltUswtrUtztnQnt = + item.guideStats && item.guideStats.length > 0 + ? item.guideStats.reduce( + (sum, guide) => sum + (Number(guide.splyUswtrUtztnQnt) || 0), + 0 + ) + : Number(item.altUswtrUtztnQnt) || 0; let obj = cityArr.find((e) => { return e.ctpvCd == item.ctpvCd }) @@ -90,9 +99,7 @@ export const rswtStatActionsExport: (set: any) => rswtStatActions = ( // obj.archArea += item.archArea 건축면적 삭제 obj.filtrTrtmfctCpct += item.filtrTrtmfctCpct //처리용량 obj.wtrUsqty += item.wtrUsqty //물사용량 - obj.rswtAltSum += (item.rswtrUsqty + item.altUswtrUtztnQnt) //중수도+대체수 - obj.rswtrUsqty += item.rswtrUsqty //중수도사용량 - obj.altUswtrUtztnQnt += item.altUswtrUtztnQnt //대체수이용량 + obj.altUswtrUtztnQnt += itemAltUswtrUtztnQnt //대체수이용량 obj.anlOperCst += item.anlOperCst //연간운영비용 } else { obj = { @@ -100,9 +107,9 @@ export const rswtStatActionsExport: (set: any) => rswtStatActions = ( ctpvCnt: 1, filtrTrtmfctCpct: item.filtrTrtmfctCpct, wtrUsqty: item.wtrUsqty, - rswtAltSum: item.rswtrUsqty + item.altUswtrUtztnQnt, - rswtrUsqty: item.rswtrUsqty, - altUswtrUtztnQnt: item.altUswtrUtztnQnt, + rswtAltSum: 0, + rswtrUsqty: 0, + altUswtrUtztnQnt: itemAltUswtrUtztnQnt, anlOperCst: item.anlOperCst, lndwtr: 0, cleanToilUswtr: 0, @@ -127,6 +134,15 @@ export const rswtStatActionsExport: (set: any) => rswtStatActions = ( }); }) } + + obj.rswtrUsqty = + obj.lndwtr + + obj.cleanToilUswtr + + obj.farmUswtr + + obj.indstUswtr + + obj.hydUswtr + + obj.ithlEtcUswtr; + obj.rswtAltSum = obj.rswtrUsqty + obj.altUswtrUtztnQnt; }); const total: any = { @@ -163,6 +179,11 @@ export const rswtStatActionsExport: (set: any) => rswtStatActions = ( total.ithlEtcUswtr += item.ithlEtcUswtr; }); + cityArr.forEach(item => { + item.anlOperCst = Math.round(Number(item.anlOperCst) / 1000000); + }); + total.anlOperCst = Math.round(Number(total.anlOperCst) / 1000000); + cityArr.unshift(total); cityArr.forEach(item => { @@ -189,16 +210,23 @@ export const rswtStatActionsExport: (set: any) => rswtStatActions = ( const rswtDetailList: RswtStatProps[] = []; res.forEach((item: RswtStatProps) => { const utztnUsgArray = new Array(); + let rswtrUsqty = 0; + let altUswtrUtztnQnt = 0; item.guideStats.forEach((guideStat) => { utztnUsgArray.push(guideStat.utztnUsgCd); + rswtrUsqty += Number(guideStat.ruseFcltUtztnQnt) || 0; + altUswtrUtztnQnt += Number(guideStat.splyUswtrUtztnQnt) || 0; }) rswtDetailList.push({ ...item, - rswtAltSum: item.rswtrUsqty + item.altUswtrUtztnQnt, + rswtAltSum: rswtrUsqty + altUswtrUtztnQnt, + rswtrUsqty, + altUswtrUtztnQnt, utztnUsgCd: utztnUsgArray.join(', '), + anlOperCst: Math.round(Number(item.anlOperCst) / 1000000), }) }); - const excelDetailData = excelRswtStat(rswtDetailList,rswtBdstType); + const excelDetailData = excelRswtStat(rswtDetailList); set((state: rswtStatState) => ({ rswtStat: { ...state.rswtStat, @@ -209,4 +237,4 @@ export const rswtStatActionsExport: (set: any) => rswtStatActions = ( })); } } -} \ No newline at end of file +} diff --git a/apps/wr/components/lib/api/index.ts b/apps/wr/components/lib/api/index.ts index 6ff75f5a6..44eef5924 100644 --- a/apps/wr/components/lib/api/index.ts +++ b/apps/wr/components/lib/api/index.ts @@ -1,4 +1,6 @@ import { DEV_URL_FO, DEV_URL_WR, LOCAL_URL } from "../config/"; +import { CLAIM_NAME, SITE_ID } from "@/src/shared/config"; +import { tokenManager } from "@/src/entities/auth/lib/token-manager"; export const fetchType = { COMMON: "common", @@ -7,6 +9,33 @@ export const fetchType = { CS: "CS", }; +const LOGIN_PAGE_URL = + process.env.NEXT_PUBLIC_CM_LOGIN_PAGE_URL || "/auth/login"; + +const getDefaultHeaders = (headers: HeadersInit = {}) => ({ + ...headers, + "X-Site-Id": SITE_ID, +}); + +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 (type: string, url: string) => { const div = type === fetchType.COMMON @@ -19,11 +48,15 @@ export const getFetch = async (type: string, url: string) => { const apiUrl = div + url; const response = await fetch(apiUrl, { method: "GET", - headers: { + headers: getDefaultHeaders({ accept: "application/json", - }, + }), }); + if (response.status === 401) { + handleUnauthorizedResponse(); + } + if (!response.ok) { console.log(`오류 발생: ${response.status}`); } @@ -67,11 +100,15 @@ export const getFetchWithCount = async (type: string, url: string) => { const response = await fetch(apiUrl, { method: "GET", - headers: { + headers: getDefaultHeaders({ accept: "application/json", - }, + }), }); + if (response.status === 401) { + handleUnauthorizedResponse(); + } + if (!response.ok) { console.log(`오류 발생: ${response.status}`); } @@ -108,12 +145,15 @@ export const postFectch = async (url: string, params: any, type?: string) => { const apiUrl = div + url; const response = await fetch(apiUrl, { method: "POST", - headers: { + headers: getDefaultHeaders({ accept: "application/json", "Content-Type": "application/json", - }, + }), body: JSON.stringify(params), }); + if (response.status === 401) { + handleUnauthorizedResponse(); + } if (!response.ok) { throw new Error(`오류 발생: ${response.status}`); } @@ -133,11 +173,14 @@ export const putFectch = async (url: string, params: any, type?: string) => { const response = await fetch(apiUrl, { method: "PUT", - headers: { + headers: getDefaultHeaders({ "Content-Type": "application/json", - }, + }), body: JSON.stringify(params), }); + if (response.status === 401) { + handleUnauthorizedResponse(); + } if (!response.ok) { throw new Error(`오류 발생: ${response.status}`); } @@ -161,11 +204,14 @@ export const deleteFectch = async ( const response = await fetch(apiUrl, { method: "DELETE", - headers: { + headers: getDefaultHeaders({ "Content-Type": "application/json", - }, + }), body: params ? JSON.stringify(params) : undefined, }); + if (response.status === 401) { + handleUnauthorizedResponse(); + } if (!response.ok) { // 에러 응답 본문 파싱 const errorText = await response.text(); diff --git a/apps/wr/components/lib/func/TableData.tsx b/apps/wr/components/lib/func/TableData.tsx index 7dfe74919..b2411eea4 100644 --- a/apps/wr/components/lib/func/TableData.tsx +++ b/apps/wr/components/lib/func/TableData.tsx @@ -2,13 +2,18 @@ import { numSize } from "@/app/store/common/code/basicCode"; import type { TableUpperProps } from "@/app/store/common/type/header"; import type { rainChkZodType } from "@/app/store/prfmncReg/chkgdnReg/rainChkgdn"; import type { rswtChkZodType } from "@/app/store/prfmncReg/chkgdnReg/rswrtChkgdn"; -import { Checkbox, Input, TableCell, TableRow } from "@common/components/ui"; +import { + Checkbox, + Input, + TableCell, + TableRow, +} from "@common/components/ui"; import moment from "moment"; import { codeToText, formatCommaNumber, formatYyyymmdd, - handleCommaNumberChange, + handleCommaNumberChange } from "./common"; export const rainChkTableData = (chkData: rainChkZodType[]) => { @@ -68,10 +73,10 @@ export const rswtChkTableData = (chkData: rswtChkZodType[]) => { root.rswtrDetails.forEach((detail) => { let flatRswtChk = {}; const findGuide = root.guideDetails.find( - (guide) => guide.utztnUsgCd === detail.utztnUsgCd, - ); + (guide) => guide.utztnUsgCd === detail.utztnUsgCd + ) const findPoluttion = root.rswtrPollutions.find( - (polution) => polution.utztnUsgCd === detail.utztnUsgCd, + (polution) => polution.utztnUsgCd === detail.utztnUsgCd ); flatRswtChk = { ...root, @@ -79,7 +84,7 @@ export const rswtChkTableData = (chkData: rswtChkZodType[]) => { ...findGuide, ...findPoluttion, ...flatRswtChk, - utztnUsgCdText: codeToText(detail.utztnUsgCd, "dmdUszCd"), + utztnUsgCdText: codeToText(detail.utztnUsgCd, 'dmdUszCd'), }; makeRswtData.push(flatRswtChk); }); @@ -96,17 +101,18 @@ export const chRowspanTable = ( handleChange: ( rowIndex: number, field: keyof (typeof makeTableData)[0], - value: string | number, + value: string | number ) => void, toggleSelection: (pk: number, checked: boolean) => void, selectedIds: number[], handleCellClick: (index: number, headId: string) => void, - disable?: boolean, + disable?: boolean ) => { let order = 0; // rowspan된 셀 렌더링 여부 추적용 const renderedMap = new Map>(); + // console.log(makeTableData); return ( <> {/* {makeTableData.map((row, rowIndex) => ( */} @@ -135,11 +141,7 @@ export const chRowspanTable = ( key={key} rowSpan={rowspan} className="align-middle text-center whitespace-normal break-words" - style={{ - width: head.widthPercent - ? `${head.widthPercent}%` - : undefined, - }} + style={{ width: head.widthPercent ? `${head.widthPercent}%` : undefined }} > {key === "checkBox" ? ( {formatYyyymmdd(value)} - ) : ( - <>{value} - )} + formatCommaNumber(value) + ) : key === "chckYmd" ? ( + <>{formatYyyymmdd(value)} + ) : + ( + <>{value} + )} ); } @@ -172,11 +174,7 @@ export const chRowspanTable = ( key={key} className="text-center whitespace-normal break-words" onClick={() => handleCellClick(rowIndex, head.id)} - style={{ - width: head.widthPercent - ? `${head.widthPercent}%` - : undefined, - }} + style={{ width: head.widthPercent ? `${head.widthPercent}%` : undefined }} > {head.edit === true ? ( [ @@ -190,7 +188,7 @@ export const chRowspanTable = ( type={head.type} onChange={(e) => { handleCommaNumberChange(e, (val) => - handleChange(rowIndex, head.id, val), + handleChange(rowIndex, head.id, val) ); }} value={formatCommaNumber(value) || ""} @@ -198,10 +196,7 @@ export const chRowspanTable = ( maxLength={numSize} onInput={(e: React.FormEvent) => { const el = e.currentTarget; - if ( - head.type === "number" && - el.value.length > numSize - ) { + if (head.type === "number" && el.value.length > numSize) { el.value = el.value.slice(0, numSize); } }} @@ -234,10 +229,7 @@ export const chRowspanTable = ( maxLength={numSize} onInput={(e: React.FormEvent) => { const el = e.currentTarget; - if ( - head.type === "number" && - el.value.length > numSize - ) { + if (head.type === "number" && el.value.length > numSize) { el.value = el.value.slice(0, numSize); } }} @@ -246,12 +238,12 @@ export const chRowspanTable = ( ) : ["smll"].includes(head.id) && head.type === "text" ? ( <>- ) : [ - "totalSplyUswtrUtztnQnt", - "totalRuseFcltUtztnQnt", - "splyUswtrUtztnQnt", - "ruseFcltUtztnQnt", - "levyAmt", - ].includes(head.id) ? ( + "totalSplyUswtrUtztnQnt", + "totalRuseFcltUtztnQnt", + "splyUswtrUtztnQnt", + "ruseFcltUtztnQnt", + "levyAmt", + ].includes(head.id) ? ( { return headList.forEach((head) => { if (head.edit !== undefined) head.edit = isEdit; @@ -304,7 +296,7 @@ const handleChange = ( rowIndex: number, field: any, value: string, - setInputData: (data: any) => void, + setInputData: (data: any) => void ) => { if (Number(value) < 0 || !Number(value)) { value = "0"; @@ -318,16 +310,36 @@ const handleChange = ( }; } return row; - }), + }) ); }; +const handleTextChange = ( + rowIndex: number, + field: any, + value: string, + setInputData: (data: any) => void +) => { + setInputData((prevData: any) => + prevData?.map((row: any, index: number) => { + if (index === rowIndex) { + return { + ...row, + [field]: value, + }; + } + return row; + }) + ); +}; + + // 재이용실적등록 > 지도점검결과 등록 - 상세 modal의 body export const InputTableBody = ( chgRes: any[] | [], headList: TableUpperProps[], setInputData: (data: any[]) => void, - isEditable: boolean = true, + isEditable: boolean = true ) => { // mergeCell 처리용 const getRowSpan = (data: any[], key: string, rowIndex: number) => { @@ -357,28 +369,14 @@ export const InputTableBody = ( return ( {headList.map((head, headIndex) => { - //냄새면 계산 못하게 - 로 수정 - // if (["smll"].includes(head.id)) { - // return ( - // - // - - // - // ); - // } - const isTextColumn = - head.id === "구분" || head.id === "utztnUsgCdText"; + + const isTextColumn = head.id === "구분" || head.id === "utztnUsgCdText"; const textAlignClass = isTextColumn ? "text-center" : "text-right"; // 총합계 행이면 "구분"에서 colSpan=2로 병합 - if ( - (data["구분"] === 0 || data["용수명"] === 0) && - head.id === "구분" - ) { + if ( (data["구분"] === 0 || data["용수명"] === 0) && head.id === "구분" ) { return ( - - handleCommaNumberChange(e, (val) => - handleChange( + {head.id === "smll" ? ( + + handleTextChange( dataIndex, head.id, - String(val), - setInputData, - ), - ) - } - value={formatCommaNumber(data[head.id as keyof any]) ?? ""} - maxLength={numSize} - /> + e.target.value, + setInputData + ) + } + value={String(data[head.id as keyof any] ?? "")} + maxLength={numSize} + /> + ) : ( + + handleCommaNumberChange(e, (val) => + handleChange( + dataIndex, + head.id, + String(val), + setInputData + ) + ) + } + value={formatCommaNumber(data[head.id as keyof any]) ?? ""} + maxLength={numSize} + /> + )} ) : ( void, - isEditable: boolean = true, + isEditable: boolean = true ) => { // mergeCell 처리용 const getRowSpan = (data: any[], key: string, rowIndex: number) => { @@ -599,8 +611,8 @@ export const pbnstTableBody = ( dataIndex, head.id, String(val), - setInputData, - ), + setInputData + ) ) } value={formatCommaNumber(data[head.id as keyof any]) ?? ""} diff --git a/apps/wr/components/ui/modal/chkgd/chkgdExcel/chkgdExcel.tsx b/apps/wr/components/ui/modal/chkgd/chkgdExcel/chkgdExcel.tsx index 595cff55e..0e4152db6 100644 --- a/apps/wr/components/ui/modal/chkgd/chkgdExcel/chkgdExcel.tsx +++ b/apps/wr/components/ui/modal/chkgd/chkgdExcel/chkgdExcel.tsx @@ -192,7 +192,7 @@ export default function ChkgdExcel({ } }) .finally(() => setRswtChkLoading(false)); - console.log("업로드파일 점검 완료"); + // console.log("업로드파일 점검 완료"); } } }, [file]); diff --git a/apps/wr/public/sample_rswtStats.xlsx b/apps/wr/public/sample_rswtStats.xlsx index 82dad8d2b..c3885eca4 100644 Binary files a/apps/wr/public/sample_rswtStats.xlsx and b/apps/wr/public/sample_rswtStats.xlsx differ diff --git a/apps/wr/public/sample_rswtStats_물사용량.xlsx b/apps/wr/public/sample_rswtStats_물사용량.xlsx new file mode 100644 index 000000000..68cae66ee Binary files /dev/null and b/apps/wr/public/sample_rswtStats_물사용량.xlsx differ diff --git a/apps/wr/src/entities/auth/api/login-service.ts b/apps/wr/src/entities/auth/api/login-service.ts index 9f2671c76..94be8b53f 100644 --- a/apps/wr/src/entities/auth/api/login-service.ts +++ b/apps/wr/src/entities/auth/api/login-service.ts @@ -20,6 +20,10 @@ export class LoginApiService implements LoginService { tokenManager.clearAuthToken(); const response = await fetch( PROXY_HOST+ 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, @@ -52,7 +56,12 @@ export class LoginApiService implements LoginService { try { const response = await fetch(PROXY_HOST+ `${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`, - { method: "PUT" }, + { + method: "PUT", + headers: { + "X-Site-Id": SITE_ID, + }, + }, ); const data = await response.json(); diff --git a/apps/wr/src/entities/auth/lib/token-manager.ts b/apps/wr/src/entities/auth/lib/token-manager.ts index 40d32f33e..919386622 100644 --- a/apps/wr/src/entities/auth/lib/token-manager.ts +++ b/apps/wr/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; // 토큰 subject + 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] ?? ""; // Payload를 가져온다. + 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: window.location.protocol === "https:", diff --git a/apps/wr/src/features/auth/login/ui/OtpModal.tsx b/apps/wr/src/features/auth/login/ui/OtpModal.tsx deleted file mode 100644 index 2c216faaf..000000000 --- a/apps/wr/src/features/auth/login/ui/OtpModal.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import { useState } from "react"; -import { ACCESS_TOKEN, AUTH_USER_ID, REFRESH_TOKEN, CLAIM_NAME } from "@/src/shared/config"; -import { tokenManager } from "@/src/entities/auth/lib/token-manager"; -import { - Button, - Dialog, - DialogContent, -} from "@common/components"; - -interface OtpModalProps { - onClose: () => void; - onSuccess: () => void; - open: boolean; - onOpenChange: (open: boolean) => void; -} - -export default function OtpModal({ onClose, onSuccess, open, onOpenChange }: OtpModalProps) { - const [otp, setOtp] = useState(""); - const [error, setError] = useState(""); - const [isLoading, setIsLoading] = useState(false); - - const handleSubmit = async () => { - try { - setIsLoading(true); - setError(""); - - const tempToken = localStorage.getItem("tempToken"); - console.log("tempToken from localStorage:", tempToken); - - if (!tempToken) { - setError("임시 토큰이 없습니다. 로그인을 다시 시도해주세요."); - return; - } - - // Proxy를 통해 OTP 인증 처리 - const response = await fetch(`/api/proxy/user-service/api/v1/auth/2fa/verify`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - otpCode: otp, - tempToken - }), - }); - - if (!response.ok) { - const errorData = await response.json(); - let errorMsg = errorData.message || 'OTP 인증 실패'; - if (Array.isArray(errorData.errors) && errorData.errors.length > 0) { - errorMsg = errorData.errors.map((e: { message: string }) => e.message).join(', '); - } - throw new Error(errorMsg); - } - - const data = await response.json(); - console.log("OTP verification response:", data); - console.log("Response headers:", Object.fromEntries(response.headers.entries())); - console.log("Response data keys:", Object.keys(data)); - console.log("Full response data:", JSON.stringify(data, null, 2)); - - // 2FA 응답에서는 access-token만 있고 token-id는 없을 수 있음 - const accessToken = data[ACCESS_TOKEN] || data.accessToken || data.access_token || data.token || data['ss-token']; - - console.log("Extracted accessToken:", !!accessToken); - console.log("Token value - accessToken:", accessToken?.substring(0, 20) + "..."); - - // access-token이 있는 경우 저장 - if (accessToken) { - console.log("Saving access token from OTP verification"); - tokenManager.setToken(CLAIM_NAME, accessToken); // Authorization으로 저장 - - // 리프레시 토큰도 확인 - const refreshToken = data[REFRESH_TOKEN] || data.refreshToken || data.refresh_token; - if (refreshToken) { - console.log("Saving refresh token"); - tokenManager.setToken(REFRESH_TOKEN, refreshToken); - } - - // userId는 원래 로그인 응답에서 가져온 값 사용 - const originalUserId = data.userId; - if (originalUserId) { - console.log("Saving userId from original login response"); - tokenManager.setToken(AUTH_USER_ID, originalUserId); - } - - console.log("All tokens saved successfully"); - } else { - console.log("No access token in OTP response"); - console.log("Available data keys:", Object.keys(data)); - console.log("Data values:", Object.entries(data).map(([k, v]) => `${k}: ${typeof v === 'string' ? v.substring(0, 20) + '...' : v}`)); - } - - onSuccess(); - } catch (error: unknown) { - console.error("OTP verification error:", error); - if (error instanceof Error) { - setError(error.message); - } else { - setError("OTP 인증 실패"); - } - } finally { - setIsLoading(false); - } - }; - - const handleClose = () => { - onOpenChange(false); - onClose(); - }; - - return ( - - -
-

OTP 인증

- -
- setOtp(e.target.value)} - disabled={isLoading} - /> - - {error && ( -
- {error} -
- )} - -
- - -
-
-
-
-
- ); -} diff --git a/apps/wr/src/features/auth/login/ui/SignupModal.tsx b/apps/wr/src/features/auth/login/ui/SignupModal.tsx deleted file mode 100644 index 2be88cce9..000000000 --- a/apps/wr/src/features/auth/login/ui/SignupModal.tsx +++ /dev/null @@ -1,781 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import type { UserSaveData } from "@/src/entities/user"; -import { ENDPOINT, BASE_URL } from "@/src/shared/config"; -import { SignupClient } from "@/src/shared/api/signup-client"; -import "./signup-modal.css"; - -interface SignupModalProps { - open: boolean; - onOpenChange: (open: boolean) => void; - onClose: () => void; - onSuccess: () => void; -} - -function SignupModal({ open, onClose, onSuccess }: SignupModalProps) { - const [currentStep, setCurrentStep] = useState<"agreement" | "signup">( - "agreement", - ); - const [agreed, setAgreed] = useState(false); - const [formData, setFormData] = useState({ - userId: "", - userName: "", - email: "", - password: "", - confirmPassword: "", - pswdHnt: "", - pswdAns: "", - mphn: "", - telno: "", - zip: "", - addr: "", - daddr: "", - laeSeCd: "", - ogdpSeCd: "", - expryYmd: "", - instSeCd: "", - dtlInstCd: "", - enoCd: "", - }); - - const [errors, setErrors] = useState>({}); - const [isLoading, setIsLoading] = useState(false); - const [isCheckingId, setIsCheckingId] = useState(false); - const [isCheckingEmail, setIsCheckingEmail] = useState(false); - const [idAvailable, setIdAvailable] = useState(null); - const [emailAvailable, setEmailAvailable] = useState(null); - - // 모달이 닫힐 때 초기화 - useEffect(() => { - if (!open) { - setCurrentStep("agreement"); - setAgreed(false); - setFormData({ - userId: "", - userName: "", - email: "", - password: "", - confirmPassword: "", - pswdHnt: "", - pswdAns: "", - mphn: "", - telno: "", - zip: "", - addr: "", - daddr: "", - laeSeCd: "", - ogdpSeCd: "", - expryYmd: "", - instSeCd: "", - dtlInstCd: "", - enoCd: "", - }); - setErrors({}); - setIdAvailable(null); - setEmailAvailable(null); - } - }, [open]); - - const handleAgreementChange = (e: React.ChangeEvent) => { - setAgreed(e.target.checked); - }; - - const handleNextStep = () => { - if (agreed) { - setCurrentStep("signup"); - } - }; - - const handleInputChange = ( - e: React.ChangeEvent, - ) => { - const { name, value } = e.target; - setFormData((prev) => ({ ...prev, [name]: value })); - - // 에러 초기화 - if (errors[name]) { - setErrors((prev) => ({ ...prev, [name]: "" })); - } - }; - - const validateForm = () => { - const newErrors: Record = {}; - - // 필수 필드 검증 - if (!formData.userId) newErrors.userId = "아이디를 입력해주세요."; - if (!formData.userName) newErrors.userName = "이름을 입력해주세요."; - if (!formData.password) newErrors.password = "비밀번호를 입력해주세요."; - if (!formData.confirmPassword) - newErrors.confirmPassword = "비밀번호 확인을 입력해주세요."; - if (!formData.pswdHnt) newErrors.pswdHnt = "비밀번호 힌트를 입력해주세요."; - if (!formData.laeSeCd) newErrors.laeSeCd = "법인구분을 선택해주세요."; - if (!formData.ogdpSeCd) newErrors.ogdpSeCd = "기관구분을 선택해주세요."; - - // 비밀번호 확인 - if ( - formData.password && - formData.confirmPassword && - formData.password !== formData.confirmPassword - ) { - newErrors.confirmPassword = "비밀번호가 일치하지 않습니다."; - } - - // 비밀번호 정규식 검증 - const passwordRegex = - /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/; - if (formData.password && !passwordRegex.test(formData.password)) { - newErrors.password = - "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다."; - } - - // 이메일 형식 검증 - if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) { - newErrors.email = "올바른 이메일 형식을 입력해주세요."; - } - - // 중복 체크 검증 - if (idAvailable === false) - newErrors.userId = "이미 사용 중인 아이디입니다."; - if (emailAvailable === false) - newErrors.email = "이미 사용 중인 이메일입니다."; - - setErrors(newErrors); - const isValid = Object.keys(newErrors).length === 0; - console.log("폼 검증 결과:", { isValid, errors: newErrors, formData }); - return isValid; - }; - - const checkUserIdDuplicate = async () => { - if (!formData.userId) { - setErrors((prev) => ({ ...prev, userId: "아이디를 입력해주세요." })); - return; - } - - setIsCheckingId(true); - try { - // SignupClient를 통해 중복 체크 API 호출 - const signupClient = SignupClient.getInstance({ - baseURL: BASE_URL, - }); - const response = await signupClient.post( - `${ENDPOINT.USER_SERVICE.USERS}/existsUserId`, - { userId: formData.userId }, - ); - - const exists = response.data; - setIdAvailable(!exists); - if (exists) { - setErrors((prev) => ({ - ...prev, - userId: "이미 사용 중인 아이디입니다.", - })); - } else { - setErrors((prev) => ({ ...prev, userId: "" })); - } - } catch (error) { - console.error("아이디 중복 체크 오류:", error); - setErrors((prev) => ({ - ...prev, - userId: "아이디 중복 체크 중 오류가 발생했습니다.", - })); - } finally { - setIsCheckingId(false); - } - }; - - const checkEmailDuplicate = async () => { - if (!formData.email) { - setErrors((prev) => ({ ...prev, email: "이메일을 입력해주세요." })); - return; - } - - setIsCheckingEmail(true); - try { - // SignupClient를 통해 중복 체크 API 호출 - const signupClient = SignupClient.getInstance({ - baseURL: BASE_URL, - }); - const response = await signupClient.post( - `${ENDPOINT.USER_SERVICE.USERS}/exists`, - { email: formData.email }, - ); - - const exists = response.data; - setEmailAvailable(!exists); - if (exists) { - setErrors((prev) => ({ - ...prev, - email: "이미 사용 중인 이메일입니다.", - })); - } else { - setErrors((prev) => ({ ...prev, email: "" })); - } - } catch (error) { - console.error("이메일 중복 체크 오류:", error); - setErrors((prev) => ({ - ...prev, - email: "이메일 중복 체크 중 오류가 발생했습니다.", - })); - } finally { - setIsCheckingEmail(false); - } - }; - - const handleSubmit = async (e: React.SubmitEvent) => { - e.preventDefault(); - console.log("회원가입 버튼 클릭됨"); - - if (!validateForm()) { - console.log("폼 검증 실패"); - return; - } - - setIsLoading(true); - try { - const signupData: UserSaveData = { - userId: formData.userId, - userName: formData.userName, - email: formData.email || undefined, - password: formData.password, - pswdHnt: formData.pswdHnt, - pswdAns: formData.pswdAns || undefined, - mphn: formData.mphn || undefined, - telno: formData.telno || undefined, - zip: formData.zip || undefined, - addr: formData.addr || undefined, - daddr: formData.daddr || undefined, - laeSeCd: formData.laeSeCd, - ogdpSeCd: formData.ogdpSeCd, - expryYmd: "", - instSeCd: formData.instSeCd || undefined, - dtlInstCd: formData.dtlInstCd || undefined, - enoCd: formData.enoCd || undefined, - }; - - // SignupClient를 통해 익명사용자 회원가입 API 호출 - const signupClient = SignupClient.getInstance({ - baseURL: BASE_URL, - }); - const response = await signupClient.post( - `${ENDPOINT.USER_SERVICE.USERS}/anonymouse/users/join`, - signupData, - ); - - console.log("회원가입 성공:", response.data); - - // 성공 시 모달 닫기 및 콜백 호출 - onSuccess(); - onClose(); - - // 폼 초기화 - setFormData({ - userId: "", - userName: "", - email: "", - password: "", - confirmPassword: "", - pswdHnt: "", - pswdAns: "", - mphn: "", - telno: "", - zip: "", - addr: "", - daddr: "", - laeSeCd: "", - ogdpSeCd: "", - expryYmd: "", - instSeCd: "", - dtlInstCd: "", - enoCd: "", - }); - setErrors({}); - setIdAvailable(null); - setEmailAvailable(null); - } catch (error: unknown) { - console.error("회원가입 오류:", error); - if (error instanceof Error) { - setErrors((prev) => ({ ...prev, submit: error.message })); - } else { - setErrors((prev) => ({ ...prev, submit: "회원가입에 실패했습니다." })); - } - } finally { - setIsLoading(false); - } - }; - - if (!open) return null; - - return ( -
-
e.stopPropagation()}> -
-

- {currentStep === "agreement" ? "개인정보제공동의" : "회원가입"} -

- -
- - {currentStep === "agreement" ? ( -
-
-

개인정보 수집 및 이용 동의

-

회원가입을 위해 다음과 같이 개인정보를 수집 및 이용합니다.

- -

1. 수집하는 개인정보 항목

-
    -
  • - 필수항목: 아이디, 이름, 비밀번호, 비밀번호 힌트, 법인구분, - 기관구분 -
  • -
  • - 선택항목: 이메일, 휴대폰번호, 전화번호, 주소, 기관코드, - 상세기관코드, 사업자등록번호 -
  • -
- -

2. 개인정보의 수집 및 이용목적

-
    -
  • 회원가입 및 관리
  • -
  • 서비스 제공 및 운영
  • -
  • 고객상담 및 문의응답
  • -
- -

3. 개인정보의 보유 및 이용기간

-

- 회원탈퇴 시까지 (단, 관련 법령에 따라 보존이 필요한 경우 해당 - 기간까지) -

- -

4. 동의 거부권 및 거부 시 불이익

-

- 개인정보 수집 및 이용에 대한 동의를 거부할 수 있으며, 동의 거부 - 시 회원가입이 제한됩니다. -

-
- -
- -
- -
- - -
-
- ) : ( -
-
-

기본 정보

- -
-
- -
- - -
- {idAvailable === true && ( - - 사용 가능한 아이디입니다. - - )} - {errors.userId && ( - {errors.userId} - )} -
-
- -
-
- - - {errors.userName && ( - {errors.userName} - )} -
-
- -
-
- -
- - -
- {emailAvailable === true && ( - - 사용 가능한 이메일입니다. - - )} - {errors.email && ( - {errors.email} - )} -
-
- -
-
- - - {errors.password && ( - {errors.password} - )} -
-
- -
-
- - - {errors.confirmPassword && ( - - {errors.confirmPassword} - - )} -
-
- -
-
- - - {errors.pswdHnt && ( - {errors.pswdHnt} - )} -
-
- -
-
- - - {errors.pswdAns && ( - {errors.pswdAns} - )} -
-
-
- -
-

연락처 정보

- -
-
- - - {errors.mphn && ( - {errors.mphn} - )} -
-
- -
-
- - - {errors.telno && ( - {errors.telno} - )} -
-
-
- -
-

주소 정보

- -
-
- - - {errors.zip && ( - {errors.zip} - )} -
-
- -
-
- - - {errors.addr && ( - {errors.addr} - )} -
-
- -
-
- - - {errors.daddr && ( - {errors.daddr} - )} -
-
-
- -
-

기관 정보

- -
-
- - - {errors.laeSeCd && ( - {errors.laeSeCd} - )} -
-
- -
-
- - - {errors.ogdpSeCd && ( - {errors.ogdpSeCd} - )} -
-
- -
-
- - - {errors.instSeCd && ( - {errors.instSeCd} - )} -
-
- -
-
- - - {errors.dtlInstCd && ( - {errors.dtlInstCd} - )} -
-
-
- - {errors.submit && ( -
-
- {errors.submit} -
-
- )} - -
- - - -
-
- )} -
-
- ); -} - -export default SignupModal; diff --git a/apps/wr/src/features/auth/login/ui/TwoFAModal.tsx b/apps/wr/src/features/auth/login/ui/TwoFAModal.tsx deleted file mode 100644 index d66747b0c..000000000 --- a/apps/wr/src/features/auth/login/ui/TwoFAModal.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import { useEffect, useState } from "react"; -import Image from "next/image"; -import { - Button, - Dialog, - DialogContent, -} from "@common/components"; - -interface TwoFAModalProps { - onClose: () => void; - onSuccess: () => void; - open: boolean; - onOpenChange: (open: boolean) => void; -} - -export default function TwoFAModal({ onClose, onSuccess, open, onOpenChange }: TwoFAModalProps) { - const [qrImage, setQrImage] = useState(""); - const [secret, setSecret] = useState(""); - const [otp, setOtp] = useState(""); - const [error, setError] = useState(""); - const [isLoading, setIsLoading] = useState(false); - const [isSetupLoading, setIsSetupLoading] = useState(true); - - useEffect(() => { - const fetchQR = async () => { - try { - setIsSetupLoading(true); - setError(""); - - const tempToken = localStorage.getItem("tempToken"); - if (!tempToken) { - setError("임시 토큰이 없습니다"); - return; - } - - // Proxy를 통해 2FA setup 처리 - const response = await fetch(`/api/proxy/user-service/api/v1/auth/2fa/setup`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tempToken }), - }); - - if (!response.ok) { - const errorData = await response.json(); - let errorMsg = errorData.message || 'QR코드 발급 실패'; - if (Array.isArray(errorData.errors) && errorData.errors.length > 0) { - errorMsg = errorData.errors.map((e: { message: string }) => e.message).join(', '); - } - throw new Error(errorMsg); - } - - const data = await response.json(); - console.log("2FA setup response:", data); - console.log("Response headers:", Object.fromEntries(response.headers.entries())); - - // Proxy 응답에서 QR 데이터 추출 - setQrImage(data.qrImage); - setSecret(data.secret); - } catch (error: unknown) { - console.error("2FA setup error:", error); - if (error instanceof Error) { - setError(error.message); - } else { - setError("QR코드 발급 실패"); - } - } finally { - setIsSetupLoading(false); - } - }; - - if (open) { - fetchQR(); - } - }, [open]); - - const handleEnable2FA = async () => { - try { - setIsLoading(true); - setError(""); - - const tempToken = localStorage.getItem("tempToken"); - if (!tempToken) { - setError("임시 토큰이 없습니다"); - return; - } - - // Proxy를 통해 2FA enable 처리 - const response = await fetch(`/api/proxy/user-service/api/v1/auth/2fa/enable`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': tempToken, - }, - body: JSON.stringify({ - otpCode: otp, - tempToken: tempToken, - secret: secret, - }), - }); - - if (!response.ok) { - const errorData = await response.json(); - let errorMsg = errorData.message || '2FA 등록 실패'; - if (Array.isArray(errorData.errors) && errorData.errors.length > 0) { - errorMsg = errorData.errors.map((e: { message: string }) => e.message).join(', '); - } - throw new Error(errorMsg); - } - - const data = await response.json(); - console.log("2FA enable response:", data); - console.log("Response headers:", Object.fromEntries(response.headers.entries())); - console.log("Response data keys:", Object.keys(data)); - - // 2FA 설정 완료 후에는 토큰을 저장하지 않음 (실제 로그인은 OTP에서 처리) - console.log("2FA setup completed successfully"); - - onSuccess(); - } catch (error: unknown) { - console.error("2FA enable error:", error); - if (error instanceof Error) { - setError(error.message); - } else { - setError("2FA 등록 실패"); - } - } finally { - setIsLoading(false); - } - }; - - const handleClose = () => { - onOpenChange(false); - onClose(); - }; - - return ( - - -
-

2FA 등록

- - {isSetupLoading ? ( -
-
QR코드 로딩 중...
-
- ) : ( - <> - {qrImage && ( -
- QR코드 -
- )} - -
- setOtp(e.target.value)} - disabled={isLoading} - /> - - {error && ( -
- {error} -
- )} - -
- - -
-
- - )} -
-
-
- ); -} diff --git a/apps/wr/src/features/auth/login/ui/UserSignupModal.tsx b/apps/wr/src/features/auth/login/ui/UserSignupModal.tsx deleted file mode 100644 index 8d3220e8e..000000000 --- a/apps/wr/src/features/auth/login/ui/UserSignupModal.tsx +++ /dev/null @@ -1,901 +0,0 @@ -"use client"; - -import React, { useState, useEffect } from "react"; -import { SignupClient } from "@/src/shared/api/signup-client"; -import { BASE_URL, ENDPOINT } from "@/src/shared/config"; -import "./user-signup-modal.css"; - -interface UserSignupModalProps { - open: boolean; - onClose: () => void; - onSuccess: () => void; -} - -interface UserSignupData { - userId: string; - userName: string; - email?: string; - password: string; - pswdHnt: string; - pswdAns?: string; - mphn?: string; - telno?: string; - zip?: string; - addr?: string; - daddr?: string; - laeSeCd: string; - ogdpSeCd: string; - instSeCd?: string; - dtlInstCd?: string; - enoCd?: string; -} - -// 익명 사용자용 CodeSelect 컴포넌트 -interface AnonymousCodeSelectProps { - upCd: string; - value: string; - onValueChange: (value: string) => void; - disabled?: boolean; - placeholder?: string; -} - -function AnonymousCodeSelect({ - upCd, - value, - onValueChange, - disabled, - placeholder = "선택", -}: AnonymousCodeSelectProps) { - const [codes, setCodes] = useState< - Array<{ lwpoCd: string; lwpoCdNm: string }> - >([]); - const [isLoading, setIsLoading] = useState(false); - - useEffect(() => { - const fetchCodes = async () => { - if (!upCd) return; - - setIsLoading(true); - try { - const signupClient = SignupClient.getInstance({ - baseURL: BASE_URL, - }); - - const response = await signupClient.get( - `${ENDPOINT.CMS_SERVICE.CODE}/${upCd}`, - ); - - if (response.data && Array.isArray(response.data)) { - setCodes(response.data); - } else { - setCodes([]); - } - } catch (error) { - console.error(`공통 코드 로드 실패 (${upCd}):`, error); - setCodes([]); - } finally { - setIsLoading(false); - } - }; - - fetchCodes(); - }, [upCd]); - - return ( - - ); -} - -function UserSignupModal({ open, onClose, onSuccess }: UserSignupModalProps) { - const [currentStep, setCurrentStep] = useState<"agreement" | "signup">( - "agreement", - ); - const [agreed, setAgreed] = useState(false); - const [formData, setFormData] = useState({ - userId: "", - userName: "", - email: "", - password: "", - confirmPassword: "", - pswdHnt: "", - pswdAns: "", - mphn: "", - telno: "", - zip: "", - addr: "", - daddr: "", - laeSeCd: "", - ogdpSeCd: "", - instSeCd: "", - dtlInstCd: "", - enoCd: "", - }); - - const [errors, setErrors] = useState>({}); - const [isLoading, setIsLoading] = useState(false); - const [isCheckingId, setIsCheckingId] = useState(false); - const [isCheckingEmail, setIsCheckingEmail] = useState(false); - const [idAvailable, setIdAvailable] = useState(null); - const [emailAvailable, setEmailAvailable] = useState(null); - - // 모달이 닫힐 때 초기화 - useEffect(() => { - if (!open) { - setCurrentStep("agreement"); - setAgreed(false); - setFormData({ - userId: "", - userName: "", - email: "", - password: "", - confirmPassword: "", - pswdHnt: "", - pswdAns: "", - mphn: "", - telno: "", - zip: "", - addr: "", - daddr: "", - laeSeCd: "", - ogdpSeCd: "", - instSeCd: "", - dtlInstCd: "", - enoCd: "", - }); - setErrors({}); - setIdAvailable(null); - setEmailAvailable(null); - } - }, [open]); - - const handleAgreementChange = (e: React.ChangeEvent) => { - setAgreed(e.target.checked); - }; - - const handleNextStep = () => { - if (agreed) { - setCurrentStep("signup"); - } - }; - - const handleInputChange = ( - e: React.ChangeEvent, - ) => { - const { name, value } = e.target; - setFormData((prev) => ({ ...prev, [name]: value })); - - // 에러 초기화 - if (errors[name]) { - setErrors((prev) => ({ ...prev, [name]: "" })); - } - }; - - const validateForm = () => { - const newErrors: Record = {}; - - console.log("폼 검증 시작:", formData); - - // 필수 필드 검증 - if (!formData.userId) newErrors.userId = "아이디를 입력해주세요."; - if (!formData.userName) newErrors.userName = "이름을 입력해주세요."; - if (!formData.password) newErrors.password = "비밀번호를 입력해주세요."; - if (!formData.confirmPassword) - newErrors.confirmPassword = "비밀번호 확인을 입력해주세요."; - if (!formData.pswdHnt) newErrors.pswdHnt = "비밀번호 힌트를 선택해주세요."; - if (!formData.laeSeCd) newErrors.laeSeCd = "법인구분을 선택해주세요."; - if (!formData.ogdpSeCd) newErrors.ogdpSeCd = "기관구분을 선택해주세요."; - - // 비밀번호 확인 - if ( - formData.password && - formData.confirmPassword && - formData.password !== formData.confirmPassword - ) { - newErrors.confirmPassword = "비밀번호가 일치하지 않습니다."; - } - - // 비밀번호 정규식 검증 - const passwordRegex = - /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/; - if (formData.password && !passwordRegex.test(formData.password)) { - newErrors.password = - "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다."; - } - - // 이메일 형식 검증 - if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) { - newErrors.email = "올바른 이메일 형식을 입력해주세요."; - } - - // 중복 체크 검증 - if (idAvailable === false) - newErrors.userId = "이미 사용 중인 아이디입니다."; - if (emailAvailable === false) - newErrors.email = "이미 사용 중인 이메일입니다."; - - setErrors(newErrors); - const isValid = Object.keys(newErrors).length === 0; - console.log("폼 검증 결과:", { isValid, errors: newErrors, formData }); - return isValid; - }; - - const checkUserIdDuplicate = async () => { - if (!formData.userId) { - setErrors((prev) => ({ ...prev, userId: "아이디를 입력해주세요." })); - return; - } - - setIsCheckingId(true); - try { - const signupClient = SignupClient.getInstance({ - baseURL: BASE_URL, - }); - const response = await signupClient.post( - `${ENDPOINT.USER_SERVICE.USERS}/existsUserId`, - { userId: formData.userId }, - ); - - const exists = response.data; - setIdAvailable(!exists); - if (exists) { - setErrors((prev) => ({ - ...prev, - userId: "이미 사용 중인 아이디입니다.", - })); - } else { - setErrors((prev) => ({ ...prev, userId: "" })); - } - } catch (error) { - console.error("아이디 중복 체크 오류:", error); - setErrors((prev) => ({ - ...prev, - userId: "아이디 중복 체크 중 오류가 발생했습니다.", - })); - } finally { - setIsCheckingId(false); - } - }; - - const checkEmailDuplicate = async () => { - if (!formData.email) { - setErrors((prev) => ({ ...prev, email: "이메일을 입력해주세요." })); - return; - } - - setIsCheckingEmail(true); - try { - const signupClient = SignupClient.getInstance({ - baseURL: BASE_URL, - }); - const response = await signupClient.post( - `${ENDPOINT.USER_SERVICE.USERS}/exists`, - { email: formData.email }, - ); - - const exists = response.data; - setEmailAvailable(!exists); - if (exists) { - setErrors((prev) => ({ - ...prev, - email: "이미 사용 중인 이메일입니다.", - })); - } else { - setErrors((prev) => ({ ...prev, email: "" })); - } - } catch (error) { - console.error("이메일 중복 체크 오류:", error); - setErrors((prev) => ({ - ...prev, - email: "이메일 중복 체크 중 오류가 발생했습니다.", - })); - } finally { - setIsCheckingEmail(false); - } - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - console.log("회원가입 버튼 클릭됨 - handleSubmit"); - console.log("현재 폼 데이터:", formData); - - if (!validateForm()) { - console.log("폼 검증 실패"); - return; - } - - console.log("폼 검증 성공, 회원가입 API 호출 시작"); - setIsLoading(true); - try { - const signupData: UserSignupData = { - userId: formData.userId, - userName: formData.userName, - email: formData.email || undefined, - password: formData.password, - pswdHnt: formData.pswdHnt, - pswdAns: formData.pswdAns || undefined, - mphn: formData.mphn || undefined, - telno: formData.telno || undefined, - zip: formData.zip || undefined, - addr: formData.addr || undefined, - daddr: formData.daddr || undefined, - laeSeCd: formData.laeSeCd, - ogdpSeCd: formData.ogdpSeCd, - instSeCd: formData.instSeCd || undefined, - dtlInstCd: formData.dtlInstCd || undefined, - enoCd: formData.enoCd || undefined, - }; - - // SignupClient를 통해 익명사용자 회원가입 API 호출 - console.log("SignupClient 생성 및 API 호출:", { - baseURL: BASE_URL, - endpoint: ENDPOINT.USER_SERVICE.USER_JOIN, - data: signupData, - }); - - const signupClient = SignupClient.getInstance({ - baseURL: BASE_URL, - }); - const response = await signupClient.post( - ENDPOINT.USER_SERVICE.USER_JOIN, - signupData, - ); - - console.log("회원가입 성공:", response.data); - - // 성공 시 모달 닫기 및 콜백 호출 - onSuccess(); - onClose(); - - // 폼 초기화 - setFormData({ - userId: "", - userName: "", - email: "", - password: "", - confirmPassword: "", - pswdHnt: "", - pswdAns: "", - mphn: "", - telno: "", - zip: "", - addr: "", - daddr: "", - laeSeCd: "", - ogdpSeCd: "", - instSeCd: "", - dtlInstCd: "", - enoCd: "", - }); - setErrors({}); - setIdAvailable(null); - setEmailAvailable(null); - } catch (error: unknown) { - console.error("회원가입 오류:", error); - if (error instanceof Error) { - setErrors((prev) => ({ ...prev, submit: error.message })); - } else { - setErrors((prev) => ({ ...prev, submit: "회원가입에 실패했습니다." })); - } - } finally { - setIsLoading(false); - } - }; - - if (!open) return null; - - return ( -
-
-
-

- {currentStep === "agreement" ? "개인정보제공동의" : "회원가입"} -

- -
- - {currentStep === "agreement" ? ( -
-
-

개인정보 수집 및 이용 동의

-

회원가입을 위해 다음과 같이 개인정보를 수집 및 이용합니다.

- -

1. 수집하는 개인정보 항목

-
    -
  • - 필수항목: 아이디, 이름, 비밀번호, 비밀번호 힌트, 법인구분, - 기관구분 -
  • -
  • - 선택항목: 이메일, 휴대폰번호, 전화번호, 주소, 기관코드, - 상세기관코드, 사업자등록번호 -
  • -
- -

2. 개인정보의 수집 및 이용목적

-
    -
  • 회원가입 및 관리
  • -
  • 서비스 제공 및 운영
  • -
  • 고객상담 및 문의응답
  • -
- -

3. 개인정보의 보유 및 이용기간

-

- 회원탈퇴 시까지 (단, 관련 법령에 따라 보존이 필요한 경우 해당 - 기간까지) -

- -

4. 동의 거부권 및 거부 시 불이익

-

- 개인정보 수집 및 이용에 대한 동의를 거부할 수 있으며, 동의 거부 - 시 회원가입이 제한됩니다. -

-
- -
- -
- -
- - -
-
- ) : ( -
-
-

기본 정보

- -
-
- -
- - -
- {idAvailable === true && ( - - 사용 가능한 아이디입니다. - - )} - {errors.userId && ( - - {errors.userId} - - )} -
-
- -
-
- - - {errors.userName && ( - - {errors.userName} - - )} -
-
- -
-
- -
- - -
- {emailAvailable === true && ( - - 사용 가능한 이메일입니다. - - )} - {errors.email && ( - - {errors.email} - - )} -
-
- -
-
- - - {errors.password && ( - - {errors.password} - - )} -
-
- -
-
- - - {errors.confirmPassword && ( - - {errors.confirmPassword} - - )} -
-
- -
-
- - - setFormData((prev) => ({ ...prev, pswdHnt: value })) - } - disabled={isLoading} - placeholder="비밀번호 힌트를 선택하세요" - /> - {errors.pswdHnt && ( - - {errors.pswdHnt} - - )} -
-
- -
-
- - - {errors.pswdAns && ( - - {errors.pswdAns} - - )} -
-
-
- -
-

연락처 정보

- -
-
- - - {errors.mphn && ( - - {errors.mphn} - - )} -
-
- -
-
- - - {errors.telno && ( - - {errors.telno} - - )} -
-
-
- -
-

주소 정보

- -
-
- - - {errors.zip && ( - - {errors.zip} - - )} -
-
- -
-
- - - {errors.addr && ( - - {errors.addr} - - )} -
-
- -
-
- - - {errors.daddr && ( - - {errors.daddr} - - )} -
-
-
- -
-

기관 정보

- -
-
- - - setFormData((prev) => ({ ...prev, laeSeCd: value })) - } - disabled={isLoading} - placeholder="법인구분을 선택하세요" - /> - {errors.laeSeCd && ( - - {errors.laeSeCd} - - )} -
-
- -
-
- - - setFormData((prev) => ({ ...prev, ogdpSeCd: value })) - } - disabled={isLoading} - placeholder="기관구분을 선택하세요" - /> - {errors.ogdpSeCd && ( - - {errors.ogdpSeCd} - - )} -
-
- -
-
- - - {errors.instSeCd && ( - - {errors.instSeCd} - - )} -
-
- -
-
- - - {errors.dtlInstCd && ( - - {errors.dtlInstCd} - - )} -
-
-
- - {errors.submit && ( -
-
- {errors.submit} -
-
- )} - -
- - - -
-
- )} -
-
- ); -} - -export default UserSignupModal; diff --git a/apps/wr/src/features/auth/login/ui/login-page.tsx b/apps/wr/src/features/auth/login/ui/login-page.tsx index cfb902be8..4c340f78e 100644 --- a/apps/wr/src/features/auth/login/ui/login-page.tsx +++ b/apps/wr/src/features/auth/login/ui/login-page.tsx @@ -1,16 +1,21 @@ "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() { - 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.SubmitEvent) => { + 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); diff --git a/apps/wr/src/features/auth/login/ui/signup-modal.css b/apps/wr/src/features/auth/login/ui/signup-modal.css deleted file mode 100644 index 0fb1585c9..000000000 --- a/apps/wr/src/features/auth/login/ui/signup-modal.css +++ /dev/null @@ -1,277 +0,0 @@ -/* 모달 오버레이 */ -.modal-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(0, 0, 0, 0.5); - display: flex; - justify-content: center; - align-items: center; - z-index: 1000; - padding: 20px; -} - -/* 모달 컨텐츠 */ -.modal-content { - background: white; - border-radius: 8px; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); - max-width: 800px; - width: 100%; - max-height: 90vh; - overflow-y: auto; - position: relative; -} - -/* 모달 헤더 */ -.modal-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 20px 24px; - border-bottom: 1px solid #e5e7eb; - background-color: #f9fafb; - border-radius: 8px 8px 0 0; -} - -.modal-header h2 { - margin: 0; - font-size: 1.8rem; - font-weight: 600; - color: #1f2937; -} - -.close-button { - background: none; - border: none; - font-size: 1.5rem; - cursor: pointer; - color: #6b7280; - padding: 4px; - border-radius: 4px; - transition: color 0.2s; -} - -.close-button:hover { - color: #374151; -} - -/* 폼 스타일 */ -.signup-form { - padding: 24px; -} - -.form-section { - margin-bottom: 32px; -} - -.form-section h3 { - margin: 0 0 16px 0; - font-size: 1.4rem; - font-weight: 600; - color: #374151; - padding-bottom: 8px; - border-bottom: 2px solid #e5e7eb; -} - -.form-row { - margin-bottom: 16px; -} - -.form-group { - display: flex; - flex-direction: column; -} - -.form-group label { - margin-bottom: 6px; - font-weight: 500; - color: #374151; - font-size: 1.1rem; -} - -.form-group input, -.form-group select { - padding: 12px 14px; - border: 1px solid #d1d5db; - border-radius: 6px; - font-size: 1.1rem; - transition: border-color 0.2s, box-shadow 0.2s; - background-color: white; -} - -.form-group input:focus, -.form-group select:focus { - outline: none; - border-color: #3b82f6; - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); -} - -.form-group input:disabled, -.form-group select:disabled { - background-color: #f3f4f6; - color: #6b7280; - cursor: not-allowed; -} - -/* 입력 필드와 버튼이 함께 있는 경우 */ -.input-with-button { - display: flex; - gap: 8px; - align-items: flex-end; -} - -.input-with-button input { - flex: 1; -} - -.check-button { - padding: 10px 16px; - background-color: #6b7280; - color: white; - border: none; - border-radius: 6px; - font-size: 0.8rem; - font-weight: 500; - cursor: pointer; - transition: background-color 0.2s; - white-space: nowrap; - min-width: 80px; -} - -.check-button:hover:not(:disabled) { - background-color: #4b5563; -} - -.check-button:disabled { - background-color: #d1d5db; - cursor: not-allowed; -} - -/* 메시지 스타일 */ -.error-message { - color: #dc2626; - font-size: 0.8rem; - margin-top: 4px; - display: block; -} - -.success-message { - color: #059669; - font-size: 0.8rem; - margin-top: 4px; - display: block; -} - -.submit-error { - background-color: #fef2f2; - border: 1px solid #fecaca; - color: #dc2626; - padding: 12px; - border-radius: 6px; - margin-bottom: 16px; - text-align: center; -} - -/* 폼 액션 버튼 */ -.form-actions { - display: flex; - gap: 12px; - justify-content: flex-end; - padding-top: 20px; - border-top: 1px solid #e5e7eb; - margin-top: 24px; -} - -.cancel-button, -.submit-button { - padding: 12px 24px; - border-radius: 6px; - font-weight: 500; - font-size: 0.9rem; - cursor: pointer; - transition: all 0.2s; - border: none; - min-width: 100px; -} - -.cancel-button { - background-color: #f3f4f6; - color: #374151; - border: 1px solid #d1d5db; -} - -.cancel-button:hover:not(:disabled) { - background-color: #e5e7eb; -} - -.submit-button { - background-color: #3b82f6; - color: white; -} - -.submit-button:hover:not(:disabled) { - background-color: #2563eb; -} - -.cancel-button:disabled, -.submit-button:disabled { - opacity: 0.6; - cursor: not-allowed; -} - -/* 반응형 디자인 */ -@media (max-width: 768px) { - .modal-content { - margin: 10px; - max-height: 95vh; - } - - .modal-header { - padding: 16px 20px; - } - - .signup-form { - padding: 20px; - } - - .form-actions { - flex-direction: column; - } - - .cancel-button, - .submit-button { - width: 100%; - } - - .input-with-button { - flex-direction: column; - align-items: stretch; - } - - .check-button { - width: 100%; - margin-top: 8px; - } -} - -/* 스크롤바 스타일 */ -.modal-content::-webkit-scrollbar { - width: 6px; -} - -.modal-content::-webkit-scrollbar-track { - background: #f1f1f1; - border-radius: 3px; -} - -.modal-content::-webkit-scrollbar-thumb { - background: #c1c1c1; - border-radius: 3px; -} - -.modal-content::-webkit-scrollbar-thumb:hover { - background: #a8a8a8; -} diff --git a/apps/wr/src/features/auth/login/ui/user-signup-modal.css b/apps/wr/src/features/auth/login/ui/user-signup-modal.css deleted file mode 100644 index 241929a2a..000000000 --- a/apps/wr/src/features/auth/login/ui/user-signup-modal.css +++ /dev/null @@ -1,436 +0,0 @@ -/* 신규 사용자 가입 모달 오버레이 */ -.user-signup-modal-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(0, 0, 0, 0.5); - display: flex; - justify-content: center; - align-items: center; - z-index: 1000; - padding: 20px; -} - -/* 신규 사용자 가입 모달 컨텐츠 */ -.user-signup-modal-content { - background: white; - border-radius: 8px; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); - max-width: 800px; - width: 100%; - max-height: 90vh; - overflow-y: auto; - position: relative; -} - -/* 신규 사용자 가입 모달 헤더 */ -.user-signup-modal-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 20px 24px; - border-bottom: 1px solid #e5e7eb; - background-color: #f9fafb; - border-radius: 8px 8px 0 0; -} - -.user-signup-modal-header h2 { - margin: 0; - font-size: 1.8rem; - font-weight: 600; - color: #1f2937; -} - -.user-signup-close-button { - background: none; - border: none; - font-size: 1.5rem; - cursor: pointer; - color: #6b7280; - padding: 4px; - border-radius: 4px; - transition: color 0.2s; -} - -.user-signup-close-button:hover { - color: #374151; -} - -/* 신규 사용자 가입 폼 스타일 */ -.user-signup-form { - padding: 24px; -} - -.user-signup-form-section { - margin-bottom: 32px; -} - -.user-signup-form-section h3 { - margin: 0 0 16px 0; - font-size: 1.4rem; - font-weight: 600; - color: #374151; - padding-bottom: 8px; - border-bottom: 2px solid #e5e7eb; -} - -.user-signup-form-row { - margin-bottom: 16px; -} - -.user-signup-form-group { - display: flex; - flex-direction: column; -} - -.user-signup-form-group label { - margin-bottom: 6px; - font-weight: 500; - color: #374151; - font-size: 1.1rem; -} - -.user-signup-form-group input, -.user-signup-form-group select { - padding: 12px 14px; - border: 1px solid #d1d5db; - border-radius: 6px; - font-size: 1.1rem; - transition: border-color 0.2s, box-shadow 0.2s; - background-color: white; -} - -/* CodeSelect 컴포넌트 스타일링 */ -.user-signup-form-group .CodeSelect { - width: 100%; -} - -.user-signup-form-group .CodeSelect select { - width: 100%; - padding: 12px 14px; - border: 1px solid #d1d5db; - border-radius: 6px; - font-size: 1.1rem; - transition: border-color 0.2s, box-shadow 0.2s; - background-color: white; -} - -.user-signup-form-group .CodeSelect select:focus { - outline: none; - border-color: #3b82f6; - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); -} - -.user-signup-form-group .CodeSelect select:disabled { - background-color: #f3f4f6; - color: #6b7280; - cursor: not-allowed; -} - -/* Select 요소 스타일링 */ -.user-signup-select { - width: 100%; - padding: 12px 14px; - border: 1px solid #d1d5db; - border-radius: 6px; - font-size: 1.1rem; - transition: border-color 0.2s, box-shadow 0.2s; - background-color: white; - cursor: pointer; -} - -.user-signup-select:focus { - outline: none; - border-color: #3b82f6; - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); -} - -.user-signup-select:disabled { - background-color: #f3f4f6; - color: #6b7280; - cursor: not-allowed; -} - -.user-signup-select option { - padding: 8px; - font-size: 1rem; -} - -.user-signup-form-group input:focus, -.user-signup-form-group select:focus { - outline: none; - border-color: #3b82f6; - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); -} - -.user-signup-form-group input:disabled, -.user-signup-form-group select:disabled { - background-color: #f3f4f6; - color: #6b7280; - cursor: not-allowed; -} - -/* 입력 필드와 버튼이 함께 있는 경우 */ -.user-signup-input-with-button { - display: flex; - gap: 8px; - align-items: flex-end; -} - -.user-signup-input-with-button input { - flex: 1; -} - -.user-signup-check-button { - padding: 10px 16px; - background-color: #6b7280; - color: white; - border: none; - border-radius: 6px; - font-size: 0.8rem; - font-weight: 500; - cursor: pointer; - transition: background-color 0.2s; - white-space: nowrap; - min-width: 80px; -} - -.user-signup-check-button:hover:not(:disabled) { - background-color: #4b5563; -} - -.user-signup-check-button:disabled { - background-color: #d1d5db; - cursor: not-allowed; -} - -/* 메시지 스타일 */ -.user-signup-error-message { - color: #dc2626; - font-size: 0.8rem; - margin-top: 4px; - display: block; -} - -.user-signup-success-message { - color: #059669; - font-size: 0.8rem; - margin-top: 4px; - display: block; -} - -.user-signup-submit-error { - background-color: #fef2f2; - border: 1px solid #fecaca; - color: #dc2626; - padding: 12px; - border-radius: 6px; - margin-bottom: 16px; - text-align: center; -} - -/* 폼 액션 버튼 */ -.user-signup-form-actions { - display: flex; - gap: 12px; - justify-content: flex-end; - padding-top: 20px; - border-top: 1px solid #e5e7eb; - margin-top: 24px; -} - -.user-signup-cancel-button, -.user-signup-submit-button, -.user-signup-back-button { - padding: 12px 24px; - border-radius: 6px; - font-weight: 500; - font-size: 0.9rem; - cursor: pointer; - transition: all 0.2s; - border: none; - min-width: 100px; -} - -.user-signup-cancel-button { - background-color: #f3f4f6; - color: #374151; - border: 1px solid #d1d5db; -} - -.user-signup-cancel-button:hover:not(:disabled) { - background-color: #e5e7eb; -} - -.user-signup-submit-button { - background-color: #3b82f6; - color: white; -} - -.user-signup-submit-button:hover:not(:disabled) { - background-color: #2563eb; -} - -.user-signup-back-button { - background-color: #6b7280; - color: white; -} - -.user-signup-back-button:hover:not(:disabled) { - background-color: #4b5563; -} - -.user-signup-cancel-button:disabled, -.user-signup-submit-button:disabled, -.user-signup-back-button:disabled { - opacity: 0.6; - cursor: not-allowed; -} - -/* 개인정보제공동의 스타일 */ -.user-signup-agreement-content { - padding: 24px; -} - -.user-signup-agreement-text { - margin-bottom: 24px; -} - -.user-signup-agreement-text h3 { - margin: 0 0 16px 0; - font-size: 1.4rem; - font-weight: 600; - color: #374151; -} - -.user-signup-agreement-text h4 { - margin: 20px 0 8px 0; - font-size: 1.1rem; - font-weight: 600; - color: #374151; -} - -.user-signup-agreement-text p { - margin: 8px 0; - color: #6b7280; - line-height: 1.5; -} - -.user-signup-agreement-text ul { - margin: 8px 0; - padding-left: 20px; - color: #6b7280; -} - -.user-signup-agreement-text li { - margin: 4px 0; - line-height: 1.5; -} - -.user-signup-agreement-checkbox { - margin-bottom: 24px; - padding: 16px; - background-color: #f9fafb; - border-radius: 6px; - border: 1px solid #e5e7eb; -} - -.user-signup-agreement-checkbox label { - display: flex; - align-items: center; - gap: 8px; - cursor: pointer; - font-weight: 500; - color: #374151; -} - -.user-signup-agreement-checkbox input[type="checkbox"] { - width: 18px; - height: 18px; - margin: 0; -} - -.user-signup-agreement-actions { - display: flex; - gap: 12px; - justify-content: flex-end; -} - -.user-signup-next-button { - padding: 12px 24px; - background-color: #3b82f6; - color: white; - border: none; - border-radius: 6px; - font-weight: 500; - font-size: 0.9rem; - cursor: pointer; - transition: background-color 0.2s; - min-width: 100px; -} - -.user-signup-next-button:hover:not(:disabled) { - background-color: #2563eb; -} - -.user-signup-next-button:disabled { - background-color: #d1d5db; - cursor: not-allowed; -} - -/* 반응형 디자인 */ -@media (max-width: 768px) { - .user-signup-modal-content { - margin: 10px; - max-height: 95vh; - } - - .user-signup-modal-header { - padding: 16px 20px; - } - - .user-signup-form { - padding: 20px; - } - - .user-signup-form-actions { - flex-direction: column; - } - - .user-signup-cancel-button, - .user-signup-submit-button, - .user-signup-back-button { - width: 100%; - } - - .user-signup-input-with-button { - flex-direction: column; - align-items: stretch; - } - - .user-signup-check-button { - width: 100%; - margin-top: 8px; - } -} - -/* 스크롤바 스타일 */ -.user-signup-modal-content::-webkit-scrollbar { - width: 6px; -} - -.user-signup-modal-content::-webkit-scrollbar-track { - background: #f1f1f1; - border-radius: 3px; -} - -.user-signup-modal-content::-webkit-scrollbar-thumb { - background: #c1c1c1; - border-radius: 3px; -} - -.user-signup-modal-content::-webkit-scrollbar-thumb:hover { - background: #a8a8a8; -} diff --git a/apps/wr/src/shared/providers/prompt-provider.tsx b/apps/wr/src/shared/providers/prompt-provider.tsx index 10338c338..7724d0326 100644 --- a/apps/wr/src/shared/providers/prompt-provider.tsx +++ b/apps/wr/src/shared/providers/prompt-provider.tsx @@ -1,3 +1,4 @@ +"use client"; import { createContext, useState } from "react"; interface PromptContext { diff --git a/packages/common/business_components/src/ui/excel/index.tsx b/packages/common/business_components/src/ui/excel/index.tsx index 23dfd0fea..54e4fe6a8 100644 --- a/packages/common/business_components/src/ui/excel/index.tsx +++ b/packages/common/business_components/src/ui/excel/index.tsx @@ -246,7 +246,7 @@ export const filePickedArray=(oFile: File, headerNum:number=0):Promise("A"); const [sidoValue, setSidoValue] = useState(initSidoValue?initSidoValue:"0"); - const [sigunValue, setSigunValue] = useState("0"); + const [sigunValue, setSigunValue] = useState(initSigunValue?initSigunValue:"0"); const [sidoText, setSidoText] = useState(""); const [sigunText, setSigunText] = useState(""); const [regionValue, setRegionValue] = useState("0"); @@ -115,18 +117,24 @@ function SearchBaseForm ({ if(initSidoValue){ setSidoValue(initSidoValue); } + if(initSigunValue){ + setSigunValue(initSigunValue); + } setResetTrigger(true); initClick(); - }, [initSidoValue, setResetTrigger, initClick]); + }, [initSidoValue, initSigunValue, setResetTrigger, initClick]); useEffect(() => { if (resetTrigger) { if(initSidoValue!=""){ setSidoValue(initSidoValue); } + if(initSigunValue!=""){ + setSigunValue(initSigunValue); + } setResetTrigger(false); } - }, [resetTrigger]); + }, [resetTrigger, initSidoValue, initSigunValue]); const excelDownload = () => { excelClick({ partValue: partValue, @@ -179,6 +187,7 @@ function SearchBaseForm ({ leftClassName={leftClassName} leftSelectClassName={leftSelectClassName} initSidoValue={initSidoValue} + initSigunValue={initSigunValue} > {leftChildren} diff --git a/packages/common/business_components/src/ui/search_form_left/index.tsx b/packages/common/business_components/src/ui/search_form_left/index.tsx index 04537ec5e..0b04562b4 100644 --- a/packages/common/business_components/src/ui/search_form_left/index.tsx +++ b/packages/common/business_components/src/ui/search_form_left/index.tsx @@ -47,6 +47,7 @@ type SearchFormLeftProps = { leftClassName?: string; leftSelectClassName?: string; initSidoValue?: string; + initSigunValue?: string; } export type EnvPartSelectDataType = SelectDataType & { @@ -59,12 +60,12 @@ const selectPartData:SelectDataType[] = [ { text: "환경청별", val: 'G' }, ]; -function SearchFormLeft({ isShowPart = false, children, selectSidoData = [], selectSigunData = [], selectRegionData = [], selectUnitWaterShedData = [], selectMiddleRegionData = [], selectEnvPartData = [], setRtnPartValue, setRtnSidoValue, setRtnSidoText, setRtnSigunValue, setRtnSigunText, setRtnRegionValue, setRtnUnitWaterShedValue, setRtnMiddleRegionValue, setRtnEnvPartValue, setRtnEnvPartSidoValue, setRtnEnvPartSigunValue, resetTrigger = false, isAdmin = false, leftClassName, leftSelectClassName, initSidoValue }: SearchFormLeftProps) { +function SearchFormLeft({ isShowPart = false, children, selectSidoData = [], selectSigunData = [], selectRegionData = [], selectUnitWaterShedData = [], selectMiddleRegionData = [], selectEnvPartData = [], setRtnPartValue, setRtnSidoValue, setRtnSidoText, setRtnSigunValue, setRtnSigunText, setRtnRegionValue, setRtnUnitWaterShedValue, setRtnMiddleRegionValue, setRtnEnvPartValue, setRtnEnvPartSidoValue, setRtnEnvPartSigunValue, resetTrigger = false, isAdmin = false, leftClassName, leftSelectClassName, initSidoValue, initSigunValue }: SearchFormLeftProps) { /******************선택된 데이터******************/ const [partValue, setPartValue] = useState('A'); //행정별 시도 시군구 const [sidoValue, setSidoValue] = useState(initSidoValue?initSidoValue : '0'); - const [sigunValue, setSigunValue] = useState('0'); + const [sigunValue, setSigunValue] = useState(initSigunValue?initSigunValue : '0'); const [sidoText, setSidoText] = useState(''); const [sigunText, setSigunText] = useState(''); //유역별 @@ -87,6 +88,39 @@ function SearchFormLeft({ isShowPart = false, children, selectSidoData = [], sel const [filteredEnvPartSigunData, setFilteredEnvPartSigunData] = useState([{ text: "전체", val: '0' }]); const [initTrigger, setInitTrigger] = useState(false); + + useEffect(() => { + if (!initSidoValue) return; + if (!selectSidoData.some((item) => item.val === initSidoValue)) return; + + const selectedSido = selectSidoData.find((item) => item.val === initSidoValue); + setSidoValue(initSidoValue); + setSidoText(selectedSido?.text || ""); + if (setRtnSidoValue) setRtnSidoValue(initSidoValue); + if (setRtnSidoText) setRtnSidoText(selectedSido?.text || ""); + initializeSigunData(initSidoValue); + }, [initSidoValue, selectSidoData]); + + useEffect(() => { + if (!initSidoValue || !initSigunValue) return; + + const initializedSigunData = [ + { text: "전체", val: '0' }, + ...selectSigunData.filter( + (item: SelectDataType) => item.val.substring(0, 2) === initSidoValue + ) + ]; + const selectedSigun = initializedSigunData.find((item) => item.val === initSigunValue); + + if (!selectedSigun) return; + + setFilteredSigunData(initializedSigunData); + setSigunValue(initSigunValue); + setSigunText(selectedSigun.text || ""); + if (setRtnSigunValue) setRtnSigunValue(initSigunValue); + if (setRtnSigunText) setRtnSigunText(selectedSigun.text || ""); + }, [initSidoValue, initSigunValue, selectSigunData, setRtnSigunText, setRtnSigunValue]); + // 시군구 세팅 const initializeSigunData = (sidoCd: string) => { if (sidoCd === '0') { @@ -204,7 +238,7 @@ function SearchFormLeft({ isShowPart = false, children, selectSidoData = [], sel setRtnSidoText(selectedSido?.text || ''); } initializeSigunData(val); - onSelectValue('0', 'sigun'); + onSelectValue(val === initSidoValue && initSigunValue ? initSigunValue : '0', 'sigun'); break; case 'sigun': if (setRtnSigunValue) { @@ -276,7 +310,7 @@ function SearchFormLeft({ isShowPart = false, children, selectSidoData = [], sel const resetAllValues = () => { setPartValue('A'); setSidoValue(initSidoValue?initSidoValue:'0'); - setSigunValue('0'); + setSigunValue(initSigunValue?initSigunValue:'0'); setRegionValue('0'); setUnitWaterShedValue('0'); setMiddleRegionValue('0'); @@ -291,7 +325,7 @@ function SearchFormLeft({ isShowPart = false, children, selectSidoData = [], sel if (setRtnPartValue) setRtnPartValue('A'); if (setRtnSidoValue) setRtnSidoValue(initSidoValue?initSidoValue:'0'); - if (setRtnSigunValue) setRtnSigunValue('0'); + if (setRtnSigunValue) setRtnSigunValue(initSigunValue?initSigunValue:'0'); if (setRtnRegionValue) setRtnRegionValue('0'); if (setRtnUnitWaterShedValue) setRtnUnitWaterShedValue('0'); if (setRtnMiddleRegionValue) setRtnMiddleRegionValue('0'); diff --git a/packages/common/business_components/src/ui/search_input/index.tsx b/packages/common/business_components/src/ui/search_input/index.tsx index 06ffce4ff..503322559 100644 --- a/packages/common/business_components/src/ui/search_input/index.tsx +++ b/packages/common/business_components/src/ui/search_input/index.tsx @@ -22,7 +22,7 @@ const SearchInput = React.forwardRef {label && } - +
) }); diff --git a/packages/common/network/utils.ts b/packages/common/network/utils.ts index 010bf2fe2..ac92db852 100644 --- a/packages/common/network/utils.ts +++ b/packages/common/network/utils.ts @@ -7,14 +7,14 @@ export function getAuthLoginRedirectUrl(): string { } export function isAtLoginRedirectUrl(loginRedirectUrl: string): boolean { - if (typeof window === "undefined") return false; + if (typeof window === "undefined") return false; // 서버 환경에서는 현재 브라우저 URL을 알 수 없으므로 false 반환 try { - const target = new URL(loginRedirectUrl, window.location.href); + const target = new URL(loginRedirectUrl, window.location.href); // 상대경로/절대경로 모두 현재 주소 기준의 완전한 URL로 변환 return ( - target.origin === window.location.origin && - target.pathname === window.location.pathname + target.origin === window.location.origin && // 도메인(프로토콜 + 호스트 + 포트)이 같은지 확인 + target.pathname === window.location.pathname // 현재 페이지 경로가 로그인 이동 경로와 같은지 확인 ); } catch { - return false; + return false; // 잘못된 URL 문자열이면 비교할 수 없으므로 false 반환 } }