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..ea85705ec 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_CM_LOGIN_PAGE_URL = /auth/login NEXT_PUBLIC_SEWERAGE_API_URL=http://localhost:8883 diff --git a/apps/sn/.env.mindone b/apps/sn/.env.mindone index 847103c97..dc567fea8 100644 --- a/apps/sn/.env.mindone +++ b/apps/sn/.env.mindone @@ -13,4 +13,4 @@ NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y} NEXT_PUBLIC_SEWERAGE_API_URL = http://con.mind-one.co.kr:11083 NEXT_PUBLIC_DC_URL = https://apigateway.apps.rsonesoft.com -NEXT_PUBLIC_CM_LOGIN_PAGE_URL = / \ No newline at end of file +NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /auth/login \ No newline at end of file diff --git a/apps/sn/.env.production b/apps/sn/.env.production index 4a7c15edc..0b8189a78 100644 --- a/apps/sn/.env.production +++ b/apps/sn/.env.production @@ -15,4 +15,3 @@ NEXT_PUBLIC_NGII_URL = http://172.29.143.87/poiact NEXT_PUBLIC_NGII_PARAM = BDE2BEF39DAFEC293AB6730150F222AC&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={y}&tilerow={x} NEXT_PUBLIC_SEWERAGE_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000 NEXT_PUBLIC_DC_URL = http://apigateway.iipbs-was.svc.cluster.local:8000 -NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login diff --git a/apps/sn/app/(map-layout)/layout.tsx b/apps/sn/app/(map-layout)/layout.tsx index f58e67489..01d2b166a 100644 --- a/apps/sn/app/(map-layout)/layout.tsx +++ b/apps/sn/app/(map-layout)/layout.tsx @@ -33,6 +33,15 @@ export default function MapLayout({ const setCurrentMenuInfo = useMenuStore((state) => state.setCurrentMenuInfo); const { fetchCtpv, fetchSgg, fetchBIZ_SE_CD, fetchBIZ_TY_CD } = useStore(); + const isPopupPage = pathname === "/map/aged-pipe-popup"; + + useEffect(() => { + document.body.classList.add("sn-map-page-lock"); + + return () => { + document.body.classList.remove("sn-map-page-lock"); + }; + }, []); useEffect(() => { fetchCtpv(); @@ -46,6 +55,10 @@ export default function MapLayout({ router.push(item.urlPath); }; + if (isPopupPage) { + return <>{children}; + } + return ( + + + ); +} diff --git a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipeLayerSettingPanel.tsx b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipeLayerSettingPanel.tsx new file mode 100644 index 000000000..9162074f4 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipeLayerSettingPanel.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { AgedPipeFilters } from "./agedPipeUtils"; + +type Props = { + filters: AgedPipeFilters; + onFiltersChange: (filters: AgedPipeFilters) => void; + onApply: (filters?: AgedPipeFilters) => void; +}; + +export default function AgedPipeLayerSettingPanel({ + filters, + onFiltersChange, + onApply, +}: Props) { + const [isCollapsed, setIsCollapsed] = useState(false); + const [elapsedYearsText, setElapsedYearsText] = useState(String(filters.elapsedYears ?? "")); + + useEffect(() => { + setElapsedYearsText(String(filters.elapsedYears ?? "")); + }, [filters.elapsedYears]); + + const commitElapsedYears = () => { + const nextFilters = { + ...filters, + elapsedYears: elapsedYearsText === "" ? ("" as unknown as number) : Number(elapsedYearsText), + }; + onFiltersChange(nextFilters); + onApply(nextFilters); + }; + + return ( +
+
+ + 노후관로 설정 +
+ + {!isCollapsed && ( +
+
+ + event.currentTarget.select()} + onChange={(event) => { + const digits = event.target.value.replace(/[^0-9]/g, ""); + setElapsedYearsText(digits); + }} + onKeyDown={(event) => { + if (event.key === "Enter") commitElapsedYears(); + }} + /> + +
+
+ )} +
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx new file mode 100644 index 000000000..efea0197c --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx @@ -0,0 +1,304 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { Button, Input } from "@common/components"; +import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui"; +import { getData } from "@/components/lib/api"; +import useStore from "@/store/useStore"; +import LoadingOverlay from "@/components/ui/LoadingBar"; +import { + AGED_PIPE_DEFAULT_FILTERS, + AGED_PIPE_EXCEL_HEADER, + AGED_PIPE_LAYER_TYPE, + AGED_PIPE_LIST_HEADLIST, + AGED_PIPE_PAGE_SIZE, + buildAgedPipeCqlFilters, + normalizeAgedPipeFilters, + toDisplayPipeRow, + validateElapsedYears, + type AgedPipeFilters, + type AgedPipeRow, +} from "./agedPipeUtils"; + +const POPUP_ROW_SELECT_MESSAGE = "SN_AGED_PIPE_ROW_SELECT"; +const POPUP_MINIMIZE_MESSAGE = "SN_AGED_PIPE_POPUP_MINIMIZE"; +const POPUP_FILTER_SYNC_MESSAGE = "SN_AGED_PIPE_FILTER_SYNC"; + +export default function AgedPipePopupPage() { + const searchParams = useSearchParams(); + const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore(); + const [filters, setFilters] = useState(() => + normalizeAgedPipeFilters({ + elapsedYears: Number(searchParams.get("elapsedYears") ?? AGED_PIPE_DEFAULT_FILTERS.elapsedYears), + ctpvCd: searchParams.get("ctpvCd") ?? AGED_PIPE_DEFAULT_FILTERS.ctpvCd, + sggCd: searchParams.get("sggCd") ?? AGED_PIPE_DEFAULT_FILTERS.sggCd, + }), + ); + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + const [currentPage, setCurrentPage] = useState(1); + const [selectedIndex, setSelectedIndex] = useState(-1); + const [isLoading, setIsLoading] = useState(false); + + const sggOptions = useMemo( + () => + filters.ctpvCd === "0" + ? sgg + : sgg.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2))), + [filters.ctpvCd, sgg], + ); + + useEffect(() => { + if (ctpv.length === 0) fetchCtpv(); + if (sgg.length === 0) fetchSgg(); + }, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]); + + const fetchRows = useCallback( + async (page: number, nextFilters: AgedPipeFilters, size: number) => { + const normalized = normalizeAgedPipeFilters(nextFilters); + const queryParams = new URLSearchParams({ + lyrTypeNm: AGED_PIPE_LAYER_TYPE, + page: String(page - 1), + size: String(size), + sort: "string", + }); + const cqlFilters = buildAgedPipeCqlFilters(normalized); + if (cqlFilters.length > 0) { + queryParams.append("cqlFiter", cqlFilters.join(" and ")); + } + + const response = await getData(`v1/fclts?${queryParams.toString()}`); + const responseRows = Array.isArray(response?.data) ? response.data : []; + + return { + rows: responseRows.map((item: Record, index: number) => + toDisplayPipeRow(item, index, page, ctpv, sgg), + ), + total: Number(response?.count ?? 0), + }; + }, + [ctpv, sgg], + ); + + const loadRows = useCallback( + async (page = 1, nextFilters = filters) => { + setIsLoading(true); + try { + const response = await fetchRows(page, nextFilters, AGED_PIPE_PAGE_SIZE); + setRows(response.rows); + setTotal(response.total); + setCurrentPage(page); + } catch (error) { + console.error("노후관로 목록 조회 중 오류:", error); + setRows([]); + setTotal(0); + } finally { + setIsLoading(false); + } + }, + [fetchRows, filters], + ); + + useEffect(() => { + loadRows(1, filters); + // 최초 로딩만 수행한다. 이후 조회는 버튼/페이지 변경으로 처리한다. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + const handleFilterSyncMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + if (event.data?.type !== POPUP_FILTER_SYNC_MESSAGE) return; + if (!event.data?.filters) return; + + const normalized = normalizeAgedPipeFilters(event.data.filters as AgedPipeFilters); + setFilters(normalized); + setSelectedIndex(-1); + loadRows(1, normalized); + }; + + window.addEventListener("message", handleFilterSyncMessage); + return () => window.removeEventListener("message", handleFilterSyncMessage); + }, [loadRows]); + + const handleSearch = (page = 1) => { + if (!validateElapsedYears(Number(filters.elapsedYears))) { + alert("경과년도는 1년~100년 범위 내에서 입력해주세요."); + return; + } + + const normalized = normalizeAgedPipeFilters(filters); + setFilters(normalized); + loadRows(page, normalized); + }; + + const handleRowClick = (index?: number) => { + if (typeof index !== "number") return; + const row = rows[index]; + if (!row) return; + + setSelectedIndex(index); + window.opener?.postMessage( + { + type: POPUP_ROW_SELECT_MESSAGE, + row, + }, + window.location.origin, + ); + }; + + const handleDownload = async () => { + if (total === 0) { + alert("다운로드할 데이터가 없습니다."); + return; + } + + try { + setIsLoading(true); + const response = await fetchRows(1, filters, 10000); + await downloadExcelWithKoreanHeaders({ + data: response.rows as JSON[], + fileName: `노후관로_현황_${new Date().toISOString().slice(0, 10)}`, + sheetNameArray: ["노후관로 현황"], + headerRange: "A1:J1", + headerMapping: AGED_PIPE_EXCEL_HEADER, + }); + } catch (error) { + console.error("노후관로 목록 다운로드 중 오류:", error); + alert("노후관로 목록 다운로드 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + const handleMinimize = () => { + window.opener?.postMessage( + { + type: POPUP_MINIMIZE_MESSAGE, + filters: normalizeAgedPipeFilters(filters), + total, + }, + window.location.origin, + ); + window.close(); + }; + + return ( +
+ +
+

노후관로 현황

+
+ +
+
+ +
+
+ + + + + + +
+ + +
+
+ + + headName={<>노후관로 조회 결과 총 {total.toLocaleString()}건} + tableData={rows} + headlist={AGED_PIPE_LIST_HEADLIST} + pageSize={10} + total={total} + cellClick={(index) => handleRowClick(index)} + onPageChange={(page) => handleSearch(page)} + curPage={currentPage} + index={selectedIndex} + widthFixed={true} + /> +
+
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts b/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts new file mode 100644 index 000000000..62bccc416 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts @@ -0,0 +1,236 @@ +"use client"; + +import type { SelectDataType } from "@common/business_components"; +import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue"; +import { + MS_SWAGE_PPL_CODES, + MS_SWAGE_PPL_USE_CODES, +} from "../common/table"; + +export const AGED_PIPE_LAYER_TYPE = "tb_ms_swage_ppl"; +export const AGED_PIPE_DEFAULT_ELAPSED_YEARS = 30; +export const AGED_PIPE_PAGE_SIZE = 10; +export const AGED_PIPE_MIN_ZOOM = 14; +export const AGED_PIPE_INVALID_INSTALL_YMD_PREFIXES = ["19000101", "19990101"]; + +export type AgedPipeFilters = { + elapsedYears: number; + ctpvCd: string; + sggCd: string; +}; + +export type AgedPipeRow = { + fid: string; + lyrTypeNm: string; + rowNumber: number; + fcltMngNo: string; + instlYmd: string; + pdmtVl: string | number; + prlgVl: string | number; + usgCd: string; + mqltCd: string; + elapsedYears: number | string; + ctpvNm?: string; + sggNm?: string; + [key: string]: unknown; +}; + +type PipeSource = Record; + +const useMap = new Map(MS_SWAGE_PPL_USE_CODES.map((item) => [item.val.trim(), item.text])); +const materialMap = new Map(MS_SWAGE_PPL_CODES.map((item) => [item.val.trim(), item.text])); + +export const AGED_PIPE_DEFAULT_FILTERS: AgedPipeFilters = { + elapsedYears: AGED_PIPE_DEFAULT_ELAPSED_YEARS, + ctpvCd: "0", + sggCd: "0", +}; + +export const AGED_PIPE_LIST_HEADLIST = [ + { id: "rowNumber", title: "NO", widthPercent: 6 }, + { id: "fcltMngNo", title: "시설관리번호", widthPercent: 18 }, + { id: "instlYmd", title: "설치일자", widthPercent: 12 }, + { id: "pdmtVl", title: "관경(mm)", widthPercent: 10 }, + { id: "prlgVl", title: "연장(m)", widthPercent: 10 }, + { id: "usgCd", title: "용도", widthPercent: 14 }, + { id: "mqltCd", title: "재질", widthPercent: 18 }, + { id: "elapsedYears", title: "경과년도", widthPercent: 12, align: "right" }, +]; + +export const AGED_PIPE_EXCEL_HEADER = { + rowNumber: "NO", + fcltMngNo: "시설관리번호", + instlYmd: "설치일자", + pdmtVl: "관경(mm)", + prlgVl: "연장(m)", + usgCd: "용도", + mqltCd: "재질", + elapsedYears: "경과년도", + ctpvNm: "시도", + sggNm: "시군구", +}; + +const pad2 = (value: number) => String(value).padStart(2, "0"); + +const normalizeYmd = (value: unknown) => + String(value ?? "").replace(/[^0-9]/g, "").slice(0, 8); + +const getToday = () => new Date(); + +export const normalizeAgedPipeFilters = (filters: AgedPipeFilters): AgedPipeFilters => { + const elapsedYears = Math.trunc(Number(filters.elapsedYears)); + + return { + ...filters, + elapsedYears: + Number.isFinite(elapsedYears) && elapsedYears >= 1 && elapsedYears <= 100 + ? elapsedYears + : AGED_PIPE_DEFAULT_ELAPSED_YEARS, + }; +}; + +export const validateElapsedYears = (value: number) => + Number.isInteger(value) && value >= 1 && value <= 100; + +export const getCutoffYmd = (elapsedYears: number) => { + const today = getToday(); + const cutoff = new Date(today.getFullYear() - elapsedYears, today.getMonth(), today.getDate()); + return `${cutoff.getFullYear()}${pad2(cutoff.getMonth() + 1)}${pad2(cutoff.getDate())}`; +}; + +export const getElapsedYears = (value: unknown) => { + const normalized = normalizeYmd(value); + if (normalized.length < 4 || isInvalidInstallYmd(value)) return null; + + const year = Number(normalized.slice(0, 4)); + const month = Number(normalized.slice(4, 6) || "1"); + const day = Number(normalized.slice(6, 8) || "1"); + if (!year) return null; + + const today = getToday(); + let elapsed = today.getFullYear() - year; + if ( + today.getMonth() + 1 < month || + (today.getMonth() + 1 === month && today.getDate() < day) + ) { + elapsed -= 1; + } + + return Math.max(elapsed, 0); +}; + +export const isInvalidInstallYmd = (value: unknown) => { + const normalized = normalizeYmd(value); + return ( + normalized.length < 8 || + AGED_PIPE_INVALID_INSTALL_YMD_PREFIXES.some((prefix) => normalized.startsWith(prefix)) + ); +}; + +export const getElapsedYearsDisplayText = (value: unknown) => { + const elapsedYears = getElapsedYears(value); + return elapsedYears == null ? "확인불가" : `${elapsedYears}년`; +}; + +export const buildAgedPipeCqlFilters = (filters: AgedPipeFilters) => { + const normalized = normalizeAgedPipeFilters(filters); + const cqlFilters = [ + "instl_ymd IS NOT NULL", + ...AGED_PIPE_INVALID_INSTALL_YMD_PREFIXES.map((prefix) => `instl_ymd NOT LIKE '${prefix}%'`), + `instl_ymd <= '${getCutoffYmd(normalized.elapsedYears)}'`, + ]; + + if (normalized.sggCd !== "0") { + cqlFilters.push(`pbadms_emd_cd LIKE '${normalized.sggCd}%'`); + } else if (normalized.ctpvCd !== "0") { + cqlFilters.push(`pbadms_emd_cd LIKE '${normalized.ctpvCd}%'`); + } + + return cqlFilters; +}; + +const getRegionCode = (source: PipeSource) => + String(source.pbadmsEmdCd ?? source.pbadms_emd_cd ?? "").trim(); + +export const matchesAgedPipeFilters = (source: PipeSource, filters: AgedPipeFilters) => { + const normalized = normalizeAgedPipeFilters(filters); + const instlYmd = normalizeYmd(source.instl_ymd ?? source.instlYmd); + if (!instlYmd || isInvalidInstallYmd(instlYmd) || instlYmd > getCutoffYmd(normalized.elapsedYears)) return false; + + const regionCode = getRegionCode(source); + if (normalized.sggCd !== "0") return regionCode.startsWith(normalized.sggCd); + if (normalized.ctpvCd !== "0") return regionCode.startsWith(normalized.ctpvCd); + return true; +}; + +const resolveRegionName = ( + code: string, + directName: unknown, + options: SelectDataType[], + prefixLength: number, +) => { + const direct = String(directName ?? "").trim(); + if (direct) return direct; + + const prefix = code.slice(0, prefixLength); + return options.find((item) => String(item.val).trim() === prefix)?.text ?? ""; +}; + +export const toDisplayPipeRow = ( + source: PipeSource, + index: number, + page: number, + ctpvOptions: SelectDataType[], + sggOptions: SelectDataType[], +): AgedPipeRow => { + const formatted = formatDisplayData(source) as PipeSource; + const codePrefix = getRegionCode(source); + const instlYmd = formatted.instlYmd ?? source.instlYmd ?? formatted.instl_ymd ?? source.instl_ymd; + const invalidInstallYmd = isInvalidInstallYmd(source.instlYmd ?? source.instl_ymd ?? instlYmd); + const trimmedUse = String(source.usgCd ?? source.usg_cd ?? "").trim(); + const trimmedMaterial = String(source.mqltCd ?? source.mqlt_cd ?? "").trim(); + const pdmtVl = formatted.pdmtVl ?? source.pdmtVl ?? source.pdmt_vl ?? "-"; + const prlgVl = formatted.prlgVl ?? source.prlgVl ?? source.prlg_vl ?? "-"; + const fcltMngNo = String(formatted.fcltMngNo ?? source.fcltMngNo ?? source.fclt_mng_no ?? "-"); + + return { + ...source, + ...formatted, + fid: String(source.fid ?? ""), + lyrTypeNm: String(source.lyrTypeNm ?? AGED_PIPE_LAYER_TYPE), + rowNumber: (page - 1) * AGED_PIPE_PAGE_SIZE + index + 1, + fcltMngNo, + swtrmtFcltCd: source.swtrmtFcltCd ?? fcltMngNo, + instlYmd: invalidInstallYmd ? "확인불가" : String(instlYmd ?? "-"), + pdmtVl: typeof pdmtVl === "number" ? pdmtVl : String(pdmtVl), + prlgVl: typeof prlgVl === "number" ? prlgVl : String(prlgVl), + usgCd: useMap.get(trimmedUse) ?? String(formatted.usgCd ?? source.usgCd ?? source.usg_cd ?? "-"), + mqltCd: + materialMap.get(trimmedMaterial) ?? + String(formatted.mqltCd ?? source.mqltCd ?? source.mqlt_cd ?? "-"), + elapsedYears: getElapsedYears(source.instlYmd ?? source.instl_ymd) ?? "확인불가", + ctpvNm: resolveRegionName( + codePrefix, + formatted.ctpvNm ?? source.ctpvNm ?? source.ctpv_nm, + ctpvOptions, + 2, + ), + sggNm: resolveRegionName( + codePrefix, + formatted.sggNm ?? source.sggNm ?? source.sgg_nm, + sggOptions, + 5, + ), + }; +}; + +export const isAgedPipeLayerNode = (node: any) => { + const data = node?.data ?? node; + const layerName = String(data?.lyrNm ?? node?.text ?? ""); + const layerTypeName = String(data?.lyrTyNm ?? ""); + + return ( + layerTypeName === "tb_old_pipe" || + layerName.includes("노후관로") + ); +}; diff --git a/apps/sn/app/(map-layout)/map/components/common/table.ts b/apps/sn/app/(map-layout)/map/components/common/table.ts index 4e501fb20..81e14a83f 100644 --- a/apps/sn/app/(map-layout)/map/components/common/table.ts +++ b/apps/sn/app/(map-layout)/map/components/common/table.ts @@ -3,6 +3,7 @@ export const MsSwagePplCol = [ { id: 'fclt_mng_no', title: '시설관리번호' }, { id: 'inst_mng_no', title: '기관관리번호' }, { id: 'instl_ymd', title: '설치일자' }, + { id: 'elapsedYears', title: '경과년도' }, { id: 'mapsht_no', title: '도엽번호' }, { id: 'pdmt_vl', title: '관경(mm)' }, { id: 'prlg_vl', title: '연장(m)' }, diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/Ssc.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/Ssc.tsx index f908fe98b..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/featureClickEventPopup.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx index 11e61b006..81be89a5d 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx @@ -41,6 +41,7 @@ import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue'; import { codeMapper } from "../common/codeMapper"; import LoadingOverlay from '@/components/ui/LoadingBar'; import { searchUpperPpls } from "./smh"; +import { getElapsedYearsDisplayText, isInvalidInstallYmd } from "../agedPipe/agedPipeUtils"; const linkedFacilityHighlightStyle = new Style({ image: new CircleStyle({ @@ -63,7 +64,6 @@ const linkedFacilityHighlightStyle = new Style({ zIndex: 9999, }); - const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, featureData: any }) => { const { gisMap } = useGisContext() as unknown as { checkedLayerIds: React.MutableRefObject; treeData: { lyrSn: number }[] | null; gisCore: any; gisMap: any; }; const [layerNm, setLayerNm] = useState(""); // 레이어 이름 상태 @@ -88,7 +88,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, const linkedFacilityOriginalStyleRef = useRef(null); // 🎯 관로관리번호 추출 - const pplMngNo = featureData?.values_?.fclt_mng_no || ""; + const pplMngNo = featureData?.values_?.fclt_mng_no || featureData?.fcltMngNo || ""; const featureId = typeof featureData.getId === 'function' ? featureData.getId() @@ -258,7 +258,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, case 'tb_ms_swage_ppl': { result = '하수관로'; setHeadlist(MsSwagePplCol2) - setLayerSpecificNm(featureData.swtrmtFcltCd); + setLayerSpecificNm(featureData.fcltMngNo ?? featureData.swtrmtFcltCd); break; } case 'tb_ms_swage_mhl': { @@ -341,6 +341,14 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, // 🎯 기본 데이터 포맷팅 const formattedData = formatDisplayData(data); + if (selectedLayerName === '하수관로') { + const installYmd = data.instl_ymd ?? data.instlYmd; + formattedData.elapsedYears = getElapsedYearsDisplayText(installYmd); + if (isInvalidInstallYmd(installYmd)) { + formattedData.instl_ymd = "확인불가"; + formattedData.instlYmd = "확인불가"; + } + } setFetchData(formattedData); setTableData(formattedData); @@ -407,8 +415,8 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, // 자체입력정보 API 엔드포인트 선택 let apiEndpoint = ''; - const fcltMngNo = featureData?.values_?.fclt_mng_no || null; - const mngInstCd = featureData?.values_?.mng_inst_cd || null; + const fcltMngNo = featureData?.values_?.fclt_mng_no || featureData?.fcltMngNo || null; + const mngInstCd = featureData?.values_?.mng_inst_cd || featureData?.mngInstCd || null; if (selectedLayerName === '하수관로') apiEndpoint = `v1/fcltppl/${fcltMngNo}/${mngInstCd}`; else if (selectedLayerName === '우수토실') apiEndpoint = `v1/fcltstofcbr/${fcltMngNo}/${mngInstCd}`; else if (selectedLayerName === '하수펌프장') apiEndpoint = `v1/fcltpump/${fcltMngNo}/${mngInstCd}`; @@ -598,7 +606,11 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
기본정보 경과년도 {tableData.elapsedYears} + : '기본정보' + } tableName1={layerNm === '중점우수토실' ? "측정지점" : undefined} tableData={[tableData]} headlist={headlist} 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..cc09bcbf1 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -18,48 +18,130 @@ "use client"; import Gis from "@common/business_components/ui/map/Gis"; -import { getData } from "../../components/lib/api"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { getData, getRawJson } from "../../components/lib/api"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import GisApp from "@common/business_components/ui/map/lib/gis.core"; import SearchModal from "./searchModal/searchModal"; import FeatureClickEventPopup from "./components/popupComponent/featureClickEventPopup"; import { WaterDepth, WATER_DEPTH_LAYER_TYPES } from "./waterDepth/waterDepth"; import { ProcSearchBar } from "@common/business_components/ui/map/searchBar"; +import { Button } from "@common/components"; import useSearchModalStore from "@/store/useSearchModalStore"; +import useStore from "@/store/useStore"; +import { downloadExcelWithKoreanHeaders } from "@common/business_components/ui"; +import AgedPipeLayerSettingPanel from "./components/agedPipe/AgedPipeLayerSettingPanel"; +import { + AGED_PIPE_EXCEL_HEADER, + AGED_PIPE_MIN_ZOOM, + AGED_PIPE_LAYER_TYPE, + AGED_PIPE_PAGE_SIZE, + buildAgedPipeCqlFilters, + isAgedPipeLayerNode, + matchesAgedPipeFilters, + normalizeAgedPipeFilters, + toDisplayPipeRow, + validateElapsedYears, + type AgedPipeFilters, + type AgedPipeRow, + AGED_PIPE_DEFAULT_FILTERS, +} from "./components/agedPipe/agedPipeUtils"; + +const AGED_PIPE_CONTROL_NODE_ID = "SN_AGED_PIPE"; +const AGED_PIPE_HIGHLIGHT_LAYER_ID = "SN_AGED_PIPE_HIGHLIGHT"; +const AGED_PIPE_ROW_SELECT_MESSAGE = "SN_AGED_PIPE_ROW_SELECT"; +const AGED_PIPE_POPUP_MINIMIZE_MESSAGE = "SN_AGED_PIPE_POPUP_MINIMIZE"; +const AGED_PIPE_FILTER_SYNC_MESSAGE = "SN_AGED_PIPE_FILTER_SYNC"; + +const AGED_PIPE_FOCUS_PADDING: [number, number, number, number] = [120, 120, 120, 120]; +const DEFAULT_FEATURE_FIT_PADDING: [number, number, number, number] = [50, 50, 50, 50]; + +const appendAgedPipeControlNode = (data: any[]) => { + if (!Array.isArray(data)) return data; + if (data.some((item) => String(item.lyrSn) === AGED_PIPE_CONTROL_NODE_ID || item.lyrNm === "노후관로")) { + return data; + } + + return [ + ...data, + { + lyrSn: AGED_PIPE_CONTROL_NODE_ID, + id: AGED_PIPE_CONTROL_NODE_ID, + lyrNm: "노후관로", + upperLyrSn: null, + upId: null, + sortOrdr: "4", + drawOrdr: null, + flterSql: null, + gobjTy: "", + lyrTyNm: "tb_old_pipe", + useYn: "Y", + minViewSize: null, + iconNm: null, + iconSize: null, + lblAtrbNm: null, + fontNm: null, + fontIntnColor: null, + fontExtnColor: null, + fontExtnWdth: null, + lnWdth: null, + lnColor: null, + polgnColor: null, + initEprssYn: "Y", + clickPsblYn: "N", + exprsYn: "Y", + cmncNm: "all", + srvcNm: "wfs", + actlLyrYn: "N", + clckZoom: null, + children: null, + }, + ]; +}; const Map = () => { const { isSearchModalOpen, setIsSearchModalOpen } = useSearchModalStore(); - const [treeData, setTreeData] = useState([]); // 초기값을 객체 또는 null로 설정 + const [treeData, setTreeData] = useState([]); // 초기값을 객체 또는 null로 설정 const gisMaps = useRef(null); const [isOpen, setIsOpen] = useState(false); // 팝업 열기/닫기 상태 - const [featureData, setFeatureData] = useState(null); // 선택된 피쳐 데이터 + const [featureData, setFeatureData] = useState(null); // 선택된 피쳐 데이터 const [showWaterDepth, setShowWaterDepth] = useState(false); // WaterDepth 표시 여부 const [isPlaying, setIsPlaying] = useState(false); + const [mapReady, setMapReady] = useState(false); + const [isAgedPipeChecked, setIsAgedPipeChecked] = useState(false); + const [draftAgedPipeFilters, setDraftAgedPipeFilters] = + useState(AGED_PIPE_DEFAULT_FILTERS); + const [appliedAgedPipeFilters, setAppliedAgedPipeFilters] = + useState(AGED_PIPE_DEFAULT_FILTERS); + const [isAgedPipeListOpen, setIsAgedPipeListOpen] = useState(false); + const [isAgedPipeListMinimized, setIsAgedPipeListMinimized] = useState(false); + const [agedPipeRows, setAgedPipeRows] = useState([]); + const [agedPipeTotalCount, setAgedPipeTotalCount] = useState(0); + const [agedPipeCurrentPage, setAgedPipeCurrentPage] = useState(1); + const [isAgedPipeListLoading, setIsAgedPipeListLoading] = useState(false); + const { ctpv, sgg } = useStore(); + const shouldFocusAgedPipeOnNextSelectRef = useRef(false); + const agedPipePopupWindowRef = useRef(null); useEffect(() => { fetchData(); }, []); + useEffect(() => { + const timer = window.setInterval(() => { + if (gisMaps.current) { + setMapReady(true); + window.clearInterval(timer); + } + }, 300); + + return () => window.clearInterval(timer); + }, []); + 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); - } - setTreeData(data); + const data = await getRawJson("/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK"); + setTreeData(appendAgedPipeControlNode(data)); } catch (error) { console.error('JSON parsing error:', error); // JSON 파싱에 실패한 경우, 원본 텍스트 또는 null 반환 @@ -112,13 +194,23 @@ const Map = () => { if (geometry) { let extent = geometry.getExtent(); + const layerTypeName = typeof foundFeature.getId === "function" + ? String(foundFeature.getId()).split(".")[0] + : data?.lyrTypeNm; + const shouldUseAgedPipeFocus = + shouldFocusAgedPipeOnNextSelectRef.current && layerTypeName === AGED_PIPE_LAYER_TYPE; + map.getView().fit(extent, { duration: 300, - padding: [50, 50, 50, 50], - maxZoom: 16, + padding: shouldUseAgedPipeFocus ? AGED_PIPE_FOCUS_PADDING : DEFAULT_FEATURE_FIT_PADDING, + maxZoom: shouldUseAgedPipeFocus ? 18 : 16, constrainResolution: true, nearest: true, }); + + if (shouldUseAgedPipeFocus) { + shouldFocusAgedPipeOnNextSelectRef.current = false; + } } select.selectFeature(foundFeature, true); } else { @@ -131,6 +223,181 @@ const Map = () => { }; const facilityTypes = [{ text: "하수처리시설", val: "tb_il_swage_prcs_fclt" }]; + + const fetchAgedPipeRows = useCallback( + async (page: number, filters: AgedPipeFilters, size: number) => { + const normalized = normalizeAgedPipeFilters(filters); + const queryParams = new URLSearchParams({ + lyrTypeNm: AGED_PIPE_LAYER_TYPE, + page: String(page - 1), + size: String(size), + sort: "string", + }); + + const cqlFilters = buildAgedPipeCqlFilters(normalized); + if (cqlFilters.length > 0) { + queryParams.append("cqlFiter", cqlFilters.join(" and ")); + } + + const response = await getData(`v1/fclts?${queryParams.toString()}`); + const rows = Array.isArray(response?.data) ? response.data : []; + + return { + rows: rows.map((item: Record, index: number) => + toDisplayPipeRow(item, index, page, ctpv, sgg), + ), + total: Number(response?.count ?? 0), + }; + }, + [ctpv, sgg], + ); + + const loadAgedPipeList = useCallback( + async (page = 1, filters = appliedAgedPipeFilters) => { + setIsAgedPipeListLoading(true); + try { + const response = await fetchAgedPipeRows(page, filters, AGED_PIPE_PAGE_SIZE); + setAgedPipeRows(response.rows); + setAgedPipeTotalCount(response.total); + setAgedPipeCurrentPage(page); + } catch (error) { + console.error("노후관로 목록 조회 중 오류:", error); + setAgedPipeRows([]); + setAgedPipeTotalCount(0); + } finally { + setIsAgedPipeListLoading(false); + } + }, + [appliedAgedPipeFilters, fetchAgedPipeRows], + ); + + const syncAgedPipePopupFilters = useCallback((filters: AgedPipeFilters) => { + const popup = agedPipePopupWindowRef.current; + if (!popup || popup.closed) { + agedPipePopupWindowRef.current = null; + return; + } + + popup.postMessage( + { + type: AGED_PIPE_FILTER_SYNC_MESSAGE, + filters: normalizeAgedPipeFilters(filters), + }, + window.location.origin, + ); + }, []); + + const handleApplyAgedPipeFilters = useCallback((filters = draftAgedPipeFilters) => { + if (!validateElapsedYears(Number(filters.elapsedYears))) { + alert("경과년도는 1년~100년 범위 내에서 입력해주세요."); + return; + } + + const normalized = normalizeAgedPipeFilters(filters); + setDraftAgedPipeFilters(normalized); + setAppliedAgedPipeFilters(normalized); + syncAgedPipePopupFilters(normalized); + + if (isAgedPipeListOpen) { + loadAgedPipeList(1, normalized); + } + }, [draftAgedPipeFilters, isAgedPipeListOpen, loadAgedPipeList, syncAgedPipePopupFilters]); + + const openAgedPipePopupWindow = useCallback(() => { + const popupParams = new URLSearchParams({ + elapsedYears: String(appliedAgedPipeFilters.elapsedYears), + ctpvCd: appliedAgedPipeFilters.ctpvCd, + sggCd: appliedAgedPipeFilters.sggCd, + }); + const popup = window.open( + `/map/aged-pipe-popup?${popupParams.toString()}`, + "sn-aged-pipe-list", + "popup=yes,width=1180,height=760,left=120,top=80,resizable=yes,scrollbars=no", + ); + + if (!popup) { + alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요."); + return null; + } + + agedPipePopupWindowRef.current = popup; + setIsAgedPipeListOpen(true); + setIsAgedPipeListMinimized(false); + return popup; + }, [appliedAgedPipeFilters]); + + const openAgedPipeList = useCallback(() => { + openAgedPipePopupWindow(); + }, [openAgedPipePopupWindow]); + + const handleAgedPipeRowDoubleClick = useCallback( + async (row: AgedPipeRow) => { + try { + shouldFocusAgedPipeOnNextSelectRef.current = true; + const core = GisApp.Module.layer as any; + await core.showSearchResultsFromGeoJSON(AGED_PIPE_LAYER_TYPE, row.fid); + } catch (error) { + shouldFocusAgedPipeOnNextSelectRef.current = false; + console.error("노후관로 위치 이동 중 오류:", error); + } + + setFeatureData(row as any); + setIsOpen(true); + setIsSearchModalOpen(false); + }, + [setIsSearchModalOpen], + ); + + const handleAgedPipeDownload = useCallback(async () => { + if (agedPipeTotalCount === 0) { + alert("다운로드할 데이터가 없습니다."); + return; + } + + try { + setIsAgedPipeListLoading(true); + const response = await fetchAgedPipeRows(1, appliedAgedPipeFilters, 10000); + await downloadExcelWithKoreanHeaders({ + data: response.rows as JSON[], + fileName: `노후관로_현황_${new Date().toISOString().slice(0, 10)}`, + sheetNameArray: ["노후관로 현황"], + headerRange: "A1:J1", + headerMapping: AGED_PIPE_EXCEL_HEADER, + }); + } catch (error) { + console.error("노후관로 목록 다운로드 중 오류:", error); + alert("노후관로 목록 다운로드 중 오류가 발생했습니다."); + } finally { + setIsAgedPipeListLoading(false); + } + }, [agedPipeTotalCount, appliedAgedPipeFilters, fetchAgedPipeRows]); + + useEffect(() => { + const handleAgedPipePopupMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + + if (event.data?.type === AGED_PIPE_ROW_SELECT_MESSAGE) { + if (!event.data?.row) return; + handleAgedPipeRowDoubleClick(event.data.row as AgedPipeRow); + return; + } + + if (event.data?.type === AGED_PIPE_POPUP_MINIMIZE_MESSAGE) { + agedPipePopupWindowRef.current = null; + if (event.data?.filters) { + const normalized = normalizeAgedPipeFilters(event.data.filters as AgedPipeFilters); + setDraftAgedPipeFilters(normalized); + setAppliedAgedPipeFilters(normalized); + } + setAgedPipeTotalCount(Number(event.data?.total ?? agedPipeTotalCount)); + setIsAgedPipeListMinimized(true); + } + }; + + window.addEventListener("message", handleAgedPipePopupMessage); + return () => window.removeEventListener("message", handleAgedPipePopupMessage); + }, [agedPipeTotalCount, handleAgedPipeRowDoubleClick]); + // WaterDepth 표시 여부를 결정하는 함수 const handleWaterDepthVisibility = useCallback((checkedIds: string[], checkedNodesData: any[]) => { const waterDepthLayers = checkedNodesData.filter(node => @@ -144,12 +411,83 @@ const Map = () => { // 체크된 레이어 변경 처리 함수 const handleCheckedLayersChange = useCallback((checkedIds: string[], checkedNodesData: any[]) => { handleWaterDepthVisibility(checkedIds, checkedNodesData); + setIsAgedPipeChecked(checkedNodesData.some((node) => isAgedPipeLayerNode(node))); }, [handleWaterDepthVisibility]); + const handleOpenLayerPopup = useCallback( + (_layerNm: string, node?: any) => { + if (isAgedPipeLayerNode(node)) { + openAgedPipeList(); + return false; + } + + return true; + }, + [openAgedPipeList], + ); + + const inlineLayerSettingView = useCallback( + (node: any) => { + if (!isAgedPipeChecked || !isAgedPipeLayerNode(node)) return null; + + return ( + + ); + }, + [draftAgedPipeFilters, handleApplyAgedPipeFilters, isAgedPipeChecked], + ); + + useEffect(() => { + if (!mapReady || !gisMaps.current) return; + + const core = GisApp.Module.core as any; + core?.deleteLayer?.(AGED_PIPE_HIGHLIGHT_LAYER_ID); + + if (!isAgedPipeChecked) return; + + const baseLayerCode = Object.values(GisApp.layerCode ?? {}).find( + (item: any) => item?.lyrTyNm === AGED_PIPE_LAYER_TYPE && item?.actlLyrYn === "Y", + ) as any; + + if (!baseLayerCode) return; + + const layerCode = { + ...baseLayerCode, + id: AGED_PIPE_HIGHLIGHT_LAYER_ID, + lyrSn: AGED_PIPE_HIGHLIGHT_LAYER_ID, + lyrNm: "노후관로", + lyrTyNm: AGED_PIPE_LAYER_TYPE, + gobjTy: "line", + actlLyrYn: "Y", + exprsYn: "Y", + clickPsblYn: "Y", + cmncNm: "bbox", + srvcNm: "wfs", + flterSql: buildAgedPipeCqlFilters(appliedAgedPipeFilters).join(" and "), + lnWdth: "4", + lnColor: "rgba(235, 40, 40, 0.82)", + drawOrdr: "9999", + minViewSize: String(AGED_PIPE_MIN_ZOOM), + }; + + const layer = core?.addWfsLayer?.(layerCode); + layer?.set?.("id", AGED_PIPE_HIGHLIGHT_LAYER_ID); + layer?.set?.("lyrNm", "노후관로"); + layer?.set?.("lyrTyNm", AGED_PIPE_LAYER_TYPE); + + return () => { + core?.deleteLayer?.(AGED_PIPE_HIGHLIGHT_LAYER_ID); + }; + }, [appliedAgedPipeFilters, isAgedPipeChecked, mapReady]); + return ( <> -
+
{ isSearchModalOpen={isSearchModalOpen} setIsSearchModalOpen={setIsSearchModalOpen} onCheckedLayersChange={handleCheckedLayersChange} + onOpenPopup={handleOpenLayerPopup} searchBar={} waterDepth={showWaterDepth ? WaterDepth : undefined} + inlineLayerSettingView={inlineLayerSettingView} + layerIconVisible={isAgedPipeLayerNode} isPlaying={isPlaying} setIsPlaying={setIsPlaying as any} targetLayerTypes={WATER_DEPTH_LAYER_TYPES as any} @@ -176,6 +517,37 @@ const Map = () => { defaultZoom={7.5} defaultLonLat={[127.75, 36]} /> + {isAgedPipeListMinimized && ( +
+
+ +
+
+
노후관로 현황
+
+ {appliedAgedPipeFilters.elapsedYears}년 이상 · {agedPipeTotalCount.toLocaleString()}건 +
+
+ + +
+ )}
); 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/AgedPipeDetailPanel.tsx b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeDetailPanel.tsx index cf6db8572..88b5ddbe8 100644 --- a/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeDetailPanel.tsx +++ b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeDetailPanel.tsx @@ -1,6 +1,7 @@ "use client"; import { Button } from "@common/components"; +import { VerticalTable } from "@/components/ui/verticalTable"; import { AGED_PIPE_DETAIL_FIELDS, getElapsedYears, @@ -16,39 +17,48 @@ export default function AgedPipeDetailPanel({ featureData, onClose }: Props) { const source = featureData?.values_ ?? featureData ?? {}; const facilityNo = String(getPipeDisplayValue(source, "fclt_mng_no") ?? "-"); const elapsedYears = getElapsedYears(source.instl_ymd ?? source.instlYmd); + const detailFields = [ + ...AGED_PIPE_DETAIL_FIELDS, + { id: "elapsedYears", title: "경과년도" }, + ]; + const tableData = detailFields.reduce>((acc, field) => { + acc[field.id] = + field.id === "elapsedYears" + ? elapsedYears === null + ? "확인불가" + : `${elapsedYears}년` + : String(getPipeDisplayValue(source, field.id) ?? "-"); + return acc; + }, {}); return ( -
-
+
+
-
노후관로 상세
-
시설관리번호 {facilityNo}
-
- 경과년도 {elapsedYears ?? "-"}년 +
+ 하수관로 정보
관리번호 : {facilityNo}
-
-
- {AGED_PIPE_DETAIL_FIELDS.map((field) => ( -
-
{field.title}
-
- {String(getPipeDisplayValue(source, field.id) ?? "-")} -
-
- ))} -
+
+ undefined} + />
); diff --git a/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeFilterPanel.tsx b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeFilterPanel.tsx index 5a94014a2..a80ced9c9 100644 --- a/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeFilterPanel.tsx +++ b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeFilterPanel.tsx @@ -1,7 +1,6 @@ "use client"; -import { Button, Input } from "@common/components"; -import useStore from "@/store/useStore"; +import { useState } from "react"; import type { AgedPipeFilters } from "./agedPipeUtils"; type OverlayProps = { @@ -13,89 +12,62 @@ type OverlayProps = { }; type Props = OverlayProps & { + title?: string; filters: AgedPipeFilters; onFiltersChange: (filters: AgedPipeFilters) => void; onApply: () => void; }; export default function AgedPipeFilterPanel({ + title = "지반침하정보", filters, onFiltersChange, onApply, }: Props) { - const { ctpv, sgg } = useStore(); - const sggOptions = filters.ctpvCd === "0" - ? sgg - : sgg.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2))); + const [isCollapsed, setIsCollapsed] = useState(false); return ( -
-
-
노후관로 기준
-
- 경과년수 이상 관로만 지도와 목록에 반영합니다. +
+
+ + {title} +
+ + {!isCollapsed && ( +
+
+ + { + const next = Number(event.target.value.replace(/[^0-9]/g, "") || "0"); + onFiltersChange({ ...filters, elapsedYears: next }); + }} + onKeyDown={(event) => { + if (event.key === "Enter") onApply(); + }} + /> + +
-
- -
- - - - - -
- - + )}
); } diff --git a/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeListModal.tsx b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeListModal.tsx index bf6a8fb02..a5c90e184 100644 --- a/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeListModal.tsx +++ b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeListModal.tsx @@ -64,14 +64,11 @@ export default function AgedPipeListModal({ return (
-
+
-
+
노후관로 목록
-
- 지도 위에 띄워지는 윈도우형 팝업 미리보기 -
+
+
HASUON
+
디지털하수관로 시스템
+
+
+ +
+ + +
+ + +
+ + +
+
+
+
+
+
+ + 하수처리시설 + 왕숙천 + 진건로 + +
+
+
+
+ + + + + + + + +
+ + + + + + + +
+
+
+ +
+ +
+ +
+ + + + diff --git a/apps/sn/public/images/bg_login.jpg b/apps/sn/public/images/bg_login.jpg new file mode 100644 index 000000000..b7793d16a Binary files /dev/null and b/apps/sn/public/images/bg_login.jpg differ diff --git a/apps/sn/public/images/map/layericon/Layer_Select_BTN.png b/apps/sn/public/images/map/layericon/Layer_Select_BTN.png new file mode 100644 index 000000000..1e810feaf Binary files /dev/null and b/apps/sn/public/images/map/layericon/Layer_Select_BTN.png differ diff --git a/apps/sn/src/app/styles/base.css b/apps/sn/src/app/styles/base.css index 8453ff9e8..6cc507bb9 100644 --- a/apps/sn/src/app/styles/base.css +++ b/apps/sn/src/app/styles/base.css @@ -10,3 +10,7 @@ body { font-family: "Noto Sans KR", sans-serif; font-size: 10px; } + +body.sn-map-page-lock { + overflow: hidden; +} diff --git a/apps/sn/src/entities/auth/api/login-service.ts b/apps/sn/src/entities/auth/api/login-service.ts index c1968fe36..4d7a7cd8d 100644 --- a/apps/sn/src/entities/auth/api/login-service.ts +++ b/apps/sn/src/entities/auth/api/login-service.ts @@ -5,6 +5,7 @@ import { ACCESS_TOKEN, AUTH_USER_ID, CLAIM_NAME, + SITE_ID, } from "@/shared/config"; import type { LoginService, ILogin } from "../model/login-interface"; import { tokenManager } from "../lib/token-manager"; @@ -23,8 +24,13 @@ export class LoginApiService implements LoginService { public async login(payload: ILogin): Promise { try { + tokenManager.clearAuthToken(); const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, { method: "POST", + headers: { + "Content-Type": "application/json", + "X-Site-Id": SITE_ID, + }, body: JSON.stringify({ userId: payload.userId, password: payload.password, @@ -57,7 +63,12 @@ export class LoginApiService implements LoginService { try { const response = await fetch( `/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`, - { method: "PUT" }, + { + method: "PUT", + headers: { + "X-Site-Id": SITE_ID, + }, + }, ); const data = await response.json(); diff --git a/apps/sn/src/entities/auth/lib/auth-wrapper.tsx b/apps/sn/src/entities/auth/lib/auth-wrapper.tsx index bc4a82aab..f37ff4041 100644 --- a/apps/sn/src/entities/auth/lib/auth-wrapper.tsx +++ b/apps/sn/src/entities/auth/lib/auth-wrapper.tsx @@ -8,6 +8,7 @@ import { useUserStore } from "@/shared/lib"; import { ENDPOINT, AUTH_USER_ID } from "@/shared/config"; import { isAtLoginRedirectUrl } from "@common/network"; import { getCookie } from "@/shared/lib"; +import { tokenManager } from "./token-manager"; const LOGIN_PAGE_URL = process.env.NEXT_PUBLIC_CM_LOGIN_PAGE_URL || "/auth/login"; @@ -38,6 +39,7 @@ const AuthWrapper = ({ children }: { children: React.ReactNode }) => { await authCheck(); } catch (error) { console.error(error); + setUserInfo(null); if (isAtLoginRedirectUrl(LOGIN_PAGE_URL)) return; diff --git a/apps/sn/src/entities/auth/lib/token-manager.ts b/apps/sn/src/entities/auth/lib/token-manager.ts index 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..4d1ab91cc 100644 --- a/apps/wr/components/lib/func/TableData.tsx +++ b/apps/wr/components/lib/func/TableData.tsx @@ -107,6 +107,7 @@ export const chRowspanTable = ( // rowspan된 셀 렌더링 여부 추적용 const renderedMap = new Map>(); + // console.log(makeTableData); return ( <> {/* {makeTableData.map((row, rowIndex) => ( */} @@ -322,6 +323,26 @@ const handleChange = ( ); }; +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[] | [], @@ -357,19 +378,8 @@ 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"; @@ -433,22 +443,39 @@ export const InputTableBody = ( key={head.id + headIndex} className={`w-36 ${bgClass}`} > - - 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} + /> + )} ) : ( setRswtChkLoading(false)); - console.log("업로드파일 점검 완료"); + // console.log("업로드파일 점검 완료"); } } }, [file]); 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 useContext(GisContext); +/** + * @param {any} props + */ function Gis({ isOpen, //우측 상세 레이어 팝업 gisMaps, @@ -49,7 +52,10 @@ function Gis({ setIsSearchModalOpen, onCheckedLayersChange, searchBar: SearchBar, - waterDepth: WaterDepth, + waterDepth: WaterDepth = null, + layerSettingView: LayerSettingView = null, + inlineLayerSettingView, + layerIconVisible, isPlaying = false, setIsPlaying = () => { }, targetLayerTypes = [], @@ -281,18 +287,21 @@ function Gis({ visible={tabVisible} setVisible={setTabVisible} onCheckedChange={handleCheckedLayersChange} - onLayerIconClick={(layerNm) => { + onLayerIconClick={(layerNm, node) => { layerNmRef.current = layerNm; setLayerNm(layerNm); - setIsOpen(true); - onOpenPopup(layerNm) + const shouldOpenDetail = onOpenPopup(layerNm, node) + if (shouldOpenDetail !== false) setIsOpen(true); }} imageVisible={imgVisble} + layerIconVisible={layerIconVisible} isPlaying={isPlaying} setIsPlaying={setIsPlaying} targetLayerTypes={targetLayerTypes} checkInterval={checkInterval} onProgressChange={handleProgressChange} + layerSettingView={LayerSettingView} + inlineLayerSettingView={inlineLayerSettingView} /> {typeof WaterDepth === "function" && ( diff --git a/packages/common/business_components/src/ui/map/GisTree.js b/packages/common/business_components/src/ui/map/GisTree.js index 197827010..38077c00a 100644 --- a/packages/common/business_components/src/ui/map/GisTree.js +++ b/packages/common/business_components/src/ui/map/GisTree.js @@ -27,6 +27,8 @@ import GisApp from "./lib/gis.core"; * - orderedLayers: 레이어 재생 순서를 지정하는 id 배열 (lyrSn) * - checkInterval: 자동 애니메이션 토글 간격(ms) * - onProgressChange(total, current): 애니메이션 진행상태 콜백 + * - layerSettingView: (선택) 좌측 레이어 트리 하단에 표시할 설정 영역 + * - layerIconVisible: (선택) 레이어 행 우측 액션 아이콘 노출 여부를 결정하는 함수 */ export default React.memo(function GisTree({ checkedNodes = [], @@ -41,6 +43,9 @@ export default React.memo(function GisTree({ targetLayerTypes = [], checkInterval = 3000, onProgressChange = () => { }, + layerSettingView: LayerSettingView = null, + layerIconVisible, + inlineLayerSettingView, }) { const { gisCore, treeData } = useGisContext(); @@ -494,9 +499,9 @@ export default React.memo(function GisTree({ /** * 각 레이어 행의 아이콘 클릭 시 호출. 선택되지 않은 레이어면 경고, 선택된 경우 콜백 전달. */ - const handleLayerImgClick = (nodeNm, nodeId) => { + const handleLayerImgClick = (nodeNm, nodeId, node) => { if (!checked.includes(nodeId)) alert("시설을 선택해주세요"); - else onLayerIconClick(nodeNm); + else onLayerIconClick(nodeNm, node); }; // ===================== @@ -534,48 +539,59 @@ export default React.memo(function GisTree({ child.parent === node.id && child.data.exprsYn === "Y" ); + const inlineLayerSetting = inlineLayerSettingView?.(node); + return ( -
- {hasVisibleChildren ? ( - - ) : ( - - )} - handleCheck(node.id)} - className="cursor-pointer" - /> - {node.icon && {node.icon}} - - {node.text} - - {imageVisible && depth > 0 && ( - - - handleLayerImgClick( - node.data.lyrTyNm, - node.id - ) - } - alt={node.text} +
+
+ {hasVisibleChildren ? ( + + ) : ( + + )} + handleCheck(node.id)} + className="cursor-pointer" + /> + {node.icon && {node.icon}} + + {node.text} + {((depth > 0 && imageVisible) || layerIconVisible?.(node)) && ( + + + handleLayerImgClick( + node.data.lyrTyNm, + node.id, + node + ) + } + alt={node.text} + /> + + )} +
+ {inlineLayerSetting && ( +
+ {typeof inlineLayerSetting === "function" + ? React.createElement(inlineLayerSetting) + : inlineLayerSetting} +
)}
); @@ -588,6 +604,11 @@ export default React.memo(function GisTree({ />
+ {LayerSettingView && ( +
+ +
+ )}
) }); 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 반환 } }