diff --git a/apps/dm/.env.mindone b/apps/dm/.env.mindone index 12ca17853..3cb162f3e 100644 --- a/apps/dm/.env.mindone +++ b/apps/dm/.env.mindone @@ -8,8 +8,8 @@ NEXT_PUBLIC_PROXY_HOST = http://con.mind-one.co.kr:11315 NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login SERVER_API_URL = http://dev.onasys.co.kr:8000 CS_SERVER_API_URL = http://dev.onasys.co.kr:8000 -FO_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1 -WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1 +FO_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service +WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service NEXT_PUBLIC_GEOSERVER_URL = http://rsone.iptime.org:10010/geoserver NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver NEXT_PUBLIC_CS_TUS_UPLOAD = /attach-service/api/v1/tus/upload diff --git a/apps/dm/.env.production b/apps/dm/.env.production index fbe11a3a9..1b16ca8ee 100644 --- a/apps/dm/.env.production +++ b/apps/dm/.env.production @@ -9,8 +9,8 @@ NEXT_PUBLIC_REFRESH_TOKEN = refresh-token #NEXT_PUBLIC_CM_LOGIN_PAGE_URL = https://devqueen.hasuon.or.kr/auth/login SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000 CS_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000 -FO_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/operation-service/api/v1 -WR_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/reuse-service/api/v1 +FO_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/operation-service +WR_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/reuse-service NEXT_PUBLIC_GEOSERVER_URL = http://172.29.143.87:8080/geoserver NEXT_PUBLIC_LX_GEOSERVER_URL = http://172.29.143.87:8080/geoserver_swl NEXT_PUBLIC_CS_TUS_UPLOAD = /attach-service/api/v1/tus/upload diff --git a/apps/dm/app/(landing)/reuse/cases/_components/RainStatusSection.tsx b/apps/dm/app/(landing)/reuse/cases/_components/RainStatusSection.tsx index ec150d55b..f67175f9e 100644 --- a/apps/dm/app/(landing)/reuse/cases/_components/RainStatusSection.tsx +++ b/apps/dm/app/(landing)/reuse/cases/_components/RainStatusSection.tsx @@ -1,133 +1,99 @@ -"use client"; +'use client'; -import React from "react"; +import React from 'react'; // 공통 섹션 타이틀 컴포넌트 function SectionTitle({ children }: { children: React.ReactNode }) { return ( -
- -

{children}

+
+ +

{children}

); } // 구분선 -function Hr({ className = "" }: { className?: string }) { +function Hr({ className = '' }: { className?: string }) { return
; } // 출처 텍스트 function SourceText() { - return ( -

- 자료: 환경부(2024), 「2023 하수도 통계」 -

- ); + return

자료: 환경부(2024), 「2023 하수도 통계」

; } const tableStyles = { - container: "w-[1000px] mx-auto overflow-hidden", - table: "w-full table-fixed border-collapse text-center text-[16px]", - th: "border border-[#858F98] py-3 font-bold text-neutral-800", - td: "border border-[#858F98] py-3 text-neutral-700 font-medium break-keep", - headerBg: "bg-[#E3E7EA]", + container: 'w-[1000px] mx-auto overflow-hidden', + table: 'w-full table-fixed border-collapse text-center text-[16px]', + th: 'border border-[#858F98] py-3 font-bold text-neutral-800', + td: 'border border-[#858F98] py-3 text-neutral-700 font-medium break-keep', + headerBg: 'bg-[#E3E7EA]', }; export default function RainStatusSection() { - const title = "빗물이용시설(현황)"; + const title = '빗물이용시설(현황)'; return ( -
+
{/* 상단 타이틀 */} -
-

- {title} - +
+

+ {title} +

-
+
{/* 1) 빗물이용시설의 건축물별 빗물이용량 현황 */} -
+
빗물이용시설의 건축물별 빗물이용량 현황 -
+
- - - - - - - - {[ - ["골프장", "6,771.8", "6,417.4", "8,371.6", "10,215.4", "11,360", "10,191.6"], - ["공공시설", "364", "325.2", "201.9", "244.6", "209.9", "299.6"], - ["공동주택", "291", "840.5", "804.2", "2,393.0", "2,386", "2,428.4"], - ["대규모점포", "169.9", "214.4", "58.4", "427.2", "371", "293.3"], - ["체육시설", "52.6", "44.7", "48.6", "75.3", "71.8", "77.6"], - ["학교", "33", "25.5", "22", "155.8", "165.4", "32.6"], - ["기타", "311.6", "901.2", "344.5", "537.8", "564", "621.2"], + ['골프장', '6,771.8', '6,417.4', '8,371.6', '10,215.4', '11,360', '10,191.6'], + ['공공시설', '364', '325.2', '201.9', '244.6', '209.9', '299.6'], + ['공동주택', '291', '840.5', '804.2', '2,393.0', '2,386', '2,428.4'], + ['대규모점포', '169.9', '214.4', '58.4', '427.2', '371', '293.3'], + ['체육시설', '52.6', '44.7', '48.6', '75.3', '71.8', '77.6'], + ['학교', '33', '25.5', '22', '155.8', '165.4', '32.6'], + ['기타', '311.6', '901.2', '344.5', '537.8', '564', '621.2'], ].map((row, idx) => ( - + {row.map((val, i) => ( - ))} @@ -141,129 +107,79 @@ export default function RainStatusSection() { -
+
{/* 2) 빗물이용시설의 용도별 분포 (2023년 기준) */} -
+
빗물이용시설의 용도별 분포 (2023년 기준) -
+
+ 구분 + 빗물이용량 (천m³/년)
+ 2018 + 2019 + 2020 + 2021 + 2022 + 2023
+ {val}
- - - - - - - - - - - - {[ - ["청소·환경정화용수", "195", "32.7", "108.8", "24.9", "5.8", "2.6", "4.4", "2.9", "-", "68.1"], - ["세척·살수용수", "12", "1.4", "37", "35", "-", "-", "1.9", "-", "-", "0.2"], - ["조경용수", "3,165", "7,440.8", "13,554.1", "168.1", "2,421.9", "290.7", "66.2", "29", "10,044", "534.3"], - ["냉·난방수", "8", "1.6", "0.1", "-", "-", "-", "-", "0.1", "-", "-"], - ["공업용수", "6", "14.9", "53.2", "52.8", "-", "-", "-", "-", "-", "0.4"], - ["농업용수", "6.0", "0.5", "0.6", "-", "-", "-", "-", "-", "-", "0.6"], - ["기타", "48", "306.1", "155.6", "2.8", "-", "-", "0.6", "0.5", "147.5", "4.2"], - ["미기재", "103", "22.9", "34.8", "16.0", "0.6", "-", "4.6", "0.2", "-", "13.5"], - ["합계", "3,543", "7,820.8", "13,944.7", "299.6", "2,428.4", "293.3", "77.6", "32.6", "10,191.6", "621.2"], + ['청소·환경정화용수', '195', '32.7', '108.8', '24.9', '5.8', '2.6', '4.4', '2.9', '-', '68.1'], + ['세척·살수용수', '12', '1.4', '37', '35', '-', '-', '1.9', '-', '-', '0.2'], + ['조경용수', '3,165', '7,440.8', '13,554.1', '168.1', '2,421.9', '290.7', '66.2', '29', '10,044', '534.3'], + ['냉·난방수', '8', '1.6', '0.1', '-', '-', '-', '-', '0.1', '-', '-'], + ['공업용수', '6', '14.9', '53.2', '52.8', '-', '-', '-', '-', '-', '0.4'], + ['농업용수', '6.0', '0.5', '0.6', '-', '-', '-', '-', '-', '-', '0.6'], + ['기타', '48', '306.1', '155.6', '2.8', '-', '-', '0.6', '0.5', '147.5', '4.2'], + ['미기재', '103', '22.9', '34.8', '16.0', '0.6', '-', '4.6', '0.2', '-', '13.5'], + ['합계', '3,543', '7,820.8', '13,944.7', '299.6', '2,428.4', '293.3', '77.6', '32.6', '10,191.6', '621.2'], ].map((row, idx) => ( - + {row.map((val, i) => { const isHighlightedCol = [1, 2].includes(i); return ( - ); @@ -278,91 +194,58 @@ export default function RainStatusSection() { -
+
{/* 3) 빗물이용시설의 용도별 빗물이용량 현황 */} -
+
빗물이용시설의 용도별 빗물이용량 현황 -
+
+ 용도 + 개소수
(개소)
- 저류용량 + + 저류조용량
(천m³)
+ 빗물이용량 (천m³/년)
+ 총사용량 + 공공시설 + 공동주택 + 대규모점포 + 체육시설 + 학교 + 골프장 + 기타
+ {val}
- - - - - - - - {[ - ["청소·화장실용수", "126.5", "134.1", "96.5", "153.9", "140.5", "108.8"], - ["세척·살수용수", "14.8", "14.6", "9.5", "49.8", "49.7", "37.0"], - ["조경용수", "7,629.8", "8,496.2", "9,415.4", "13,360.4", "14,466.3", "13,554.1"], - ["친수용수", "1.0", "0.1", "0.1", "0.1", "0.1", "0.1"], - ["공업용수", "152.0", "77.0", "77.0", "75.4", "0.4", "53.2"], - ["농업용수", "4.4", "4.3", "4.3", "4.3", "0.6", "0.6"], - ["기타", "37.7", "42.6", "230.7", "394.3", "463.2", "155.6"], + ['청소·화장실용수', '126.5', '134.1', '96.5', '153.9', '140.5', '108.8'], + ['세척·살수용수', '14.8', '14.6', '9.5', '49.8', '49.7', '37.0'], + ['조경용수', '7,629.8', '8,496.2', '9,415.4', '13,360.4', '14,466.3', '13,554.1'], + ['친수용수', '1.0', '0.1', '0.1', '0.1', '0.1', '0.1'], + ['공업용수', '152.0', '77.0', '77.0', '75.4', '0.4', '53.2'], + ['농업용수', '4.4', '4.3', '4.3', '4.3', '0.6', '0.6'], + ['기타', '37.7', '42.6', '230.7', '394.3', '463.2', '155.6'], ].map((row, idx) => ( - + {row.map((val, i) => ( - ))} @@ -378,4 +261,4 @@ export default function RainStatusSection() { ); -} \ No newline at end of file +} diff --git a/apps/dm/app/(landing)/reuse/cases/_components/SewageStatusSection.tsx b/apps/dm/app/(landing)/reuse/cases/_components/SewageStatusSection.tsx index 0a2b34d5d..5a953e2c3 100644 --- a/apps/dm/app/(landing)/reuse/cases/_components/SewageStatusSection.tsx +++ b/apps/dm/app/(landing)/reuse/cases/_components/SewageStatusSection.tsx @@ -1,60 +1,56 @@ -"use client"; +'use client'; -import React from "react"; +import React from 'react'; function SectionTitle({ children }: { children: React.ReactNode }) { return ( -
- -

{children}

+
+ +

{children}

); } -function Hr({ className = "" }: { className?: string }) { +function Hr({ className = '' }: { className?: string }) { return
; } function SourceText() { - return ( -

- 자료: 환경부(2024), 「2023 하수도 통계」 -

- ); + return

자료: 환경부(2024), 「2023 하수도 통계」

; } const tableStyles = { - container: "w-[1000px] mx-auto overflow-hidden", - table: "w-full table-fixed border-collapse text-center text-[16px]", - th: "border border-[#858F98] py-3 font-bold text-neutral-800", - td: "border border-[#858F98] py-3 text-neutral-700 font-medium break-keep", - headerBg: "bg-[#E3E7EA]", + container: 'w-[1000px] mx-auto overflow-hidden', + table: 'w-full table-fixed border-collapse text-center text-[16px]', + th: 'border border-[#858F98] py-3 font-bold text-neutral-800', + td: 'border border-[#858F98] py-3 text-neutral-700 font-medium break-keep', + headerBg: 'bg-[#E3E7EA]', }; export default function SewageStatusSection() { - const title = "하수처리수 재이용시설(현황)"; + const title = '하수처리수 재이용시설(현황)'; return ( -
+
{/* 상단 타이틀 */} -
-

- {title} - +
+

+ {title} +

-
+
{/* 1) 연도별 하수처리수 재이용 현황 */} -
+
연도별 하수처리수 재이용 현황 -
+

+ 용도 + 빗물이용량 (천m³/년)
+ 2018 + 2019 + 2020 + 2021 + 2022 + 2023
+ {val}
- - + + @@ -64,166 +60,52 @@ export default function SewageStatusSection() { - - - - - - - + + + + + + {[ { - group: "전체", + group: '전체', rows: [ - [ - "개소수 (개소)", - "671", - "681", - "698", - "704", - "714", - "717", - ], - [ - "하수처리량 (백만m³/년)", - "7,163.7", - "7,140.4", - "7,375.6", - "7,335.8", - "7,378.9", - "7,537.2", - ], - [ - "재이용량 (백만m³/년)", - "1,112.7", - "1,148.7", - "1,148.7", - "1,127.0", - "1,136.8", - "1,137.5", - ], - [ - "재이용률 (%)", - "17.4", - "16.5", - "15.5", - "14.1", - "14.3", - "14.4", - ], + ["개소수 (개소)", '671', '681', '698', '704', '714', '717'], + ["하수처리량 (백만m³/년)", '7,163.7', '7,140.4', '7,375.6', '7,335.8', '7,378.9', '7,537.2'], + ["재이용량 (백만m³/년)", '1,112.7', '1,148.7', '1,148.7', '1,127.0', '1,136.8', '1,137.5'], + ["재이용률 (%)", '17.4', '16.5', '15.5', '14.1', '14.3', '14.4'], ], }, { - group: "법적 대상", + group: '법적 대상', rows: [ - [ - "개소수 (개소)", - "68", - "82", - "94", - "97", - "101", - "118", - ], - [ - "하수처리량 (백만m³/년)", - "761.2", - "839.0", - "935.3", - "1,158.7", - "1,220.6", - "1,473.6", - ], - [ - "재이용량 (백만m³/년)", - "162.2", - "190.6", - "218.5", - "244.6", - "247.2", - "324.9", - ], - [ - "재이용률 (%)", - "24.7", - "25.4", - "27.8", - "25.7", - "24.2", - "24.1", - ], + ["개소수 (개소)", '68', '82', '94', '97', '101', '118'], + ["하수처리량 (백만m³/년)", '761.2', '839.0', '935.3', '1,158.7', '1,220.6', '1,473.6'], + ["재이용량 (백만m³/년)", '162.2', '190.6', '218.5', '244.6', '247.2', '324.9'], + ["재이용률 (%)", '24.7', '25.4', '27.8', '25.7', '24.2', '24.1'], ], }, { - group: "법적 미대상", + group: '법적 미대상', rows: [ - [ - "개소수 (개소)", - "603", - "599", - "604", - "607", - "613", - "599", - ], - [ - "하수처리량 (백만m³/년)", - "6,402.5", - "6,301.4", - "6,440.3", - "6,177.1", - "6,158.3", - "6,063.5", - ], - [ - "재이용량 (백만m³/년)", - "950.6", - "958.1", - "926.2", - "882.4", - "889.5", - "812.7", - ], - [ - "재이용률 (%)", - "16.5", - "15.3", - "13.6", - "12.2", - "12.6", - "12.5", - ], + ["개소수 (개소)", '603', '599', '604', '607', '613', '599'], + ["하수처리량 (백만m³/년)", '6,402.5', '6,301.4', '6,440.3', '6,177.1', '6,158.3', '6,063.5'], + ["재이용량 (백만m³/년)", '950.6', '958.1', '926.2', '882.4', '889.5', '812.7'], + ["재이용률 (%)", '16.5', '15.3', '13.6', '12.2', '12.6', '12.5'], ], }, ].map((section, sIdx) => ( {section.rows.map((row, rIdx) => ( - + {rIdx === 0 && ( - )} @@ -234,9 +116,7 @@ export default function SewageStatusSection() { return ( +
+ 구분 - 2018 - - 2019 - - 2020 - - 2021 - - 2022 - - 2023 - 201820192020202120222023
+ {section.group} ); @@ -253,17 +133,17 @@ export default function SewageStatusSection() { -
+
{/* 2) 용도별 하수처리수 재이용 현황 */} -
+
용도별 하수처리수 재이용 현황 -
+
- + @@ -273,88 +153,59 @@ export default function SewageStatusSection() { - - - - - - - + + + + + + + {[ - { label: "총 재이용량", type: "total", values: ["1,112.7", "1,148.7", "1,144.7", "1,127.0", "1,136.8", "1,137.5"] }, - { label: "장내 재이용", type: "category", values: ["520.6", "519.8", "513.9", "520.3", "502.2", "490.0"] }, - { label: "세척수", type: "red-item", values: ["177.9", "174.4", "169.4", "180.9", "196.8", "191.2"] }, - { label: "냉각수", type: "red-item", values: ["45.5", "46.8", "50.4", "40.6", "38.3", "41.7"] }, - { label: "청소수", type: "red-item", values: ["17.6", "21.2", "21.2", "21.7", "20.8", "20.5"] }, - { label: "조경용수", type: "red-item", values: ["0.1", "0.1", "-", "-", "-", "-"] }, - { label: "회생용수", type: "red-item", values: ["15.8", "16.7", "17.8", "15.7", "14.4", "14.0"] }, - { label: "장내급수도", type: "red-item", values: ["2.4", "-", "-", "-", "-", "-"] }, - { label: "장내기타용수", type: "red-item", values: ["261.3", "260.6", "255.0", "261.1", "231.8", "222.6"] }, - { label: "장외 재이용", type: "category", values: ["592.1", "628.9", "630.8", "606.7", "634.6", "647.5"] }, - { label: "청소·화장실용수", type: "blue-item", values: ["0.6", "0.4", "0.4", "0.4", "0.4", "0.4"] }, - { label: "세척·살수용수", type: "blue-item", values: ["5.6", "9.9", "2.9", "2.1", "3.0", "3.6"] }, - { label: "조경용수", type: "blue-item", values: ["10.3", "16.4", "5.9", "4.5", "8.9", "9.5"] }, - { label: "친수용수", type: "blue-item", values: ["7.8", "3.1", "3.1", "3.0", "10.9", "10.5"] }, - { label: "하천유지용수", type: "blue-item", values: ["480.5", "482.5", "496.2", "442.7", "471.7", "484.0"] }, - { label: "농업용수", type: "blue-item", values: ["12.2", "11.9", "13.4", "10.7", "10.6", "11.4"] }, - { label: "지하수 충전", type: "blue-item", values: ["0.0", "0.0", "0.0", "13.4", "0.0", "0.0"] }, - { label: "공업용수", type: "blue-item", values: ["75.2", "104.6", "108.9", "129.9", "129.0", "128.1"] }, + { label: '총 재이용량', type: 'total', values: ['1,112.7', '1,148.7', '1,144.7', '1,127.0', '1,136.8', '1,137.5'] }, + { label: '장내 재이용', type: 'category', values: ['520.6', '519.8', '513.9', '520.3', '502.2', '490.0'] }, + { label: '세척수', type: 'red-item', values: ['177.9', '174.4', '169.4', '180.9', '196.8', '191.2'] }, + { label: '냉각수', type: 'red-item', values: ['45.5', '46.8', '50.4', '40.6', '38.3', '41.7'] }, + { label: '청소수', type: 'red-item', values: ['17.6', '21.2', '21.2', '21.7', '20.8', '20.5'] }, + { label: '조경용수', type: 'red-item', values: ['0.1', '0.1', '-', '-', '-', '-'] }, + { label: '회생용수', type: 'red-item', values: ['15.8', '16.7', '17.8', '15.7', '14.4', '14.0'] }, + { label: '장내급수도', type: 'red-item', values: ['2.4', '-', '-', '-', '-', '-'] }, + { label: '장내기타용수', type: 'red-item', values: ['261.3', '260.6', '255.0', '261.1', '231.8', '222.6'] }, + { label: '장외 재이용', type: 'category', values: ['592.1', '628.9', '630.8', '606.7', '634.6', '647.5'] }, + { label: '청소·화장실용수', type: 'blue-item', values: ['0.6', '0.4', '0.4', '0.4', '0.4', '0.4'] }, + { label: '세척·살수용수', type: 'blue-item', values: ['5.6', '9.9', '2.9', '2.1', '3.0', '3.6'] }, + { label: '조경용수', type: 'blue-item', values: ['10.3', '16.4', '5.9', '4.5', '8.9', '9.5'] }, + { label: '친수용수', type: 'blue-item', values: ['7.8', '3.1', '3.1', '3.0', '10.9', '10.5'] }, + { label: '하천유지용수', type: 'blue-item', values: ['480.5', '482.5', '496.2', '442.7', '471.7', '484.0'] }, + { label: '농업용수', type: 'blue-item', values: ['12.2', '11.9', '13.4', '10.7', '10.6', '11.4'] }, + { label: '지하수 충전', type: 'blue-item', values: ['0.0', '0.0', '0.0', '13.4', '0.0', '0.0'] }, + { label: '공업용수', type: 'blue-item', values: ['75.2', '104.6', '108.9', '129.9', '129.0', '128.1'] }, ].map((row, idx) => { - const isSubItem = row.type === "red-item" || row.type === "blue-item"; + const isSubItem = row.type === 'red-item' || row.type === 'blue-item'; const isLastInGroup = idx === 8 || idx === 17; return ( - + {row.values.map((val, i) => ( - ))} @@ -369,18 +220,18 @@ export default function SewageStatusSection() { -
+
{/* 3) 하수처리시설 설치 및 이용 현황 */} -
+
하수처리시설 설치 및 이용 현황 -
+
- 구분 - - 2018 - - 2019 - - 2020 - - 2021 - - 2022 - - 2023 - 구분201820192020202120222023
{isSubItem ? ( <> +
-
- - {row.label} - + {row.label} ) : ( - - {row.label} - + {row.label} )}
+ {val}
- - + + @@ -390,139 +241,49 @@ export default function SewageStatusSection() { - - - - - - - + + + + + + {[ { - group: "전체", + group: '전체', rows: [ - [ - "시설수 (개소)", - "671", - "681", - "698", - "704", - "714", - "717", - ], - [ - "시설용량 (만m³/일)", - "25,846", - "26,067", - "26,244", - "26,492", - "26,586", - "26,766", - ], - [ - "처리용량 (만m³/일)", - "1,112.7", - "1,148.7", - "1,144.7", - "1,127.0", - "1,136.8", - "1,137.5", - ], + ["시설수 (개소)", '671', '681', '698', '704', '714', '717'], + ["시설용량 (만m³/일)", '25,846', '26,067', '26,244', '26,492', '26,586', '26,766'], + ["처리용량 (만m³/일)", '1,112.7', '1,148.7', '1,144.7', '1,127.0', '1,136.8', '1,137.5'], ], }, { - group: "법적 대상", + group: '법적 대상', rows: [ - [ - "시설수 (개소)", - "68", - "82", - "94", - "97", - "101", - "118", - ], - [ - "시설용량 (만m³/일)", - "3,088.9", - "3,359.2", - "3,612.6", - "4,412.2", - "4,692.3", - "5,503.8", - ], - [ - "처리용량 (만m³/일)", - "162.2", - "190.6", - "218.5", - "244.6", - "247.2", - "324.9", - ], + ["시설수 (개소)", '68', '82', '94', '97', '101', '118'], + ["시설용량 (만m³/일)", '3,088.9', '3,359.2', '3,612.6', '4,412.2', '4,692.3', '5,503.8'], + ["처리용량 (만m³/일)", '162.2', '190.6', '218.5', '244.6', '247.2', '324.9'], ], }, { - group: "법적 미대상", + group: '법적 미대상', rows: [ - [ - "시설수 (개소)", - "603", - "599", - "604", - "607", - "613", - "599", - ], - [ - "시설용량 (만m³/일)", - "22,757", - "22,708", - "22,631", - "22,080", - "21,894", - "21,262", - ], - [ - "처리용량 (만m³/일)", - "950.6", - "958.1", - "926.2", - "882.4", - "889.5", - "812.7", - ], + ["시설수 (개소)", '603', '599', '604', '607', '613', '599'], + ["시설용량 (만m³/일)", '22,757', '22,708', '22,631', '22,080', '21,894', '21,262'], + ["처리용량 (만m³/일)", '950.6', '958.1', '926.2', '882.4', '889.5', '812.7'], ], }, ].map((section, sIdx) => ( {section.rows.map((row, rIdx) => ( - + {rIdx === 0 && ( - )} @@ -533,9 +294,7 @@ export default function SewageStatusSection() { return ( )); }; - const renderBuildingUsageCells = ( - row: DmStatusBildTpYearsType | DmStatusUsgYearsType | DmStatusUsgYearsType, - headers: TableHeaderType[] - ) => { + const renderBuildingUsageCells = (row: DmStatusBildTpYearsType | DmStatusUsgYearsType | DmStatusUsgYearsType, headers: TableHeaderType[]) => { const rowData = row as Record; const firstHeaderId = headers[0]?.id; - const rowKey = firstHeaderId ? String(rowData[firstHeaderId] ?? "") : ""; + const rowKey = firstHeaderId ? String(rowData[firstHeaderId] ?? '') : ''; return headers.map((header, index) => { const value = rowData[header.id]; return ( - ); @@ -126,63 +84,49 @@ export default function RainwaterStatusPage() { const renderRainyByUsgBildTpCells = (row: DmStatusUsgBildTpType) => { return rainyByUsgBildTpHeader.map((header, index) => { const value = row[header.id as keyof DmStatusUsgBildTpType]; - const displayValue = - index === 0 - ? value - : header.id === "CNT" - ? Number(value).toLocaleString("ko-KR") - : formatUsage(value as string | number, 1000, 1); + const displayValue = index === 0 ? value : header.id === 'CNT' ? Number(value).toLocaleString('ko-KR') : formatUsage(value as string | number, 1000, 1); return ( - ); }); }; return ( -
+
-
-
+
+
{/* 1) 빗물이용시설의 건축물별 빗물이용량 현황 */} -
+
빗물이용시설의 건축물별 빗물이용량 현황 -
+
+ 구분 - 2018 - - 2019 - - 2020 - - 2021 - - 2022 - - 2023 - 201820192020202120222023
+ {section.group} ); @@ -552,24 +311,19 @@ export default function SewageStatusSection() { -
+
{/* 4) 시도별 하수처리수 재이용 현황 */} -
+
시도별 하수처리수 재이용 현황 -
+
-
- (단위: 백만m³/일) -
+
(단위: 백만m³/년)
- + @@ -583,110 +337,71 @@ export default function SewageStatusSection() { - - - - - - - - - - - - - + + + + + {[ - ["합계", "7,537.2", "1,137.5", "490.0", "647.5", "484.0", "128.1", "11.4", "24.0", "15.1%", ""], - ["서울특별시", "1,567.0", "114.9", "59.3", "55.6", "55.1", "-", "-", "0.5", "7.3%", ""], - ["부산광역시", "491.5", "146.2", "120.7", "25.5", "25.3", "-", "-", "0.2", "29.7%", ""], - ["대구광역시", "432.7", "102.4", "38.2", "64.2", "64.1", "-", "-", "0.1", "23.7%", ""], - ["인천광역시", "302.3", "57.7", "11.6", "46.1", "29.2", "12.1", "0.5", "4.3", "19.1%", ""], - ["광주광역시", "211.1", "28.7", "6.5", "22.3", "22.2", "-", "-", "0.1", "13.6%", ""], - ["대전광역시", "244.6", "11.9", "11.9", "0.68", "0.0", "-", "-", "0.1", "4.9%", ""], - ["울산광역시", "235.1", "36.1", "6.1", "29.9", "29.7", "0.2", "-", "-", "15.3%", ""], - ["세종특별자치시", "38.5", "2.5", "2.0", "0.5", "0.4", "-", "0.2", "-", "6.6%", ""], - ["경기도", "1,926.8", "313.3", "122.2", "191.0", "153.0", "33.3", "0.4", "4.3", "16.3%", ""], - ["강원도", "230.4", "19.6", "15.4", "4.2", "2.1", "-", "-", "2.1", "8.5%", ""], - ["충청북도", "190.9", "15.6", "6.3", "9.2", "7.4", "0.6", "-", "1.2", "8.2%", ""], - ["충청남도", "241.5", "41.4", "9.8", "31.5", "17.7", "8.6", "4.5", "0.8", "17.1%", ""], - ["전라북도", "292.4", "42.7", "7.5", "35.1", "33.5", "0.2", "1.0", "0.5", "14.6%", ""], - ["전라남도", "254.0", "23.6", "12.3", "11.3", "8.7", "0.0", "1.0", "1.6", "9.3%", ""], - ["경상북도", "378.4", "129.1", "33.7", "95.3", "20.8", "72.5", "0.8", "1.3", "34.1%", ""], - ["경상남도", "411.3", "50.1", "25.0", "25.0", "14.7", "0.5", "3.0", "6.9", "12.2%", ""], - ["제주특별자치도", "88.6", "1.7", "1.5", "0.12", "-", "-", "0.1", "-", "1.9%", ""], + ['합계', '7,537.2', '1,137.5', '490.0', '647.5', '484.0', '128.1', '11.4', '24.0', '15.1%', ''], + ['서울특별시', '1,567.0', '114.9', '59.3', '55.6', '55.1', '-', '-', '0.5', '7.3%', ''], + ['부산광역시', '491.5', '146.2', '120.7', '25.5', '25.3', '-', '-', '0.2', '29.7%', ''], + ['대구광역시', '432.7', '102.4', '38.2', '64.2', '64.1', '-', '-', '0.1', '23.7%', ''], + ['인천광역시', '302.3', '57.7', '11.6', '46.1', '29.2', '12.1', '0.5', '4.3', '19.1%', ''], + ['광주광역시', '211.1', '28.7', '6.5', '22.3', '22.2', '-', '-', '0.1', '13.6%', ''], + ['대전광역시', '244.6', '11.9', '11.9', '0.68', '0.0', '-', '-', '0.1', '4.9%', ''], + ['울산광역시', '235.1', '36.1', '6.1', '29.9', '29.7', '0.2', '-', '-', '15.3%', ''], + ['세종특별자치시', '38.5', '2.5', '2.0', '0.5', '0.4', '-', '0.2', '-', '6.6%', ''], + ['경기도', '1,926.8', '313.3', '122.2', '191.0', '153.0', '33.3', '0.4', '4.3', '16.3%', ''], + ['강원도', '230.4', '19.6', '15.4', '4.2', '2.1', '-', '-', '2.1', '8.5%', ''], + ['충청북도', '190.9', '15.6', '6.3', '9.2', '7.4', '0.6', '-', '1.2', '8.2%', ''], + ['충청남도', '241.5', '41.4', '9.8', '31.5', '17.7', '8.6', '4.5', '0.8', '17.1%', ''], + ['전라북도', '292.4', '42.7', '7.5', '35.1', '33.5', '0.2', '1.0', '0.5', '14.6%', ''], + ['전라남도', '254.0', '23.6', '12.3', '11.3', '8.7', '0.0', '1.0', '1.6', '9.3%', ''], + ['경상북도', '378.4', '129.1', '33.7', '95.3', '20.8', '72.5', '0.8', '1.3', '34.1%', ''], + ['경상남도', '411.3', '50.1', '25.0', '25.0', '14.7', '0.5', '3.0', '6.9', '12.2%', ''], + ['제주특별자치도', '88.6', '1.7', '1.5', '0.12', '-', '-', '0.1', '-', '1.9%', ''], ].map((row, idx) => ( - + {row.map((val, i) => ( - ))} @@ -702,4 +417,4 @@ export default function SewageStatusSection() { ); -} \ No newline at end of file +} diff --git a/apps/dm/app/(landing)/reuse/intro/_components/LawIntroSection.tsx b/apps/dm/app/(landing)/reuse/intro/_components/LawIntroSection.tsx index de39aa798..8c2a535ce 100644 --- a/apps/dm/app/(landing)/reuse/intro/_components/LawIntroSection.tsx +++ b/apps/dm/app/(landing)/reuse/intro/_components/LawIntroSection.tsx @@ -1,123 +1,92 @@ -"use client"; +'use client'; -import React from "react"; -import Image, { type StaticImageData } from "next/image"; +import React from 'react'; +import Image, { type StaticImageData } from 'next/image'; -import lawPlan from "@/public/images/landing/intro/law_timeline.png"; -import planPerformance from "@/public/images/landing/intro/performance_left.png"; -import domesticChange from "@/public/images/landing/intro/domestic_textbox.png"; -import policyHistory from "@/public/images/landing/intro/policy_timeline.png"; -import lawRelation from "@/public/images/landing/intro/rel_note.png"; -import domesticLinkage from "@/public/images/landing/intro/linkage.png"; +import lawPlan from '@/public/images/landing/intro/law_timeline.png'; +import planPerformance from '@/public/images/landing/intro/performance_left.png'; +import domesticChange from '@/public/images/landing/intro/domestic_textbox.png'; +import policyHistory from '@/public/images/landing/intro/policy_timeline.png'; +import lawRelation from '@/public/images/landing/intro/rel_note.png'; +import domesticLinkage from '@/public/images/landing/intro/linkage.png'; function SectionTitle({ children }: { children: React.ReactNode }) { return ( -
- -

{children}

+
+ +

{children}

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

- {title} - +
+
+

+ {title} +

-
- - +
+ + -
+
- - + + -
+
- - + + -
+
- - + + -
+
- - + + -
+
- - + +
); -} \ No newline at end of file +} diff --git a/apps/dm/app/(landing)/reuse/intro/law/page.tsx b/apps/dm/app/(landing)/reuse/intro/law/page.tsx index c9952bf0b..d8d391202 100644 --- a/apps/dm/app/(landing)/reuse/intro/law/page.tsx +++ b/apps/dm/app/(landing)/reuse/intro/law/page.tsx @@ -1,20 +1,20 @@ // apps/dm/app/(landing)/reuse/intro/law/page.tsx -"use client"; +'use client'; -import Image, { type StaticImageData } from "next/image"; -import SubPageHeader from "../../_components/SubPageHeader"; -import lawTimeline from "@/public/images/landing/intro/law_timeline.png"; -import performanceLeft from "@/public/images/landing/intro/performance_left.png"; -import domesticTextbox from "@/public/images/landing/intro/domestic_textbox.png"; -import policyTimeline from "@/public/images/landing/intro/policy_timeline.png"; -import relNote from "@/public/images/landing/intro/rel_note.png"; -import linkage from "@/public/images/landing/intro/linkage.png"; +import Image, { type StaticImageData } from 'next/image'; +import SubPageHeader from '../../_components/SubPageHeader'; +import lawTimeline from '@/public/images/landing/intro/law_timeline.png'; +import performanceLeft from '@/public/images/landing/intro/performance_left.png'; +import domesticTextbox from '@/public/images/landing/intro/domestic_textbox.png'; +import policyTimeline from '@/public/images/landing/intro/policy_timeline.png'; +import relNote from '@/public/images/landing/intro/rel_note.png'; +import linkage from '@/public/images/landing/intro/linkage.png'; function SectionTitle({ children }: { children: React.ReactNode }) { return ( -
- -

{children}

+
+ +

{children}

); } @@ -23,7 +23,7 @@ type HrProps = { className?: string; }; -function Hr({ className = "" }: HrProps) { +function Hr({ className = '' }: HrProps) { return
; } @@ -34,95 +34,87 @@ type ImgCardProps = { function ImgCard({ src, alt }: ImgCardProps) { return ( -
- +
+
); } export default function WaterReuseLawPage() { return ( -
+
-
+
{/* 1) 법제도/계획 수립 타임라인 */} -
+
법제도 · 계획 및 기본계획 수립 -
- +
+
-
+
{/* 2) 제2차 계획 대비 실적 현황 (2컬럼) */}
제 2차 계획 대비 실적 현황 -
- +
+
-
+
{/* 3) 국내 관련 현황 */}
국내 관련 현황 -
- +
+
-
+
{/* 4) 정책 변천 추이 */}
물 재이용 정책 변천 추이 -
- +
+
-
+
{/* 5) 타 법령과의 관계도 */}
타법령과의 관계도 -
- +
+
-
+
{/* 6) 국내 연계 현황 */} -
+
국내 연계 현황 -
-
-

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

-

국가 상위계획의 정책 방향이 기후위기 대응 등 수자원의 공급 안전성을 위한 물 재이용 활성화로 정책 실효성 제고 필요
- • 물관리 계획(국가수도기본계획, 국가 및 유역하수도종합계획, 하천유역수자원관리계획 등)과의 연계성 확보 → 통합적인 관점에서 물이용 체계 구축 필요 +

+
+

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

+

+ 국가 상위계획의 정책 방향이 기후위기 대응 등 수자원의 공급 안전성을 위한 물 재이용 활성화로 정책 실효성 제고 필요 +
• 물관리 계획(국가수도기본계획, 국가 및 유역하수도종합계획, 하천유역수자원관리계획 등)과의 연계성 확보 → 통합적인 관점에서 물이용 체계 구축 필요

- +
diff --git a/apps/dm/app/(with-layout)/intro/_components/LawIntroSection.tsx b/apps/dm/app/(with-layout)/intro/_components/LawIntroSection.tsx index 0ca6ccf69..ea4ad810d 100644 --- a/apps/dm/app/(with-layout)/intro/_components/LawIntroSection.tsx +++ b/apps/dm/app/(with-layout)/intro/_components/LawIntroSection.tsx @@ -1,4 +1,4 @@ -"use client"; +'use client'; export default function LawIntroSection() { const contentsHtml = ` @@ -84,10 +84,5 @@ export default function LawIntroSection() {
`; - return ( -
- ); + return
; } diff --git a/apps/dm/app/(with-layout)/intro/law/page.tsx b/apps/dm/app/(with-layout)/intro/law/page.tsx index 63f504af4..9e881b921 100644 --- a/apps/dm/app/(with-layout)/intro/law/page.tsx +++ b/apps/dm/app/(with-layout)/intro/law/page.tsx @@ -1,22 +1,22 @@ // DM 물 재이용 소개(물 재이용 법) 페이지 -"use client"; +'use client'; -import Image, { type StaticImageData } from "next/image"; -import SubPageHeader from "@/components/SubPageHeader"; +import Image, { type StaticImageData } from 'next/image'; +import SubPageHeader from '@/components/SubPageHeader'; // import BusinessSubNav from "@/components/BusinessSubNav"; -import lawTimeline from "@/public/images/landing/intro/law_timeline.png"; -import performanceLeft from "@/public/images/landing/intro/performance_left.png"; -import domesticTextbox from "@/public/images/landing/intro/domestic_textbox.png"; -import policyTimeline from "@/public/images/landing/intro/policy_timeline.png"; -import relNote from "@/public/images/landing/intro/rel_note.png"; -import linkage from "@/public/images/landing/intro/linkage.png"; +import lawTimeline from '@/public/images/landing/intro/law_timeline.png'; +import performanceLeft from '@/public/images/landing/intro/performance_left.png'; +import domesticTextbox from '@/public/images/landing/intro/domestic_textbox.png'; +import policyTimeline from '@/public/images/landing/intro/policy_timeline.png'; +import relNote from '@/public/images/landing/intro/rel_note.png'; +import linkage from '@/public/images/landing/intro/linkage.png'; function SectionTitle({ children }: { children: React.ReactNode }) { return ( -
- -

{children}

+
+ +

{children}

); } @@ -25,7 +25,7 @@ type HrProps = { className?: string; }; -function Hr({ className = "" }: HrProps) { +function Hr({ className = '' }: HrProps) { return
; } @@ -36,14 +36,8 @@ type ImgCardProps = { function ImgCard({ src, alt }: ImgCardProps) { return ( -
- +
+
); } @@ -55,109 +49,73 @@ type SectionShellProps = { className?: string; }; -function SectionShell({ id, title, children, className = "" }: SectionShellProps) { +function SectionShell({ id, title, children, className = '' }: SectionShellProps) { return ( -
+
{title} -
{children}
+
{children}
); } export default function WaterReuseLawPage() { return ( -
+
{/* */} -
-
+
+
- - + + -
+
- - + + -
+
- - + + -
+
- - + + -
+
- - + + -
+
- -
-

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

-

- 국가 상위계획의 정책 방향이 기후위기 대응 등 수자원의 공급 안전성을 위한 - 물 재이용 활성화로 정책 실효성 제고 필요 -
- • 물관리 계획(국가수도기본계획, 국가 및 유역하수도종합계획, - 하천유역수자원관리계획 등)과의 연계성 확보 → 통합적인 관점에서 - 물이용 체계 구축 필요 + +

+

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

+

+ 국가 상위계획의 정책 방향이 기후위기 대응 등 수자원의 공급 안전성을 위한 물 재이용 활성화로 정책 실효성 제고 필요 +
• 물관리 계획(국가수도기본계획, 국가 및 유역하수도종합계획, 하천유역수자원관리계획 등)과의 연계성 확보 → 통합적인 관점에서 물이용 체계 구축 필요

- +
); -} \ No newline at end of file +} diff --git a/apps/dm/app/(with-layout)/status/_components/RainStatusSection.tsx b/apps/dm/app/(with-layout)/status/_components/RainStatusSection.tsx index 6a534294d..e30cc8df0 100644 --- a/apps/dm/app/(with-layout)/status/_components/RainStatusSection.tsx +++ b/apps/dm/app/(with-layout)/status/_components/RainStatusSection.tsx @@ -1,30 +1,24 @@ -"use client"; +'use client'; -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo } from 'react'; // import SubPageHeader from "@/components/SubPageHeader"; -import Image from "next/image"; -import { useDmStore } from "@/app/store"; -import { formatUsage } from "@/app/common/transValueFormat"; -import type { - DmStatusBildTpYearsType, - DmStatusUsgBildTpType, - DmStatusUsgYearsType, - TableHeaderType, -} from "@/app/store/dm-status/type"; - +import Image from 'next/image'; +import { useDmStore } from '@/app/store'; +import { formatUsage } from '@/app/common/transValueFormat'; +import type { DmStatusBildTpYearsType, DmStatusUsgBildTpType, DmStatusUsgYearsType, TableHeaderType } from '@/app/store/dm-status/type'; // 공통 섹션 타이틀 컴포넌트 function SectionTitle({ children }: { children: React.ReactNode }) { return ( -
- -

{children}

+
+ +

{children}

); } // 구분선 -function Hr({ className = "" }: { className?: string }) { +function Hr({ className = '' }: { className?: string }) { return
; } @@ -32,62 +26,49 @@ function Hr({ className = "" }: { className?: string }) { function RainIntroSummary() { const items = [ { - icon: "/images/landing/icon_chart.png", - title: "지속적인 이용량 증가", - desc: "2018년 약 797만m³이었던 빗물 이용량은 5년 사이 약 75% 증가하여 수자원 절약에 기여하고 있습니다.", + icon: '/images/landing/icon_chart.png', + title: '지속적인 이용량 증가', + desc: '2018년 약 797만m³이었던 빗물 이용량은 5년 사이 약 75% 증가하여 수자원 절약에 기여하고 있습니다.', }, { - icon: "/images/landing/icon_hand.png", - title: "조경용수의 핵심 자원", - desc: "재이용되는 빗물의 약 97%가 조경용수로 활용되고 있으며 이는 공원, 아파트 단지 등의 녹지 관리에 중요한 역할을 합니다.", + icon: '/images/landing/icon_hand.png', + title: '조경용수의 핵심 자원', + desc: '재이용되는 빗물의 약 97%가 조경용수로 활용되고 있으며 이는 공원, 아파트 단지 등의 녹지 관리에 중요한 역할을 합니다.', }, { - icon: "/images/landing/icon_building.png", - title: "민간·공공의 협력", - desc: "골프장과 같은 대규모 스포츠 시설은 물론, 최근에는 공동주택·아파트 등 내 이용량이 급증하며 일상생활 속 빗물 활용이 확산되는 추세입니다.", + icon: '/images/landing/icon_building.png', + title: '민간·공공의 협력', + desc: '골프장과 같은 대규모 스포츠 시설은 물론, 최근에는 공동주택·아파트 등 내 이용량이 급증하며 일상생활 속 빗물 활용이 확산되는 추세입니다.', }, ]; return ( -
-
+
+
{/* 좌측 */}
-

+

기후위기 시대의 수자원 확보,
- - 빗물 재이용 - - 이 앞장섭니다. + 빗물 재이용이 앞장섭니다.

-

+

2023년 기준 전국 3,500여 개 시설에서
연간 약 1,394만m³의 빗물을 소중한 자원으로 다시 활용하고 있습니다.

-
+
{items.map((item) => ( -
-
- +
+
+
- - {item.title} - -

- {item.desc} -

+ {item.title} +

{item.desc}

))} @@ -95,15 +76,8 @@ function RainIntroSummary() {
{/* 우측 이미지 */} -
- +
+
@@ -111,31 +85,15 @@ function RainIntroSummary() { } const tableStyles = { - container: "w-[1000px] mx-auto overflow-hidden", - table: "w-full table-fixed border-collapse text-center text-[16px]", - th: "border border-[#858F98] py-3 font-bold text-neutral-800", - td: "border border-[#858F98] py-3 text-neutral-700 font-medium break-keep", - headerBg: "bg-[#E3E7EA]", + container: 'w-[1000px] mx-auto overflow-hidden', + table: 'w-full table-fixed border-collapse text-center text-[16px]', + th: 'border border-[#858F98] py-3 font-bold text-neutral-800', + td: 'border border-[#858F98] py-3 text-neutral-700 font-medium break-keep', + headerBg: 'bg-[#E3E7EA]', }; -const headerColors6 = [ - "bg-[#01ACE5]", - "bg-[#17AFC7]", - "bg-[#2CB2A9]", - "bg-[#42B58B]", - "bg-[#57B86D]", - "bg-[#81C784]", -]; -const headerColors8 = [ - "bg-[#01ACE5]", - "bg-[#10AED0]", - "bg-[#20B0BA]", - "bg-[#2FB2A5]", - "bg-[#3FB58F]", - "bg-[#4EB77A]", - "bg-[#5EB964]", - "bg-[#6DBB4F]", -]; +const headerColors6 = ['bg-[#01ACE5]', 'bg-[#17AFC7]', 'bg-[#2CB2A9]', 'bg-[#42B58B]', 'bg-[#57B86D]', 'bg-[#81C784]']; +const headerColors8 = ['bg-[#01ACE5]', 'bg-[#10AED0]', 'bg-[#20B0BA]', 'bg-[#2FB2A5]', 'bg-[#3FB58F]', 'bg-[#4EB77A]', 'bg-[#5EB964]', 'bg-[#6DBB4F]']; export default function RainStatusSection() { const getRainyByBildTpYears = useDmStore((state) => state.getRainyByBildTpYears); const getRainyByUsgBildTp = useDmStore((state) => state.getRainyByUsgBildTp); @@ -148,19 +106,11 @@ export default function RainStatusSection() { const rainyByUsgBildTp = useDmStore((state) => state.rainy.rainyByUsgBildTp); const rainyByUsgYearsHeader = useDmStore((state) => state.rainy.rainyByUsgYearsHeader); const rainyByUsgYears = useDmStore((state) => state.rainy.rainyByUsgYears); - const partBildTpHeader = useMemo( - () => rainyByUsgBildTpHeader.slice(3).map((header) => header.title), - [rainyByUsgBildTpHeader] - ); - + const partBildTpHeader = useMemo(() => rainyByUsgBildTpHeader.slice(3).map((header) => header.title), [rainyByUsgBildTpHeader]); // 출처 텍스트 function SourceText() { - return ( -

- 자료: 하수도정보시스템 {selectYear} 하수도 통계 -

- ); + return

자료: 하수도정보시스템 {selectYear} 하수도 통계

; } useEffect(() => { @@ -171,32 +121,22 @@ export default function RainStatusSection() { const renderPartHeaders = (headers: string[], headerColors: string[]) => { return headers.map((header, index) => ( -

)); }; - const renderBuildingUsageCells = ( - row: DmStatusBildTpYearsType | DmStatusUsgYearsType | DmStatusUsgYearsType, - headers: TableHeaderType[] - ) => { + const renderBuildingUsageCells = (row: DmStatusBildTpYearsType | DmStatusUsgYearsType | DmStatusUsgYearsType, headers: TableHeaderType[]) => { const rowData = row as Record; const firstHeaderId = headers[0]?.id; - const rowKey = firstHeaderId ? String(rowData[firstHeaderId] ?? "") : ""; + const rowKey = firstHeaderId ? String(rowData[firstHeaderId] ?? '') : ''; return headers.map((header, index) => { const value = rowData[header.id]; return ( - ); @@ -206,102 +146,99 @@ export default function RainStatusSection() { const renderRainyByUsgBildTpCells = (row: DmStatusUsgBildTpType) => { return rainyByUsgBildTpHeader.map((header, index) => { const value = row[header.id as keyof DmStatusUsgBildTpType]; - const displayValue = - index === 0 - ? value - : header.id === "CNT" - ? Number(value).toLocaleString("ko-KR") - : formatUsage(value as string | number, 1000, 1); + const displayValue = index === 0 ? value : header.id === 'CNT' ? Number(value).toLocaleString('ko-KR') : formatUsage(value as string | number, 1000, 1); return ( - ); }); }; - - const title = "빗물이용시설(현황)"; + + const title = '빗물이용시설(현황)'; return ( -
+
{/* 상단 타이틀 */} -
-

- {title} - +
+

+ {title} +

-
+
{/* 1) 빗물이용시설의 건축물별 빗물이용량 현황 */}
{/* 인트로 영역 */} - -
+ +
빗물이용시설의 건축물별 빗물이용량 현황 -
-
+
+

+ 시도 + 연간
하수
처리량
+ 재이용현황 + 재이용률
(%)
+ 비고
+ 계 + 장내 + 장외
- 소계 - - 하천유지용수 - - 공업용수 - - 농업용수 - - 기타용수 - 소계하천유지용수공업용수농업용수기타용수
+ {val} + {header} + {index === 0 ? value : formatUsage(value as string | number, 1000, 1)} - {displayValue == 0.0 ? "-" : displayValue} + + {displayValue == 0.0 ? '-' : displayValue}
- + - - + + - - {renderPartHeaders(rainyYears, headerColors6)} - - - + {renderPartHeaders(rainyYears, headerColors6)} + + {rainyByBildTpYears.map((row, idx) => ( - + {renderBuildingUsageCells(row, rainyByBildTpYearsHeader)} - + ))} - +
+ 구분 - + 빗물이용량 (천m³/년) -
-
+
- +
-
+
{/* 2) 빗물이용시설의 용도별 분포 (xxxx년 기준) */} -
+
빗물이용시설의 용도별 분포 ({selectYear}년 기준) -
+
- - - - - - - {renderPartHeaders(partBildTpHeader, headerColors8)} + + + + + {renderPartHeaders(partBildTpHeader, headerColors8)} {rainyByUsgBildTp.map((row, idx) => ( - + {renderRainyByUsgBildTpCells(row)} ))} @@ -313,30 +250,29 @@ export default function RainStatusSection() { -
+
{/* 3) 빗물이용시설의 용도별 빗물이용량 현황 */} -
+
빗물이용시설의 용도별 빗물이용량 현황 -
+
용도개소수
(개소)
저류용량
(천m³)
빗물이용량 (천m³/년)
+ 용도 + + 개소수 +
+ (개소) +
+ 저류조용량 +
+ (천m³) +
+ 빗물이용량 (천m³/년) +
- - - - - {renderPartHeaders(rainyYears, headerColors6)} + + + {renderPartHeaders(rainyYears, headerColors6)} {rainyByUsgYears.map((row, idx) => ( - + {renderBuildingUsageCells(row, rainyByUsgYearsHeader)} ))} @@ -350,4 +286,4 @@ export default function RainStatusSection() { ); -} \ No newline at end of file +} diff --git a/apps/dm/app/(with-layout)/status/_components/SewageStatusSection.tsx b/apps/dm/app/(with-layout)/status/_components/SewageStatusSection.tsx index f7eb49a84..c1ae8e05e 100644 --- a/apps/dm/app/(with-layout)/status/_components/SewageStatusSection.tsx +++ b/apps/dm/app/(with-layout)/status/_components/SewageStatusSection.tsx @@ -1,116 +1,97 @@ /** * 하수처리수 */ -"use client"; +'use client'; -import React from "react"; -import Image from "next/image"; -import { useEffect } from "react"; -import { useDmStore } from "@/app/store"; -import { formatUsage } from "@/app/common/transValueFormat"; -import type { - DmStatusLawYearsType, - DmStatusBildTpYearsType, - DmStatusUsgYearsType, - DmStatusSidoUsgType, - TableHeaderType, -} from "@/app/store/dm-status/type"; -import { Col2Name } from "@/app/store/dm-status/type"; +import React from 'react'; +import Image from 'next/image'; +import { useEffect } from 'react'; +import { useDmStore } from '@/app/store'; +import { formatUsage } from '@/app/common/transValueFormat'; +import type { DmStatusLawYearsType, DmStatusBildTpYearsType, DmStatusUsgYearsType, DmStatusSidoUsgType, TableHeaderType } from '@/app/store/dm-status/type'; +import { Col2Name } from '@/app/store/dm-status/type'; function SectionTitle({ children }: { children: React.ReactNode }) { return ( -
- -

{children}

+
+ +

{children}

); } -function Hr({ className = "" }: { className?: string }) { +function Hr({ className = '' }: { className?: string }) { return
; } function SewageIntroSummary() { const items = [ { - icon: "/images/landing/icon_cycle_water.png", - title: "안정적인 재이용률 유지", + icon: '/images/landing/icon_cycle_water.png', + title: '안정적인 재이용률 유지', desc: ( - <> + <> 연간 약 1,136백만m³의 하수처리수가 재이용되고 있으며, -
+
약 15%에 달하는 높은 재이용률을 통해 -
+
수자원 선순환 체계를 구축하고 있습니다. - + ), }, { - icon: "/images/landing/icon_water_flow.png", - title: "메마른 하천을 살리는 물", + icon: '/images/landing/icon_water_flow.png', + title: '메마른 하천을 살리는 물', desc: ( - <> + <> 재이용되는 물의 가장 많은 부분이 하천유지용수로 사용되어 -
+
도심 속 하천의 생태계를 보호하고 쾌적한 수변 환경을 조성합니다. - + ), }, { - icon: "/images/landing/icon_location.png", - title: "지역별 균형 있는 활용", + icon: '/images/landing/icon_location.png', + title: '지역별 균형 있는 활용', desc: ( - <> + <> 전국적으로 고르게 시설이 확충되고 있으며, 특히 경기 지역을 중심으로 -
+
대규모 공업용수 공급망을 구축하여 -
+
지역 경제와 환경 보호를 동시에 실현하고 있습니다. - + ), }, ]; return ( -
-
+
+
{/* 좌측 */}
-

+

버려지는 하수에서 찾는 새로운 가치,
- - 하수처리수 재이용 - - 이 도시를 숨쉬게 합니다. + 하수처리수 재이용이 도시를 숨쉬게 합니다.

-

+

하수처리장에서 정화된 물은 하천의 수량을 조절하고
산업 현장에 용수를 공급하는 소중한 자원입니다.

-
+
{items.map((item) => ( -
-
- +
+
+
- - {item.title} - -

- {item.desc} -

+ {item.title} +

{item.desc}

))} @@ -118,15 +99,8 @@ function SewageIntroSummary() {
{/* 우측 이미지 */} -
- +
+
@@ -134,31 +108,24 @@ function SewageIntroSummary() { } const tableStyles = { - container: "w-[1000px] mx-auto overflow-hidden", - table: "w-full table-fixed border-collapse text-center text-[16px]", - th: "border border-[#858F98] py-3 font-bold text-neutral-800", - td: "border border-[#858F98] py-3 text-neutral-700 font-medium break-keep", - headerBg: "bg-[#E3E7EA]", + container: 'w-[1000px] mx-auto overflow-hidden', + table: 'w-full table-fixed border-collapse text-center text-[16px]', + th: 'border border-[#858F98] py-3 font-bold text-neutral-800', + td: 'border border-[#858F98] py-3 text-neutral-700 font-medium break-keep', + headerBg: 'bg-[#E3E7EA]', }; -const headerColors6 = [ - "bg-[#01ACE5]", - "bg-[#17AFC7]", - "bg-[#2CB2A9]", - "bg-[#42B58B]", - "bg-[#57B86D]", - "bg-[#81C784]", -]; +const headerColors6 = ['bg-[#01ACE5]', 'bg-[#17AFC7]', 'bg-[#2CB2A9]', 'bg-[#42B58B]', 'bg-[#57B86D]', 'bg-[#81C784]']; export default function SewageStatusSection() { const getPbnstByLawYears = useDmStore((state) => state.getPbnstByLawYears); - const getPbnstByUsgYears = useDmStore((state) => state.getPbnstByUsgYears); - const getPbnstByCpcYears = useDmStore((state) => state.getPbnstByCpcYears); - const getPbnstBySido = useDmStore((state) => state.getPbnstBySido); + const getPbnstByUsgYears = useDmStore((state) => state.getPbnstByUsgYears); + const getPbnstByCpcYears = useDmStore((state) => state.getPbnstByCpcYears); + const getPbnstBySido = useDmStore((state) => state.getPbnstBySido); const pbnstYears = useDmStore((state) => state.pbnst.pbnstYears); const selectYear = pbnstYears.slice(-1)[0]; const pbnstByLawYearsHeader = useDmStore((state) => state.pbnst.pbnstByLawYearsHeader); - const pbnstByLawYears = useDmStore((state) => state.pbnst.pbnstByLawYears); + const pbnstByLawYears = useDmStore((state) => state.pbnst.pbnstByLawYears); const pbnstByUsgYearsHeader = useDmStore((state) => state.pbnst.pbnstByUsgYearsHeader); const pbnstByUsgYears = useDmStore((state) => state.pbnst.pbnstByUsgYears); const pbnstByCpcYearsHeader = useDmStore((state) => state.pbnst.pbnstByCpcYearsHeader); @@ -168,15 +135,11 @@ export default function SewageStatusSection() { // 출처 텍스트 function SourceText() { - return ( -

- 자료: 하수도정보시스템 {selectYear} 하수도 통계 -

- ); + return

자료: 하수도정보시스템 {selectYear} 하수도 통계

; } - + useEffect(() => { - void getPbnstByLawYears(); + void getPbnstByLawYears(); void getPbnstByUsgYears(); void getPbnstByCpcYears(); void getPbnstBySido(); @@ -184,10 +147,7 @@ export default function SewageStatusSection() { const renderPartHeaders = (headers: string[], headerColors: string[]) => { return headers.map((header, index) => ( -
)); @@ -198,10 +158,10 @@ export default function SewageStatusSection() { row: DmStatusLawYearsType, data: DmStatusLawYearsType[], headers: TableHeaderType[], - unitSize: number = 1, // 단위 변경 1000 인 경우, 100,000 -> 100 으로 변경 + unitSize: number = 1 // 단위 변경 1000 인 경우, 100,000 -> 100 으로 변경 ) => { const rowData = row as Record; - const currentCol1 = String(rowData["COL1"] ?? ""); + const currentCol1 = String(rowData['COL1'] ?? ''); // 이전 값 const prevCol1 = rowIdx > 0 ? data[rowIdx - 1]?.COL1 : null; @@ -220,56 +180,38 @@ export default function SewageStatusSection() { const value = rowData[header.id]; // COL1 처리 (rowspan 핵심) - if (header.id === "COL1") { + if (header.id === 'COL1') { // 이전과 같으면 렌더링 안함 if (currentCol1 === prevCol1) return null; return ( - ); - } else if (header.id === "COL2") { - return ( - ); }); }; - const renderPbnstUsgCells = ( - rowIdx: number, - row: DmStatusBildTpYearsType | DmStatusUsgYearsType, - headers: TableHeaderType[] - ) => { + const renderPbnstUsgCells = (rowIdx: number, row: DmStatusBildTpYearsType | DmStatusUsgYearsType, headers: TableHeaderType[]) => { const rowData = row as Record; const isLastInGroup = rowIdx === 8 || rowIdx === 17; - const getType = (colNm:string) => { + const getType = (colNm: string) => { const last3 = colNm.slice(-3); // 끝 3자리 - const prefix = last3[0]; // S 또는 D + const prefix = last3[0]; // S 또는 D if (prefix === 'S') { const num = parseInt(last3.slice(1), 10); // 숫자 @@ -282,149 +224,117 @@ export default function SewageStatusSection() { return headers.map((header, index) => { const value = rowData[header.id]; - const colNm = String(rowData["COL_NM"] ?? ""); + const colNm = String(rowData['COL_NM'] ?? ''); const type = getType(colNm); - const isSubItem = type === "red-item" || type === "blue-item"; - + const isSubItem = type === 'red-item' || type === 'blue-item'; + if (index === 0) { // console.log(header.id, value); return ( - ); - } + ); + } return ( - ); - }); }; - const renderSidoUsgCells = ( - rowIdx: number, - row: DmStatusSidoUsgType, - headers: TableHeaderType[] - ) => { + const renderSidoUsgCells = (rowIdx: number, row: DmStatusSidoUsgType, headers: TableHeaderType[]) => { const rowData = row as Record; return headers.map((header, index) => { let value = rowData[header.id]; - value = (index === 0||index ===9) ? value : formatUsage(value as string | number, 1000, 1); - value = value === "0.0"? "-" : value; + value = index === 0 || index === 9 ? value : formatUsage(value as string | number, 1000, 1); + value = value === '0.0' ? '-' : value; return ( - ); }); }; - const title = "하수처리수 재이용시설(현황)"; + const title = '하수처리수 재이용시설(현황)'; return ( -
+
{/* 상단 타이틀 */} -
-

- {title} - +
+

+ {title} +

-
+
{/* 1) 연도별 하수처리수 재이용 현황 */}
- {/* 인트로 */} - + {/* 인트로 */} + -
- 연도별 하수처리수 재이용 현황 -
-
-

용도빗물이용량 (천m³/년)
+ 용도 + + 빗물이용량 (천m³/년) +
+ {header} + {value} - ); + } else if (header.id === 'COL2') { + return ; } - + return ( - - {(String(rowData["COL2"]).indexOf("CNT") > -1) - ? Number(value).toLocaleString("ko-KR") - : ((String(rowData["COL2"]).indexOf("REUSERT") > -1) + + {String(rowData['COL2']).indexOf('CNT') > -1 + ? Number(value).toLocaleString('ko-KR') + : String(rowData['COL2']).indexOf('REUSERT') > -1 ? formatUsage(value as string | number, 1, 1) - : formatUsage(value as string | number, unitSize, 1)) - } + : formatUsage(value as string | number, unitSize, 1)} {isSubItem ? ( <> -
-
- - {value} - +
+
+ {value} ) : ( - - {value} - + {value} )}
+ {formatUsage(value as string | number, 1000, 1)} + {value}
- - - - - - - - - - - - - - {renderPartHeaders(pbnstYears, headerColors6)} - - - - {pbnstByLawYears.map((row, idx, arr) => ( - - {renderCells(idx, row, arr, pbnstByLawYearsHeader, 1000)} - - ))} - -
- 구분 -
-
+
+ 연도별 하수처리수 재이용 현황 +
+
+ + + + + + + + + + + + + + + {renderPartHeaders(pbnstYears, headerColors6)} + + + + {pbnstByLawYears.map((row, idx, arr) => ( + {renderCells(idx, row, arr, pbnstByLawYearsHeader, 1000)} + ))} + +
+ 구분 +
+
- -
+
+
-
+
{/* 2) 용도별 하수처리수 재이용 현황 */} -
+
용도별 하수처리수 재이용 현황 -
+
-
- (단위: 백만m³/일) -
+
(단위: 백만m³/년)
- + @@ -434,17 +344,15 @@ export default function SewageStatusSection() { - + {renderPartHeaders(pbnstYears, headerColors6)} {pbnstByUsgYears.map((row, idx) => ( - + {renderPbnstUsgCells(idx, row, pbnstByUsgYearsHeader)} - + ))}
- 구분 - 구분
@@ -454,18 +362,18 @@ export default function SewageStatusSection() {
-
+
{/* 3) 하수처리시설 설치 및 이용 현황 */} -
+
하수처리시설 설치 및 이용 현황 -
+
- - + + @@ -475,10 +383,7 @@ export default function SewageStatusSection() { - {renderPartHeaders(pbnstYears, headerColors6)} @@ -486,9 +391,7 @@ export default function SewageStatusSection() { {pbnstByCpcYears.map((row, idx, arr) => ( - - {renderCells(idx, row, arr, pbnstByCpcYearsHeader, 10000)} - + {renderCells(idx, row, arr, pbnstByCpcYearsHeader, 10000)} ))}
+ 구분
@@ -498,24 +401,19 @@ export default function SewageStatusSection() {
-
+
{/* 4) 시도별 하수처리수 재이용 현황 */} -
+
시도별 하수처리수 재이용 현황 -
+
-
- (단위: 백만m³/일) -
+
(단위: 백만m³/년)
- + @@ -529,54 +427,53 @@ export default function SewageStatusSection() { - - - + - - - - - - - - - - + + + + + {pbnstBySido.map((row, idx) => ( - + {renderSidoUsgCells(idx, row, pbnstBySidoHeader)} - + ))}
시도 - 연간
하수
처리량 +
+ {' '} + 시도{' '} + + 연간 +
+ 하수 +
+ 처리량 +
재이용현황 - 재이용률
(%) +
+ 재이용률 +
+ (%)
+ 비고
+ 계 + 장내 + 장외
- 소계 - - 하천유지용수 - - 공업용수 - - 농업용수 - - 기타용수 - 소계하천유지용수공업용수농업용수기타용수
diff --git a/apps/dm/app/(with-layout)/status/rain/page.tsx b/apps/dm/app/(with-layout)/status/rain/page.tsx index 88f041edf..fe4caa7c4 100644 --- a/apps/dm/app/(with-layout)/status/rain/page.tsx +++ b/apps/dm/app/(with-layout)/status/rain/page.tsx @@ -1,60 +1,36 @@ -"use client"; +'use client'; -import { useEffect, useMemo } from "react"; -import SubPageHeader from "@/components/SubPageHeader"; -import { useDmStore } from "@/app/store"; -import { formatUsage } from "@/app/common/transValueFormat"; -import type { - DmStatusBildTpYearsType, - DmStatusUsgBildTpType, - DmStatusUsgYearsType, - TableHeaderType, -} from "@/app/store/dm-status/type"; +import { useEffect, useMemo } from 'react'; +import SubPageHeader from '@/components/SubPageHeader'; +import { useDmStore } from '@/app/store'; +import { formatUsage } from '@/app/common/transValueFormat'; +import type { DmStatusBildTpYearsType, DmStatusUsgBildTpType, DmStatusUsgYearsType, TableHeaderType } from '@/app/store/dm-status/type'; // 공통 섹션 타이틀 컴포넌트 function SectionTitle({ children }: { children: React.ReactNode }) { return ( -
- -

{children}

+
+ +

{children}

); } // 구분선 -function Hr({ className = "" }: { className?: string }) { +function Hr({ className = '' }: { className?: string }) { return
; } - - const tableStyles = { - container: "w-[1000px] mx-auto overflow-hidden", - table: "w-full table-fixed border-collapse text-center text-[16px]", - th: "border border-[#858F98] py-3 font-bold text-neutral-800", - td: "border border-[#858F98] py-3 text-neutral-700 font-medium break-keep", - headerBg: "bg-[#E3E7EA]", + container: 'w-[1000px] mx-auto overflow-hidden', + table: 'w-full table-fixed border-collapse text-center text-[16px]', + th: 'border border-[#858F98] py-3 font-bold text-neutral-800', + td: 'border border-[#858F98] py-3 text-neutral-700 font-medium break-keep', + headerBg: 'bg-[#E3E7EA]', }; -const headerColors6 = [ - "bg-[#01ACE5]", - "bg-[#17AFC7]", - "bg-[#2CB2A9]", - "bg-[#42B58B]", - "bg-[#57B86D]", - "bg-[#81C784]", -]; -const headerColors8 = [ - "bg-[#01ACE5]", - "bg-[#10AED0]", - "bg-[#20B0BA]", - "bg-[#2FB2A5]", - "bg-[#3FB58F]", - "bg-[#4EB77A]", - "bg-[#5EB964]", - "bg-[#6DBB4F]", -]; - +const headerColors6 = ['bg-[#01ACE5]', 'bg-[#17AFC7]', 'bg-[#2CB2A9]', 'bg-[#42B58B]', 'bg-[#57B86D]', 'bg-[#81C784]']; +const headerColors8 = ['bg-[#01ACE5]', 'bg-[#10AED0]', 'bg-[#20B0BA]', 'bg-[#2FB2A5]', 'bg-[#3FB58F]', 'bg-[#4EB77A]', 'bg-[#5EB964]', 'bg-[#6DBB4F]']; export default function RainwaterStatusPage() { const getRainyByBildTpYears = useDmStore((state) => state.getRainyByBildTpYears); @@ -68,19 +44,11 @@ export default function RainwaterStatusPage() { const rainyByUsgBildTp = useDmStore((state) => state.rainy.rainyByUsgBildTp); const rainyByUsgYearsHeader = useDmStore((state) => state.rainy.rainyByUsgYearsHeader); const rainyByUsgYears = useDmStore((state) => state.rainy.rainyByUsgYears); - const partBildTpHeader = useMemo( - () => rainyByUsgBildTpHeader.slice(3).map((header) => header.title), - [rainyByUsgBildTpHeader] - ); - + const partBildTpHeader = useMemo(() => rainyByUsgBildTpHeader.slice(3).map((header) => header.title), [rainyByUsgBildTpHeader]); // 출처 텍스트 function SourceText() { - return ( -

- 자료: 환경부(2024), 「{selectYear} 하수도 통계」 -

- ); + return

자료: 환경부(2024), 「{selectYear} 하수도 통계」

; } useEffect(() => { @@ -91,32 +59,22 @@ export default function RainwaterStatusPage() { const renderPartHeaders = (headers: string[], headerColors: string[]) => { return headers.map((header, index) => ( -
+ {header} + {index === 0 ? value : formatUsage(value as string | number, 1000, 1)} - {displayValue == 0.0 ? "-" : displayValue} + + {displayValue == 0.0 ? '-' : displayValue}
- - - {renderPartHeaders(rainyYears, headerColors6)} - + {renderPartHeaders(rainyYears, headerColors6)} {rainyByBildTpYears.map((row, idx) => ( - + {renderBuildingUsageCells(row, rainyByBildTpYearsHeader)} ))} @@ -193,28 +137,38 @@ export default function RainwaterStatusPage() { -
+
{/* 2) 빗물이용시설의 용도별 분포 (2023년 기준) */} -
+
빗물이용시설의 용도별 분포 ({selectYear}년 기준) -
+
+ 구분 빗물이용량 (천m³/년)
- - - - - - - {renderPartHeaders(partBildTpHeader, headerColors8)} + + + + + {renderPartHeaders(partBildTpHeader, headerColors8)} {rainyByUsgBildTp.map((row, idx) => ( - + {renderRainyByUsgBildTpCells(row)} ))} @@ -225,26 +179,28 @@ export default function RainwaterStatusPage() { -
+
{/* 3) 빗물이용시설의 용도별 빗물이용량 현황 */} -
+
빗물이용시설의 용도별 빗물이용량 현황 -
+
용도개소수
(개소)
저류용량
(천m³)
빗물이용량 (천m³/년)
+ 용도 + + 개소수 +
+ (개소) +
+ 저류조용량 +
+ (천m³) +
+ 빗물이용량 (천m³/년) +
- - - - - {renderPartHeaders(rainyYears, headerColors6)} + + + {renderPartHeaders(rainyYears, headerColors6)} {rainyByUsgYears.map((row, idx) => ( - + {renderBuildingUsageCells(row, rainyByUsgYearsHeader)} ))} diff --git a/apps/dm/app/(with-layout)/status/sewage/page.tsx b/apps/dm/app/(with-layout)/status/sewage/page.tsx index fdb1fe8f9..98d49bfc1 100644 --- a/apps/dm/app/(with-layout)/status/sewage/page.tsx +++ b/apps/dm/app/(with-layout)/status/sewage/page.tsx @@ -1,30 +1,26 @@ -"use client"; +'use client'; -import React from "react"; -import SubPageHeader from "@/components/SubPageHeader"; +import React from 'react'; +import SubPageHeader from '@/components/SubPageHeader'; // 공통 섹션 타이틀 컴포넌트 function SectionTitle({ children }: { children: React.ReactNode }) { return ( -
- -

{children}

+
+ +

{children}

); } // 구분선 -function Hr({ className = "" }: { className?: string }) { +function Hr({ className = '' }: { className?: string }) { return
; } // 출처 텍스트 function SourceText() { - return ( -

- 자료: 환경부(2024), 「2023 하수도 통계」 -

- ); + return

자료: 환경부(2024), 「2023 하수도 통계」

; } /** @@ -33,11 +29,11 @@ function SourceText() { * - 폰트 사이즈: 16px로 수정 */ const tableStyles = { - container: "w-[1000px] mx-auto overflow-hidden", - table: "w-full table-fixed border-collapse text-center text-[16px]", - th: "border border-[#858F98] py-3 font-bold text-neutral-800", - td: "border border-[#858F98] py-3 text-neutral-700 font-medium break-keep", - headerBg: "bg-[#E3E7EA]", + container: 'w-[1000px] mx-auto overflow-hidden', + table: 'w-full table-fixed border-collapse text-center text-[16px]', + th: 'border border-[#858F98] py-3 font-bold text-neutral-800', + td: 'border border-[#858F98] py-3 text-neutral-700 font-medium break-keep', + headerBg: 'bg-[#E3E7EA]', }; // export default function TreatedWaterPage() { @@ -51,368 +47,382 @@ const tableStyles = { export default function TreatedStatusPage() { return ( -
+
{/* */} -
-
+
+
- {/* 1) 연도별 하수처리수 재이용 현황 */} -
- 연도별 하수처리수 재이용 현황 -
-
-
용도빗물이용량 (천m³/년)
+ 용도 + + 빗물이용량 (천m³/년) +
- - - - - - - - - - - - - - - - - - - - - - - {[ - { - group: "전체", - rows: [ - ["개소수 (개소)", "671", "681", "698", "704", "714", "717"], - ["하수처리량 (백만m³/년)", "7,163.7", "7,140.4", "7,375.6", "7,335.8", "7,378.9", "7,537.2"], - ["재이용량 (백만m³/년)", "1,112.7", "1,148.7", "1,148.7", "1,127.0", "1,136.8", "1,137.5"], - ["재이용률 (%)", "17.4", "16.5", "15.5", "14.1", "14.3", "14.4"], - ] - }, - { - group: "법적 대상", - rows: [ - ["개소수 (개소)", "68", "82", "94", "97", "101", "118"], - ["하수처리량 (백만m³/년)", "761.2", "839.0", "935.3", "1,158.7", "1,220.6", "1,473.6"], - ["재이용량 (백만m³/년)", "162.2", "190.6", "218.5", "244.6", "247.2", "324.9"], - ["재이용률 (%)", "24.7", "25.4", "27.8", "25.7", "24.2", "24.1"], - ] - }, - { - group: "법적 미대상", - rows: [ - ["개소수 (개소)", "603", "599", "604", "607", "613", "599"], - ["하수처리량 (백만m³/년)", "6,402.5", "6,301.4", "6,440.3", "6,177.1", "6,158.3", "6,063.5"], - ["재이용량 (백만m³/년)", "950.6", "958.1", "926.2", "882.4", "889.5", "812.7"], - ["재이용률 (%)", "16.5", "15.3", "13.6", "12.2", "12.6", "12.5"], - ] - } - ].map((section, sIdx) => ( - - {section.rows.map((row, rIdx) => ( - - {rIdx === 0 && ( - - )} - {row.map((val, i) => { - const isFirstCol = i === 0; - const isHighlightedCol = [1, 3, 5].includes(i); - return ( - - ))} - - ))} - -
구분201820192020202120222023
- {section.group} - - ); - })} -
+ {/* 1) 연도별 하수처리수 재이용 현황 */} +
+ 연도별 하수처리수 재이용 현황 +
+
+ + + + + + + + + + + + + + + + + + + + + + + + {[ + { + group: '전체', + rows: [ + ["개소수 (개소)", '671', '681', '698', '704', '714', '717'], + ["하수처리량 (백만m³/년)", '7,163.7', '7,140.4', '7,375.6', '7,335.8', '7,378.9', '7,537.2'], + ["재이용량 (백만m³/년)", '1,112.7', '1,148.7', '1,148.7', '1,127.0', '1,136.8', '1,137.5'], + ["재이용률 (%)", '17.4', '16.5', '15.5', '14.1', '14.3', '14.4'], + ], + }, + { + group: '법적 대상', + rows: [ + ["개소수 (개소)", '68', '82', '94', '97', '101', '118'], + ["하수처리량 (백만m³/년)", '761.2', '839.0', '935.3', '1,158.7', '1,220.6', '1,473.6'], + ["재이용량 (백만m³/년)", '162.2', '190.6', '218.5', '244.6', '247.2', '324.9'], + ["재이용률 (%)", '24.7', '25.4', '27.8', '25.7', '24.2', '24.1'], + ], + }, + { + group: '법적 미대상', + rows: [ + ["개소수 (개소)", '603', '599', '604', '607', '613', '599'], + ["하수처리량 (백만m³/년)", '6,402.5', '6,301.4', '6,440.3', '6,177.1', '6,158.3', '6,063.5'], + ["재이용량 (백만m³/년)", '950.6', '958.1', '926.2', '882.4', '889.5', '812.7'], + ["재이용률 (%)", '16.5', '15.3', '13.6', '12.2', '12.6', '12.5'], + ], + }, + ].map((section, sIdx) => ( + + {section.rows.map((row, rIdx) => ( + + {rIdx === 0 && ( + + )} + {row.map((val, i) => { + const isFirstCol = i === 0; + const isHighlightedCol = [1, 3, 5].includes(i); + return ( + + ))} + + ))} + +
+ 구분 + 201820192020202120222023
+ {section.group} + + ); + })} +
+
+
- -
-
+
-
+
- {/* 2) 용도별 하수처리수 재이용 현황 */} -
- 용도별 하수처리수 재이용 현황 -
-
- - - - - - - - - - - - - - - - - - - - - - - {[ - { label: "총 재이용량", type: "total", values: ["1,112.7", "1,148.7", "1,144.7", "1,127.0", "1,136.8", "1,137.5"] }, - { label: "장내 재이용", type: "category", values: ["520.6", "519.8", "513.9", "520.3", "502.2", "490.0"] }, - { label: "세척수", type: "red-item", values: ["177.9", "174.4", "169.4", "180.9", "196.8", "191.2"] }, - { label: "냉각수", type: "red-item", values: ["45.5", "46.8", "50.4", "40.6", "38.3", "41.7"] }, - { label: "청소수", type: "red-item", values: ["17.6", "21.2", "21.2", "21.7", "20.8", "20.5"] }, - { label: "조경용수", type: "red-item", values: ["0.1", "0.1", "-", "-", "-", "-"] }, - { label: "회생용수", type: "red-item", values: ["15.8", "16.7", "17.8", "15.7", "14.4", "14.0"] }, - { label: "장내급수도", type: "red-item", values: ["2.4", "-", "-", "-", "-", "-"] }, - { label: "장내기타용수", type: "red-item", values: ["261.3", "260.6", "255.0", "261.1", "231.8", "222.6"] }, - { label: "장외 재이용", type: "category", values: ["592.1", "628.9", "630.8", "606.7", "634.6", "647.5"] }, - { label: "청소·화장실용수", type: "blue-item", values: ["0.6", "0.4", "0.4", "0.4", "0.4", "0.4"] }, - { label: "세척·살수용수", type: "blue-item", values: ["5.6", "9.9", "2.9", "2.1", "3.0", "3.6"] }, - { label: "조경용수", type: "blue-item", values: ["10.3", "16.4", "5.9", "4.5", "8.9", "9.5"] }, - { label: "친수용수", type: "blue-item", values: ["7.8", "3.1", "3.1", "3.0", "10.9", "10.5"] }, - { label: "하천유지용수", type: "blue-item", values: ["480.5", "482.5", "496.2", "442.7", "471.7", "484.0"] }, - { label: "농업용수", type: "blue-item", values: ["12.2", "11.9", "13.4", "10.7", "10.6", "11.4"] }, - { label: "지하수 충전", type: "blue-item", values: ["0.0", "0.0", "0.0", "13.4", "0.0", "0.0"] }, - { label: "공업용수", type: "blue-item", values: ["75.2", "104.6", "108.9", "129.9", "129.0", "128.1"] }, - ].map((row, idx) => { - const isSubItem = row.type === "red-item" || row.type === "blue-item"; - const isLastInGroup = idx === 8 || idx === 17; + {/* 2) 용도별 하수처리수 재이용 현황 */} +
+ 용도별 하수처리수 재이용 현황 +
+
+
구분201820192020202120222023
+ + + + + + + + + + + + + + + + + + + + + + {[ + { label: '총 재이용량', type: 'total', values: ['1,112.7', '1,148.7', '1,144.7', '1,127.0', '1,136.8', '1,137.5'] }, + { label: '장내 재이용', type: 'category', values: ['520.6', '519.8', '513.9', '520.3', '502.2', '490.0'] }, + { label: '세척수', type: 'red-item', values: ['177.9', '174.4', '169.4', '180.9', '196.8', '191.2'] }, + { label: '냉각수', type: 'red-item', values: ['45.5', '46.8', '50.4', '40.6', '38.3', '41.7'] }, + { label: '청소수', type: 'red-item', values: ['17.6', '21.2', '21.2', '21.7', '20.8', '20.5'] }, + { label: '조경용수', type: 'red-item', values: ['0.1', '0.1', '-', '-', '-', '-'] }, + { label: '회생용수', type: 'red-item', values: ['15.8', '16.7', '17.8', '15.7', '14.4', '14.0'] }, + { label: '장내급수도', type: 'red-item', values: ['2.4', '-', '-', '-', '-', '-'] }, + { label: '장내기타용수', type: 'red-item', values: ['261.3', '260.6', '255.0', '261.1', '231.8', '222.6'] }, + { label: '장외 재이용', type: 'category', values: ['592.1', '628.9', '630.8', '606.7', '634.6', '647.5'] }, + { label: '청소·화장실용수', type: 'blue-item', values: ['0.6', '0.4', '0.4', '0.4', '0.4', '0.4'] }, + { label: '세척·살수용수', type: 'blue-item', values: ['5.6', '9.9', '2.9', '2.1', '3.0', '3.6'] }, + { label: '조경용수', type: 'blue-item', values: ['10.3', '16.4', '5.9', '4.5', '8.9', '9.5'] }, + { label: '친수용수', type: 'blue-item', values: ['7.8', '3.1', '3.1', '3.0', '10.9', '10.5'] }, + { label: '하천유지용수', type: 'blue-item', values: ['480.5', '482.5', '496.2', '442.7', '471.7', '484.0'] }, + { label: '농업용수', type: 'blue-item', values: ['12.2', '11.9', '13.4', '10.7', '10.6', '11.4'] }, + { label: '지하수 충전', type: 'blue-item', values: ['0.0', '0.0', '0.0', '13.4', '0.0', '0.0'] }, + { label: '공업용수', type: 'blue-item', values: ['75.2', '104.6', '108.9', '129.9', '129.0', '128.1'] }, + ].map((row, idx) => { + const isSubItem = row.type === 'red-item' || row.type === 'blue-item'; + const isLastInGroup = idx === 8 || idx === 17; - return ( - - - {row.values.map((val, i) => ( - + + {row.values.map((val, i) => ( + + ))} + + ); + })} + +
구분201820192020202120222023
- {isSubItem ? ( - <> -
-
- {row.label} - - ) : ( - {row.label} - )} -
+ return ( +
+ {isSubItem ? ( + <> +
+
+ {row.label} + + ) : ( + {row.label} + )} +
+ {val} +
+
+ +
+
+ +
+ + {/* 3) 하수처리시설 설치 및 이용 현황 */} +
+ 하수처리시설 설치 및 이용 현황 +
+
+ + + + + + + + + + + + + + + + + + + + + + + + {[ + { + group: '전체', + rows: [ + ["시설수 (개소)", '671', '681', '698', '704', '714', '717'], + ["시설용량 (만m³/일)", '25,846', '26,067', '26,244', '26,492', '26,586', '26,766'], + ["처리용량 (만m³/일)", '1,112.7', '1,148.7', '1,144.7', '1,127.0', '1,136.8', '1,137.5'], + ], + }, + { + group: '법적 대상', + rows: [ + ["시설수 (개소)", '68', '82', '94', '97', '101', '118'], + ["시설용량 (만m³/일)", '3,088.9', '3,359.2', '3,612.6', '4,412.2', '4,692.3', '5,503.8'], + ["처리용량 (만m³/일)", '162.2', '190.6', '218.5', '244.6', '247.2', '324.9'], + ], + }, + { + group: '법적 미대상', + rows: [ + ["시설수 (개소)", '603', '599', '604', '607', '613', '599'], + ["시설용량 (만m³/일)", '22,757', '22,708', '22,631', '22,080', '21,894', '21,262'], + ["처리용량 (만m³/일)", '950.6', '958.1', '926.2', '882.4', '889.5', '812.7'], + ], + }, + ].map((section, sIdx) => ( + + {section.rows.map((row, rIdx) => ( + + {rIdx === 0 && ( + + )} + {row.map((val, i) => { + const isFirstCol = i === 0; + const isHighlightedCol = [1, 3, 5].includes(i); + return ( + + ))} + + ))} + +
+ 구분 + 201820192020202120222023
+ {section.group} + + ); + })} +
+
+ +
+
+ +
+ + {/* 4) 시도별 하수처리수 재이용 현황 */} +
+ 시도별 하수처리수 재이용 현황 +
+
+
(단위: 백만m³/년)
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* 💡 하천유지용수는 14px, 나머지 하위 헤더는 16px로 적용 */} + + + + + + + + + {[ + ['합계', '7,537.2', '1,137.5', '490.0', '647.5', '484.0', '128.1', '11.4', '24.0', '15.1%', ''], + ['서울특별시', '1,567.0', '114.9', '59.3', '55.6', '55.1', '-', '-', '0.5', '7.3%', ''], + ['부산광역시', '491.5', '146.2', '120.7', '25.5', '25.3', '-', '-', '0.2', '29.7%', ''], + ['대구광역시', '432.7', '102.4', '38.2', '64.2', '64.1', '-', '-', '0.1', '23.7%', ''], + ['인천광역시', '302.3', '57.7', '11.6', '46.1', '29.2', '12.1', '0.5', '4.3', '19.1%', ''], + ['광주광역시', '211.1', '28.7', '6.5', '22.3', '22.2', '-', '-', '0.1', '13.6%', ''], + ['대전광역시', '244.6', '11.9', '11.9', '0.68', '0.0', '-', '-', '0.1', '4.9%', ''], + ['울산광역시', '235.1', '36.1', '6.1', '29.9', '29.7', '0.2', '-', '-', '15.3%', ''], + ['세종특별자치시', '38.5', '2.5', '2.0', '0.5', '0.4', '-', '0.2', '-', '6.6%', ''], + ['경기도', '1,926.8', '313.3', '122.2', '191.0', '153.0', '33.3', '0.4', '4.3', '16.3%', ''], + ['강원도', '230.4', '19.6', '15.4', '4.2', '2.1', '-', '-', '2.1', '8.5%', ''], + ['충청북도', '190.9', '15.6', '6.3', '9.2', '7.4', '0.6', '-', '1.2', '8.2%', ''], + ['충청남도', '241.5', '41.4', '9.8', '31.5', '17.7', '8.6', '4.5', '0.8', '17.1%', ''], + ['전라북도', '292.4', '42.7', '7.5', '35.1', '33.5', '0.2', '1.0', '0.5', '14.6%', ''], + ['전라남도', '254.0', '23.6', '12.3', '11.3', '8.7', '0.0', '1.0', '1.6', '9.3%', ''], + ['경상북도', '378.4', '129.1', '33.7', '95.3', '20.8', '72.5', '0.8', '1.3', '34.1%', ''], + ['경상남도', '411.3', '50.1', '25.0', '25.0', '14.7', '0.5', '3.0', '6.9', '12.2%', ''], + ['제주특별자치도', '88.6', '1.7', '1.5', '0.12', '-', '-', '0.1', '-', '1.9%', ''], + ].map((row, idx) => ( + + {/* 💡 연간하수처리량(index 1), 장내(index 3), 재이용률(index 9) 컬러 하이라이트 적용 */} + {row.map((val, i) => ( + ))} - ); - })} - -
+ 시도 + + 연간 +
+ 하수 +
+ 처리량 +
+ 재이용현황 + + 재이용률 +
+ (%) +
+ 비고 +
+ 계 + + 장내 + + 장외 +
소계하천유지용수공업용수농업용수기타용수
{val}
+ ))} +
+
+
- -
-
- -
- - {/* 3) 하수처리시설 설치 및 이용 현황 */} -
- 하수처리시설 설치 및 이용 현황 -
-
- - - - - - - - - - - - - - - - - - - - - - - - {[ - { - group: "전체", - rows: [ - ["시설수 (개소)", "671", "681", "698", "704", "714", "717"], - ["시설용량 (만m³/일)", "25,846", "26,067", "26,244", "26,492", "26,586", "26,766"], - ["처리용량 (만m³/일)", "1,112.7", "1,148.7", "1,144.7", "1,127.0", "1,136.8", "1,137.5"], - ] - }, - { - group: "법적 대상", - rows: [ - ["시설수 (개소)", "68", "82", "94", "97", "101", "118"], - ["시설용량 (만m³/일)", "3,088.9", "3,359.2", "3,612.6", "4,412.2", "4,692.3", "5,503.8"], - ["처리용량 (만m³/일)", "162.2", "190.6", "218.5", "244.6", "247.2", "324.9"], - ] - }, - { - group: "법적 미대상", - rows: [ - ["시설수 (개소)", "603", "599", "604", "607", "613", "599"], - ["시설용량 (만m³/일)", "22,757", "22,708", "22,631", "22,080", "21,894", "21,262"], - ["처리용량 (만m³/일)", "950.6", "958.1", "926.2", "882.4", "889.5", "812.7"], - ] - } - ].map((section, sIdx) => ( - - {section.rows.map((row, rIdx) => ( - - {rIdx === 0 && ( - - )} - {row.map((val, i) => { - const isFirstCol = i === 0; - const isHighlightedCol = [1, 3, 5].includes(i); - return ( - - ))} - - ))} - -
구분201820192020202120222023
- {section.group} - - ); - })} -
-
- -
-
- -
- - {/* 4) 시도별 하수처리수 재이용 현황 */} -
- 시도별 하수처리수 재이용 현황 -
-
-
(단위: 백만m³/일)
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - {/* 💡 하천유지용수는 14px, 나머지 하위 헤더는 16px로 적용 */} - - - - - - - - - {[ - ["합계", "7,537.2", "1,137.5", "490.0", "647.5", "484.0", "128.1", "11.4", "24.0", "15.1%", ""], - ["서울특별시", "1,567.0", "114.9", "59.3", "55.6", "55.1", "-", "-", "0.5", "7.3%", ""], - ["부산광역시", "491.5", "146.2", "120.7", "25.5", "25.3", "-", "-", "0.2", "29.7%", ""], - ["대구광역시", "432.7", "102.4", "38.2", "64.2", "64.1", "-", "-", "0.1", "23.7%", ""], - ["인천광역시", "302.3", "57.7", "11.6", "46.1", "29.2", "12.1", "0.5", "4.3", "19.1%", ""], - ["광주광역시", "211.1", "28.7", "6.5", "22.3", "22.2", "-", "-", "0.1", "13.6%", ""], - ["대전광역시", "244.6", "11.9", "11.9", "0.68", "0.0", "-", "-", "0.1", "4.9%", ""], - ["울산광역시", "235.1", "36.1", "6.1", "29.9", "29.7", "0.2", "-", "-", "15.3%", ""], - ["세종특별자치시", "38.5", "2.5", "2.0", "0.5", "0.4", "-", "0.2", "-", "6.6%", ""], - ["경기도", "1,926.8", "313.3", "122.2", "191.0", "153.0", "33.3", "0.4", "4.3", "16.3%", ""], - ["강원도", "230.4", "19.6", "15.4", "4.2", "2.1", "-", "-", "2.1", "8.5%", ""], - ["충청북도", "190.9", "15.6", "6.3", "9.2", "7.4", "0.6", "-", "1.2", "8.2%", ""], - ["충청남도", "241.5", "41.4", "9.8", "31.5", "17.7", "8.6", "4.5", "0.8", "17.1%", ""], - ["전라북도", "292.4", "42.7", "7.5", "35.1", "33.5", "0.2", "1.0", "0.5", "14.6%", ""], - ["전라남도", "254.0", "23.6", "12.3", "11.3", "8.7", "0.0", "1.0", "1.6", "9.3%", ""], - ["경상북도", "378.4", "129.1", "33.7", "95.3", "20.8", "72.5", "0.8", "1.3", "34.1%", ""], - ["경상남도", "411.3", "50.1", "25.0", "25.0", "14.7", "0.5", "3.0", "6.9", "12.2%", ""], - ["제주특별자치도", "88.6", "1.7", "1.5", "0.12", "-", "-", "0.1", "-", "1.9%", ""], - ].map((row, idx) => ( - - {/* 💡 연간하수처리량(index 1), 장내(index 3), 재이용률(index 9) 컬러 하이라이트 적용 */} - {row.map((val, i) => ( - - ))} - - ))} - -
시도연간
하수
처리량
재이용현황재이용률
(%)
비고
계장내장외
소계하천유지용수공업용수농업용수기타용수
- {val} -
-
- -
-
- +
); -} \ No newline at end of file +} diff --git a/apps/dm/components/LoginModal.tsx b/apps/dm/components/LoginModal.tsx index 086deedff..292718977 100644 --- a/apps/dm/components/LoginModal.tsx +++ b/apps/dm/components/LoginModal.tsx @@ -20,9 +20,24 @@ type FieldKey = "bizNo" | "workerNo" | "pw"; const REGISTER_HREF = "/apply/dmdco"; function isBizNoValid(v: string) { - return /^\d{10}$/.test(v); + return /^\d{10}$/.test(v.replace(/\D/g, "")); } +const getBusinessNumberDigits = (value: string) => + value.replace(/\D/g, "").slice(0, 10); + +const formatBusinessNumber = (value: string) => { + const normalized = value.replace(/[^\d-]/g, "").slice(0, 12); + const digits = getBusinessNumberDigits(normalized); + + if (normalized.endsWith("-") && digits.length === 3) return `${digits}-`; + if (normalized.endsWith("-") && digits.length === 5) + return `${digits.slice(0, 3)}-${digits.slice(3)}-`; + if (digits.length <= 3) return digits; + if (digits.length <= 5) return `${digits.slice(0, 3)}-${digits.slice(3)}`; + return `${digits.slice(0, 3)}-${digits.slice(3, 5)}-${digits.slice(5)}`; +}; + function inputCls(opts: { hasError?: boolean; disabled?: boolean }) { const base = "h-[48px] w-full rounded-[4px] border px-5 text-[18px] outline-none"; @@ -102,7 +117,7 @@ export default function LoginModal({ const validate = () => { const next: Partial> = {}; - const b = bizNo.trim(); + const b = getBusinessNumberDigits(bizNo); const w = workerNo.trim(); const p = pw.trim(); @@ -122,23 +137,6 @@ export default function LoginModal({ return next; }; - const formatBusinessNumber = (value: string) => { - // 숫자만 추출 - const digitsOnly = value.replace(/\D/g, ""); - - // 10자리까지만 처리 - const limitedDigits = digitsOnly.slice(0, 10); - - // 3-2-5 형식으로 포맷팅 - if (limitedDigits.length <= 3) { - return limitedDigits; - } else if (limitedDigits.length <= 5) { - return `${limitedDigits.slice(0, 3)}-${limitedDigits.slice(3)}`; - } else { - return `${limitedDigits.slice(0, 3)}-${limitedDigits.slice(3, 5)}-${limitedDigits.slice(5)}`; - } - }; - const markTouched = (key: FieldKey) => setTouched((p) => ({ ...p, [key]: true })); @@ -154,8 +152,9 @@ export default function LoginModal({ try { setSubmitting(true); + const businessNumber = getBusinessNumberDigits(bizNo); const param = { - dmdInfoId: bizNo + (noWorker ? "0000" : workerNo), // 종사업자번호 해당없음 "0000"으로 채움 + dmdInfoId: businessNumber + (noWorker ? "0000" : workerNo), // 종사업자번호 해당없음 "0000"으로 채움 dmdAplyPswd: sha512.hex(pw), }; await actionLogin(param as DemandCompType); @@ -227,9 +226,9 @@ export default function LoginModal({ 사업자 등록번호 { - setBizNo(e.target.value.replace(/\D/g, "").slice(0, 10)); + setBizNo(formatBusinessNumber(e.target.value)); setErrors((p) => ({ ...p, bizNo: undefined })); }} onBlur={() => { diff --git a/apps/dm/public/images/landing/intro/domestic_textbox.png b/apps/dm/public/images/landing/intro/domestic_textbox.png index 046af8127..3f302f661 100644 Binary files a/apps/dm/public/images/landing/intro/domestic_textbox.png and b/apps/dm/public/images/landing/intro/domestic_textbox.png differ diff --git a/apps/dm/public/images/landing/intro/greywater_mandatory.png b/apps/dm/public/images/landing/intro/greywater_mandatory.png index f3dc58ab6..4476f1245 100644 Binary files a/apps/dm/public/images/landing/intro/greywater_mandatory.png and b/apps/dm/public/images/landing/intro/greywater_mandatory.png differ diff --git a/apps/dm/public/images/landing/intro/greywater_standard.png b/apps/dm/public/images/landing/intro/greywater_standard.png index d544a1fc7..52fcb0518 100644 Binary files a/apps/dm/public/images/landing/intro/greywater_standard.png and b/apps/dm/public/images/landing/intro/greywater_standard.png differ diff --git a/apps/dm/public/images/landing/intro/law_timeline.png b/apps/dm/public/images/landing/intro/law_timeline.png index 56ff1c347..f4e0a1417 100644 Binary files a/apps/dm/public/images/landing/intro/law_timeline.png and b/apps/dm/public/images/landing/intro/law_timeline.png differ diff --git a/apps/dm/public/images/landing/intro/linkage.png b/apps/dm/public/images/landing/intro/linkage.png index a7595a598..91f98fa98 100644 Binary files a/apps/dm/public/images/landing/intro/linkage.png and b/apps/dm/public/images/landing/intro/linkage.png differ diff --git a/apps/dm/public/images/landing/intro/performance_left.png b/apps/dm/public/images/landing/intro/performance_left.png index b85bdd920..4bdd1e836 100644 Binary files a/apps/dm/public/images/landing/intro/performance_left.png and b/apps/dm/public/images/landing/intro/performance_left.png differ diff --git a/apps/dm/public/images/landing/intro/policy_timeline.png b/apps/dm/public/images/landing/intro/policy_timeline.png index 296bad776..744ec87c9 100644 Binary files a/apps/dm/public/images/landing/intro/policy_timeline.png and b/apps/dm/public/images/landing/intro/policy_timeline.png differ diff --git a/apps/dm/public/images/landing/intro/rainwater_design.png b/apps/dm/public/images/landing/intro/rainwater_design.png index 714f506f3..5bff2e747 100644 Binary files a/apps/dm/public/images/landing/intro/rainwater_design.png and b/apps/dm/public/images/landing/intro/rainwater_design.png differ diff --git a/apps/dm/public/images/landing/intro/rel_note.png b/apps/dm/public/images/landing/intro/rel_note.png index 94880c743..5f00b677e 100644 Binary files a/apps/dm/public/images/landing/intro/rel_note.png and b/apps/dm/public/images/landing/intro/rel_note.png differ diff --git a/apps/dm/public/images/landing/intro/treatedwater_mandatory.png b/apps/dm/public/images/landing/intro/treatedwater_mandatory.png index 84704c6ec..617bf1391 100644 Binary files a/apps/dm/public/images/landing/intro/treatedwater_mandatory.png and b/apps/dm/public/images/landing/intro/treatedwater_mandatory.png differ diff --git a/apps/sn/.env.local b/apps/sn/.env.local index c603cdbb6..dce96bf40 100644 --- a/apps/sn/.env.local +++ b/apps/sn/.env.local @@ -18,4 +18,5 @@ 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 = /auth/login # NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.66:8883 -NEXT_PUBLIC_COOKIE_COMAIN=.mind-one.co.kr \ No newline at end of file +NEXT_PUBLIC_COOKIE_COMAIN=.mind-one.co.kr +NEXT_PUBLIC_NODE_ENV = "development" \ No newline at end of file diff --git a/apps/sn/.env.mindone b/apps/sn/.env.mindone index da2d0fd5c..84202b57b 100644 --- a/apps/sn/.env.mindone +++ b/apps/sn/.env.mindone @@ -14,4 +14,5 @@ NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&ver SEWERAGE_API_URL = http://con.mind-one.co.kr:11083 DC_URL = https://apigateway.apps.rsonesoft.com NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /auth/login -NEXT_PUBLIC_COOKIE_COMAIN=.mind-one.co.kr \ No newline at end of file +NEXT_PUBLIC_COOKIE_COMAIN=.mind-one.co.kr +NEXT_PUBLIC_NODE_ENV = "development" \ No newline at end of file diff --git a/apps/sn/AGENTS.md b/apps/sn/AGENTS.md index 291eb61d6..3c369a72d 100644 --- a/apps/sn/AGENTS.md +++ b/apps/sn/AGENTS.md @@ -101,7 +101,7 @@ Codex 작업 루트는 가능하면 `../../../../../`로 잡고, 공통 시작 ## 작업 원칙 -1. 모든 변경은 2차 요구사항, 화면설계, 기능 명세 또는 승인된 작업 문서를 근거로 한다. +1. 모든 변경은 원본 요구사항정의서와 화면설계서, 2차 요구사항, 기능 명세 또는 승인된 작업 문서를 근거로 한다. 2. `apps/sn`과 `packages/common/business_components` 범위를 넘는 수정은 하지 않는다. 3. 공통 패키지 수정은 `sn` 요구사항을 충족하기 위한 최소 범위로 제한한다. 4. 다른 앱 전용 로직을 `business_components`에 넣지 않는다. @@ -117,6 +117,18 @@ Codex 작업 루트는 가능하면 `../../../../../`로 잡고, 공통 시작 14. `BaseLayout`, `NoFooterLayout`, `PageLayout`, `SearchDiv`, `SearchFormLeft/Right`, `ListDiv`, `UiTable`, `UiModal`, `Gis` 조합을 우선 재사용한다. 15. 새 UI 구조가 꼭 필요하면 기존 패턴으로 해결되지 않는 이유를 먼저 설명한다. +## 원본 요구사항 사전 확인 + +- 모든 `sn` 화면 설계, 구현, 수정, 검증과 문서 작업은 시작 전에 아래 원본 파일에서 관련 범위를 직접 확인한다. + - `COMMON_DOCS_ROOT/기준문서/화면설계서.pptx` + - `COMMON_DOCS_ROOT/기준문서/요구사항정의서.xlsx` +- 화면설계서의 관련 슬라이드, 화면 ID, 입력항목, 버튼, 표 컬럼과 사용자 흐름을 확인한다. +- 요구사항정의서의 관련 시트, 요구사항 ID, 기능 목적과 완료 조건을 함께 대조한다. +- `de`나 메인 에이전트의 요약, 기존 작업계획 또는 현재 화면만으로 원본 확인을 대체하지 않는다. +- 확인한 요구사항 ID, 화면 ID와 슬라이드를 작업계획 또는 결과 보고에 남긴다. +- 원본에 없는 화면 동작이나 데이터 항목은 임의로 추가하지 않고 `설계 제안` 또는 `확인 필요`로 구분한다. +- 상세 절차는 `COMMON_DOCS_ROOT/기준문서/012-원본요구사항사전확인.md`를 따른다. + --- ## 허용 작업 @@ -181,6 +193,15 @@ Codex 작업 루트는 가능하면 `../../../../../`로 잡고, 공통 시작 VSCode나 별도 터미널에서 `sn` Next 개발 서버가 실행 중이면 `next build`, `pnpm --filter sn run build`, `pnpm turbo run build --filter=sn` 같은 빌드 명령을 실행하지 않는다. 개발 서버와 빌드가 동시에 `.next` 산출물을 갱신하면 개발 서버가 종료되거나 꼬일 수 있으므로, 빌드 검증은 사용자에게 확인한 뒤 개발 서버를 끄고 수행한다. +### 프론트엔드 실행 프로세스 관리 + +- Codex 에이전트가 작업 또는 검증을 위해 직접 기동한 `next dev`, `dev:sn`, Turborepo dev와 기타 프론트엔드 장시간 실행 프로세스는 작업 종료 전에 반드시 종료한다. +- 셸, 백그라운드, 숨김 창 등 VSCode 밖에서 에이전트가 기동한 프로세스도 종료 대상이다. +- 사용자가 VSCode 또는 별도 터미널에서 직접 실행한 프론트엔드 개발 서버는 임의로 종료·재시작·교체하지 않는다. 종료가 필요하면 먼저 사용자에게 확인한다. +- 기동 시 PID와 포트를 기록하고, 종료 시에는 에이전트가 기동한 정확한 PID와 하위 프로세스 트리만 종료한다. +- 사용자가 계속 실행해 두라고 명시한 경우에는 종료하지 않고 최종 보고에 PID, 포트와 종료 방법을 남긴다. +- 최종 보고 전에 에이전트가 사용한 프론트엔드 포트 해제와 임시 로그 파일 핸들 종료를 확인한다. + 이 저장소는 `main` 브랜치 push 전에 `.husky/pre-push`에서 아래 빌드를 수행한다. ```sh diff --git a/apps/sn/app/(auth)/auth/layout.tsx b/apps/sn/app/(auth)/auth/layout.tsx index 42f63e71a..2b4eb89a8 100644 --- a/apps/sn/app/(auth)/auth/layout.tsx +++ b/apps/sn/app/(auth)/auth/layout.tsx @@ -9,9 +9,5 @@ export default function AuthLayout({ }: { children: React.ReactNode; }) { - return ( - - {children} - - ); + return children; } diff --git a/apps/sn/app/(map-layout)/map/components/InputSection.tsx b/apps/sn/app/(map-layout)/map/components/InputSection.tsx index 1d45103a1..68c4a3a83 100644 --- a/apps/sn/app/(map-layout)/map/components/InputSection.tsx +++ b/apps/sn/app/(map-layout)/map/components/InputSection.tsx @@ -1,49 +1,78 @@ -import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@common/components/ui"; +import { + Table, + TableHeader, + TableRow, + TableHead, + TableBody, + TableCell, +} from "@common/components/ui"; import React from "react"; interface InputSectionProps { title: string; - data: { year: string; type: string, bizInfoNm: string, bizType: string }[]; + data: { year: string; type: string; bizInfoNm: string; bizType: string }[]; sectionKey: string; - onInputChange: (section: string, index: number, field: string, value: string) => void; + onInputChange: ( + section: string, + index: number, + field: string, + value: string, + ) => void; } -const InputSection: React.FC = ({ title, data, sectionKey, onInputChange }) => { +const InputSection: React.FC = ({ + title, + data, + sectionKey, + onInputChange, +}) => { return (
{/* 섹션 제목 */}
-

{title}

+

+ {title} +

{/* 테이블 */} -
+
- 구분 - 진단연도 - 사업명 - 유형 + + 구분 + + + 진단연도 + + + 사업명 + + + 유형 + {data.map((row, index) => ( - + {row.type} - + onInputChange(sectionKey, index, "year", e.target.value)} - className="w-full text-xl border-radius-0 px-3 py-2 text-base text-center leading-normal" + onChange={(e) => + onInputChange(sectionKey, index, "year", e.target.value) + } + className="border-radius-0 w-full px-3 py-2 text-center text-base text-xl leading-normal" /> - + {row.bizInfoNm} - + {row.bizType} @@ -55,4 +84,4 @@ const InputSection: React.FC = ({ title, data, sectionKey, on ); }; -export default InputSection; \ No newline at end of file +export default InputSection; diff --git a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipeLayerSettingPanel.tsx b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipeLayerSettingPanel.tsx index c4c3db13c..9a6174db1 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipeLayerSettingPanel.tsx +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipeLayerSettingPanel.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState } from "react"; +import { useAlertStore } from "@/shared/lib"; import { validateElapsedYears, type AgedPipeFilters } from "./agedPipeUtils"; type MaybePromise = T | Promise; @@ -18,15 +19,19 @@ export default function AgedPipeLayerSettingPanel({ onApply, compact = false, }: Props) { + const alert = useAlertStore((state) => state.setMessage); const [isCollapsed, setIsCollapsed] = useState(false); - const [elapsedYearsText, setElapsedYearsText] = useState(String(filters.elapsedYears ?? "")); + const [elapsedYearsText, setElapsedYearsText] = useState( + String(filters.elapsedYears ?? ""), + ); useEffect(() => { setElapsedYearsText(String(filters.elapsedYears ?? "")); }, [filters.elapsedYears]); const commitElapsedYears = async () => { - const elapsedYears = elapsedYearsText === "" ? NaN : Number(elapsedYearsText); + const elapsedYears = + elapsedYearsText === "" ? NaN : Number(elapsedYearsText); if (!validateElapsedYears(elapsedYears)) { alert("경과년도는 1년~100년 범위 내에서 입력해주세요."); return; @@ -41,16 +46,22 @@ export default function AgedPipeLayerSettingPanel({ alert(`노후관로 경과년도 ${elapsedYears}년으로 반영되었습니다.`); }; - const inputClass = "h-[22px] border border-[#ccd6df] px-[2px] text-center text-[1rem] leading-none text-[#222]"; + const inputClass = + "h-[22px] border border-input bg-background px-[2px] text-center text-[1rem] leading-none text-foreground"; const numberInputClass = inputClass + " w-[40px]"; - const applyButtonClass = "grid h-[20px] w-[20px] shrink-0 place-items-center rounded-full bg-[#31a83b] text-[0.95rem] font-bold leading-none text-white"; - const indicator = ; + const applyButtonClass = + "grid h-[20px] w-[20px] shrink-0 place-items-center rounded-full bg-primary text-[0.95rem] font-bold leading-none text-primary-foreground"; + const indicator = ( + + ); const settingRow = ( -
+
{indicator} - +
+
{settingRow}
); @@ -93,23 +104,21 @@ export default function AgedPipeLayerSettingPanel({ return (
-
+
- 노후관로 설정 + + 노후관로 설정 +
- {!isCollapsed && ( -
- {settingRow} -
- )} + {!isCollapsed &&
{settingRow}
}
); } diff --git a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx index c900fe2c2..e5a180fbc 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx @@ -3,8 +3,9 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useSearchParams } from "next/navigation"; import { Button, Input } from "@common/components"; -import { UiTable } from "@common/business_components/ui"; +import { StandardPopupLayout } from "@common/business_components"; import { getData } from "@/components/lib/api"; +import { useAlertStore } from "@/shared/lib"; import useStore from "@/store/useStore"; import LoadingOverlay from "@/components/ui/LoadingBar"; import { @@ -20,17 +21,23 @@ import { type AgedPipeRow, } from "./agedPipeUtils"; import { downloadAgedPipeCsv } from "./agedPipeExport"; +import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable"; +import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel"; 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 alert = useAlertStore((state) => state.setMessage); const searchParams = useSearchParams(); const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore(); const [filters, setFilters] = useState(() => normalizeAgedPipeFilters({ - elapsedYears: Number(searchParams.get("elapsedYears") ?? AGED_PIPE_DEFAULT_FILTERS.elapsedYears), + 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, }), @@ -38,7 +45,6 @@ export default function AgedPipePopupPage() { 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 [downloadProgressText, setDownloadProgressText] = useState(""); @@ -46,7 +52,9 @@ export default function AgedPipePopupPage() { () => filters.ctpvCd === "0" ? sgg - : sgg.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2))), + : sgg.filter((item) => + String(item.val).startsWith(filters.ctpvCd.slice(0, 2)), + ), [filters.ctpvCd, sgg], ); @@ -62,7 +70,6 @@ export default function AgedPipePopupPage() { lyrTypeNm: AGED_PIPE_LAYER_TYPE, page: String(page - 1), size: String(size), - sort: "string", }); const cqlFilters = buildAgedPipeCqlFilters(normalized); if (cqlFilters.length > 0) { @@ -72,12 +79,13 @@ export default function AgedPipePopupPage() { const response = await getData(`v1/fclts?${queryParams.toString()}`); const responseRows = Array.isArray(response?.data) ? response.data : []; - return { - rows: responseRows.map((item: Record, index: number) => + const total = Number(response?.count ?? 0); + const rows = responseRows.map( + (item: Record, index: number) => toDisplayPipeRow(item, index, page, ctpv, sgg), - ), - total: Number(response?.count ?? 0), - }; + ); + + return { rows, total }; }, [ctpv, sgg], ); @@ -86,7 +94,11 @@ export default function AgedPipePopupPage() { async (page = 1, nextFilters = filters) => { setIsLoading(true); try { - const response = await fetchRows(page, nextFilters, AGED_PIPE_PAGE_SIZE); + const response = await fetchRows( + page, + nextFilters, + AGED_PIPE_PAGE_SIZE, + ); setRows(response.rows); setTotal(response.total); setCurrentPage(page); @@ -113,9 +125,10 @@ export default function AgedPipePopupPage() { if (event.data?.type !== POPUP_FILTER_SYNC_MESSAGE) return; if (!event.data?.filters) return; - const normalized = normalizeAgedPipeFilters(event.data.filters as AgedPipeFilters); + const normalized = normalizeAgedPipeFilters( + event.data.filters as AgedPipeFilters, + ); setFilters(normalized); - setSelectedIndex(-1); loadRows(1, normalized); }; @@ -134,12 +147,7 @@ export default function AgedPipePopupPage() { await loadRows(page, normalized); }; - const handleRowClick = (index?: number) => { - if (typeof index !== "number") return; - const row = rows[index]; - if (!row) return; - - setSelectedIndex(index); + const handleRowClick = (row: AgedPipeRow) => { window.opener?.postMessage( { type: POPUP_ROW_SELECT_MESSAGE, @@ -196,181 +204,133 @@ export default function AgedPipePopupPage() { window.close(); }; - const getCellClassName = (_row: AgedPipeRow, headId: string) => { - if (headId === "mqltCd") { - return "break-all whitespace-normal"; - } - - return "whitespace-normal"; - }; - return ( -
+ + 최소화 + + } + > -
-

노후관로 현황

-
- -
-
{downloadProgressText ? ( -
+
{downloadProgressText}
) : null} -
-
- - - - - - -
- - -
-
- -
- - headName={<>노후관로 조회 결과 총 {total.toLocaleString()}건} - tableData={rows} - headlist={AGED_PIPE_LIST_HEADLIST} - pageSize={10} - total={total} - cellClick={(index) => handleRowClick(index)} - onPageChange={(page) => void handleSearch(page)} - cellClassName={getCellClassName} - curPage={currentPage} - index={selectedIndex} - widthFixed={true} - isHideCount={true} - className="!m-0 overflow-x-hidden" + + + + + + + +
+ +
-
- -
+ /> + ); } diff --git a/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts b/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts index 8419e929c..736bb9478 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts @@ -54,7 +54,7 @@ const normalizeRegionFilterCode = (value: unknown, prefixLength: number) => { }; export const AGED_PIPE_LIST_HEADLIST = [ - { id: "rowNumber", title: "NO", widthPercent: 6 }, + { id: "rowNumber", title: "순번", widthPercent: 6 }, { id: "fcltMngNo", title: "시설관리번호", widthPercent: 18 }, { id: "instlYmd", title: "설치일자", widthPercent: 12 }, { id: "pdmtVl", title: "관경(mm)", widthPercent: 10 }, diff --git a/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/AgedPipeStatusByCtpvPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/AgedPipeStatusByCtpvPopupPage.tsx new file mode 100644 index 000000000..e9ba16d7b --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/AgedPipeStatusByCtpvPopupPage.tsx @@ -0,0 +1,249 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Button, Input } from "@common/components"; +import { StandardPopupLayout } from "@common/business_components"; +import { getRawJson } from "@/components/lib/api"; +import LoadingOverlay from "@/components/ui/LoadingBar"; +import { useAlertStore } from "@/shared/lib"; +import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefinitions"; +import { + AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS, + buildAgedPipeStatusByCtpvHeadList, + buildAgedPipeStatusYearOptions, + normalizeAgedPipeStatusByCtpvResponse, + normalizeAgedPipeStatusByCtpvFilters, + toAgedPipeStatusByCtpvRows, + validateAgedPipeStatusByCtpvFilters, + type AgedPipeStatusByCtpvFilters, + type AgedPipeStatusByCtpvResponse, + type AgedPipeStatusByCtpvRow, +} from "./agedPipeStatusByCtpvUtils"; +import { downloadAgedPipeStatusByCtpvCsv } from "./agedPipeStatusByCtpvExport"; +import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable"; +import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel"; + +// CS 목록과 동일하게 한 페이지에는 최대 10개 시도만 표시한다. +const AGED_PIPE_STATUS_PAGE_SIZE = 10; + +export default function AgedPipeStatusByCtpvPopupPage() { + const alert = useAlertStore((state) => state.setMessage); + // 조회조건 입력 상태다. 기본값 30년, 최근 연도 기준 단년 조회로 시작한다. + const [filters, setFilters] = useState(() => + normalizeAgedPipeStatusByCtpvFilters({ + elapsedYears: AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS, + }), + ); + // 서버 응답을 화면 그리드에 맞게 평탄화한 row 목록이다. + const [rows, setRows] = useState([]); + // 동적 컬럼 생성을 위한 기준년도 목록이다. + const [years, setYears] = useState([]); + // 조회/다운로드 중 로딩 overlay를 제어한다. + const [isLoading, setIsLoading] = useState(false); + // 사용자에게 현재 다운로드 상태를 보여주는 진행 문구다. + const [downloadProgressText, setDownloadProgressText] = useState(""); + const [currentPage, setCurrentPage] = useState(1); + + const yearOptions = useMemo(() => buildAgedPipeStatusYearOptions(), []); + const headList = useMemo( + () => [ + { id: "rowNumber", title: "순번", widthPercent: 7 }, + ...buildAgedPipeStatusByCtpvHeadList(years), + ], + [years], + ); + const pageRows = useMemo( + () => + rows.slice( + (currentPage - 1) * AGED_PIPE_STATUS_PAGE_SIZE, + currentPage * AGED_PIPE_STATUS_PAGE_SIZE, + ), + [currentPage, rows], + ); + + const loadRows = useCallback( + async (nextFilters: AgedPipeStatusByCtpvFilters) => { + setIsLoading(true); + try { + const normalized = normalizeAgedPipeStatusByCtpvFilters(nextFilters); + const queryParams = new URLSearchParams({ + startYear: String(normalized.startYear), + endYear: String(normalized.endYear), + elapsedYears: String(normalized.elapsedYears), + }); + const rawResponse = await getRawJson( + `v1/swagePpl/aged-status-by-ctpv?${queryParams.toString()}`, + ); + const response = normalizeAgedPipeStatusByCtpvResponse( + rawResponse, + ) as AgedPipeStatusByCtpvResponse; + setYears(Array.isArray(response?.years) ? response.years : []); + setRows(toAgedPipeStatusByCtpvRows(response)); + setCurrentPage(1); + } catch (error) { + console.error("시도별 노후관로현황 조회 중 오류:", error); + setYears([]); + setRows([]); + } finally { + setIsLoading(false); + } + }, + [], + ); + + useEffect(() => { + void loadRows(filters); + }, [filters, loadRows]); + + const handleSearch = async () => { + const normalized = normalizeAgedPipeStatusByCtpvFilters(filters); + if (!validateAgedPipeStatusByCtpvFilters(normalized)) { + alert( + "기준년도는 최대 3년 범위 내에서 선택하고, 경과년도는 1~100 사이로 입력해주세요.", + ); + return; + } + setFilters(normalized); + await loadRows(normalized); + }; + + const handleDownload = async () => { + try { + setIsLoading(true); + setDownloadProgressText("시도별 노후관로현황 CSV를 준비하고 있습니다."); + await downloadAgedPipeStatusByCtpvCsv(filters); + setDownloadProgressText(""); + } catch (error) { + console.error("시도별 노후관로현황 다운로드 중 오류:", error); + setDownloadProgressText(""); + alert( + error instanceof Error + ? error.message + : "시도별 노후관로현황 CSV 다운로드 중 오류가 발생했습니다.", + ); + } finally { + setIsLoading(false); + } + }; + + return ( + + + + {downloadProgressText ? ( +
+ {downloadProgressText} +
+ ) : null} + + + + + + + + +
+ + +
+
+ + > + } + headlist={headList} + title="시도별 노후관로 목록" + total={rows.length} + page={currentPage} + pageSize={AGED_PIPE_STATUS_PAGE_SIZE} + onPageChange={setCurrentPage} + scrollMode={years.length > 2 ? "horizontal" : "none"} + minTableWidth={years.length > 2 ? "120rem" : "100%"} + actions={ + + } + /> +
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvExport.ts b/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvExport.ts new file mode 100644 index 000000000..df5dbf0d0 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvExport.ts @@ -0,0 +1,25 @@ +"use client"; + +import { postBlobData } from "@/components/lib/api"; +import { fileDownload } from "@/components/lib/utils/fileDownload"; +import { + normalizeAgedPipeStatusByCtpvFilters, + type AgedPipeStatusByCtpvFilters, +} from "./agedPipeStatusByCtpvUtils"; + +/** 시도별 노후관로현황 CSV 다운로드를 요청한다. */ +export const downloadAgedPipeStatusByCtpvCsv = async ( + filters: AgedPipeStatusByCtpvFilters, +) => { + const normalizedFilters = normalizeAgedPipeStatusByCtpvFilters(filters); + const { blob, fileName } = await postBlobData("v1/swagePpl/aged-status-by-ctpv/exportJobs/download", { + startYear: normalizedFilters.startYear, + endYear: normalizedFilters.endYear, + elapsedYears: normalizedFilters.elapsedYears, + format: "csv", + }); + + const resolvedFileName = fileName || "시도별_노후관로현황.csv"; + fileDownload(blob, resolvedFileName); + return resolvedFileName; +}; diff --git a/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvUtils.ts b/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvUtils.ts new file mode 100644 index 000000000..6a01cc9de --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvUtils.ts @@ -0,0 +1,176 @@ +"use client"; + +import type { UiHeadProps } from "@common/business_components/ui"; + +export const AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS = 30; +export const AGED_PIPE_STATUS_BY_CTPV_MAX_YEAR_SPAN = 3; +export const AGED_PIPE_STATUS_BY_CTPV_PAGE_SIZE = 50; + +export type AgedPipeStatusByCtpvFilters = { + startYear: number; + endYear: number; + elapsedYears: number; +}; + +export type AgedPipeStatusByCtpvYearStat = { + year: number; + totalLengthKm: number; + agedLengthKm: number; + agedRate: number; +}; + +export type AgedPipeStatusByCtpvRow = { + rowNumber: number; + ctpvCd: string; + ctpvNm: string; + yearStats: AgedPipeStatusByCtpvYearStat[]; + [key: string]: string | number | AgedPipeStatusByCtpvYearStat[]; +}; + +export type AgedPipeStatusByCtpvResponse = { + startYear: number; + endYear: number; + elapsedYears: number; + years: number[]; + rows: Array<{ + ctpvCd: string; + ctpvNm: string; + yearStats: AgedPipeStatusByCtpvYearStat[]; + }>; +}; + +export const normalizeAgedPipeStatusByCtpvResponse = ( + response: Partial | null | undefined, +): AgedPipeStatusByCtpvResponse => ({ + startYear: Number(response?.startYear ?? 0), + endYear: Number(response?.endYear ?? 0), + elapsedYears: Number( + response?.elapsedYears ?? AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS, + ), + years: Array.isArray(response?.years) + ? response.years.map((year) => Number(year)) + : [], + rows: Array.isArray(response?.rows) ? response.rows : [], +}); + +export const buildAgedPipeStatusYearOptions = () => { + const currentYear = new Date().getFullYear(); + return Array.from( + { length: currentYear - 1900 + 1 }, + (_, index) => currentYear - index, + ); +}; + +export const normalizeAgedPipeStatusByCtpvFilters = ( + filters: Partial, +): AgedPipeStatusByCtpvFilters => { + const currentYear = new Date().getFullYear(); + let startYear = Math.trunc(Number(filters.startYear ?? currentYear)); + let endYear = Math.trunc(Number(filters.endYear ?? startYear)); + let elapsedYears = Math.trunc( + Number( + filters.elapsedYears ?? AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS, + ), + ); + + if (!Number.isFinite(startYear)) startYear = currentYear; + if (!Number.isFinite(endYear)) endYear = startYear; + if ( + !Number.isFinite(elapsedYears) || + elapsedYears < 1 || + elapsedYears > 100 + ) { + elapsedYears = AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS; + } + if (startYear > endYear) { + const swap = startYear; + startYear = endYear; + endYear = swap; + } + if (endYear - startYear + 1 > AGED_PIPE_STATUS_BY_CTPV_MAX_YEAR_SPAN) { + endYear = startYear + (AGED_PIPE_STATUS_BY_CTPV_MAX_YEAR_SPAN - 1); + } + + return { + startYear, + endYear, + elapsedYears, + }; +}; + +export const validateAgedPipeStatusByCtpvFilters = ( + filters: AgedPipeStatusByCtpvFilters, +) => { + if (filters.startYear > filters.endYear) return false; + if ( + filters.endYear - filters.startYear + 1 > + AGED_PIPE_STATUS_BY_CTPV_MAX_YEAR_SPAN + ) + return false; + return filters.elapsedYears >= 1 && filters.elapsedYears <= 100; +}; + +const formatMetric = (value: number) => + Number(value ?? 0).toLocaleString("ko-KR", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + +export const toAgedPipeStatusByCtpvRows = ( + response: Partial | null | undefined, +): AgedPipeStatusByCtpvRow[] => + normalizeAgedPipeStatusByCtpvResponse(response).rows.map((row, index) => { + const displayRow: AgedPipeStatusByCtpvRow = { + rowNumber: index + 1, + ctpvCd: String(row.ctpvCd ?? "").trim(), + ctpvNm: String(row.ctpvNm ?? "").trim(), + yearStats: row.yearStats ?? [], + }; + + (row.yearStats ?? []).forEach((yearStat) => { + displayRow[`year${yearStat.year}TotalLengthKm`] = formatMetric( + Number(yearStat.totalLengthKm ?? 0), + ); + displayRow[`year${yearStat.year}AgedLengthKm`] = formatMetric( + Number(yearStat.agedLengthKm ?? 0), + ); + displayRow[`year${yearStat.year}AgedRate`] = formatMetric( + Number(yearStat.agedRate ?? 0), + ); + }); + + return displayRow; + }); + +export const buildAgedPipeStatusByCtpvHeadList = ( + years: number[], +): UiHeadProps[] => { + const heads: UiHeadProps[] = [ + { id: "ctpvNm", title: "시도", widthPercent: 14 }, + ]; + + years.forEach((year) => { + heads.push( + { + id: `year${year}TotalLengthKm`, + title: `${year} 전체관로연장(km)`, + widthPercent: 13, + align: "right", + }, + { + id: `year${year}AgedLengthKm`, + title: `${year} 노후관로연장(km)`, + widthPercent: 13, + align: "right", + }, + { + id: `year${year}AgedRate`, + title: `${year} 노후화율(%)`, + widthPercent: 11, + align: "right", + }, + ); + }); + + return heads; +}; diff --git a/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx index 51aff4d1b..582016955 100644 --- a/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx +++ b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx @@ -3,8 +3,8 @@ import { useEffect, useState, type ComponentType, type ReactNode } from "react"; import { ChartColumnBig, - ChevronLeft, - ChevronRight, + ChevronDown, + ChevronUp, PanelsTopLeft, PencilRuler, Search, @@ -12,7 +12,10 @@ import { TriangleAlert, Waves, } from "lucide-react"; -import type { WindowPopupKey } from "@/features/windowPopup/model/popupDefinitions"; +import { + windowPopupDefinitions, + type WindowPopupKey, +} from "@/features/windowPopup/model/popupDefinitions"; type Props = { onOpenPopup: (popupKey: WindowPopupKey) => Window | null; @@ -58,14 +61,51 @@ const ANALYSIS_POPUP_ITEMS: Array<{ icon?: ComponentType<{ className?: string }>; renderIcon?: (props: MenuIconProps) => ReactNode; }> = [ - { key: "infoSearch", label: "정보검색", icon: Search }, - { key: "pipeAnalysisPopup", label: "관로분석", icon: ChartColumnBig }, - { key: "floodPredictionMapPopup", label: "침수예측지도", icon: Waves }, - { key: "gisEditPopup", label: "GIS 편집", icon: PencilRuler }, - { key: "gisManagementPopup", label: "GIS 관리", icon: Settings2 }, - { key: "flowRiskPopup", label: "통수 위험", icon: TriangleAlert }, - { key: "inftwtResultPopup", label: "침입수 분석결과", renderIcon: InftwtMenuIcon }, - { key: "rdiiResultPopup", label: "RDII 분석결과", icon: ChartColumnBig }, + { + key: "infoSearch", + label: windowPopupDefinitions.mapSearch.title, + icon: Search, + }, + { + key: "pipeAnalysisPopup", + label: windowPopupDefinitions.pipeAnalysisPopup.title, + icon: ChartColumnBig, + }, + { + key: "fldngPredcPrst", + label: windowPopupDefinitions.fldngPredcPrst.title, + icon: Waves, + }, + { + key: "agedPipeStatusByCtpvPopup", + label: windowPopupDefinitions.agedPipeStatusByCtpvPopup.title, + icon: ChartColumnBig, + }, + { + key: "gisEditPopup", + label: windowPopupDefinitions.gisEditPopup.title, + icon: PencilRuler, + }, + { + key: "gisManagementPopup", + label: windowPopupDefinitions.gisManagementPopup.title, + icon: Settings2, + }, + { + key: "flowRiskPopup", + label: windowPopupDefinitions.flowRiskPopup.title, + icon: TriangleAlert, + }, + { + key: "inftwtResultPopup", + label: windowPopupDefinitions.inftwtResultPopup.title, + renderIcon: InftwtMenuIcon, + }, + { + key: "rdiiResultPopup", + label: windowPopupDefinitions.rdiiResultPopup.title, + icon: ChartColumnBig, + }, ]; export default function AnalysisPopupMenu({ @@ -77,7 +117,9 @@ export default function AnalysisPopupMenu({ initialActiveKey = null, }: Props) { const [isOpen, setIsOpen] = useState(true); - const [activeKey, setActiveKey] = useState(initialActiveKey); + const [activeKey, setActiveKey] = useState< + WindowPopupKey | "infoSearch" | null + >(initialActiveKey); useEffect(() => { if (!isGisEditActive && activeKey === "gisEditPopup") { @@ -97,87 +139,91 @@ export default function AnalysisPopupMenu({ }, [initialActiveKey]); return ( -
-
- {isOpen ? ( -
-
-
- - 분석도구 -
-
+
+
+
+
+ + 분석도구 +
-
- {ANALYSIS_POPUP_ITEMS.map((item) => { - const Icon = item.icon; - const isActive = item.key === "gisEditPopup" + +
+ + {isOpen ? ( +
+ {ANALYSIS_POPUP_ITEMS.map((item) => { + const Icon = item.icon; + const isActive = + item.key === "gisEditPopup" ? isGisEditActive : activeKey === item.key; - return ( - - ); - })} -
+ const popupWindow = onOpenPopup(item.key); + if (!popupWindow) { + setActiveKey(null); + return; + } + + const closeWatcher = window.setInterval(() => { + if (!popupWindow.closed) return; + window.clearInterval(closeWatcher); + setActiveKey((currentKey) => + currentKey === item.key ? null : currentKey, + ); + }, 300); + }} + > + {item.renderIcon ? item.renderIcon({ isActive }) : null} + {Icon ? ( + + ) : null} + {item.label} + + ); + })}
) : null} - -
); diff --git a/apps/sn/app/(map-layout)/map/components/common/index.ts b/apps/sn/app/(map-layout)/map/components/common/index.ts index dd20952b3..377df2344 100644 --- a/apps/sn/app/(map-layout)/map/components/common/index.ts +++ b/apps/sn/app/(map-layout)/map/components/common/index.ts @@ -17,10 +17,11 @@ type LayerComponentProps = { chmbrYdnts: string; gisObject?: Record; // GIS 객체 추가 rawFeatureData?: any; + surveyResultContent?: React.ReactNode; }; type LayerContentMap = { [key: string]: { - component?: React.FC; + component?: React.ComponentType; props?: any; }; }; @@ -45,7 +46,7 @@ export const getLayerContentComponent = (layerNm: string): React.FC null; } - return component.component + return component.component as React.FC }; // 사용자 입력 필드 (보수 및 갱신, 청소 및 준설, 점검 및 진단) export const handleInputChange = (section: string, 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 6818b45e4..bd3a44dbe 100644 --- a/apps/sn/app/(map-layout)/map/components/common/table.ts +++ b/apps/sn/app/(map-layout)/map/components/common/table.ts @@ -32,7 +32,7 @@ export const MsSwagePplCol = [ ] // 하수관로 searchModal 기본정보tab export const MsSwagePplCol2 = [ - { id: 'rowNumber', title: '일련\n번호' }, + { id: 'rowNumber', title: '순번' }, { id: 'fcltMngNo', title: '시설\n관리번호' }, { id: 'instMngNo', title: '기관\n관리번호' }, { id: 'flowDirectionBasis', title: '유하방향\n근거' }, @@ -398,7 +398,7 @@ export const SwtrmtFcltCol = [ // { id: 'obsrvnSptCd', title: '관측지점코드' }, ]; export const SwtrmtFcltCol2 = [ - { id: 'rowNumber', title: '일련\n번호' }, + { id: 'rowNumber', title: '순번' }, { id: 'swtrmtFcltNm', title: '하수처리\n시설명' }, { id: 'fcltCpct', title: '시설\n용량\n(㎥/d)' }, { id: 'rgnExpln', title: '지역설명' }, @@ -461,7 +461,7 @@ export const SwtrmtPrcsZoneCol = [ { id: 'swtrmt_fclt_nm', title: '하수처리시설명' }, ]; export const SwtrmtPrcsZoneCol2 = [ - { id: 'rowNumber', title: '일련번호' }, + { id: 'rowNumber', title: '순번' }, // { id: 'swtrmtZoneSn', title: '하수처리구역 일련번호' }, { id: 'swtrmtZoneNm', title: '하수처리구역명' }, // { id: 'swtrmt_fclt_cd', title: '하수처리시설코드' }, @@ -478,6 +478,65 @@ export const SwtrmtPrcsZoneExcelCol = { 'swtrmtFcltNm': '하수처리시설명' }; +// 침수흔적도 +export const FldngWtlvMapCol = [ + { id: 'fldng_yr', title: '침수년도' }, + { id: 'fldng_dstr_nm', title: '침수재해명' }, + { id: 'ctpv_nm', title: '시도명' }, + { id: 'sgg_nm', title: '시군구명' }, + { id: 'fldng_bgng_dtm', title: '침수시작일시' }, + { id: 'fldng_end_dtm', title: '침수종료일시' }, + { id: 'fldng_hr', title: '침수시간' }, + { id: 'fldng_dstrct_nm', title: '침수지구명' }, + { id: 'fldng_cs_dtl_cd', title: '침수원인상세코드' }, + { id: 'dtl_ocrn_cs_cn', title: '침수발생원인' }, + { id: 'avg_fldng_wtlv', title: '평균침수위' }, + { id: 'fldng_area', title: '침수면적' }, + { id: 'exmn_yr', title: '조사년도' }, + { id: 'dam_amt', title: '피해금액' }, + { id: 'rain_bgng_tm', title: '강우시작일시' }, + { id: 'rain_end_tm', title: '강우종료일시' }, + { id: 'rain_tot', title: '총강우량(mm)' }, + { id: 'rain_str', title: '강우강도(mm/hr)' }, +]; +export const FldngWtlvMapCol2 = [ + { id: 'rowNumber', title: '순번' }, + { id: 'fldngYr', title: '침수년도' }, + { id: 'fldngDstrNm', title: '침수재해명' }, + { id: 'ctpvNm', title: '시도' }, + { id: 'sggNm', title: '시군구' }, + { id: 'fldngBgngDtm', title: '침수시작일시' }, + { id: 'fldngEndDtm', title: '침수종료일시' }, + { id: 'fldngHr', title: '침수시간' }, + { id: 'fldngDstrctNm', title: '침수지구명' }, + { id: 'fldngCsDtlCd', title: '침수원인상세코드' }, + { id: 'avgFldngWtlv', title: '평균침수위' }, + { id: 'fldngArea', title: '침수면적' }, + { id: 'exmnYr', title: '조사년도' }, + { id: 'damAmt', title: '피해금액' }, + { id: 'rainBgngTm', title: '강우시작일시' }, + { id: 'rainEndTm', title: '강우종료일시' }, + { id: 'rainTot', title: '총강우량(mm)' }, + { id: 'rainStr', title: '강우강도(mm/hr)' }, +]; +export const FldngWtlvMapExcelCol = { + 'fldngYr': '침수년도', + 'fldngDstrNm': '침수재해명', + 'ctpvNm': '시도', + 'sggNm': '시군구', + 'bgngYmd': '침수시작일자', + 'fldngBgngHm': '침수시작시분', + 'endYmd': '침수종료일자', + 'fldngEndHm': '침수종료시분', + 'fldngHr': '침수시간', + 'fldngDstrctNm': '침수지구명', + 'fldngCsDtlCd': '침수원인상세코드', + 'avgFldngWtlv': '평균침수위', + 'fldngArea': '침수면적', + 'exmnYr': '조사년도', + 'damAmt': '피해금액' +}; + // 하수맨홀 export const MsSwageMhlCol = [ // { id: 'fid', title: 'FID' }, @@ -502,7 +561,7 @@ export const MsSwageMhlCol = [ // { id: 'inst_mng_no', title: '기관관리번호' } ]; export const MsSwageMhlCol2 = [ - { id: 'rowNumber', title: '일련번호' }, + { id: 'rowNumber', title: '순번' }, // { id: 'fid', title: 'FID' }, // { id: 'geom', title: 'geometry' }, // { id: 'ftreSmbolCd', title: '지형지물부호코드' }, @@ -624,7 +683,7 @@ export const ExclnSewagePumpCol = [ // { id: 'instMngNo', title: '기관관리번호' }, ] export const ExclnSewagePumpCol2 = [ - { id: 'rowNumber', title: '일련번호' }, + { id: 'rowNumber', title: '순번' }, // { id: 'fid', title: 'FID' }, // { id: 'geom', title: 'geometry' }, // { id: 'ftreSmbolCd', title: '지형지물부호코드' }, @@ -680,7 +739,7 @@ export const DrtwSewagePumpCol = [ // { id: 'instMngNo', title: '기관관리번호' }, ] export const DrtwSewagePumpCol2 = [ - { id: 'rowNumber', title: '일련번호' }, + { id: 'rowNumber', title: '순번' }, // { id: 'fid', title: 'FID' }, // { id: 'geom', title: 'geometry' }, // { id: 'ftreSmbolCd', title: '지형지물부호코드' }, @@ -729,7 +788,7 @@ export const stofcbrCol = [ { id: 'drtn_angl', title: '방향각도(°)' }, ] export const stofcbrCol2 = [ - { id: 'rowNumber', title: '일련번호' }, + { id: 'rowNumber', title: '순번' }, // { id: 'ftreSmbolCd', title: '지형지물부호코드' }, { id: 'fcltMngNo', title: '시설관리번호' }, // { id: 'pbadmsEmdCd', title: '행정읍면동코드' }, @@ -819,7 +878,8 @@ export const dateFields = [ 'instlYmd', 'lastDrdgnYmd', 'cmcnYmd', 'oprtngStrtYmd', 'clsgYmd', 'mesureBeginDt', 'mesureEndDt', 'qltwtrBeginDt', 'qltwtrEndDt', 'rainBeginDt', 'rainEndDt', - 'flfmtBgngYmd', 'flfmtEndYmd', 'flfmtYr' + 'flfmtBgngYmd', 'flfmtEndYmd', 'flfmtYr', + 'bgng_ymd', 'end_ymd', 'bgngYmd', 'endYmd' ]; // 🎯 연월 필드들 (yyyymm -> yyyy년 m월) @@ -848,5 +908,7 @@ export const numberFields = [ 'precdngDry', 'rainFall', 'cntncTime', 'rainIn', 'mesureIntrvl', 'qltwtrCo', 'rnysnFvlc', 'clarFvlc', 'bizAmt', 'count', - 'chmbr_xcnts', 'chmbr_ydnts' + 'chmbr_xcnts', 'chmbr_ydnts', + 'fldng_hr', 'avg_fldng_wtlv', 'fldng_area', 'dam_amt', + 'fldngHr', 'avgFldngWtlv', 'fldngArea', 'damAmt' ]; diff --git a/apps/sn/app/(map-layout)/map/components/exmn/ExmnLayerSettingPanel.tsx b/apps/sn/app/(map-layout)/map/components/exmn/ExmnLayerSettingPanel.tsx new file mode 100644 index 000000000..8d0907f5a --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/exmn/ExmnLayerSettingPanel.tsx @@ -0,0 +1,88 @@ +"use client"; + +import type { ExmnMapFilters, ExmnMapJobType } from "./exmnMapTypes"; + +type Props = { + jobType: ExmnMapJobType; + filters: ExmnMapFilters; + onFiltersChange: (filters: ExmnMapFilters) => void; +}; + +const GRADE_OPTIONS = ["", "1", "2", "3", "4", "5"]; + +/** 조사결과 레이어의 세 가지 자동 적용 필터다. */ +export default function ExmnLayerSettingPanel({ + jobType, + filters, + onFiltersChange, +}: Props) { + const updateFilter = (key: keyof ExmnMapFilters, value: string) => { + onFiltersChange({ + ...filters, + [key]: value.replace(/[^0-9]/g, ""), + }); + }; + const selectClass = + "h-[24px] w-[54px] rounded-[4px] border border-input bg-background px-2 text-[1rem] text-foreground outline-none focus:border-primary"; + const labelClass = "shrink-0 text-[1.05rem] font-medium text-label"; + + return ( +
+
+ {/* 관로/맨홀 조사유형을 구분하는 지도 범례 의미색이므로 고정색을 유지한다. */} + + + {jobType === "PPL" ? "관로조사" : "맨홀조사"} + +
+
+ + + +
+
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/exmn/ExmnSurveyResultSection.tsx b/apps/sn/app/(map-layout)/map/components/exmn/ExmnSurveyResultSection.tsx new file mode 100644 index 000000000..601d34a30 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/exmn/ExmnSurveyResultSection.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { + Button, + Table, + TableBody, + TableCell, + TableRow, +} from "@common/components"; + +import { getExmnPipeReportAvailability } from "@/features/exmn"; +import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup"; +import { useAlertStore } from "@/shared/lib"; + +type ExmnSurveyResultSectionProps = { + featureData: any; +}; + +const readRawFeatureProperty = (featureData: any, keys: string[]) => { + for (const key of keys) { + const value = + featureData?.get?.(key) ?? + featureData?.values_?.[key] ?? + featureData?.[key]; + if (value !== undefined && value !== null) return value; + } + return undefined; +}; + +const readFeatureProperty = (featureData: any, keys: string[]) => + String(readRawFeatureProperty(featureData, keys) ?? "").trim(); + +const readBooleanFeatureProperty = (featureData: any, keys: string[]) => { + const value = readRawFeatureProperty(featureData, keys); + return ( + value === true || + String(value ?? "") + .trim() + .toLowerCase() === "true" + ); +}; + +const displayValue = (value: string) => value || "-"; + +export default function ExmnSurveyResultSection({ + featureData, +}: ExmnSurveyResultSectionProps) { + const alert = useAlertStore((state) => state.setMessage); + const exmnBizId = readFeatureProperty(featureData, ["exmnBizId"]); + const jobType = readFeatureProperty(featureData, ["jobType"]); + const pipeNumber = readFeatureProperty(featureData, [ + "gisManagementNo", + "fclt_mng_no", + "fcltMngNo", + ]); + const reportAvailable = readBooleanFeatureProperty(featureData, [ + "reportAvailable", + ]); + const canOpenPipeReport = + jobType === "PPL" && Boolean(exmnBizId && pipeNumber); + + const openPipeReport = async () => { + if (!canOpenPipeReport) return; + if (!reportAvailable) { + alert("등록된 관로 조사보고서가 없습니다."); + return; + } + + try { + const availability = await getExmnPipeReportAvailability( + exmnBizId, + pipeNumber, + ); + if (!availability.available) { + alert( + availability.message?.trim() || + `선택한 관로번호의 조사보고서가 등록된 PDF에 없습니다: ${pipeNumber}`, + ); + return; + } + + const reportWindow = openWindowPopup("exmnPipeReport", { + exmnBizId, + pipeNumber, + reportNo: readFeatureProperty(featureData, ["reportNo"]), + }); + if (!reportWindow) { + alert( + "팝업이 차단되었습니다. 브라우저의 팝업 허용 설정을 확인해주세요.", + ); + } + } catch (error) { + alert( + error instanceof Error && error.message + ? error.message + : "관로 조사보고서 열람 가능 여부를 확인하지 못했습니다.", + ); + } + }; + + const rows = [ + ["사업명", readFeatureProperty(featureData, ["bizNm"])], + ["사업차수", readFeatureProperty(featureData, ["bizCycl"])], + ["처리분구", readFeatureProperty(featureData, ["trtdstNm"])], + ["조사유형", jobType === "MNHL" ? "맨홀조사" : "관로조사"], + ["보고서번호", readFeatureProperty(featureData, ["reportNo"])], + ["구조적결함 등급", readFeatureProperty(featureData, ["structuralGrade"])], + ["운영적결함 등급", readFeatureProperty(featureData, ["operationalGrade"])], + ]; + + return ( +
+
+

조사 결과

+ {jobType === "PPL" && ( + + )} +
+ +
+
+ + {rows.map(([label, value]) => ( + + + {label} + + + {displayValue(value ?? "")} + + + ))} + +
+
+ + ); +} diff --git a/apps/sn/app/(map-layout)/map/components/exmn/exmnMapApi.ts b/apps/sn/app/(map-layout)/map/components/exmn/exmnMapApi.ts new file mode 100644 index 000000000..390bdf31f --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/exmn/exmnMapApi.ts @@ -0,0 +1,50 @@ +import { getRawJson } from "../../../../components/lib/api"; +import type { ExmnMapFilters, ExmnMapJobType } from "./exmnMapTypes"; + +type ExmnLayerScope = { + sggCd?: string; + ctpvNm?: string; + sggNm?: string; +}; + +/** 구버전 응답이 하류맨홀을 포함해도 업로드 관로조사의 상류맨홀 피처만 지도에 전달한다. */ +export const retainUpstreamManholeFeatures = (response: any) => { + const featureCollection = response?.data?.type === "FeatureCollection" + ? response.data + : response?.type === "FeatureCollection" + ? response + : null; + if (!featureCollection || !Array.isArray(featureCollection.features)) return response; + + const features = featureCollection.features.filter( + (feature: any) => String(feature?.properties?.manholePosition ?? "").toUpperCase() === "UPSTREAM", + ); + const upstreamCollection = { + ...featureCollection, + features, + totalFeatures: features.length, + numberReturned: features.length, + }; + + return response?.data === featureCollection + ? { ...response, data: upstreamCollection } + : upstreamCollection; +}; + +/** 사업차수·구조등급·운영등급 AND 조건으로 조사결과 GeoJSON을 조회한다. */ +export const getExmnMapLayer = async ( + jobType: ExmnMapJobType, + filters: ExmnMapFilters, + scope: ExmnLayerScope, +) => { + const query = new URLSearchParams({ jobType }); + if (scope.sggCd?.trim()) query.set("sggCd", scope.sggCd.trim()); + if (scope.ctpvNm?.trim()) query.set("ctpvNm", scope.ctpvNm.trim()); + if (scope.sggNm?.trim()) query.set("sggNm", scope.sggNm.trim()); + if (filters.bizCycl) query.set("bizCycl", filters.bizCycl); + if (filters.structuralGrade) query.set("structuralGrade", filters.structuralGrade); + if (filters.operationalGrade) query.set("operationalGrade", filters.operationalGrade); + + const response = await getRawJson(`/sewerage-service/api/v1/exmnMap/layers?${query.toString()}`); + return jobType === "MNHL" ? retainUpstreamManholeFeatures(response) : response; +}; diff --git a/apps/sn/app/(map-layout)/map/components/exmn/exmnMapTypes.ts b/apps/sn/app/(map-layout)/map/components/exmn/exmnMapTypes.ts new file mode 100644 index 000000000..248c8547b --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/exmn/exmnMapTypes.ts @@ -0,0 +1,298 @@ +export const EXMN_RESULT_ROOT_NODE_ID = "SN_EXMN_RESULT"; +export const EXMN_RESULT_PPL_NODE_ID = "SN_EXMN_RESULT_PPL"; +export const EXMN_RESULT_MNHL_NODE_ID = "SN_EXMN_RESULT_MNHL"; +export const EXMN_RESULT_PPL_LAYER_ID = "SN_EXMN_RESULT_PPL_LAYER"; +export const EXMN_RESULT_MNHL_LAYER_ID = "SN_EXMN_RESULT_MNHL_LAYER"; +export const EXMN_RESULT_ROOT_LAYER_TYPE = "sn_exmn_result_group"; +export const EXMN_RESULT_PPL_LAYER_TYPE = "sn_exmn_result_ppl"; +export const EXMN_RESULT_MNHL_LAYER_TYPE = "sn_exmn_result_mnhl"; +// 레이어 트리에서 조사 관로와 기존 맨홀 분류 심볼을 즉시 구분하기 위한 전용 범례 스타일이다. +export const EXMN_RESULT_PPL_LEGEND_COLOR = "#facc15"; +export const EXMN_RESULT_MNHL_LEGEND_ICON = "SN_EXMN_MANHOLE_PURPLE.png?v=3"; + +export type ExmnMapJobType = "PPL" | "MNHL"; + +export type ExmnMapFilters = { + bizCycl: string; + structuralGrade: string; + operationalGrade: string; +}; + +export type ExmnMapEntryContext = { + exmnBizId: string; + ctpvNm: string; + sggNm: string; + bizCycl: string; + jobType: ExmnMapJobType | null; +}; + +export const EMPTY_EXMN_MAP_FILTERS: ExmnMapFilters = { + bizCycl: "", + structuralGrade: "", + operationalGrade: "", +}; + +const buildControlNode = ( + id: string, + name: string, + upperId: string | number | null, + layerType: string, + initialVisible: boolean, +) => { + const isPipeNode = layerType === EXMN_RESULT_PPL_LAYER_TYPE; + const isManholeNode = layerType === EXMN_RESULT_MNHL_LAYER_TYPE; + + return { + lyrSn: id, + id, + lyrNm: name, + upperLyrSn: upperId, + upId: upperId, + sortOrdr: upperId ? (id === EXMN_RESULT_PPL_NODE_ID ? "1" : "2") : "5", + drawOrdr: null, + flterSql: null, + gobjTy: isPipeNode ? "line" : "", + lyrTyNm: layerType, + useYn: "Y", + minViewSize: null, + iconNm: isManholeNode ? EXMN_RESULT_MNHL_LEGEND_ICON : null, + iconSize: null, + lblAtrbNm: null, + fontNm: null, + fontIntnColor: null, + fontExtnColor: null, + fontExtnWdth: null, + lnWdth: null, + lnColor: isPipeNode ? EXMN_RESULT_PPL_LEGEND_COLOR : null, + polgnColor: null, + initEprssYn: initialVisible ? "Y" : "N", + clickPsblYn: "N", + exprsYn: "Y", + cmncNm: "all", + srvcNm: "api", + actlLyrYn: "N", + clckZoom: null, + children: null, + }; +}; + +const resolveNodeData = (node: any) => node?.data ?? node ?? {}; + +const resolveNodeId = (node: any) => { + const nodeData = resolveNodeData(node); + return nodeData?.lyrSn ?? nodeData?.id ?? ""; +}; + +const resolveNodeParentId = (node: any) => { + const nodeData = resolveNodeData(node); + return nodeData?.upperLyrSn ?? nodeData?.upId ?? ""; +}; + +const resolveNodeLayerType = (node: any) => { + const nodeData = resolveNodeData(node); + return String( + nodeData?.lyrTyNm ?? + nodeData?.lyrTypeNm ?? + nodeData?.lyr_type_nm ?? + "", + ).trim(); +}; + +const resolveNodeName = (node: any) => { + const nodeData = resolveNodeData(node); + return String(nodeData?.lyrNm ?? nodeData?.name ?? "").trim(); +}; + +const sameNodeId = (left: unknown, right: unknown) => + String(left ?? "") === String(right ?? ""); + +const isNodeType = (node: any, layerType: string) => + resolveNodeLayerType(node) === layerType; + +const normalizeControlNode = ( + node: any, + fallbackId: string, + fallbackName: string, + upperId: string | number | null, + layerType: string, + initialVisible: boolean, +) => { + const nodeId = resolveNodeId(node) || fallbackId; + const isPipeNode = layerType === EXMN_RESULT_PPL_LAYER_TYPE; + const isManholeNode = layerType === EXMN_RESULT_MNHL_LAYER_TYPE; + + return { + ...node, + lyrSn: nodeId, + id: nodeId, + // DB에 저장된 과거 명칭과 무관하게 조사관리 표준 화면 명칭으로 표시한다. + lyrNm: fallbackName, + upperLyrSn: upperId, + upId: upperId, + lyrTyNm: layerType, + gobjTy: isPipeNode ? "line" : "", + iconNm: isManholeNode ? EXMN_RESULT_MNHL_LEGEND_ICON : null, + lnColor: isPipeNode ? EXMN_RESULT_PPL_LEGEND_COLOR : null, + initEprssYn: initialVisible ? "Y" : "N", + clickPsblYn: "N", + srvcNm: "api", + actlLyrYn: "N", + }; +}; + +/** + * 서버 레이어 설정의 조사결과 노드를 우선 사용한다. + * DB 설정이 아직 배포되지 않았거나 일부만 등록된 환경에서는 누락 노드만 가상 노드로 보완한다. + */ +export const appendExmnResultControlNodes = ( + data: unknown[], + initialJobType: ExmnMapJobType | null, +) => { + if (!Array.isArray(data)) return data; + + const rootNode = + data.find((item: any) => isNodeType(item, EXMN_RESULT_ROOT_LAYER_TYPE)) ?? + data.find((item: any) => + sameNodeId(resolveNodeId(item), EXMN_RESULT_ROOT_NODE_ID), + ) ?? + data.find( + (item: any) => + resolveNodeName(item) === "조사결과" && !resolveNodeParentId(item), + ); + const rootId = resolveNodeId(rootNode) || EXMN_RESULT_ROOT_NODE_ID; + + const pplNode = + data.find((item: any) => isNodeType(item, EXMN_RESULT_PPL_LAYER_TYPE)) ?? + data.find((item: any) => + sameNodeId(resolveNodeId(item), EXMN_RESULT_PPL_NODE_ID), + ) ?? + data.find( + (item: any) => + ["관로조사", "CCTV조사"].includes(resolveNodeName(item)) && + sameNodeId(resolveNodeParentId(item), rootId), + ); + const mnhlNode = + data.find((item: any) => isNodeType(item, EXMN_RESULT_MNHL_LAYER_TYPE)) ?? + data.find((item: any) => + sameNodeId(resolveNodeId(item), EXMN_RESULT_MNHL_NODE_ID), + ) ?? + data.find( + (item: any) => + resolveNodeName(item) === "맨홀조사" && + sameNodeId(resolveNodeParentId(item), rootId), + ); + + const normalizedPplNode = pplNode + ? normalizeControlNode( + pplNode, + EXMN_RESULT_PPL_NODE_ID, + "관로조사", + rootId, + EXMN_RESULT_PPL_LAYER_TYPE, + initialJobType === "PPL", + ) + : buildControlNode( + EXMN_RESULT_PPL_NODE_ID, + "관로조사", + rootId, + EXMN_RESULT_PPL_LAYER_TYPE, + initialJobType === "PPL", + ); + const normalizedMnhlNode = mnhlNode + ? normalizeControlNode( + mnhlNode, + EXMN_RESULT_MNHL_NODE_ID, + "맨홀조사", + rootId, + EXMN_RESULT_MNHL_LAYER_TYPE, + initialJobType === "MNHL", + ) + : buildControlNode( + EXMN_RESULT_MNHL_NODE_ID, + "맨홀조사", + rootId, + EXMN_RESULT_MNHL_LAYER_TYPE, + initialJobType === "MNHL", + ); + const childIds = [ + resolveNodeId(normalizedPplNode), + resolveNodeId(normalizedMnhlNode), + ]; + const previousChildren = Array.isArray((rootNode as any)?.children) + ? (rootNode as any).children + : []; + const normalizedRootNode = { + ...(rootNode + ? normalizeControlNode( + rootNode, + EXMN_RESULT_ROOT_NODE_ID, + "조사결과", + null, + EXMN_RESULT_ROOT_LAYER_TYPE, + false, + ) + : buildControlNode( + EXMN_RESULT_ROOT_NODE_ID, + "조사결과", + null, + EXMN_RESULT_ROOT_LAYER_TYPE, + false, + )), + children: [...previousChildren, ...childIds].filter( + (childId, index, children) => + childId !== "" && + children.findIndex((candidate) => sameNodeId(candidate, childId)) === + index, + ), + }; + + const recognizedIds = new Set( + [ + EXMN_RESULT_ROOT_NODE_ID, + EXMN_RESULT_PPL_NODE_ID, + EXMN_RESULT_MNHL_NODE_ID, + resolveNodeId(rootNode), + resolveNodeId(pplNode), + resolveNodeId(mnhlNode), + ] + .filter((id) => id !== "") + .map((id) => String(id)), + ); + const filtered = data.filter((item: any) => { + const nodeId = String(resolveNodeId(item)); + return ( + !recognizedIds.has(nodeId) && + !isNodeType(item, EXMN_RESULT_ROOT_LAYER_TYPE) && + !isNodeType(item, EXMN_RESULT_PPL_LAYER_TYPE) && + !isNodeType(item, EXMN_RESULT_MNHL_LAYER_TYPE) + ); + }); + + return [ + ...filtered, + normalizedRootNode, + normalizedPplNode, + normalizedMnhlNode, + ]; +}; + +export const isExmnResultPplNode = (node: any) => + sameNodeId(resolveNodeId(node), EXMN_RESULT_PPL_NODE_ID) || + isNodeType(node, EXMN_RESULT_PPL_LAYER_TYPE); + +export const isExmnResultMnhlNode = (node: any) => + sameNodeId(resolveNodeId(node), EXMN_RESULT_MNHL_NODE_ID) || + isNodeType(node, EXMN_RESULT_MNHL_LAYER_TYPE); + +export const buildExmnMapEntryContext = ( + searchParams: Pick, +): ExmnMapEntryContext => { + const rawJobType = String(searchParams.get("exmnJobType") ?? "").toUpperCase(); + return { + exmnBizId: String(searchParams.get("exmnBizId") ?? "").trim(), + ctpvNm: String(searchParams.get("ctpvNm") ?? "").trim(), + sggNm: String(searchParams.get("sggNm") ?? "").trim(), + bizCycl: String(searchParams.get("bizCycl") ?? "").replace(/[^0-9]/g, ""), + jobType: rawJobType === "PPL" || rawJobType === "MNHL" ? rawJobType : null, + }; +}; diff --git a/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryDetailPanel.tsx b/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryDetailPanel.tsx new file mode 100644 index 000000000..d0ad85a20 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryDetailPanel.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + formatFloodArea, + formatFloodDepth, + type FldngHstryItem, +} from "./fldngHstryTypes"; + +type Props = { + detail: FldngHstryItem; + onClose: () => void; + position: { + left: number; + top: number; + }; +}; + +type DetailTab = "year" | "disaster" | "rainfall"; + +const renderRows = (rows: Array<[string, string]>) => ( + + + + + + + {rows.map(([label, value], index) => ( + + + + + ))} + +
+ {label} + + {value} +
+); + +export default function FldngHstryDetailPanel({ + detail, + onClose, + position, +}: Props) { + // 지도에서 침수흔적도 polygon을 클릭했을 때, 상세정보는 업무 의미 기준으로 3개 탭에 나눠 보여준다. + const [activeTab, setActiveTab] = useState("year"); + + const yearRows = useMemo( + () => + [ + ["침수심", `${detail.floodDepthM.toFixed(2)} m`], + ["침수면적", formatFloodArea(detail.floodAreaHa)], + ["침수년도", `${detail.floodYear}년`], + ["등급", detail.gradeLabel], + ["침수구역", detail.districtName], + ] as Array<[string, string]>, + [detail], + ); + + const disasterRows = useMemo( + () => + [ + ["침수재해명", detail.disasterName], + ["침수면적", formatFloodArea(detail.floodAreaHa)], + ["침수원인상세명", detail.causeDetailName], + ["침수시작년월일", detail.startDate], + ["침수종료년월일", detail.endDate], + ["침수시작시간", detail.startTime], + ] as Array<[string, string]>, + [detail], + ); + + const rainfallRows = useMemo( + () => + [ + ["침수시작일자", detail.startDate], + ["침수종료일자", detail.endDate], + ["침수시작시간", detail.startTime], + ["침수종료시간", detail.endTime], + ["침수시간", detail.floodHour], + ["강우시작시간", detail.rainStartTime], + ["강우종료시간", detail.rainEndTime], + ["총강우량(mm)", `${detail.totalRainfallMm.toFixed(0)}`], + ["강우강도(mm/hr)", `${detail.rainfallIntensityMmHr.toFixed(0)}`], + ["침수시간", detail.floodHour], + ["침수원인상세코드", detail.causeDetailCode], + ["피해금액", detail.damageAmount], + ] as Array<[string, string]>, + [detail], + ); + + const currentRows = + activeTab === "year" + ? yearRows + : activeTab === "disaster" + ? disasterRows + : rainfallRows; + + return ( +
+
+
+ 상세정보 +
+ +
+ +
+
+ {[ + ["year", "년도탭"], + ["disaster", "재해명탭"], + ["rainfall", "침수발생강우탭"], + ].map(([tabId, label]) => { + const isActive = activeTab === tabId; + + return ( + + ); + })} +
+
+ +
+
+ {renderRows(currentRows)} +
+
+
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryPopupPage.tsx new file mode 100644 index 000000000..ee4789351 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryPopupPage.tsx @@ -0,0 +1,414 @@ +/** + * 침수발생이력 popup + */ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Button, Input } from "@common/components"; +import { StandardPopupLayout } from "@common/business_components"; +import { getRawJson } from "@/components/lib/api"; +import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types"; +import { useAlertStore } from "@/shared/lib"; +import useStore from "@/store/useStore"; +import LoadingOverlay from "@/components/ui/LoadingBar"; +import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable"; +import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel"; +import { + FLOOD_HISTORY_LAYER_TYPE, + FLDNG_HSTRY_DEFAULT_FILTERS, + FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE, + FLDNG_HSTRY_DOWNLOAD_HEADERS, + FLDNG_HSTRY_LIST_HEADLIST, + FLDNG_HSTRY_PAGE_SIZE, + FLDNG_HSTRY_ROW_SELECT_MESSAGE, + attachFldngHstryGeometry, + buildFldngHstryListQueryString, + buildFldngHstryYearOptions, + normalizeFldngHstryFilters, + toFldngHstryTableRows, + type FldngHstryApiItem, + type FldngHstryFilters, + type FldngHstryTableRow, +} from "./fldngHstryTypes"; + +const unwrapPayload = (response: any): T => + (response?.data ?? response) as T; + +const resolveOptionText = ( + code: string, + options: Array<{ text: string; val: string | number }>, + prefixLength?: number, +) => { + const normalized = String(code ?? "").trim(); + if (!normalized || normalized === "0") return ""; + const lookup = prefixLength ? normalized.slice(0, prefixLength) : normalized; + return options.find((item) => String(item.val).trim() === lookup)?.text ?? ""; +}; + +const resolveFldngHstryMapFeatureFid = (row: FldngHstryTableRow) => { + const fid = String(row.fid ?? "").trim(); + if (fid.startsWith(`${FLOOD_HISTORY_LAYER_TYPE}.`)) return fid; + if (fid) return `${FLOOD_HISTORY_LAYER_TYPE}.${fid}`; + + const mapFid = String(row.fldngMapFid ?? "").trim(); + return mapFid || ""; +}; + +const buildDownloadRows = (rows: FldngHstryTableRow[]) => + rows.map((row) => ({ + rowNo: row.rowNo, + floodYearDisplay: row.floodYearDisplay, + ctpvNm: row.ctpvNm, + sggNm: row.sggNm, + disasterName: row.disasterName, + rainBgngTmDisplay: row.rainBgngTmDisplay, + rainEndTmDisplay: row.rainEndTmDisplay, + rainTotDisplay: row.rainTotDisplay, + rainStrDisplay: row.rainStrDisplay, + floodHourDisplay: row.floodHourDisplay, + districtName: row.districtName, + causeDetailCode: row.causeDetailCode, + causeDetailName: row.causeDetailName, + avgFloodDepthDisplay: row.avgFloodDepthDisplay, + floodAreaDisplay: row.floodAreaDisplay, + exmnYearDisplay: row.exmnYearDisplay, + damageAmountDisplay: row.damageAmountDisplay, + })); + +const resolveRegionNames = ( + row: FldngHstryTableRow, + ctpvOptions: Array<{ text: string; val: string | number }>, + sggOptions: Array<{ text: string; val: string | number }>, +): FldngHstryTableRow => ({ + ...row, + ctpvNm: resolveOptionText(row.ctpvCd ?? "", ctpvOptions, 2) || row.ctpvNm, + sggNm: resolveOptionText(row.sggCd ?? "", sggOptions, 5) || row.sggNm, +}); + +export default function FldngHstryPopupPage() { + const alert = useAlertStore((state) => state.setMessage); + const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore(); + const [filters, setFilters] = useState( + FLDNG_HSTRY_DEFAULT_FILTERS, + ); + const [appliedFilters, setAppliedFilters] = useState( + FLDNG_HSTRY_DEFAULT_FILTERS, + ); + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + const [currentPage, setCurrentPage] = useState(1); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (ctpv.length === 0) void fetchCtpv(); + if (sgg.length === 0) void fetchSgg(); + }, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]); + + useEffect(() => { + if (rows.length === 0) return; + if (ctpv.length === 0 && sgg.length === 0) return; + + setRows((current) => + current.map((row) => resolveRegionNames(row, ctpv as any, sgg as any)), + ); + }, [ctpv, rows.length, sgg]); + + const fldngYearOptions = useMemo(() => buildFldngHstryYearOptions(), []); + + const sggOptions = useMemo( + () => + filters.ctpvCd === "0" + ? sgg + : sgg.filter((item) => + String(item.val).startsWith(filters.ctpvCd.slice(0, 2)), + ), + [filters.ctpvCd, sgg], + ); + + const fetchRows = useCallback( + async (page: number, nextFilters: FldngHstryFilters, size: number) => { + const normalized = normalizeFldngHstryFilters(nextFilters); + const queryString = buildFldngHstryListQueryString( + normalized, + page, + size, + ); + const response = await getRawJson( + `/sewerage-service/api/v1/fldng/hstry?${queryString}`, + ); + const payload = unwrapPayload<{ + items?: FldngHstryApiItem[]; + total?: number; + }>(response); + const items = Array.isArray(payload?.items) ? payload.items : []; + + const displayItems = items.map((item) => { + const hydrated = attachFldngHstryGeometry(item); + return { + ...hydrated, + ctpvNm: resolveOptionText(hydrated.ctpvCd ?? "", ctpv as any, 2), + sggNm: resolveOptionText(hydrated.sggCd ?? "", sgg as any, 5), + }; + }); + + return { + rows: toFldngHstryTableRows(displayItems, page, size), + total: Number(payload?.total ?? 0), + }; + }, + [ctpv, sgg], + ); + + const loadRows = useCallback( + async (page: number, nextFilters: FldngHstryFilters) => { + setIsLoading(true); + try { + const normalized = normalizeFldngHstryFilters(nextFilters); + const response = await fetchRows( + page, + normalized, + FLDNG_HSTRY_PAGE_SIZE, + ); + setRows(response.rows); + setTotal(response.total); + setCurrentPage(page); + setAppliedFilters(normalized); + } catch (error) { + console.error("침수발생이력 목록 조회 중 오류:", error); + setRows([]); + setTotal(0); + alert( + error instanceof Error + ? error.message + : "침수발생이력 목록 조회 중 오류가 발생했습니다.", + ); + } finally { + setIsLoading(false); + } + }, + [alert, fetchRows], + ); + + useEffect(() => { + void loadRows(1, FLDNG_HSTRY_DEFAULT_FILTERS); + // 최초 로딩은 한 번만 수행한다. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleSearch = async (page = 1) => { + const normalized = normalizeFldngHstryFilters(filters); + + if (normalized.sggCd !== "0" && normalized.ctpvCd === "0") { + alert("시도를 먼저 선택하세요."); + return; + } + + setFilters(normalized); + await loadRows(page, normalized); + }; + + const handlePageChange = async (page: number) => { + await loadRows(page, appliedFilters); + }; + + const handleRowClick = (row: FldngHstryTableRow) => { + const mapFeatureFid = resolveFldngHstryMapFeatureFid(row); + if (mapFeatureFid) { + window.opener?.postMessage( + { + type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SELECT_FEATURE, + payload: { + layerNm: FLOOD_HISTORY_LAYER_TYPE, + fid: mapFeatureFid, + }, + }, + window.location.origin, + ); + return; + } + + window.opener?.postMessage( + { + type: FLDNG_HSTRY_ROW_SELECT_MESSAGE, + row, + openDetail: false, + }, + window.location.origin, + ); + }; + + const handleDownload = async () => { + if (total === 0) { + alert("다운로드할 침수발생이력 데이터가 없습니다."); + return; + } + + setIsLoading(true); + try { + const response = await fetchRows( + 1, + appliedFilters, + Math.max(total, FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE), + ); + await downloadExcelWithKoreanHeaders({ + data: buildDownloadRows(response.rows) as unknown as JSON[], + fileName: `침수발생이력_${new Date().toISOString().slice(0, 10)}.xlsx`, + sheetNameArray: ["침수발생이력"], + headerRange: "A1:Q1", + headerMapping: FLDNG_HSTRY_DOWNLOAD_HEADERS, + }); + } catch (error) { + console.error("침수발생이력 다운로드 중 오류:", error); + alert( + error instanceof Error + ? error.message + : "침수발생이력 다운로드 중 오류가 발생했습니다.", + ); + } finally { + setIsLoading(false); + } + }; + + return ( + + + + + + + + + + + + +
+ + +
+
+ + >} + headlist={FLDNG_HSTRY_LIST_HEADLIST} + title="침수발생이력 목록" + total={total} + page={currentPage} + pageSize={FLDNG_HSTRY_PAGE_SIZE} + onPageChange={(page) => void handlePageChange(page)} + onRowClick={handleRowClick} + rowId={(row, index) => String(row.fid ?? index)} + scrollMode="horizontal" + minTableWidth="220rem" + actions={ + + } + /> + +
+ 행을 클릭하면 메인 지도에서 해당 침수흔적을 표시합니다. +
+
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/fldngHstry/fldngHstryTypes.ts b/apps/sn/app/(map-layout)/map/components/fldngHstry/fldngHstryTypes.ts new file mode 100644 index 000000000..9c625791f --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/fldngHstry/fldngHstryTypes.ts @@ -0,0 +1,497 @@ +"use client"; + +import type { UiHeadProps } from "@common/business_components/ui"; + +export const FLOOD_HISTORY_POPUP_KEY = "fldngHstryPopup"; +export const FLDNG_HSTRY_ROW_SELECT_MESSAGE = "SN_FLOOD_HISTORY_ROW_SELECT"; +export const FLOOD_HISTORY_LAYER_TYPE = "tb_ms_fldng_wtlv_map"; +export const FLOOD_HISTORY_PREVIEW_LAYER_TYPE = "tb_sn_flood_trace_mock"; +export const FLOOD_HISTORY_LAYER_NAME = "침수흔적도"; +export const FLOOD_HISTORY_AREA_LAYER_ID = "SN_FLOOD_HISTORY_AREA"; +export const FLOOD_HISTORY_DEPTH_LAYER_ID = "SN_FLOOD_HISTORY_DEPTH"; +export const FLDNG_HSTRY_PAGE_SIZE = 10; +export const FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE = 2000; + +export type FldngHstryFilters = { + ctpvCd: string; + sggCd: string; + floodYear: string; + disasterName: string; +}; + +export type FldngHstryDepthZone = { + zoneId: string; + depthLabel: string; + color: string; + ring: [number, number][]; +}; + +export type FldngHstryApiItem = { + fid: string; // FID + fldngMapFid?: string; // 침수흔적도지도 FID + fldngWtlvMapSn?: number | null; // 침수흔적도지도 일련번호 + taskClCd?: string; // 업무 구분 코드 + fldngDstrNm?: string; // 침수재해명 + exmnYr?: string; // 조사년도 + avgFldngWtlv?: number | null; // 평균침수위 + fldngArea?: number | null; // 침수면적 + dtlOcrnCsCn?: string; // 상세발생원인내용 + stdgCd?: string; // 표준지역코드 + ctpvCd?: string; // 시도코드 + sggCd?: string; // 시군구코드 + dataSn?: number | null; // 데이터 일련번호 + bgngYmd?: string; // 시작일자 + endYmd?: string; // 종료일자 + fldngBgngHm?: string; // 침수시작시분 + fldngEndHm?: string; // 침수종료시분 + fldngCsDtlCd?: string; // 침수원인상세코드 + fldngDstrctNm?: string; // 침수지구명 + damAmt?: number | null; // 피해금액 + fldngHr?: number | null; // 침수시간 + fldngYr?: string; // 침수년도 + cstrnFbctnYn?: string; // 공사여부 + atchFileSn?: number | null; // 첨부파일 일련번호 + confArea?: number | null; // 확정면적 + confLen?: number | null; // 확정연장 + rainBgngTm?: string | null; // 강우시작일시 + rainEndTm?: string | null; // 강우종료일시 + rainTot?: number | null; // 총강우량 + rainStr?: number | null; // 강우강도 +}; + +export type FldngHstryItem = FldngHstryApiItem & { + historyId: string; + ctpvNm: string; + sggNm: string; + floodYear: string; + exmnYear: string; + disasterName: string; + districtName: string; + floodAreaHa: number; + avgFloodDepthM: number; + floodDepthM: number; + gradeLabel: string; + startDate: string; + endDate: string; + startTime: string; + endTime: string; + floodHour: string; + causeDetailCode: string; + causeDetailName: string; + damageAmount: string; + rainStartDateTime: string; + rainEndDateTime: string; + rainStartTime: string; + rainEndTime: string; + totalRainfallMm: number; + rainfallIntensityMmHr: number; + areaRing: [number, number][]; + depthZones: FldngHstryDepthZone[]; +}; + +export type FldngHstryTableRow = FldngHstryItem & { + rowNo: number; + floodYearDisplay: string; + floodHourDisplay: string; + avgFloodDepthDisplay: string; + floodAreaDisplay: string; + exmnYearDisplay: string; + damageAmountDisplay: string; + rainBgngTmDisplay: string; + rainEndTmDisplay: string; + rainTotDisplay: string; + rainStrDisplay: string; +}; + +export const FLDNG_HSTRY_DEFAULT_FILTERS: FldngHstryFilters = { + ctpvCd: "0", + sggCd: "0", + floodYear: "", + disasterName: "", +}; + +export const FLDNG_HSTRY_DEPTH_LEGEND_ITEMS = [ + { color: "#fff27a", label: "0.0~0.5m" }, + { color: "#c2f13d", label: "0.5~1.0m" }, + { color: "#67d8ff", label: "1.0~2.0m" }, + { color: "#8f8bff", label: "2.0~5.0m" }, + { color: "#f2528b", label: "5.0m 이상" }, +] as const; + +export const FLDNG_HSTRY_LIST_HEADLIST: UiHeadProps[] = [ + { id: "rowNo", title: "순번", widthPercent: 5 }, + { id: "floodYearDisplay", title: "침수\n년도", widthPercent: 6 }, + { id: "ctpvNm", title: "시도", widthPercent: 7 }, + { id: "sggNm", title: "시군구", widthPercent: 8 }, + { id: "disasterName", title: "침수재해명", widthPercent: 14 }, + { id: "rainBgngTmDisplay", title: "강우\n시작일시", widthPercent: 10 }, + { id: "rainEndTmDisplay", title: "강우\n종료일시", widthPercent: 10 }, + { id: "rainTotDisplay", title: "총강우량(mm)", widthPercent: 8 }, + { id: "rainStrDisplay", title: "강우강도(mm/hr)", widthPercent: 8 }, + { id: "floodHourDisplay", title: "침수시간", widthPercent: 7 }, + { id: "districtName", title: "침수지구명", widthPercent: 10 }, + { id: "avgFloodDepthDisplay", title: "평균\n침수위", widthPercent: 7 }, + { id: "floodAreaDisplay", title: "침수\n면적\(ha)", widthPercent: 7 }, + { id: "exmnYearDisplay", title: "조사년도", widthPercent: 6 }, + { id: "damageAmountDisplay", title: "피해금액", widthPercent: 9 }, +]; + +export const FLDNG_HSTRY_DOWNLOAD_HEADERS: Record = { + rowNo: "순번", + floodYearDisplay: "침수년도", + ctpvNm: "시도", + sggNm: "시군구", + disasterName: "침수재해명", + rainBgngTmDisplay: "강우시작일시", + rainEndTmDisplay: "강우종료일시", + rainTotDisplay: "총강우량(mm)", + rainStrDisplay: "강우강도(mm/hr)", + floodHourDisplay: "침수시간", + districtName: "침수지구명", + causeDetailCode: "침수원인상세코드", + causeDetailName: "침수원인상세", + avgFloodDepthDisplay: "평균침수위", + floodAreaDisplay: "침수면적", + exmnYearDisplay: "조사년도", + damageAmountDisplay: "피해금액", +}; + +const toRadian = (degree: number) => (degree * Math.PI) / 180; + +const createIrregularRing = ({ + centerX, + centerY, + radiusX, + radiusY, + scales, + rotationDeg = 0, +}: { + centerX: number; + centerY: number; + radiusX: number; + radiusY: number; + scales: number[]; + rotationDeg?: number; +}): [number, number][] => { + const rotation = toRadian(rotationDeg); + const points = scales.map((scale, index) => { + const angle = rotation + (Math.PI * 2 * index) / scales.length; + const jitterX = radiusX * scale * Math.cos(angle); + const jitterY = radiusY * scale * Math.sin(angle); + return [centerX + jitterX, centerY + jitterY] as [number, number]; + }); + + return points.length > 0 ? [...points, points[0]!] : []; +}; + +const toNumericSeed = (value: string) => + Array.from(value).reduce( + (sum, char, index) => sum + char.charCodeAt(0) * (index + 1), + 0, + ); + +const buildGeometrySeed = (fid: string) => { + const seed = toNumericSeed(fid); + return { + centerX: 1050000 + (seed % 120000), + centerY: 1680000 + (seed % 150000), + width: 14000 + (seed % 9000), + height: 11000 + (seed % 7000), + }; +}; + +const createFldngHstryDepthZones = ( + historyId: string, + centerX: number, + centerY: number, +): FldngHstryDepthZone[] => { + return [ + { + zoneId: `${historyId}-depth-1`, + depthLabel: "0.0~0.5m", + color: "#fff27a", + ring: createIrregularRing({ + centerX: centerX - 2600, + centerY: centerY - 1200, + radiusX: 2900, + radiusY: 2300, + rotationDeg: -18, + scales: [1.02, 0.78, 0.9, 1.08, 0.86, 0.72, 0.94, 1.04], + }), + }, + { + zoneId: `${historyId}-depth-2`, + depthLabel: "0.5~1.0m", + color: "#c2f13d", + ring: createIrregularRing({ + centerX: centerX + 1800, + centerY: centerY - 600, + radiusX: 3500, + radiusY: 2800, + rotationDeg: 12, + scales: [1.04, 0.88, 1.02, 0.8, 0.92, 1.08, 0.84, 0.95], + }), + }, + { + zoneId: `${historyId}-depth-3`, + depthLabel: "1.0~2.0m", + color: "#67d8ff", + ring: createIrregularRing({ + centerX, + centerY: centerY + 1800, + radiusX: 4700, + radiusY: 3900, + rotationDeg: 8, + scales: [1.12, 0.86, 0.94, 1.06, 0.82, 0.97, 1.04, 0.9, 1.08, 0.84], + }), + }, + { + zoneId: `${historyId}-depth-4`, + depthLabel: "2.0~5.0m", + color: "#8f8bff", + ring: createIrregularRing({ + centerX: centerX + 3200, + centerY: centerY + 3200, + radiusX: 2600, + radiusY: 2000, + rotationDeg: 26, + scales: [1.05, 0.82, 1.08, 0.88, 0.96, 0.78, 1.02, 0.9], + }), + }, + { + zoneId: `${historyId}-depth-5`, + depthLabel: "5.0m 이상", + color: "#f2528b", + ring: createIrregularRing({ + centerX: centerX + 400, + centerY: centerY - 3600, + radiusX: 1200, + radiusY: 1050, + rotationDeg: -10, + scales: [1.0, 0.8, 1.06, 0.84, 0.94, 0.76, 0.98], + }), + }, + ]; +}; + +const normalizeDate = (value?: string | null) => { + const source = String(value ?? "").trim(); + if (!/^\d{8}$/.test(source)) return ""; + return `${source.slice(0, 4)}-${source.slice(4, 6)}-${source.slice(6, 8)}`; +}; + +const normalizeHm = (value?: string | null) => { + const digits = String(value ?? "").replace(/[^0-9]/g, ""); + if (!digits) return ""; + if (digits.length === 1) return `0${digits}:00`; + if (digits.length === 2) return `${digits}:00`; + if (digits.length === 3) + return `0${digits.slice(0, 1)}:${digits.slice(1, 3)}`; + return `${digits.slice(0, 2)}:${digits.slice(2, 4)}`; +}; + +const normalizeDateTime = (value?: string | null) => { + const source = String(value ?? "").trim(); + if (!source) return ""; + if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(source)) return source; + const date = new Date(source); + if (Number.isNaN(date.getTime())) + return source.slice(0, 16).replace("T", " "); + const yyyy = date.getFullYear(); + const mm = `${date.getMonth() + 1}`.padStart(2, "0"); + const dd = `${date.getDate()}`.padStart(2, "0"); + const hh = `${date.getHours()}`.padStart(2, "0"); + const mi = `${date.getMinutes()}`.padStart(2, "0"); + return `${yyyy}-${mm}-${dd} ${hh}:${mi}`; +}; + +const formatNumber = (value?: number | null, digits = 1, suffix = "") => { + const numeric = Number(value ?? 0); + if (!Number.isFinite(numeric)) return `0${suffix}`; + return `${numeric.toFixed(digits)}${suffix}`; +}; + +const formatInteger = (value?: number | null, suffix = "") => { + const numeric = Number(value ?? 0); + if (!Number.isFinite(numeric)) return `0${suffix}`; + return `${numeric.toLocaleString("ko-KR")}${suffix}`; +}; + +const formatOptionalNumber = ( + value?: number | null, + digits = 1, + suffix = "", +) => { + if (value === null || value === undefined) return "-"; + const numeric = Number(value); + if (!Number.isFinite(numeric)) return "-"; + return `${numeric.toFixed(digits)}${suffix}`; +}; + +const buildCauseDetailName = (item: FldngHstryApiItem) => { + const direct = String(item.dtlOcrnCsCn ?? "").trim(); + if (direct) return direct; + const code = String(item.fldngCsDtlCd ?? "").trim(); + return code ? `원인코드 ${code}` : "-"; +}; + +const buildGradeLabel = (avgFloodDepthM: number) => { + if (avgFloodDepthM >= 2) return "3등급"; + if (avgFloodDepthM >= 1) return "2등급"; + return "1등급"; +}; + +export const normalizeFldngHstryFilters = ( + filters: FldngHstryFilters, +): FldngHstryFilters => ({ + ctpvCd: String(filters.ctpvCd ?? "0") || "0", + sggCd: String(filters.sggCd ?? "0") || "0", + floodYear: String(filters.floodYear ?? "") + .replace(/[^0-9]/g, "") + .slice(0, 4), + disasterName: String(filters.disasterName ?? "").trim(), +}); + +export const buildFldngHstryYearOptions = () => { + const currentYear = new Date().getFullYear(); + return Array.from({ length: 30 }, (_, index) => String(currentYear - index)); +}; + +export const buildFldngHstryListQueryString = ( + filters: FldngHstryFilters, + page: number, + size: number, +) => { + const normalized = normalizeFldngHstryFilters(filters); + const params = new URLSearchParams({ + page: String(page - 1), + size: String(size), + }); + + if (normalized.ctpvCd !== "0") + params.set("ctpvCd", normalized.ctpvCd.slice(0, 2)); + if (normalized.sggCd !== "0") + params.set("sggCd", normalized.sggCd.slice(0, 5)); + if (normalized.floodYear) params.set("fldngYr", normalized.floodYear); + if (normalized.disasterName) + params.set("fldngDstrNm", normalized.disasterName); + return params.toString(); +}; + +export const formatFloodArea = (value: number) => + Number(value ?? 0).toLocaleString("ko-KR", { + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }); + +export const formatFloodDepth = (value: number) => value.toFixed(2); + +export const attachFldngHstryGeometry = ( + item: FldngHstryApiItem, +): FldngHstryItem => { + const historyId = String(item.fid ?? ""); + const geometrySeed = buildGeometrySeed(historyId); + const startDate = normalizeDate(item.bgngYmd); + const endDate = normalizeDate(item.endYmd); + const startTime = normalizeHm(item.fldngBgngHm); + const endTime = normalizeHm(item.fldngEndHm); + const avgFloodDepthM = Number(item.avgFldngWtlv ?? 0); + const floodAreaHa = Number(item.fldngArea ?? 0); + + return { + ...item, + historyId, + ctpvNm: "", + sggNm: "", + floodYear: String(item.fldngYr ?? "").trim(), + exmnYear: String(item.exmnYr ?? "").trim(), + disasterName: String(item.fldngDstrNm ?? "").trim(), + districtName: String(item.fldngDstrctNm ?? "").trim(), + floodAreaHa, + avgFloodDepthM, + floodDepthM: avgFloodDepthM, + gradeLabel: buildGradeLabel(avgFloodDepthM), + startDate, + endDate, + startTime, + endTime, + floodHour: formatNumber(Number(item.fldngHr ?? 0), 1), + causeDetailCode: String(item.fldngCsDtlCd ?? "").trim(), + causeDetailName: buildCauseDetailName(item), + damageAmount: formatInteger(Number(item.damAmt ?? 0)), + rainStartDateTime: normalizeDateTime(item.rainBgngTm), + rainEndDateTime: normalizeDateTime(item.rainEndTm), + rainStartTime: normalizeDateTime(item.rainBgngTm).slice(11, 16), + rainEndTime: normalizeDateTime(item.rainEndTm).slice(11, 16), + totalRainfallMm: Number(item.rainTot ?? 0), + rainfallIntensityMmHr: Number(item.rainStr ?? 0), + areaRing: createIrregularRing({ + centerX: geometrySeed.centerX, + centerY: geometrySeed.centerY, + radiusX: geometrySeed.width / 2, + radiusY: geometrySeed.height / 2, + rotationDeg: 9, + scales: [1.08, 0.9, 1.04, 0.82, 0.98, 1.12, 0.88, 0.96, 1.03, 0.86], + }), + depthZones: createFldngHstryDepthZones( + historyId, + geometrySeed.centerX, + geometrySeed.centerY, + ), + }; +}; + +export const toFldngHstryTableRows = ( + items: FldngHstryItem[], + page: number, + pageSize = FLDNG_HSTRY_PAGE_SIZE, +): FldngHstryTableRow[] => + items.map((item, index) => ({ + ...item, + rowNo: (page - 1) * pageSize + index + 1, + floodYearDisplay: item.floodYear || "-", + floodHourDisplay: item.floodHour || "-", + avgFloodDepthDisplay: formatFloodDepth(item.avgFloodDepthM), + floodAreaDisplay: formatFloodArea(item.floodAreaHa), + exmnYearDisplay: item.exmnYear || "-", + damageAmountDisplay: item.damageAmount || "-", + rainBgngTmDisplay: item.rainStartDateTime || "-", + rainEndTmDisplay: item.rainEndDateTime || "-", + rainTotDisplay: formatOptionalNumber(item.rainTot, 1), + rainStrDisplay: formatOptionalNumber(item.rainStr, 1), + })); + +const resolveFldngHstryNodeName = (node: any) => + String( + node?.text ?? + node?.data?.lyrNm ?? + node?.data?.name ?? + node?.lyrNm ?? + node?.name ?? + "", + ).trim(); + +const resolveFldngHstryLayerType = (node: any) => + String( + node?.data?.lyrTyNm ?? + node?.data?.lyrTypeNm ?? + node?.lyrTyNm ?? + node?.lyrTypeNm ?? + "", + ).trim(); + +export const isFldngHstryNode = (node: any) => { + const name = resolveFldngHstryNodeName(node); + const layerType = resolveFldngHstryLayerType(node); + return ( + name === FLOOD_HISTORY_LAYER_NAME || + layerType === FLOOD_HISTORY_LAYER_TYPE || + layerType === FLOOD_HISTORY_PREVIEW_LAYER_TYPE + ); +}; + +export const isFloodHistoryNode = isFldngHstryNode; +export const FLOOD_HISTORY_ROW_SELECT_MESSAGE = FLDNG_HSTRY_ROW_SELECT_MESSAGE; +export const FLOOD_HISTORY_PAGE_SIZE = FLDNG_HSTRY_PAGE_SIZE; +export const FLOOD_HISTORY_DOWNLOAD_BATCH_SIZE = + FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE; diff --git a/apps/sn/app/(map-layout)/map/components/floodDngrMap/FloodDngrMapPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/floodDngrMap/FloodDngrMapPopupPage.tsx new file mode 100644 index 000000000..b65c7b38f --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/floodDngrMap/FloodDngrMapPopupPage.tsx @@ -0,0 +1,398 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Button } from "@common/components"; +import { StandardPopupLayout } from "@common/business_components"; +import { getData } from "@/components/lib/api"; +import LoadingOverlay from "@/components/ui/LoadingBar"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types"; +import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable"; +import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel"; +import useStore from "@/store/useStore"; +import { + FLOOD_DNGR_MAP_DEFAULT_FILTERS, + FLOOD_DNGR_MAP_DEPTH_OPTIONS, + FLOOD_DNGR_MAP_FREQUENCY_OPTIONS, + FLOOD_DNGR_MAP_HEADLIST, + FLOOD_DNGR_MAP_PAGE_SIZE, + buildFloodDngrMapListQueryString, + toFloodDngrMapTableRows, + type FloodDngrMapApiItem, + type FloodDngrMapExtent, + type FloodDngrMapFilters, + type FloodDngrMapTableRow, +} from "./floodDngrMapTypes"; + +const FLOOD_DNGR_MAP_REQUEST_TIMEOUT_MS = 10_000; +const FLOOD_DNGR_MAP_LOADING_WATCHDOG_MS = 12_000; + +/** Abort 처리 지연 여부와 무관하게 제한 시간이 지나면 팝업 요청 Promise를 종료한다. */ +const getFloodDngrMapJson = async (url: string) => { + let timeoutId: number | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = window.setTimeout(() => { + reject( + new Error( + "도시침수지도 조회 시간이 초과되었습니다. 검색조건을 좁혀 다시 조회해주세요.", + ), + ); + }, FLOOD_DNGR_MAP_REQUEST_TIMEOUT_MS); + }); + try { + return await Promise.race([getData(url), timeoutPromise]); + } finally { + if (timeoutId !== undefined) window.clearTimeout(timeoutId); + } +}; + +/** 시설물 레이어의 정보제공포털 도시침수지도 피처를 조건별 목록으로 제공한다. */ +export default function FloodDngrMapPopupPage() { + const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore(); + const [filters, setFilters] = useState( + FLOOD_DNGR_MAP_DEFAULT_FILTERS, + ); + const [appliedFilters, setAppliedFilters] = useState( + FLOOD_DNGR_MAP_DEFAULT_FILTERS, + ); + const [items, setItems] = useState([]); + const [administrativeScopeCodes, setAdministrativeScopeCodes] = useState< + string[] | null + >(null); + const [total, setTotal] = useState(0); + const [currentPage, setCurrentPage] = useState(1); + const [isLoading, setIsLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + + const availableCtpvCodes = useMemo( + () => + new Set( + (administrativeScopeCodes ?? []) + .map((code) => code.slice(0, 2)) + .filter(Boolean), + ), + [administrativeScopeCodes], + ); + + const availableSggCodes = useMemo( + () => + new Set( + (administrativeScopeCodes ?? []) + .map((code) => code.slice(0, 5)) + .filter(Boolean), + ), + [administrativeScopeCodes], + ); + + const ctpvOptions = useMemo( + () => + administrativeScopeCodes === null + ? ctpv + : ctpv.filter((item) => + availableCtpvCodes.has(String(item.val).trim().slice(0, 2)), + ), + [administrativeScopeCodes, availableCtpvCodes, ctpv], + ); + + const sggOptions = useMemo(() => { + const scopedOptions = + administrativeScopeCodes === null + ? sgg + : sgg.filter((item) => + availableSggCodes.has(String(item.val).trim().slice(0, 5)), + ); + return filters.ctpvCd === "0" + ? scopedOptions + : scopedOptions.filter((item) => + String(item.val).startsWith(filters.ctpvCd.slice(0, 2)), + ); + }, [administrativeScopeCodes, availableSggCodes, filters.ctpvCd, sgg]); + + const rows = useMemo( + () => + toFloodDngrMapTableRows( + items, + currentPage - 1, + FLOOD_DNGR_MAP_PAGE_SIZE, + ctpv, + sgg, + total, + ), + [ctpv, currentPage, items, sgg, total], + ); + + useEffect(() => { + if (ctpv.length === 0) void fetchCtpv(); + if (sgg.length === 0) void fetchSgg(); + }, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]); + + /** 요청 Promise가 예상과 다르게 정리되지 않아도 로딩 오버레이를 최대 12초 안에 해제한다. */ + useEffect(() => { + if (!isLoading) return; + const watchdogId = window.setTimeout(() => { + setIsLoading(false); + setErrorMessage( + (current) => + current || + "도시침수지도 조회가 지연되어 로딩을 종료했습니다. 검색조건을 좁혀 다시 조회해주세요.", + ); + }, FLOOD_DNGR_MAP_LOADING_WATCHDOG_MS); + return () => window.clearTimeout(watchdogId); + }, [isLoading]); + + /** 마지막 적용 조건으로 서버 페이지를 조회한다. */ + const loadRows = useCallback( + async (page: number, nextFilters: FloodDngrMapFilters) => { + setIsLoading(true); + setErrorMessage(""); + try { + const queryString = buildFloodDngrMapListQueryString( + nextFilters, + page - 1, + FLOOD_DNGR_MAP_PAGE_SIZE, + ); + const response = await getFloodDngrMapJson( + `/sewerage-service/api/v1/fldng/flood-dngr-map?${queryString}`, + ); + const payload = response as { + items?: FloodDngrMapApiItem[]; + total?: number; + } | null; + if (!payload || !Array.isArray(payload.items)) { + throw new Error( + "도시침수지도 목록 API 응답 형식이 올바르지 않습니다.", + ); + } + setItems(payload.items); + setTotal(Number(payload?.total ?? 0)); + setCurrentPage(page); + setAppliedFilters(nextFilters); + } catch (error) { + console.error("도시침수지도 목록 조회 중 오류:", error); + setItems([]); + setTotal(0); + setErrorMessage( + error instanceof Error + ? error.message + : "도시침수지도 목록 조회 중 오류가 발생했습니다.", + ); + } finally { + setIsLoading(false); + } + }, + [], + ); + + /** 실제 도시침수지도 데이터가 존재하는 행정구역 코드만 검색 선택지로 제한한다. */ + const loadAdministrativeScope = useCallback(async () => { + try { + const response = await getFloodDngrMapJson( + "/sewerage-service/api/v1/fldng/flood-dngr-map/scope", + ); + if (!Array.isArray(response)) { + throw new Error( + "도시침수지도 행정구역 API 응답 형식이 올바르지 않습니다.", + ); + } + setAdministrativeScopeCodes(response); + } catch (error) { + console.error("도시침수지도 행정구역 범위 조회 중 오류:", error); + setAdministrativeScopeCodes(null); + } + }, []); + + useEffect(() => { + void loadRows(1, FLOOD_DNGR_MAP_DEFAULT_FILTERS); + void loadAdministrativeScope(); + }, [loadAdministrativeScope, loadRows]); + + const handleSearch = () => { + void loadRows(1, filters); + }; + + /** 시군구·침수빈도 그룹의 범위와 선택 조건을 부모 지도에 전달한다. */ + const handleRowClick = async (row: FloodDngrMapTableRow) => { + if (!row?.sggCd || !row?.sbmrgFrqExpln) return; + setIsLoading(true); + setErrorMessage(""); + try { + const params = new URLSearchParams({ + sggCd: row.sggCd, + sbmrgFrqExpln: row.sbmrgFrqExpln, + }); + const response = await getFloodDngrMapJson( + `/sewerage-service/api/v1/fldng/flood-dngr-map/group/extent?${params.toString()}`, + ); + const payload = response as FloodDngrMapExtent; + const extent = [ + payload?.minX, + payload?.minY, + payload?.maxX, + payload?.maxY, + ].map(Number); + if ( + extent.length !== 4 || + extent.some((value) => !Number.isFinite(value)) + ) { + throw new Error("해당 도시침수지도 피처의 위치 정보가 없습니다."); + } + window.opener?.postMessage( + { + type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_FLOOD_GROUP, + payload: { + extent, + sggCd: row.sggCd, + sbmrgFrqExpln: row.sbmrgFrqExpln, + }, + }, + window.location.origin, + ); + } catch (error) { + console.error("도시침수지도 위치 이동 중 오류:", error); + const message = + error instanceof Error && error.message.includes("404") + ? "해당 도시침수지도 데이터에 위치 정보가 없어 이동할 수 없습니다." + : error instanceof Error + ? error.message + : "도시침수지도 위치 이동 중 오류가 발생했습니다."; + setErrorMessage(message); + } finally { + setIsLoading(false); + } + }; + + return ( + + + + + + + + + + + +
+ + +
+
+ + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + + >} + headlist={FLOOD_DNGR_MAP_HEADLIST} + title="도시침수지도 목록" + total={total} + page={currentPage} + pageSize={FLOOD_DNGR_MAP_PAGE_SIZE} + onPageChange={(page) => void loadRows(page, appliedFilters)} + onRowClick={(row) => void handleRowClick(row)} + rowId={(row, index) => `${row.sggCd}-${row.sbmrgFrqExpln}-${index}`} + scrollMode="horizontal" + minTableWidth="120rem" + /> +
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/floodDngrMap/floodDngrMapTypes.ts b/apps/sn/app/(map-layout)/map/components/floodDngrMap/floodDngrMapTypes.ts new file mode 100644 index 000000000..fd5a335b1 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/floodDngrMap/floodDngrMapTypes.ts @@ -0,0 +1,187 @@ +"use client"; + +import type { UiHeadProps } from "@common/business_components/ui"; + +export const FLOOD_DNGR_MAP_POPUP_KEY = "floodDngrMapPopup"; +export const FLOOD_DNGR_MAP_LAYER_TYPE = "tb_ms_flood_dngr_map"; +export const FLOOD_DNGR_MAP_LAYER_NAME = "도시침수지도(정보제공포털)"; +export const FLOOD_DNGR_MAP_PAGE_SIZE = 20; + +export type FloodDngrMapFilters = { + ctpvCd: string; + sggCd: string; + sbmrgDpthlCd: string; + sbmrgFrqExpln: string; +}; + +export type FloodDngrMapApiItem = { + ctpvCd: string; + sggCd: string; + minSbmrgDpthlCd: string; + maxSbmrgDpthlCd: string; + sbmrgFrqExpln: string; +}; + +export type FloodDngrMapExtent = { + minX: number; + minY: number; + maxX: number; + maxY: number; +}; + +export type FloodDngrMapTableRow = FloodDngrMapApiItem & { + rowNo: number; + ctpvNm: string; + sggNm: string; + sbmrgDpthlDisplay: string; + sbmrgFrqDisplay: string; +}; + +type AdministrativeOption = { + val: unknown; + text: unknown; +}; + +export const FLOOD_DNGR_MAP_DEFAULT_FILTERS: FloodDngrMapFilters = { + ctpvCd: "0", + sggCd: "0", + sbmrgDpthlCd: "0", + sbmrgFrqExpln: "0", +}; + +export const FLOOD_DNGR_MAP_FREQUENCY_OPTIONS = ["030", "050", "080", "100"]; + +export const FLOOD_DNGR_MAP_DEPTH_LABELS: Record = { + N330: "0.0~0.5m 미만", + N331: "0.5~1.0m 미만", + N332: "1.0~2.0m 미만", + N333: "2.0~5.0m 미만", + N334: "5.0m 이상", +}; + +export const FLOOD_DNGR_MAP_DEPTH_OPTIONS = Object.entries( + FLOOD_DNGR_MAP_DEPTH_LABELS, +).map(([value, label]) => ({ value, label })); + +export const FLOOD_DNGR_MAP_HEADLIST: UiHeadProps[] = [ + { id: "rowNo", title: "순번", widthPercent: 8 }, + { id: "ctpvNm", title: "시도", widthPercent: 18 }, + { id: "sggNm", title: "시군구", widthPercent: 20 }, + { + id: "sbmrgDpthlDisplay", + title: "침수심도(최소~최대)", + widthPercent: 31, + }, + { + id: "sbmrgFrqDisplay", + title: "침수빈도", + widthPercent: 23, + }, +]; + +/** API가 허용하는 검색 조건과 페이징 파라미터만 쿼리 문자열로 만든다. */ +export const buildFloodDngrMapListQueryString = ( + filters: FloodDngrMapFilters, + page: number, + size: number, +) => { + const params = new URLSearchParams({ + page: String(page), + size: String(size), + }); + if (filters.ctpvCd && filters.ctpvCd !== "0") + params.set("ctpvCd", filters.ctpvCd.slice(0, 2)); + if (filters.sggCd && filters.sggCd !== "0") + params.set("sggCd", filters.sggCd.slice(0, 5)); + if (filters.sbmrgDpthlCd && filters.sbmrgDpthlCd !== "0") { + params.set("sbmrgDpthlCd", filters.sbmrgDpthlCd); + } + if (filters.sbmrgFrqExpln && filters.sbmrgFrqExpln !== "0") { + params.set("sbmrgFrqExpln", filters.sbmrgFrqExpln); + } + return params.toString(); +}; + +const resolveAdministrativeName = ( + value: unknown, + options: AdministrativeOption[], + codeLength: number, +) => { + const code = String(value ?? "") + .trim() + .slice(0, codeLength); + const matched = options.find( + (option) => + String(option.val ?? "") + .trim() + .slice(0, codeLength) === code, + ); + return String(matched?.text ?? "").trim() || "-"; +}; + +const toFrequencyDisplay = (value: unknown) => { + const normalized = String(value ?? "").trim(); + if (!normalized) return "-"; + const numericValue = Number(normalized); + return Number.isFinite(numericValue) ? `${numericValue}년` : normalized; +}; + +const FLOOD_DNGR_MAP_DEPTH_BOUNDS: Record< + string, + { min: string; max: string; openEnded?: boolean } +> = { + N330: { min: "0.0", max: "0.5" }, + N331: { min: "0.5", max: "1.0" }, + N332: { min: "1.0", max: "2.0" }, + N333: { min: "2.0", max: "5.0" }, + N334: { min: "5.0", max: "5.0", openEnded: true }, +}; + +/** 그룹의 최소·최대 침수심도 코드를 하나의 범위 문구로 표시한다. */ +const toDepthRangeDisplay = (minCode: string, maxCode: string) => { + if (!minCode && !maxCode) return "-"; + if (minCode === maxCode) + return FLOOD_DNGR_MAP_DEPTH_LABELS[minCode] || minCode || "-"; + const minDepth = FLOOD_DNGR_MAP_DEPTH_BOUNDS[minCode]; + const maxDepth = FLOOD_DNGR_MAP_DEPTH_BOUNDS[maxCode]; + if (!minDepth || !maxDepth) return `${minCode || "-"} ~ ${maxCode || "-"}`; + return maxDepth.openEnded + ? `${minDepth.min}m ~ ${maxDepth.min}m 이상` + : `${minDepth.min}m ~ ${maxDepth.max}m 미만`; +}; + +/** 서버 페이지 번호와 코드 표시값을 화면 행으로 변환한다. */ +export const toFloodDngrMapTableRows = ( + items: FloodDngrMapApiItem[], + page: number, + pageSize = FLOOD_DNGR_MAP_PAGE_SIZE, + ctpvOptions: AdministrativeOption[] = [], + sggOptions: AdministrativeOption[] = [], + total = items.length, +): FloodDngrMapTableRow[] => + items.map((item, index) => ({ + ...item, + rowNo: total - page * pageSize - index, + ctpvNm: resolveAdministrativeName(item.ctpvCd, ctpvOptions, 2), + sggNm: resolveAdministrativeName(item.sggCd, sggOptions, 5), + sbmrgDpthlDisplay: toDepthRangeDisplay( + item.minSbmrgDpthlCd, + item.maxSbmrgDpthlCd, + ), + sbmrgFrqDisplay: toFrequencyDisplay(item.sbmrgFrqExpln), + })); + +const resolveNodeName = (node: any) => + String( + node?.text ?? + node?.data?.lyrNm ?? + node?.data?.name ?? + node?.lyrNm ?? + node?.name ?? + "", + ).trim(); + +/** 빈도·침수심 자식 노드가 아니라 도시침수지도 루트만 목록보기 대상으로 판별한다. */ +export const isFloodDngrMapRootNode = (node: any) => + resolveNodeName(node).replace(/\s/g, "") === + FLOOD_DNGR_MAP_LAYER_NAME.replace(/\s/g, ""); diff --git a/apps/sn/app/(map-layout)/map/components/floodHistory/FloodHistoryDetailPanel.tsx b/apps/sn/app/(map-layout)/map/components/floodHistory/FloodHistoryDetailPanel.tsx deleted file mode 100644 index 66ed55078..000000000 --- a/apps/sn/app/(map-layout)/map/components/floodHistory/FloodHistoryDetailPanel.tsx +++ /dev/null @@ -1,142 +0,0 @@ -"use client"; - -import { useMemo, useState } from "react"; -import { formatFloodArea, formatFloodDepth, type FloodHistoryItem } from "./floodHistoryTypes"; - -type Props = { - detail: FloodHistoryItem; - onClose: () => void; - position: { - left: number; - top: number; - }; -}; - -type DetailTab = "year" | "disaster" | "rainfall"; - -const renderRows = (rows: Array<[string, string]>) => ( - - - - - - - {rows.map(([label, value], index) => ( - - - - - ))} - -
- {label} - - {value} -
-); - -export default function FloodHistoryDetailPanel({ detail, onClose, position }: Props) { - // 지도에서 침수흔적도 polygon을 클릭했을 때, 상세정보는 업무 의미 기준으로 3개 탭에 나눠 보여준다. - const [activeTab, setActiveTab] = useState("year"); - - const yearRows = useMemo( - () => [ - ["침수심", `${detail.floodDepthM.toFixed(2)} m`], - ["침수면적", formatFloodArea(detail.floodAreaHa)], - ["침수년도", `${detail.floodYear}년`], - ["등급", detail.gradeLabel], - ["침수구역", detail.districtName], - ] as Array<[string, string]>, - [detail], - ); - - const disasterRows = useMemo( - () => [ - ["침수재해명", detail.disasterName], - ["침수면적", formatFloodArea(detail.floodAreaHa)], - ["침수원인상세명", detail.causeDetailName], - ["침수시작년월일", detail.startDate], - ["침수종료년월일", detail.endDate], - ["침수시작시간", detail.startTime], - ] as Array<[string, string]>, - [detail], - ); - - const rainfallRows = useMemo( - () => [ - ["침수시작일자", detail.startDate], - ["침수종료일자", detail.endDate], - ["침수시작시간", detail.startTime], - ["침수종료시간", detail.endTime], - ["침수시간", detail.floodHour], - ["강우시작시간", detail.rainStartTime], - ["강우종료시간", detail.rainEndTime], - ["총강우량(mm)", `${detail.totalRainfallMm.toFixed(0)}`], - ["강우강도(mm/hr)", `${detail.rainfallIntensityMmHr.toFixed(0)}`], - ["침수시간", detail.floodHour], - ["침수원인상세코드", detail.causeDetailCode], - ["피해금액", detail.damageAmount], - ] as Array<[string, string]>, - [detail], - ); - - const currentRows = activeTab === "year" - ? yearRows - : activeTab === "disaster" - ? disasterRows - : rainfallRows; - - return ( -
-
-
상세정보
- -
- -
-
- {[ - ["year", "년도탭"], - ["disaster", "재해명탭"], - ["rainfall", "침수발생강우탭"], - ].map(([tabId, label]) => { - const isActive = activeTab === tabId; - - return ( - - ); - })} -
-
- -
-
- {renderRows(currentRows)} -
-
-
- ); -} diff --git a/apps/sn/app/(map-layout)/map/components/floodHistory/FloodHistoryPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/floodHistory/FloodHistoryPopupPage.tsx deleted file mode 100644 index f9ae60995..000000000 --- a/apps/sn/app/(map-layout)/map/components/floodHistory/FloodHistoryPopupPage.tsx +++ /dev/null @@ -1,408 +0,0 @@ -"use client"; - -import { useEffect, useMemo, useState } from "react"; -import { Button, Input, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components"; -import { downloadExcelWithKoreanHeaders } from "@common/business_components/ui"; -import UiPageNations from "@common/business_components/ui/table/uiPageNations"; -import useStore from "@/store/useStore"; -import { - buildFloodHistoryYearOptions, - buildFloodHistoryTableRows, - FLOOD_HISTORY_DEFAULT_FILTERS, - FLOOD_HISTORY_DOWNLOAD_HEADERS, - FLOOD_HISTORY_ITEMS, - FLOOD_HISTORY_PAGE_SIZE, - FLOOD_HISTORY_ROW_SELECT_MESSAGE, - formatFloodArea, - formatFloodDepth, - normalizeFloodHistoryFilters, - type FloodHistoryFilters, - type FloodHistoryItem, -} from "./floodHistoryTypes"; - -const resolveOptionText = ( - code: string, - options: Array<{ text: string; val: string | number }>, - prefixLength?: number, -) => { - const normalized = String(code ?? "").trim(); - if (!normalized || normalized === "0") return ""; - const lookup = prefixLength ? normalized.slice(0, prefixLength) : normalized; - return options.find((item) => String(item.val).trim() === lookup)?.text ?? ""; -}; - -const GRID_COLUMNS = [ - { id: "rowNo", title: "NO", width: "64px", align: "center" }, - { id: "ctpvNm", title: "시도", width: "90px", align: "center" }, - { id: "sggNm", title: "시군구", width: "100px", align: "center" }, - { id: "startDateDisplay", title: "침수시작일자", width: "118px", align: "center" }, - { id: "endDateDisplay", title: "침수종료일자", width: "118px", align: "center" }, - { id: "startTimeDisplay", title: "침수시작시간", width: "98px", align: "center" }, - { id: "endTimeDisplay", title: "침수종료시간", width: "98px", align: "center" }, - { id: "floodYearDisplay", title: "침수년도", width: "96px", align: "center" }, - { id: "floodHourDisplay", title: "침수시간", width: "100px", align: "center" }, - { id: "disasterName", title: "침수재해명", width: "190px", align: "left" }, - { id: "districtNameDisplay", title: "침수지구명", width: "150px", align: "left" }, - { id: "causeCodeDisplay", title: "침수원인상세코드", width: "132px", align: "center" }, - { id: "causeDetailDisplay", title: "침수원인상세", width: "190px", align: "left" }, - { id: "avgFloodDepthDisplay", title: "평균침수위", width: "104px", align: "right" }, - { id: "floodAreaDisplay", title: "침수면적", width: "104px", align: "right" }, - { id: "exmnYearDisplay", title: "조사년도", width: "96px", align: "center" }, - { id: "damageAmountDisplay", title: "피해금액", width: "132px", align: "right" }, -] as const; - -const buildDownloadRows = (rows: FloodHistoryItem[]) => - rows.map((row, index) => ({ - rowNo: index + 1, - ctpvNm: row.ctpvNm, - sggNm: row.sggNm, - disasterName: row.disasterName, - exmnYearDisplay: `${row.exmnYear}년`, - avgFloodDepthDisplay: formatFloodDepth(row.avgFloodDepthM), - floodAreaDisplay: formatFloodArea(row.floodAreaHa), - causeDetailDisplay: row.causeDetailName, - startDateDisplay: row.startDate, - endDateDisplay: row.endDate, - startTimeDisplay: row.startTime, - endTimeDisplay: row.endTime, - causeCodeDisplay: row.causeDetailCode, - districtNameDisplay: row.districtName, - damageAmountDisplay: row.damageAmount, - floodHourDisplay: row.floodHour, - floodYearDisplay: `${row.floodYear}년`, - })); - -export default function FloodHistoryPopupPage() { - const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore(); - // 조회조건 draft는 popup 내부에서 사용자가 편집 중인 값이다. - const [filters, setFilters] = useState(FLOOD_HISTORY_DEFAULT_FILTERS); - // 적용된 조회조건과 선택행은 다운로드/페이지 이동에도 같은 기준을 유지하기 위해 분리한다. - const [appliedFilters, setAppliedFilters] = useState(FLOOD_HISTORY_DEFAULT_FILTERS); - const [currentPage, setCurrentPage] = useState(1); - const [selectedHistoryId, setSelectedHistoryId] = useState(""); - - useEffect(() => { - if (ctpv.length === 0) void fetchCtpv(); - if (sgg.length === 0) void fetchSgg(); - }, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]); - - const floodYearOptions = useMemo( - () => buildFloodHistoryYearOptions(FLOOD_HISTORY_ITEMS), - [], - ); - - const sggOptions = useMemo( - () => - filters.ctpvCd === "0" - ? sgg - : sgg.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2))), - [filters.ctpvCd, sgg], - ); - - const filteredRows = useMemo(() => { - const normalized = normalizeFloodHistoryFilters(appliedFilters); - const selectedCtpvName = resolveOptionText(normalized.ctpvCd, ctpv as any, 2); - const selectedSggName = resolveOptionText(normalized.sggCd, sgg as any, 5); - const disasterToken = normalized.disasterName.toLocaleLowerCase("ko-KR"); - - return FLOOD_HISTORY_ITEMS.filter((item) => { - if (selectedCtpvName && item.ctpvNm !== selectedCtpvName) return false; - if (selectedSggName && item.sggNm !== selectedSggName) return false; - if (normalized.floodYear && item.floodYear !== normalized.floodYear) return false; - if ( - disasterToken && - !item.disasterName.toLocaleLowerCase("ko-KR").includes(disasterToken) && - !item.districtName.toLocaleLowerCase("ko-KR").includes(disasterToken) - ) { - return false; - } - return true; - }).sort((left, right) => { - if (left.floodYear !== right.floodYear) { - return Number(right.floodYear) - Number(left.floodYear); - } - return left.disasterName.localeCompare(right.disasterName, "ko"); - }); - }, [appliedFilters, ctpv, sgg]); - - const pagedRows = useMemo(() => { - const start = (currentPage - 1) * FLOOD_HISTORY_PAGE_SIZE; - const end = start + FLOOD_HISTORY_PAGE_SIZE; - return buildFloodHistoryTableRows( - filteredRows.slice(start, end), - currentPage, - FLOOD_HISTORY_PAGE_SIZE, - ); - }, [currentPage, filteredRows]); - - const handleSearch = () => { - const normalized = normalizeFloodHistoryFilters(filters); - - if (normalized.sggCd !== "0" && normalized.ctpvCd === "0") { - alert("시도를 먼저 선택하세요."); - return; - } - if (normalized.floodYear && normalized.floodYear.length !== 4) { - alert("침수발생년도는 4자리 연도로 입력하세요."); - return; - } - - setAppliedFilters(normalized); - setCurrentPage(1); - setSelectedHistoryId(""); - }; - - const handleDownload = async () => { - if (filteredRows.length === 0) { - alert("다운로드할 침수발생이력 데이터가 없습니다."); - return; - } - - await downloadExcelWithKoreanHeaders({ - data: buildDownloadRows(filteredRows) as unknown as JSON[], - fileName: `침수흔적도_${new Date().toISOString().slice(0, 10)}.xlsx`, - sheetNameArray: ["침수흔적도"], - headerRange: "A1:Q1", - headerMapping: FLOOD_HISTORY_DOWNLOAD_HEADERS, - }); - }; - - const handleRowDoubleClick = (row: FloodHistoryItem) => { - window.opener?.postMessage( - { - type: FLOOD_HISTORY_ROW_SELECT_MESSAGE, - row, - openDetail: false, - }, - window.location.origin, - ); - window.close(); - }; - - const handleRowClick = (row: FloodHistoryItem) => { - setSelectedHistoryId(row.historyId); - // popup을 유지한 채 메인 지도에 mock 침수흔적/침수심 preview를 먼저 보여준다. - window.opener?.postMessage( - { - type: FLOOD_HISTORY_ROW_SELECT_MESSAGE, - row, - openDetail: false, - }, - window.location.origin, - ); - }; - - return ( -
-
-

침수흔적도 목록

-
-
- -
-
-
- - - - - - - - -
- - -
-
-
- -
-
-

- 침수흔적도 조회 결과 {filteredRows.length.toLocaleString("ko-KR")}건 -

-
- -
- - - {GRID_COLUMNS.map((head) => ( - - ))} - - - - NO - 시도 - 시군구 - 강우 분석정보 - 침수재해명 - 침수년도 - 침수시간 - 침수지구명 - 침수원인상세코드 - 침수원인상세 - 평균침수위 - 침수면적 - 조사년도 - 피해금액 - - - 침수시작일자 - 침수종료일자 - 침수시작시간 - 침수종료시간 - - - - {pagedRows.length > 0 ? ( - pagedRows.map((row) => { - const isSelected = selectedHistoryId === row.historyId; - - return ( - handleRowClick(row)} - onDoubleClick={() => handleRowDoubleClick(row)} - > - {row.rowNo} - {row.ctpvNm} - {row.sggNm} - {row.startDateDisplay} - {row.endDateDisplay} - {row.startTimeDisplay} - {row.endTimeDisplay} - {row.disasterName} - {row.floodYearDisplay} - {row.floodHourDisplay} - {row.districtNameDisplay} - {row.causeCodeDisplay} - {row.causeDetailDisplay} - {row.avgFloodDepthDisplay} - {row.floodAreaDisplay} - {row.exmnYearDisplay} - {row.damageAmountDisplay} - - ); - }) - ) : ( - - - 조회된 데이터가 없습니다. - - - )} - -
-
- - {filteredRows.length > 0 ? ( - - ) : null} - -
- 행 클릭 시 메인 지도에 미리 표시되고, 더블클릭하면 팝업을 닫습니다. 상세정보는 지도 위 침수흔적 영역을 클릭해 확인합니다. -
-
-
-
- ); -} diff --git a/apps/sn/app/(map-layout)/map/components/floodHistory/floodHistoryTypes.ts b/apps/sn/app/(map-layout)/map/components/floodHistory/floodHistoryTypes.ts deleted file mode 100644 index fd6ef484e..000000000 --- a/apps/sn/app/(map-layout)/map/components/floodHistory/floodHistoryTypes.ts +++ /dev/null @@ -1,458 +0,0 @@ -"use client"; - -export const FLOOD_HISTORY_POPUP_KEY = "floodHistoryPopup"; -export const FLOOD_HISTORY_ROW_SELECT_MESSAGE = "SN_FLOOD_HISTORY_ROW_SELECT"; -export const FLOOD_HISTORY_LAYER_TYPE = "tb_sn_flood_trace_mock"; -export const FLOOD_HISTORY_LAYER_NAME = "침수흔적도"; -export const FLOOD_HISTORY_AREA_LAYER_ID = "SN_FLOOD_HISTORY_AREA"; -export const FLOOD_HISTORY_DEPTH_LAYER_ID = "SN_FLOOD_HISTORY_DEPTH"; -export const FLOOD_HISTORY_PAGE_SIZE = 10; - -export type FloodHistoryFilters = { - ctpvCd: string; - sggCd: string; - floodYear: string; - disasterName: string; -}; - -export type FloodHistoryDepthZone = { - zoneId: string; - depthLabel: string; - color: string; - ring: [number, number][]; -}; - -export type FloodHistoryItem = { - historyId: string; - ctpvNm: string; - sggNm: string; - floodYear: string; - exmnYear: string; - disasterName: string; - districtName: string; - floodAreaHa: number; - avgFloodDepthM: number; - floodDepthM: number; - gradeLabel: string; - startDate: string; - endDate: string; - startTime: string; - endTime: string; - floodHour: string; - causeDetailCode: string; - causeDetailName: string; - damageAmount: string; - rainStartTime: string; - rainEndTime: string; - totalRainfallMm: number; - rainfallIntensityMmHr: number; - areaRing: [number, number][]; - depthZones: FloodHistoryDepthZone[]; -}; - -export type FloodHistoryTableRow = FloodHistoryItem & { - rowNo: number; - exmnYearDisplay: string; - avgFloodDepthDisplay: string; - floodAreaDisplay: string; - causeDetailDisplay: string; - startDateDisplay: string; - endDateDisplay: string; - startTimeDisplay: string; - endTimeDisplay: string; - causeCodeDisplay: string; - districtNameDisplay: string; - damageAmountDisplay: string; - floodHourDisplay: string; - floodYearDisplay: string; -}; - -export const FLOOD_HISTORY_DEFAULT_FILTERS: FloodHistoryFilters = { - ctpvCd: "0", - sggCd: "0", - floodYear: "", - disasterName: "", -}; - -export const FLOOD_HISTORY_DOWNLOAD_HEADERS: Record = { - rowNo: "NO", - ctpvNm: "시도", - sggNm: "시군구", - disasterName: "침수재해명", - exmnYearDisplay: "조사년도", - avgFloodDepthDisplay: "평균침수위", - floodAreaDisplay: "침수면적", - causeDetailDisplay: "침수원인상세", - startDateDisplay: "침수시작일자", - endDateDisplay: "침수종료일자", - startTimeDisplay: "침수시작시간", - endTimeDisplay: "침수종료시간", - causeCodeDisplay: "침수원인상세코드", - districtNameDisplay: "침수지구명", - damageAmountDisplay: "피해금액", - floodHourDisplay: "침수시간", - floodYearDisplay: "침수년도", -}; - -export const FLOOD_HISTORY_DEPTH_LEGEND_ITEMS = [ - { color: "#fff27a", label: "0.0~0.5m" }, - { color: "#c2f13d", label: "0.5~1.0m" }, - { color: "#67d8ff", label: "1.0~2.0m" }, - { color: "#8f8bff", label: "2.0~5.0m" }, - { color: "#f2528b", label: "5.0m 이상" }, -] as const; - -const toRadian = (degree: number) => (degree * Math.PI) / 180; - -const createIrregularRing = ({ - centerX, - centerY, - radiusX, - radiusY, - scales, - rotationDeg = 0, -}: { - centerX: number; - centerY: number; - radiusX: number; - radiusY: number; - scales: number[]; - rotationDeg?: number; -}): [number, number][] => { - const rotation = toRadian(rotationDeg); - const points = scales.map((scale, index) => { - const angle = rotation + (Math.PI * 2 * index) / scales.length; - const jitterX = radiusX * scale * Math.cos(angle); - const jitterY = radiusY * scale * Math.sin(angle); - return [centerX + jitterX, centerY + jitterY] as [number, number]; - }); - - return [...points, points[0]]; -}; - -const createDepthZones = ( - historyId: string, - centerX: number, - centerY: number, -): FloodHistoryDepthZone[] => { - return [ - { - zoneId: `${historyId}-depth-1`, - depthLabel: "0.0~0.5m", - color: "#fff27a", - ring: createIrregularRing({ - centerX: centerX - 5200, - centerY: centerY - 2600, - radiusX: 3600, - radiusY: 2900, - rotationDeg: -18, - scales: [1.02, 0.78, 0.9, 1.08, 0.86, 0.72, 0.94, 1.04], - }), - }, - { - zoneId: `${historyId}-depth-2`, - depthLabel: "0.5~1.0m", - color: "#c2f13d", - ring: createIrregularRing({ - centerX: centerX + 2600, - centerY: centerY - 1200, - radiusX: 4300, - radiusY: 3600, - rotationDeg: 12, - scales: [1.04, 0.88, 1.02, 0.8, 0.92, 1.08, 0.84, 0.95], - }), - }, - { - zoneId: `${historyId}-depth-3`, - depthLabel: "1.0~2.0m", - color: "#67d8ff", - ring: createIrregularRing({ - centerX: centerX - 600, - centerY: centerY + 2600, - radiusX: 5600, - radiusY: 4700, - rotationDeg: 8, - scales: [1.12, 0.86, 0.94, 1.06, 0.82, 0.97, 1.04, 0.9, 1.08, 0.84], - }), - }, - { - zoneId: `${historyId}-depth-4`, - depthLabel: "2.0~5.0m", - color: "#8f8bff", - ring: createIrregularRing({ - centerX: centerX + 4200, - centerY: centerY + 5200, - radiusX: 3200, - radiusY: 2500, - rotationDeg: 26, - scales: [1.05, 0.82, 1.08, 0.88, 0.96, 0.78, 1.02, 0.9], - }), - }, - { - zoneId: `${historyId}-depth-5`, - depthLabel: "5.0m 이상", - color: "#f2528b", - ring: createIrregularRing({ - centerX: centerX + 600, - centerY: centerY - 5200, - radiusX: 1500, - radiusY: 1350, - rotationDeg: -10, - scales: [1.0, 0.8, 1.06, 0.84, 0.94, 0.76, 0.98], - }), - }, - ]; -}; - -const createHistory = ( - item: Omit & { - centerX: number; - centerY: number; - width: number; - height: number; - }, -): FloodHistoryItem => { - const { centerX, centerY, width, height, ...detail } = item; - - return { - ...detail, - areaRing: createIrregularRing({ - centerX, - centerY, - radiusX: width / 2, - radiusY: height / 2, - rotationDeg: 9, - scales: [1.08, 0.9, 1.04, 0.82, 0.98, 1.12, 0.88, 0.96, 1.03, 0.86], - }), - depthZones: createDepthZones(detail.historyId, centerX, centerY), - }; -}; - -export const FLOOD_HISTORY_ITEMS: FloodHistoryItem[] = [ - createHistory({ - historyId: "FH-2024-001", - ctpvNm: "부산광역시", - sggNm: "중구", - floodYear: "2024", - exmnYear: "2025", - disasterName: "중앙동 저지대 침수", - districtName: "중앙동 저지대", - floodAreaHa: 1.8, - avgFloodDepthM: 0.62, - floodDepthM: 0.62, - gradeLabel: "1등급", - startDate: "2024-07-18", - endDate: "2024-07-18", - startTime: "14:10", - endTime: "18:20", - floodHour: "4.2시간", - causeDetailCode: "FC-01", - causeDetailName: "집중강우", - damageAmount: "120,000,000원", - rainStartTime: "13:50", - rainEndTime: "18:20", - totalRainfallMm: 91, - rainfallIntensityMmHr: 31, - centerX: 1122000, - centerY: 1705000, - width: 18000, - height: 14000, - }), - createHistory({ - historyId: "FH-2023-002", - ctpvNm: "경상남도", - sggNm: "창원시", - floodYear: "2023", - exmnYear: "2024", - disasterName: "팔용동 공단 배수지연 침수", - districtName: "팔용동 공단", - floodAreaHa: 4.8, - avgFloodDepthM: 1.92, - floodDepthM: 1.92, - gradeLabel: "2등급", - startDate: "2023-08-11", - endDate: "2023-08-11", - startTime: "09:40", - endTime: "15:10", - floodHour: "5.5시간", - causeDetailCode: "FC-04", - causeDetailName: "배수시설 처리능력 초과", - damageAmount: "340,000,000원", - rainStartTime: "08:55", - rainEndTime: "15:05", - totalRainfallMm: 138, - rainfallIntensityMmHr: 42, - centerX: 1081000, - centerY: 1778000, - width: 25000, - height: 17000, - }), - createHistory({ - historyId: "FH-2022-003", - ctpvNm: "울산광역시", - sggNm: "남구", - floodYear: "2022", - exmnYear: "2023", - disasterName: "태화강 제내지 도로 침수", - districtName: "여천동 하류부", - floodAreaHa: 3.1, - avgFloodDepthM: 1.15, - floodDepthM: 1.15, - gradeLabel: "2등급", - startDate: "2022-09-06", - endDate: "2022-09-06", - startTime: "07:20", - endTime: "12:40", - floodHour: "5.3시간", - causeDetailCode: "FC-02", - causeDetailName: "하천 수위 상승", - damageAmount: "210,000,000원", - rainStartTime: "06:30", - rainEndTime: "11:50", - totalRainfallMm: 117, - rainfallIntensityMmHr: 37, - centerX: 1168000, - centerY: 1811000, - width: 22000, - height: 16000, - }), - createHistory({ - historyId: "FH-2021-004", - ctpvNm: "부산광역시", - sggNm: "서구", - floodYear: "2021", - exmnYear: "2022", - disasterName: "서대신동 급경사지 유출 침수", - districtName: "서대신동 일원", - floodAreaHa: 2.4, - avgFloodDepthM: 0.85, - floodDepthM: 0.85, - gradeLabel: "1등급", - startDate: "2021-07-07", - endDate: "2021-07-07", - startTime: "16:00", - endTime: "20:10", - floodHour: "4.1시간", - causeDetailCode: "FC-03", - causeDetailName: "우수관로 월류", - damageAmount: "175,000,000원", - rainStartTime: "15:20", - rainEndTime: "19:50", - totalRainfallMm: 104, - rainfallIntensityMmHr: 29, - centerX: 1111000, - centerY: 1709000, - width: 20000, - height: 14500, - }), - createHistory({ - historyId: "FH-2020-005", - ctpvNm: "부산광역시", - sggNm: "영도구", - floodYear: "2020", - exmnYear: "2021", - disasterName: "청학동 해안도로 월파 침수", - districtName: "청학동 해안부", - floodAreaHa: 1.2, - avgFloodDepthM: 0.48, - floodDepthM: 0.48, - gradeLabel: "1등급", - startDate: "2020-10-22", - endDate: "2020-10-22", - startTime: "03:10", - endTime: "06:30", - floodHour: "3.3시간", - causeDetailCode: "FC-06", - causeDetailName: "해안 월파", - damageAmount: "82,000,000원", - rainStartTime: "02:40", - rainEndTime: "05:50", - totalRainfallMm: 55, - rainfallIntensityMmHr: 18, - centerX: 1129000, - centerY: 1695000, - width: 15000, - height: 12000, - }), - createHistory({ - historyId: "FH-2019-006", - ctpvNm: "경상남도", - sggNm: "김해시", - floodYear: "2019", - exmnYear: "2020", - disasterName: "장유 배수분구 내수 침수", - districtName: "장유 배수분구", - floodAreaHa: 2.9, - avgFloodDepthM: 0.96, - floodDepthM: 0.96, - gradeLabel: "1등급", - startDate: "2019-09-21", - endDate: "2019-09-21", - startTime: "11:30", - endTime: "16:15", - floodHour: "4.8시간", - causeDetailCode: "FC-05", - causeDetailName: "배수펌프 지연 가동", - damageAmount: "143,000,000원", - rainStartTime: "10:45", - rainEndTime: "15:50", - totalRainfallMm: 88, - rainfallIntensityMmHr: 24, - centerX: 1098000, - centerY: 1734000, - width: 21000, - height: 15000, - }), -]; - -export const normalizeFloodHistoryFilters = ( - filters: FloodHistoryFilters, -): FloodHistoryFilters => ({ - ctpvCd: String(filters.ctpvCd ?? "0") || "0", - sggCd: String(filters.sggCd ?? "0") || "0", - floodYear: String(filters.floodYear ?? "").replace(/[^0-9]/g, "").slice(0, 4), - disasterName: String(filters.disasterName ?? "").trim(), -}); - -export const formatFloodArea = (value: number) => `${value.toFixed(1)}ha`; - -export const formatFloodDepth = (value: number) => `${value.toFixed(2)}m`; - -export const buildFloodHistoryTableRows = ( - items: FloodHistoryItem[], - page: number, - pageSize = FLOOD_HISTORY_PAGE_SIZE, -): FloodHistoryTableRow[] => - items.map((item, index) => ({ - ...item, - rowNo: (page - 1) * pageSize + index + 1, - exmnYearDisplay: `${item.exmnYear}년`, - avgFloodDepthDisplay: formatFloodDepth(item.avgFloodDepthM), - floodAreaDisplay: formatFloodArea(item.floodAreaHa), - causeDetailDisplay: item.causeDetailName, - startDateDisplay: item.startDate, - endDateDisplay: item.endDate, - startTimeDisplay: item.startTime, - endTimeDisplay: item.endTime, - causeCodeDisplay: item.causeDetailCode, - districtNameDisplay: item.districtName, - damageAmountDisplay: item.damageAmount, - floodHourDisplay: item.floodHour, - floodYearDisplay: `${item.floodYear}년`, - })); - -export const buildFloodHistoryYearOptions = (items: FloodHistoryItem[]) => - Array.from(new Set(items.map((item) => item.floodYear))) - .sort((left, right) => Number(right) - Number(left)); - -const resolveFloodHistoryNodeName = (node: any) => - String(node?.text ?? node?.data?.lyrNm ?? node?.data?.name ?? node?.lyrNm ?? node?.name ?? "").trim(); - -const resolveFloodHistoryLayerType = (node: any) => - String(node?.data?.lyrTyNm ?? node?.data?.lyrTypeNm ?? node?.lyrTyNm ?? node?.lyrTypeNm ?? "").trim(); - -export const isFloodHistoryNode = (node: any) => { - const name = resolveFloodHistoryNodeName(node); - const layerType = resolveFloodHistoryLayerType(node); - return name === FLOOD_HISTORY_LAYER_NAME || layerType === FLOOD_HISTORY_LAYER_TYPE; -}; diff --git a/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditPipeSessionPopup.tsx b/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditPipeSessionPopup.tsx index 591077005..2591591c6 100644 --- a/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditPipeSessionPopup.tsx +++ b/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditPipeSessionPopup.tsx @@ -25,9 +25,27 @@ type MngInstOption = { const EMPTY_SELECT_OPTION = { value: "", label: "선택" }; const PIPE_BASIC_INFO_FIELDS = [ - { id: "mngInstCd", sourceId: "mng_inst_cd", title: "관리기관", type: "select", required: true }, - { id: "fcltMngNo", sourceId: "fclt_mng_no", title: "시설관리번호", type: "input", required: true }, - { id: "instMngNo", sourceId: "inst_mng_no", title: "기관관리번호", type: "input", required: true }, + { + id: "mngInstCd", + sourceId: "mng_inst_cd", + title: "관리기관", + type: "select", + required: true, + }, + { + id: "fcltMngNo", + sourceId: "fclt_mng_no", + title: "시설관리번호", + type: "input", + required: true, + }, + { + id: "instMngNo", + sourceId: "inst_mng_no", + title: "기관관리번호", + type: "input", + required: true, + }, { id: "instlYmd", sourceId: "instl_ymd", title: "설치일자", type: "date" }, { id: "mapshtNo", sourceId: "mapsht_no", title: "도엽번호", type: "input" }, { id: "pdmtVl", sourceId: "pdmt_vl", title: "관경(mm)", type: "input" }, @@ -40,7 +58,10 @@ const PIPE_BASIC_INFO_FIELDS = [ required: true, options: [ EMPTY_SELECT_OPTION, - ...MS_SWAGE_PPL_USE_CODES.map((item) => ({ value: item.val.trim(), label: item.text })), + ...MS_SWAGE_PPL_USE_CODES.map((item) => ({ + value: item.val.trim(), + label: item.text, + })), ], }, { @@ -50,33 +71,95 @@ const PIPE_BASIC_INFO_FIELDS = [ type: "select", options: [ EMPTY_SELECT_OPTION, - ...MS_SWAGE_PPL_CODES.map((item) => ({ value: item.val.trim(), label: item.text })), + ...MS_SWAGE_PPL_CODES.map((item) => ({ + value: item.val.trim(), + label: item.text, + })), ], }, - { id: "avgGrdn", sourceId: "avg_grdn", title: "평균구배(m/m)", type: "input" }, - { id: "drtwMltvArea", sourceId: "drtw_mltv_area", title: "오수배수면적(m²)", type: "input" }, - { id: "exclnMltvArea", sourceId: "excln_mltv_area", title: "우수배수면적(m²)", type: "input" }, - { id: "tmptDpth", sourceId: "tmpt_dpth", title: "시점깊이(m)", type: "input" }, - { id: "tmptMhlMngNo", sourceId: "tmpt_mhl_mng_no", title: "시점맨홀관리번호", type: "input" }, - { id: "scendDpth", sourceId: "scend_dpth", title: "종점깊이(m)", type: "input" }, - { id: "scendMhlMngNo", sourceId: "scend_mhl_mng_no", title: "종점맨홀관리번호", type: "input" }, + { + id: "avgGrdn", + sourceId: "avg_grdn", + title: "평균구배(m/m)", + type: "input", + }, + { + id: "drtwMltvArea", + sourceId: "drtw_mltv_area", + title: "오수배수면적(m²)", + type: "input", + }, + { + id: "exclnMltvArea", + sourceId: "excln_mltv_area", + title: "우수배수면적(m²)", + type: "input", + }, + { + id: "tmptDpth", + sourceId: "tmpt_dpth", + title: "시점깊이(m)", + type: "input", + }, + { + id: "tmptMhlMngNo", + sourceId: "tmpt_mhl_mng_no", + title: "시점맨홀관리번호", + type: "input", + }, + { + id: "scendDpth", + sourceId: "scend_dpth", + title: "종점깊이(m)", + type: "input", + }, + { + id: "scendMhlMngNo", + sourceId: "scend_mhl_mng_no", + title: "종점맨홀관리번호", + type: "input", + }, { id: "trflnCnt", sourceId: "trfln_cnt", title: "차선수", type: "input" }, { id: "vrtcLen", sourceId: "vrtc_len", title: "세로길이(m)", type: "input" }, { id: "wdthLen", sourceId: "wdth_len", title: "가로길이(m)", type: "input" }, ]; const PIPE_INPUT_INFO_FIELDS = [ - { id: "instlMbdyCd", title: "설치주체", type: "select", options: [EMPTY_SELECT_OPTION, "BTL", "재정사업"] }, + { + id: "instlMbdyCd", + title: "설치주체", + type: "select", + options: [EMPTY_SELECT_OPTION, "BTL", "재정사업"], + }, { id: "swtrmtZoneNm", title: "처리구역", type: "input" }, { id: "mltvZoneNm", title: "배수구역", type: "input" }, { id: "mltvDtdvNm", title: "배수분구", type: "input" }, - { id: "lineCd", title: "지선/간선", type: "select", options: [EMPTY_SELECT_OPTION, "지선", "간선"] }, - { id: "floCd", title: "흐름방식", type: "select", options: [EMPTY_SELECT_OPTION, "자연유하", "압송"] }, - { id: "layCd", title: "매설위치", type: "select", options: [EMPTY_SELECT_OPTION, "도로", "하상"] }, + { + id: "lineCd", + title: "지선/간선", + type: "select", + options: [EMPTY_SELECT_OPTION, "지선", "간선"], + }, + { + id: "floCd", + title: "흐름방식", + type: "select", + options: [EMPTY_SELECT_OPTION, "자연유하", "압송"], + }, + { + id: "layCd", + title: "매설위치", + type: "select", + options: [EMPTY_SELECT_OPTION, "도로", "하상"], + }, ]; -const PIPE_VISIBLE_BASIC_FIELD_IDS = PIPE_BASIC_INFO_FIELDS.map((field) => field.id); -const PIPE_VISIBLE_INPUT_FIELD_IDS = PIPE_INPUT_INFO_FIELDS.map((field) => field.id); +const PIPE_VISIBLE_BASIC_FIELD_IDS = PIPE_BASIC_INFO_FIELDS.map( + (field) => field.id, +); +const PIPE_VISIBLE_INPUT_FIELD_IDS = PIPE_INPUT_INFO_FIELDS.map( + (field) => field.id, +); const toEditableText = (value: unknown) => { const text = String(value ?? "").trim(); @@ -84,7 +167,10 @@ const toEditableText = (value: unknown) => { }; const isInvalidPipeLookupKey = (value: string) => - !value || value === "-" || /^NEW-/i.test(value) || /^(null|undefined)$/i.test(value); + !value || + value === "-" || + /^NEW-/i.test(value) || + /^(null|undefined)$/i.test(value); const toDateInputValue = (value: unknown) => { const text = toEditableText(value).replaceAll(".", "").replaceAll("-", ""); @@ -92,10 +178,13 @@ const toDateInputValue = (value: unknown) => { return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`; }; -const toDbYmdValue = (value: unknown) => toEditableText(value).replaceAll("-", ""); +const toDbYmdValue = (value: unknown) => + toEditableText(value).replaceAll("-", ""); -const getObjectValue = (values: Record, field: { id: string; sourceId?: string }) => - values[field.id] ?? (field.sourceId ? values[field.sourceId] : undefined); +const getObjectValue = ( + values: Record, + field: { id: string; sourceId?: string }, +) => values[field.id] ?? (field.sourceId ? values[field.sourceId] : undefined); const pickFields = (values: Record, fieldIds: string[]) => fieldIds.reduce>((acc, fieldId) => { @@ -116,13 +205,17 @@ const normalizeBasicFormValues = (values: Record) => { const buildBaseFeatureValues = (featureData: any) => { const resolveUsageCode = (value: unknown) => { const raw = String(value ?? "").trim(); - const matched = MS_SWAGE_PPL_USE_CODES.find((item) => item.val === raw || item.text === raw); + const matched = MS_SWAGE_PPL_USE_CODES.find( + (item) => item.val === raw || item.text === raw, + ); return matched?.val.trim() ?? raw; }; const resolveMaterialCode = (value: unknown) => { const raw = String(value ?? "").trim(); - const matched = MS_SWAGE_PPL_CODES.find((item) => item.val === raw || item.text === raw); + const matched = MS_SWAGE_PPL_CODES.find( + (item) => item.val === raw || item.text === raw, + ); return matched?.val.trim() ?? raw; }; @@ -149,7 +242,11 @@ const buildBaseFeatureValues = (featureData: any) => { const getPipeUsageLabel = (value: unknown) => { const raw = String(value ?? "").trim(); if (!raw) return ""; - return MS_SWAGE_PPL_USE_CODES.find((item) => item.val.trim() === raw || item.text === raw)?.text ?? raw; + return ( + MS_SWAGE_PPL_USE_CODES.find( + (item) => item.val.trim() === raw || item.text === raw, + )?.text ?? raw + ); }; const buildInitialInputDraft = (change: GisEditChange) => ({ @@ -169,11 +266,16 @@ export default function GisEditPipeSessionPopup({ onSave, }: Props) { const isNewPipe = change.editType === "ADD"; - const baseFeatureValues = useMemo(() => buildBaseFeatureValues(featureData), [featureData]); - const [basicInfoTableData, setBasicInfoTableData] = useState>({}); - const [inputInfoTableData, setInputInfoTableData] = useState>( - buildInitialInputDraft(change), + const baseFeatureValues = useMemo( + () => buildBaseFeatureValues(featureData), + [featureData], ); + const [basicInfoTableData, setBasicInfoTableData] = useState< + Record + >({}); + const [inputInfoTableData, setInputInfoTableData] = useState< + Record + >(buildInitialInputDraft(change)); const [mngInstOptions, setMngInstOptions] = useState([]); useEffect(() => { @@ -195,24 +297,21 @@ export default function GisEditPipeSessionPopup({ }; }, []); - const mngInstSelectOptions = useMemo( - () => { - const sortedOptions = [...mngInstOptions].sort((a, b) => - String(a.lwrInstNm ?? a.mngInstCd ?? "").localeCompare( - String(b.lwrInstNm ?? b.mngInstCd ?? ""), - "ko", - ), - ); - return [ - EMPTY_SELECT_OPTION, - ...sortedOptions.map((option) => ({ - value: String(option.mngInstCd ?? "").trim(), - label: option.lwrInstNm || String(option.mngInstCd ?? "").trim(), - })), - ]; - }, - [mngInstOptions], - ); + const mngInstSelectOptions = useMemo(() => { + const sortedOptions = [...mngInstOptions].sort((a, b) => + String(a.lwrInstNm ?? a.mngInstCd ?? "").localeCompare( + String(b.lwrInstNm ?? b.mngInstCd ?? ""), + "ko", + ), + ); + return [ + EMPTY_SELECT_OPTION, + ...sortedOptions.map((option) => ({ + value: String(option.mngInstCd ?? "").trim(), + label: option.lwrInstNm || String(option.mngInstCd ?? "").trim(), + })), + ]; + }, [mngInstOptions]); const basicInfoFields = useMemo( () => @@ -239,14 +338,17 @@ export default function GisEditPipeSessionPopup({ const fcltMngNo = String(change.featureRef?.fcltMngNo ?? "").trim(); const mngInstCd = String(change.featureRef?.mngInstCd ?? "").trim(); - if (isInvalidPipeLookupKey(fcltMngNo) || isInvalidPipeLookupKey(mngInstCd)) return; + if (isInvalidPipeLookupKey(fcltMngNo) || isInvalidPipeLookupKey(mngInstCd)) + return; let isMounted = true; const fetchPipeInputInfo = async () => { try { const safeFcltMngNo = encodeURIComponent(fcltMngNo); const safeMngInstCd = encodeURIComponent(mngInstCd); - const result = await getData(`v1/fcltppl/${safeFcltMngNo}/${safeMngInstCd}`); + const result = await getData( + `v1/fcltppl/${safeFcltMngNo}/${safeMngInstCd}`, + ); if (!isMounted || !result) return; setInputInfoTableData((previous) => ({ @@ -263,14 +365,25 @@ export default function GisEditPipeSessionPopup({ return () => { isMounted = false; }; - }, [change.changeId, change.featureRef?.fcltMngNo, change.featureRef?.mngInstCd, isNewPipe]); + }, [ + change.changeId, + change.featureRef?.fcltMngNo, + change.featureRef?.mngInstCd, + isNewPipe, + ]); const buildMergedDraft = ( nextBasicInfoTableData: Record, nextInputInfoTableData: Record, ): GisEditPipeBasicInfo => { - const basicDraft = pickFields(nextBasicInfoTableData, PIPE_VISIBLE_BASIC_FIELD_IDS); - const inputDraft = pickFields(nextInputInfoTableData, PIPE_VISIBLE_INPUT_FIELD_IDS); + const basicDraft = pickFields( + nextBasicInfoTableData, + PIPE_VISIBLE_BASIC_FIELD_IDS, + ); + const inputDraft = pickFields( + nextInputInfoTableData, + PIPE_VISIBLE_INPUT_FIELD_IDS, + ); if (basicDraft.instlYmd) { basicDraft.instlYmd = toDbYmdValue(basicDraft.instlYmd); } @@ -290,29 +403,43 @@ export default function GisEditPipeSessionPopup({ onSave(change.changeId, buildMergedDraft(basicInfoTableData, nextFormData)); }; - const basicInfoTableRows = useMemo(() => [basicInfoTableData], [basicInfoTableData]); - const inputInfoTableRows = useMemo(() => [inputInfoTableData], [inputInfoTableData]); - const displayManagementNo = toEditableText(change.featureRef?.fcltMngNo ?? change.objectId ?? ""); + const basicInfoTableRows = useMemo( + () => [basicInfoTableData], + [basicInfoTableData], + ); + const inputInfoTableRows = useMemo( + () => [inputInfoTableData], + [inputInfoTableData], + ); + const displayManagementNo = toEditableText( + change.featureRef?.fcltMngNo ?? change.objectId ?? "", + ); return ( -