Merge branch 'iipbs/wr/dev'

This commit is contained in:
2026-08-25 15:48:05 +09:00
315 changed files with 44118 additions and 13142 deletions
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
@@ -1,133 +1,99 @@
"use client";
'use client';
import React from "react";
import React from 'react';
// 공통 섹션 타이틀 컴포넌트
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
<div className='flex items-center gap-3'>
<span className='h-5 w-[3px] bg-sky-500' />
<h3 className='text-[24px] font-bold text-neutral-900'>{children}</h3>
</div>
);
}
// 구분선
function Hr({ className = "" }: { className?: string }) {
function Hr({ className = '' }: { className?: string }) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
// 출처 텍스트
function SourceText() {
return (
<p className="mt-7 text-center text-[13px] text-neutral-500">
자료: 환경부(2024), 「2023 하수도 통계」
</p>
);
return <p className='mt-7 text-center text-[13px] text-neutral-500'>자료: 환경부(2024), 「2023 하수도 통계」</p>;
}
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 (
<div className="bg-white">
<div className='bg-white'>
{/* 상단 타이틀 */}
<div className="inline-block">
<h2 className="relative inline-block px-2 text-[28px] font-extrabold tracking-tight leading-none text-slate-900">
<span className="relative z-10">{title}</span>
<span className="absolute left-0 bottom-[-4px] z-0 h-3 w-full bg-gradient-to-r from-sky-300 to-green-300" />
<div className='inline-block'>
<h2 className='relative inline-block px-2 text-[28px] font-extrabold tracking-tight leading-none text-slate-900'>
<span className='relative z-10'>{title}</span>
<span className='absolute left-0 bottom-[-4px] z-0 h-3 w-full bg-gradient-to-r from-sky-300 to-green-300' />
</h2>
</div>
<div className="mt-10 pb-[120px]">
<div className='mt-10 pb-[120px]'>
{/* 1) 빗물이용시설의 건축물별 빗물이용량 현황 */}
<section id="status-rain-building" className="mt-10 scroll-mt-[120px]">
<section id='status-rain-building' className='mt-10 scroll-mt-[120px]'>
<SectionTitle>빗물이용시설의 건축물별 빗물이용량 현황</SectionTitle>
<div className="mt-10">
<div className='mt-10'>
<div className={tableStyles.container}>
<table className={tableStyles.table}>
<thead>
<tr>
<th
rowSpan={2}
className={`${tableStyles.th} ${tableStyles.headerBg}`}
style={{ width: "160px" }}
>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: '160px' }}>
구분
</th>
<th
colSpan={6}
className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}
>
<th colSpan={6} className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}>
빗물이용량 (천m³/년)
</th>
</tr>
<tr>
<th
className={`${tableStyles.th} bg-[#01ACE5] text-white`}
style={{ width: "140px" }}
>
<th className={`${tableStyles.th} bg-[#01ACE5] text-white`} style={{ width: '140px' }}>
2018
</th>
<th
className={`${tableStyles.th} bg-[#17AFC7] text-white`}
style={{ width: "140px" }}
>
<th className={`${tableStyles.th} bg-[#17AFC7] text-white`} style={{ width: '140px' }}>
2019
</th>
<th
className={`${tableStyles.th} bg-[#2CB2A9] text-white`}
style={{ width: "140px" }}
>
<th className={`${tableStyles.th} bg-[#2CB2A9] text-white`} style={{ width: '140px' }}>
2020
</th>
<th
className={`${tableStyles.th} bg-[#42B58B] text-white`}
style={{ width: "140px" }}
>
<th className={`${tableStyles.th} bg-[#42B58B] text-white`} style={{ width: '140px' }}>
2021
</th>
<th
className={`${tableStyles.th} bg-[#57B86D] text-white`}
style={{ width: "140px" }}
>
<th className={`${tableStyles.th} bg-[#57B86D] text-white`} style={{ width: '140px' }}>
2022
</th>
<th
className={`${tableStyles.th} bg-[#81C784] text-white`}
style={{ width: "140px" }}
>
<th className={`${tableStyles.th} bg-[#81C784] text-white`} style={{ width: '140px' }}>
2023
</th>
</tr>
</thead>
<tbody>
{[
["골프장", "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) => (
<tr key={idx} className="bg-white">
<tr key={idx} className='bg-white'>
{row.map((val, i) => (
<td
key={i}
className={`${tableStyles.td} ${
i === 0 ? "!font-bold !text-neutral-900" : ""
} ${[1, 3, 5].includes(i) ? "bg-[#EAF5F5]" : ""}`}
>
<td key={i} className={`${tableStyles.td} ${i === 0 ? '!font-bold !text-neutral-900' : ''} ${[1, 3, 5].includes(i) ? 'bg-[#EAF5F5]' : ''}`}>
{val}
</td>
))}
@@ -141,129 +107,79 @@ export default function RainStatusSection() {
</div>
</section>
<Hr className="my-11" />
<Hr className='my-11' />
{/* 2) 빗물이용시설의 용도별 분포 (2023년 기준) */}
<section
id="status-rain-purpose-distribution"
className="scroll-mt-[120px]"
>
<section id='status-rain-purpose-distribution' className='scroll-mt-[120px]'>
<SectionTitle>빗물이용시설의 용도별 분포 (2023년 기준)</SectionTitle>
<div className="mt-8">
<div className='mt-8'>
<div className={tableStyles.container}>
<table className={tableStyles.table}>
<thead>
<tr>
<th
rowSpan={2}
className={`${tableStyles.th} ${tableStyles.headerBg}`}
style={{ width: "160px" }}
>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: '160px' }}>
용도
</th>
<th
rowSpan={2}
className={`${tableStyles.th} ${tableStyles.headerBg}`}
style={{ width: "84px" }}
>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: '84px' }}>
개소수
<br />
(개소)
</th>
<th
rowSpan={2}
className={`${tableStyles.th} ${tableStyles.headerBg}`}
style={{ width: "84px" }}
>
저류용량
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: '84px' }}>
저류조용량
<br />
(천m³)
</th>
<th
colSpan={8}
className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}
>
<th colSpan={8} className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}>
빗물이용량 (천m³/년)
</th>
</tr>
<tr>
<th
className={`${tableStyles.th} bg-[#01ACE5] py-2 text-[13px] text-white`}
style={{ width: "84px" }}
>
<th className={`${tableStyles.th} bg-[#01ACE5] py-2 text-[13px] text-white`} style={{ width: '84px' }}>
총사용량
</th>
<th
className={`${tableStyles.th} bg-[#10AED0] py-2 text-[13px] text-white`}
style={{ width: "84px" }}
>
<th className={`${tableStyles.th} bg-[#10AED0] py-2 text-[13px] text-white`} style={{ width: '84px' }}>
공공시설
</th>
<th
className={`${tableStyles.th} bg-[#20B0BA] py-2 text-[13px] text-white`}
style={{ width: "84px" }}
>
<th className={`${tableStyles.th} bg-[#20B0BA] py-2 text-[13px] text-white`} style={{ width: '84px' }}>
공동주택
</th>
<th
className={`${tableStyles.th} bg-[#2FB2A5] py-2 text-[13px] text-white`}
style={{ width: "84px" }}
>
<th className={`${tableStyles.th} bg-[#2FB2A5] py-2 text-[13px] text-white`} style={{ width: '84px' }}>
대규모점포
</th>
<th
className={`${tableStyles.th} bg-[#3FB58F] py-2 text-[13px] text-white`}
style={{ width: "84px" }}
>
<th className={`${tableStyles.th} bg-[#3FB58F] py-2 text-[13px] text-white`} style={{ width: '84px' }}>
체육시설
</th>
<th
className={`${tableStyles.th} bg-[#4EB77A] py-2 text-[13px] text-white`}
style={{ width: "84px" }}
>
<th className={`${tableStyles.th} bg-[#4EB77A] py-2 text-[13px] text-white`} style={{ width: '84px' }}>
학교
</th>
<th
className={`${tableStyles.th} bg-[#5EB964] py-2 text-[13px] text-white`}
style={{ width: "84px" }}
>
<th className={`${tableStyles.th} bg-[#5EB964] py-2 text-[13px] text-white`} style={{ width: '84px' }}>
골프장
</th>
<th
className={`${tableStyles.th} bg-[#6DBB4F] py-2 text-[13px] text-white`}
style={{ width: "84px" }}
>
<th className={`${tableStyles.th} bg-[#6DBB4F] py-2 text-[13px] text-white`} style={{ width: '84px' }}>
기타
</th>
</tr>
</thead>
<tbody>
{[
["청소·환경정화용수", "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) => (
<tr
key={idx}
className={idx === 8 ? "bg-white font-bold" : "bg-white"}
>
<tr key={idx} className={idx === 8 ? 'bg-white font-bold' : 'bg-white'}>
{row.map((val, i) => {
const isHighlightedCol = [1, 2].includes(i);
return (
<td
key={i}
className={`${tableStyles.td} ${
i === 0 ? "!font-bold !text-neutral-900" : ""
} ${isHighlightedCol ? "bg-[#EAF5F5]" : ""}`}
>
<td key={i} className={`${tableStyles.td} ${i === 0 ? '!font-bold !text-neutral-900' : ''} ${isHighlightedCol ? 'bg-[#EAF5F5]' : ''}`}>
{val}
</td>
);
@@ -278,91 +194,58 @@ export default function RainStatusSection() {
</div>
</section>
<Hr className="my-11" />
<Hr className='my-11' />
{/* 3) 빗물이용시설의 용도별 빗물이용량 현황 */}
<section
id="status-rain-purpose-usage"
className="scroll-mt-[120px]"
>
<section id='status-rain-purpose-usage' className='scroll-mt-[120px]'>
<SectionTitle>빗물이용시설의 용도별 빗물이용량 현황</SectionTitle>
<div className="mt-8">
<div className='mt-8'>
<div className={tableStyles.container}>
<table className={tableStyles.table}>
<thead>
<tr>
<th
rowSpan={2}
className={`${tableStyles.th} ${tableStyles.headerBg}`}
style={{ width: "160px" }}
>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: '160px' }}>
용도
</th>
<th
colSpan={6}
className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}
>
<th colSpan={6} className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}>
빗물이용량 (천m³/년)
</th>
</tr>
<tr>
<th
className={`${tableStyles.th} bg-[#01ACE5] text-white`}
style={{ width: "140px" }}
>
<th className={`${tableStyles.th} bg-[#01ACE5] text-white`} style={{ width: '140px' }}>
2018
</th>
<th
className={`${tableStyles.th} bg-[#14AFCD] text-white`}
style={{ width: "140px" }}
>
<th className={`${tableStyles.th} bg-[#14AFCD] text-white`} style={{ width: '140px' }}>
2019
</th>
<th
className={`${tableStyles.th} bg-[#28B2B4] text-white`}
style={{ width: "140px" }}
>
<th className={`${tableStyles.th} bg-[#28B2B4] text-white`} style={{ width: '140px' }}>
2020
</th>
<th
className={`${tableStyles.th} bg-[#3CB59A] text-white`}
style={{ width: "140px" }}
>
<th className={`${tableStyles.th} bg-[#3CB59A] text-white`} style={{ width: '140px' }}>
2021
</th>
<th
className={`${tableStyles.th} bg-[#50B880] text-white`}
style={{ width: "140px" }}
>
<th className={`${tableStyles.th} bg-[#50B880] text-white`} style={{ width: '140px' }}>
2022
</th>
<th
className={`${tableStyles.th} bg-[#6DBB4F] text-white`}
style={{ width: "140px" }}
>
<th className={`${tableStyles.th} bg-[#6DBB4F] text-white`} style={{ width: '140px' }}>
2023
</th>
</tr>
</thead>
<tbody>
{[
["청소·화장실용수", "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) => (
<tr key={idx} className="bg-white">
<tr key={idx} className='bg-white'>
{row.map((val, i) => (
<td
key={i}
className={`${tableStyles.td} ${
i === 0 ? "!font-bold !text-neutral-900" : ""
} ${[1, 3, 5].includes(i) ? "bg-[#EAF5F5]" : ""}`}
>
<td key={i} className={`${tableStyles.td} ${i === 0 ? '!font-bold !text-neutral-900' : ''} ${[1, 3, 5].includes(i) ? 'bg-[#EAF5F5]' : ''}`}>
{val}
</td>
))}
@@ -378,4 +261,4 @@ export default function RainStatusSection() {
</div>
</div>
);
}
}
@@ -1,60 +1,56 @@
"use client";
'use client';
import React from "react";
import React from 'react';
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
<div className='flex items-center gap-3'>
<span className='h-5 w-[3px] bg-sky-500' />
<h3 className='text-[24px] font-bold text-neutral-900'>{children}</h3>
</div>
);
}
function Hr({ className = "" }: { className?: string }) {
function Hr({ className = '' }: { className?: string }) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
function SourceText() {
return (
<p className="mt-7 text-center text-[13px] text-neutral-500">
자료: 환경부(2024), 「2023 하수도 통계」
</p>
);
return <p className='mt-7 text-center text-[13px] text-neutral-500'>자료: 환경부(2024), 「2023 하수도 통계」</p>;
}
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 (
<div className="relative bg-white">
<div className='relative bg-white'>
{/* 상단 타이틀 */}
<div className="inline-block">
<h2 className="relative inline-block px-2 text-[28px] font-extrabold tracking-tight leading-none text-slate-900">
<span className="relative z-10">{title}</span>
<span className="absolute left-0 bottom-[-4px] z-0 h-3 w-full bg-gradient-to-r from-sky-300 to-green-300" />
<div className='inline-block'>
<h2 className='relative inline-block px-2 text-[28px] font-extrabold tracking-tight leading-none text-slate-900'>
<span className='relative z-10'>{title}</span>
<span className='absolute left-0 bottom-[-4px] z-0 h-3 w-full bg-gradient-to-r from-sky-300 to-green-300' />
</h2>
</div>
<div className="mt-10">
<div className='mt-10'>
{/* 1) 연도별 하수처리수 재이용 현황 */}
<section id="status-treated-year" className="mt-10 scroll-mt-[120px]">
<section id='status-treated-year' className='mt-10 scroll-mt-[120px]'>
<SectionTitle>연도별 하수처리수 재이용 현황</SectionTitle>
<div className="mt-10">
<div className='mt-10'>
<div className={tableStyles.container}>
<table className={tableStyles.table}>
<colgroup>
<col style={{ width: "130px" }} />
<col style={{ width: "180px" }} />
<col style={{ width: '130px' }} />
<col style={{ width: '180px' }} />
<col />
<col />
<col />
@@ -64,166 +60,52 @@ export default function SewageStatusSection() {
</colgroup>
<thead>
<tr>
<th
colSpan={2}
className={`${tableStyles.th} ${tableStyles.headerBg}`}
>
<th colSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
구분
</th>
<th className={`${tableStyles.th} bg-[#01ACE5] text-white`}>
2018
</th>
<th className={`${tableStyles.th} bg-[#14AFCD] text-white`}>
2019
</th>
<th className={`${tableStyles.th} bg-[#28B2B4] text-white`}>
2020
</th>
<th className={`${tableStyles.th} bg-[#3CB59A] text-white`}>
2021
</th>
<th className={`${tableStyles.th} bg-[#50B880] text-white`}>
2022
</th>
<th className={`${tableStyles.th} bg-[#6DBB4F] text-white`}>
2023
</th>
<th className={`${tableStyles.th} bg-[#01ACE5] text-white`}>2018</th>
<th className={`${tableStyles.th} bg-[#14AFCD] text-white`}>2019</th>
<th className={`${tableStyles.th} bg-[#28B2B4] text-white`}>2020</th>
<th className={`${tableStyles.th} bg-[#3CB59A] text-white`}>2021</th>
<th className={`${tableStyles.th} bg-[#50B880] text-white`}>2022</th>
<th className={`${tableStyles.th} bg-[#6DBB4F] text-white`}>2023</th>
</tr>
</thead>
<tbody>
{[
{
group: "전체",
group: '전체',
rows: [
[
"개소수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>",
"671",
"681",
"698",
"704",
"714",
"717",
],
[
"하수처리량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>",
"7,163.7",
"7,140.4",
"7,375.6",
"7,335.8",
"7,378.9",
"7,537.2",
],
[
"재이용량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>",
"1,112.7",
"1,148.7",
"1,148.7",
"1,127.0",
"1,136.8",
"1,137.5",
],
[
"재이용률 <span class='text-[13px] font-normal text-[#767676]'>(%)</span>",
"17.4",
"16.5",
"15.5",
"14.1",
"14.3",
"14.4",
],
["개소수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>", '671', '681', '698', '704', '714', '717'],
["하수처리량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>", '7,163.7', '7,140.4', '7,375.6', '7,335.8', '7,378.9', '7,537.2'],
["재이용량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>", '1,112.7', '1,148.7', '1,148.7', '1,127.0', '1,136.8', '1,137.5'],
["재이용률 <span class='text-[13px] font-normal text-[#767676]'>(%)</span>", '17.4', '16.5', '15.5', '14.1', '14.3', '14.4'],
],
},
{
group: "법적 대상",
group: '법적 대상',
rows: [
[
"개소수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>",
"68",
"82",
"94",
"97",
"101",
"118",
],
[
"하수처리량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>",
"761.2",
"839.0",
"935.3",
"1,158.7",
"1,220.6",
"1,473.6",
],
[
"재이용량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>",
"162.2",
"190.6",
"218.5",
"244.6",
"247.2",
"324.9",
],
[
"재이용률 <span class='text-[13px] font-normal text-[#767676]'>(%)</span>",
"24.7",
"25.4",
"27.8",
"25.7",
"24.2",
"24.1",
],
["개소수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>", '68', '82', '94', '97', '101', '118'],
["하수처리량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>", '761.2', '839.0', '935.3', '1,158.7', '1,220.6', '1,473.6'],
["재이용량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>", '162.2', '190.6', '218.5', '244.6', '247.2', '324.9'],
["재이용률 <span class='text-[13px] font-normal text-[#767676]'>(%)</span>", '24.7', '25.4', '27.8', '25.7', '24.2', '24.1'],
],
},
{
group: "법적 미대상",
group: '법적 미대상',
rows: [
[
"개소수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>",
"603",
"599",
"604",
"607",
"613",
"599",
],
[
"하수처리량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>",
"6,402.5",
"6,301.4",
"6,440.3",
"6,177.1",
"6,158.3",
"6,063.5",
],
[
"재이용량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>",
"950.6",
"958.1",
"926.2",
"882.4",
"889.5",
"812.7",
],
[
"재이용률 <span class='text-[13px] font-normal text-[#767676]'>(%)</span>",
"16.5",
"15.3",
"13.6",
"12.2",
"12.6",
"12.5",
],
["개소수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>", '603', '599', '604', '607', '613', '599'],
["하수처리량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>", '6,402.5', '6,301.4', '6,440.3', '6,177.1', '6,158.3', '6,063.5'],
["재이용량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>", '950.6', '958.1', '926.2', '882.4', '889.5', '812.7'],
["재이용률 <span class='text-[13px] font-normal text-[#767676]'>(%)</span>", '16.5', '15.3', '13.6', '12.2', '12.6', '12.5'],
],
},
].map((section, sIdx) => (
<React.Fragment key={sIdx}>
{section.rows.map((row, rIdx) => (
<tr key={`${sIdx}-${rIdx}`} className="bg-white">
<tr key={`${sIdx}-${rIdx}`} className='bg-white'>
{rIdx === 0 && (
<td
rowSpan={section.rows.length}
className={`${tableStyles.td} ${tableStyles.headerBg} !text-[18px] !font-bold !text-neutral-900`}
>
<td rowSpan={section.rows.length} className={`${tableStyles.td} ${tableStyles.headerBg} !text-[18px] !font-bold !text-neutral-900`}>
{section.group}
</td>
)}
@@ -234,9 +116,7 @@ export default function SewageStatusSection() {
return (
<td
key={i}
className={`${tableStyles.td} ${
isFirstCol ? "!font-bold !text-neutral-900" : ""
} ${isHighlightedCol ? "bg-[#EAF5F5]" : ""}`}
className={`${tableStyles.td} ${isFirstCol ? '!font-bold !text-neutral-900' : ''} ${isHighlightedCol ? 'bg-[#EAF5F5]' : ''}`}
dangerouslySetInnerHTML={{ __html: val }}
/>
);
@@ -253,17 +133,17 @@ export default function SewageStatusSection() {
</div>
</section>
<Hr className="my-11" />
<Hr className='my-11' />
{/* 2) 용도별 하수처리수 재이용 현황 */}
<section id="status-treated-usage" className="scroll-mt-[120px]">
<section id='status-treated-usage' className='scroll-mt-[120px]'>
<SectionTitle>용도별 하수처리수 재이용 현황</SectionTitle>
<div className="mt-8">
<div className='mt-8'>
<div className={tableStyles.container}>
<table className={tableStyles.table}>
<colgroup>
<col style={{ width: "180px" }} />
<col style={{ width: '180px' }} />
<col />
<col />
<col />
@@ -273,88 +153,59 @@ export default function SewageStatusSection() {
</colgroup>
<thead>
<tr>
<th className={`${tableStyles.th} ${tableStyles.headerBg}`}>
구분
</th>
<th className={`${tableStyles.th} bg-[#01ACE5] text-white`}>
2018
</th>
<th className={`${tableStyles.th} bg-[#14AFCD] text-white`}>
2019
</th>
<th className={`${tableStyles.th} bg-[#28B2B4] text-white`}>
2020
</th>
<th className={`${tableStyles.th} bg-[#3CB59A] text-white`}>
2021
</th>
<th className={`${tableStyles.th} bg-[#50B880] text-white`}>
2022
</th>
<th className={`${tableStyles.th} bg-[#6DBB4F] text-white`}>
2023
</th>
<th className={`${tableStyles.th} ${tableStyles.headerBg}`}>구분</th>
<th className={`${tableStyles.th} bg-[#01ACE5] text-white`}>2018</th>
<th className={`${tableStyles.th} bg-[#14AFCD] text-white`}>2019</th>
<th className={`${tableStyles.th} bg-[#28B2B4] text-white`}>2020</th>
<th className={`${tableStyles.th} bg-[#3CB59A] text-white`}>2021</th>
<th className={`${tableStyles.th} bg-[#50B880] text-white`}>2022</th>
<th className={`${tableStyles.th} bg-[#6DBB4F] text-white`}>2023</th>
</tr>
</thead>
<tbody>
{[
{ 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 (
<tr key={idx} className="bg-white">
<tr key={idx} className='bg-white'>
<td className={`${tableStyles.td} relative`}>
{isSubItem ? (
<>
<div className={`absolute left-[1px] w-[16px] bg-white z-10 top-[-2px] ${isLastInGroup ? 'bottom-[0px]' : 'bottom-[-2px]'}`} />
<div
className={`absolute left-[1px] w-[16px] bg-white z-10 top-[-2px] ${
isLastInGroup ? "bottom-[0px]" : "bottom-[-2px]"
className={`absolute left-[16px] w-[4px] z-20 ${row.type === 'red-item' ? 'bg-[#FF8B8B]' : 'bg-[#4ECBFF]'} top-[-2px] ${
isLastInGroup ? 'bottom-[0px]' : 'bottom-[-2px]'
}`}
/>
<div
className={`absolute left-[16px] w-[4px] z-20 ${
row.type === "red-item" ? "bg-[#FF8B8B]" : "bg-[#4ECBFF]"
} top-[-2px] ${
isLastInGroup ? "bottom-[0px]" : "bottom-[-2px]"
}`}
/>
<span className="text-[#4f4f4f] font-bold block w-full text-center relative z-30 translate-x-[8px]">
{row.label}
</span>
<span className='text-[#4f4f4f] font-bold block w-full text-center relative z-30 translate-x-[8px]'>{row.label}</span>
</>
) : (
<span className="font-bold text-neutral-900 block w-full text-center">
{row.label}
</span>
<span className='font-bold text-neutral-900 block w-full text-center'>{row.label}</span>
)}
</td>
{row.values.map((val, i) => (
<td
key={i}
className={`${tableStyles.td} ${
[0, 2, 4].includes(i) ? "bg-[#EAF5F5]" : ""
}`}
>
<td key={i} className={`${tableStyles.td} ${[0, 2, 4].includes(i) ? 'bg-[#EAF5F5]' : ''}`}>
{val}
</td>
))}
@@ -369,18 +220,18 @@ export default function SewageStatusSection() {
</div>
</section>
<Hr className="my-11" />
<Hr className='my-11' />
{/* 3) 하수처리시설 설치 및 이용 현황 */}
<section id="status-treated-facility" className="scroll-mt-[120px]">
<section id='status-treated-facility' className='scroll-mt-[120px]'>
<SectionTitle>하수처리시설 설치 및 이용 현황</SectionTitle>
<div className="mt-8">
<div className='mt-8'>
<div className={tableStyles.container}>
<table className={tableStyles.table}>
<colgroup>
<col style={{ width: "130px" }} />
<col style={{ width: "160px" }} />
<col style={{ width: '130px' }} />
<col style={{ width: '160px' }} />
<col />
<col />
<col />
@@ -390,139 +241,49 @@ export default function SewageStatusSection() {
</colgroup>
<thead>
<tr>
<th
colSpan={2}
className={`${tableStyles.th} ${tableStyles.headerBg}`}
>
<th colSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
구분
</th>
<th className={`${tableStyles.th} bg-[#01ACE5] text-white`}>
2018
</th>
<th className={`${tableStyles.th} bg-[#14AFCD] text-white`}>
2019
</th>
<th className={`${tableStyles.th} bg-[#28B2B4] text-white`}>
2020
</th>
<th className={`${tableStyles.th} bg-[#3CB59A] text-white`}>
2021
</th>
<th className={`${tableStyles.th} bg-[#50B880] text-white`}>
2022
</th>
<th className={`${tableStyles.th} bg-[#6DBB4F] text-white`}>
2023
</th>
<th className={`${tableStyles.th} bg-[#01ACE5] text-white`}>2018</th>
<th className={`${tableStyles.th} bg-[#14AFCD] text-white`}>2019</th>
<th className={`${tableStyles.th} bg-[#28B2B4] text-white`}>2020</th>
<th className={`${tableStyles.th} bg-[#3CB59A] text-white`}>2021</th>
<th className={`${tableStyles.th} bg-[#50B880] text-white`}>2022</th>
<th className={`${tableStyles.th} bg-[#6DBB4F] text-white`}>2023</th>
</tr>
</thead>
<tbody>
{[
{
group: "전체",
group: '전체',
rows: [
[
"시설수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>",
"671",
"681",
"698",
"704",
"714",
"717",
],
[
"시설용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>",
"25,846",
"26,067",
"26,244",
"26,492",
"26,586",
"26,766",
],
[
"처리용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>",
"1,112.7",
"1,148.7",
"1,144.7",
"1,127.0",
"1,136.8",
"1,137.5",
],
["시설수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>", '671', '681', '698', '704', '714', '717'],
["시설용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>", '25,846', '26,067', '26,244', '26,492', '26,586', '26,766'],
["처리용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>", '1,112.7', '1,148.7', '1,144.7', '1,127.0', '1,136.8', '1,137.5'],
],
},
{
group: "법적 대상",
group: '법적 대상',
rows: [
[
"시설수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>",
"68",
"82",
"94",
"97",
"101",
"118",
],
[
"시설용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>",
"3,088.9",
"3,359.2",
"3,612.6",
"4,412.2",
"4,692.3",
"5,503.8",
],
[
"처리용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>",
"162.2",
"190.6",
"218.5",
"244.6",
"247.2",
"324.9",
],
["시설수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>", '68', '82', '94', '97', '101', '118'],
["시설용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>", '3,088.9', '3,359.2', '3,612.6', '4,412.2', '4,692.3', '5,503.8'],
["처리용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>", '162.2', '190.6', '218.5', '244.6', '247.2', '324.9'],
],
},
{
group: "법적 미대상",
group: '법적 미대상',
rows: [
[
"시설수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>",
"603",
"599",
"604",
"607",
"613",
"599",
],
[
"시설용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>",
"22,757",
"22,708",
"22,631",
"22,080",
"21,894",
"21,262",
],
[
"처리용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>",
"950.6",
"958.1",
"926.2",
"882.4",
"889.5",
"812.7",
],
["시설수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>", '603', '599', '604', '607', '613', '599'],
["시설용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>", '22,757', '22,708', '22,631', '22,080', '21,894', '21,262'],
["처리용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>", '950.6', '958.1', '926.2', '882.4', '889.5', '812.7'],
],
},
].map((section, sIdx) => (
<React.Fragment key={sIdx}>
{section.rows.map((row, rIdx) => (
<tr key={`${sIdx}-${rIdx}`} className="bg-white">
<tr key={`${sIdx}-${rIdx}`} className='bg-white'>
{rIdx === 0 && (
<td
rowSpan={section.rows.length}
className={`${tableStyles.td} ${tableStyles.headerBg} !text-[18px] !font-bold !text-neutral-900`}
>
<td rowSpan={section.rows.length} className={`${tableStyles.td} ${tableStyles.headerBg} !text-[18px] !font-bold !text-neutral-900`}>
{section.group}
</td>
)}
@@ -533,9 +294,7 @@ export default function SewageStatusSection() {
return (
<td
key={i}
className={`${tableStyles.td} ${
isFirstCol ? "!font-bold !text-neutral-900" : ""
} ${isHighlightedCol ? "bg-[#EAF5F5]" : ""}`}
className={`${tableStyles.td} ${isFirstCol ? '!font-bold !text-neutral-900' : ''} ${isHighlightedCol ? 'bg-[#EAF5F5]' : ''}`}
dangerouslySetInnerHTML={{ __html: val }}
/>
);
@@ -552,24 +311,19 @@ export default function SewageStatusSection() {
</div>
</section>
<Hr className="my-11" />
<Hr className='my-11' />
{/* 4) 시도별 하수처리수 재이용 현황 */}
<section
id="status-treated-region"
className="scroll-mt-[120px]"
>
<section id='status-treated-region' className='scroll-mt-[120px]'>
<SectionTitle>시도별 하수처리수 재이용 현황</SectionTitle>
<div className="mt-8">
<div className='mt-8'>
<div className={tableStyles.container}>
<div className="mb-2 text-right text-[13px] text-neutral-600">
(단위: 백만m³/일)
</div>
<div className='mb-2 text-right text-[13px] text-neutral-600'>(단위: 백만m³/년)</div>
<table className={tableStyles.table}>
<colgroup>
<col style={{ width: "160px" }} />
<col style={{ width: '160px' }} />
<col />
<col />
<col />
@@ -583,110 +337,71 @@ export default function SewageStatusSection() {
</colgroup>
<thead>
<tr>
<th
rowSpan={3}
className={`${tableStyles.th} ${tableStyles.headerBg}`}
>
<th rowSpan={3} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
시도
</th>
<th
rowSpan={3}
className={`${tableStyles.th} ${tableStyles.headerBg}`}
>
<th rowSpan={3} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
연간
<br />
하수
<br />
처리량
</th>
<th
colSpan={7}
className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}
>
<th colSpan={7} className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}>
재이용현황
</th>
<th
rowSpan={3}
className={`${tableStyles.th} ${tableStyles.headerBg}`}
>
<th rowSpan={3} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
재이용률
<br />
(%)
</th>
<th
rowSpan={3}
className={`${tableStyles.th} ${tableStyles.headerBg}`}
>
<th rowSpan={3} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
비고
</th>
</tr>
<tr>
<th
rowSpan={2}
className={`${tableStyles.th} ${tableStyles.headerBg}`}
>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
계
</th>
<th
rowSpan={2}
className={`${tableStyles.th} ${tableStyles.headerBg}`}
>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
장내
</th>
<th
colSpan={5}
className={`${tableStyles.th} ${tableStyles.headerBg} py-1`}
>
<th colSpan={5} className={`${tableStyles.th} ${tableStyles.headerBg} py-1`}>
장외
</th>
</tr>
<tr>
<th className={`${tableStyles.th} bg-[#01ACE5] text-white py-2 text-[16px]`}>
소계
</th>
<th className={`${tableStyles.th} bg-[#1FB1C2] text-white py-2 text-[13px]`}>
하천유지용수
</th>
<th className={`${tableStyles.th} bg-[#3EB59F] text-white py-2 text-[16px]`}>
공업용수
</th>
<th className={`${tableStyles.th} bg-[#5DB97C] text-white py-2 text-[16px]`}>
농업용수
</th>
<th className={`${tableStyles.th} bg-[#6DBB4F] text-white py-2 text-[16px]`}>
기타용수
</th>
<th className={`${tableStyles.th} bg-[#01ACE5] text-white py-2 text-[16px]`}>소계</th>
<th className={`${tableStyles.th} bg-[#1FB1C2] text-white py-2 text-[13px]`}>하천유지용수</th>
<th className={`${tableStyles.th} bg-[#3EB59F] text-white py-2 text-[16px]`}>공업용수</th>
<th className={`${tableStyles.th} bg-[#5DB97C] text-white py-2 text-[16px]`}>농업용수</th>
<th className={`${tableStyles.th} bg-[#6DBB4F] text-white py-2 text-[16px]`}>기타용수</th>
</tr>
</thead>
<tbody>
{[
["합계", "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) => (
<tr key={idx} className="bg-white">
<tr key={idx} className='bg-white'>
{row.map((val, i) => (
<td
key={i}
className={`${tableStyles.td} ${
i === 0 ? "!font-bold !text-neutral-900" : ""
} ${[1, 3, 9].includes(i) ? "bg-[#EAF5F5]" : ""}`}
>
<td key={i} className={`${tableStyles.td} ${i === 0 ? '!font-bold !text-neutral-900' : ''} ${[1, 3, 9].includes(i) ? 'bg-[#EAF5F5]' : ''}`}>
{val}
</td>
))}
@@ -702,4 +417,4 @@ export default function SewageStatusSection() {
</div>
</div>
);
}
}
@@ -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 (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
<div className='flex items-center gap-3'>
<span className='h-5 w-[3px] bg-sky-500' />
<h3 className='text-[24px] font-bold text-neutral-900'>{children}</h3>
</div>
);
}
function Hr({ className = "" }: { className?: string }) {
function Hr({ className = '' }: { className?: string }) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
function ImgCard({ src, alt }: { src: StaticImageData; alt: string }) {
return (
<div className="w-full overflow-x-auto">
<Image
src={src}
alt={alt}
className="mx-auto block max-w-none"
style={{ width: "auto", height: "auto" }}
/>
<div className='w-full overflow-x-auto'>
<Image src={src} alt={alt} className='mx-auto block max-w-none' style={{ width: 'auto', height: 'auto' }} />
</div>
);
}
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 (
<section id={id} className={`scroll-mt-[120px] ${className}`}>
<SectionTitle>{title}</SectionTitle>
<div className="mt-8">{children}</div>
<div className='mt-8'>{children}</div>
</section>
);
}
export default function LawIntroSection() {
const title = "물 재이용 법";
const title = '물 재이용 법';
return (
<div className="bg-white">
<div className="inline-block">
<h2 className="relative inline-block px-2 text-[28px] font-extrabold leading-none tracking-tight text-slate-900">
<span className="relative z-10">{title}</span>
<span className="absolute bottom-[-4px] left-0 z-0 h-3 w-full bg-gradient-to-r from-sky-300 to-green-300" />
<div className='bg-white'>
<div className='inline-block'>
<h2 className='relative inline-block px-2 text-[28px] font-extrabold leading-none tracking-tight text-slate-900'>
<span className='relative z-10'>{title}</span>
<span className='absolute bottom-[-4px] left-0 z-0 h-3 w-full bg-gradient-to-r from-sky-300 to-green-300' />
</h2>
</div>
<div className="mt-10">
<SectionShell
id="law-plan"
title="법률 제·개정 및 기본계획 수립"
className="mt-10"
>
<ImgCard src={lawPlan} alt="법률 제·개정 및 기본계획 수립" />
<div className='mt-10'>
<SectionShell id='law-plan' title='법률 제·개정 및 기본계획 수립' className='mt-10'>
<ImgCard src={lawPlan} alt='법률 제·개정 및 기본계획 수립' />
</SectionShell>
<Hr className="my-11" />
<Hr className='my-11' />
<SectionShell
id="plan-performance"
title="제2차 계획 대비 실적 현황"
>
<ImgCard src={planPerformance} alt="제2차 계획 대비 실적 현황" />
<SectionShell id='plan-performance' title='제2차 계획 대비 실적 현황'>
<ImgCard src={planPerformance} alt='제2차 계획 대비 실적 현황' />
</SectionShell>
<Hr className="my-11" />
<Hr className='my-11' />
<SectionShell id="domestic-change" title="우리나라의 수자원 현황 및 물이용 여건">
<ImgCard src={domesticChange} alt="우리나라의 수자원 현황 및 물이용 여건" />
<SectionShell id='domestic-change' title='우리나라의 수자원 현황 및 물이용 여건'>
<ImgCard src={domesticChange} alt='우리나라의 수자원 현황 및 물이용 여건' />
</SectionShell>
<Hr className="my-11" />
<Hr className='my-11' />
<SectionShell
id="policy-history"
title="물 재이용 정책 변화 추이"
>
<ImgCard src={policyHistory} alt="물 재이용 정책 변화 추이" />
<SectionShell id='policy-history' title='물 재이용 정책 변화 추이'>
<ImgCard src={policyHistory} alt='물 재이용 정책 변화 추이' />
</SectionShell>
<Hr className="my-11" />
<Hr className='my-11' />
<SectionShell
id="law-relation"
title="타법령과의 관계 검토"
>
<ImgCard src={lawRelation} alt="타법령과의 관계 검토" />
<SectionShell id='law-relation' title='타법령과의 관계 검토'>
<ImgCard src={lawRelation} alt='타법령과의 관계 검토' />
</SectionShell>
<Hr className="my-11" />
<Hr className='my-11' />
<SectionShell
id="domestic-linkage"
title="주요 국가계획의 정책방향과 연계한 물 재이용 정책 필요"
>
<ImgCard src={domesticLinkage} alt="주요 국가계획의 정책방향과 연계한 물 재이용 정책 필요" />
<SectionShell id='domestic-linkage' title='주요 국가계획의 정책방향과 연계한 물 재이용 정책 필요'>
<ImgCard src={domesticLinkage} alt='주요 국가계획의 정책방향과 연계한 물 재이용 정책 필요' />
</SectionShell>
</div>
</div>
);
}
}
+44 -52
View File
@@ -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 (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
<div className='flex items-center gap-3'>
<span className='h-5 w-[3px] bg-sky-500' />
<h3 className='text-[24px] font-bold text-neutral-900'>{children}</h3>
</div>
);
}
@@ -23,7 +23,7 @@ type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
function Hr({ className = '' }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
@@ -34,95 +34,87 @@ type ImgCardProps = {
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<Image
src={src}
alt={alt}
className="block mx-auto max-w-none"
style={{ width: "auto", height: "auto" }}
/>
<div className='w-full overflow-x-auto'>
<Image src={src} alt={alt} className='block mx-auto max-w-none' style={{ width: 'auto', height: 'auto' }} />
</div>
);
}
export default function WaterReuseLawPage() {
return (
<div className="bg-white">
<div className='bg-white'>
<SubPageHeader
title="물 재이용 법"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 소개", href: "/reuse/intro/law" },
{ label: "물 재이용 법" },
]}
title='물 재이용 법'
crumbs={[{ label: 'Home', href: '/reuse' }, { label: '물 재이용 소개', href: '/reuse/intro/law' }, { label: '물 재이용 법' }]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
wrapClassName='!pb-0'
/>
<Hr className="my-7" />
<Hr className='my-7' />
{/* 1) 법제도/계획 수립 타임라인 */}
<section className="mt-6">
<section className='mt-6'>
<SectionTitle>법제도 · 계획 및 기본계획 수립</SectionTitle>
<div className="mt-8">
<ImgCard src={lawTimeline} alt="법제도 타임라인" />
<div className='mt-8'>
<ImgCard src={lawTimeline} alt='법제도 타임라인' />
</div>
</section>
<Hr className="my-11" />
<Hr className='my-11' />
{/* 2) 제2차 계획 대비 실적 현황 (2컬럼) */}
<section>
<SectionTitle>제 2차 계획 대비 실적 현황</SectionTitle>
<div className="mt-8">
<ImgCard src={performanceLeft} alt="실적 현황 요약" />
<div className='mt-8'>
<ImgCard src={performanceLeft} alt='실적 현황 요약' />
</div>
</section>
<Hr className="my-11" />
<Hr className='my-11' />
{/* 3) 국내 관련 현황 */}
<section>
<SectionTitle>국내 관련 현황</SectionTitle>
<div className="mt-8">
<ImgCard src={domesticTextbox} alt="국내 관련 현황 설명" />
<div className='mt-8'>
<ImgCard src={domesticTextbox} alt='국내 관련 현황 설명' />
</div>
</section>
<Hr className="my-11" />
<Hr className='my-11' />
{/* 4) 정책 변천 추이 */}
<section>
<SectionTitle>물 재이용 정책 변천 추이</SectionTitle>
<div className="mt-8">
<ImgCard src={policyTimeline} alt="정책 변천 타임라인" />
<div className='mt-8'>
<ImgCard src={policyTimeline} alt='정책 변천 타임라인' />
</div>
</section>
<Hr className="my-11" />
<Hr className='my-11' />
{/* 5) 타 법령과의 관계도 */}
<section>
<SectionTitle>타법령과의 관계도</SectionTitle>
<div className="mt-8">
<ImgCard src={relNote} alt="관계도 설명" />
<div className='mt-8'>
<ImgCard src={relNote} alt='관계도 설명' />
</div>
</section>
<Hr className="my-11" />
<Hr className='my-11' />
{/* 6) 국내 연계 현황 */}
<section className="pb-[200px]">
<section className='pb-[200px]'>
<SectionTitle>국내 연계 현황</SectionTitle>
<div className="mt-8">
<div className="mx-[20px] mb-8">
<p className="text-[24px] font-semibold mb-2">주요 국가계획의 정책방향과 연계한 물 재이용 정책 필요</p>
<p className="text-[#4F4F4F]">국가 상위계획의 정책 방향이 기후위기 대응 등 수자원의 공급 안전성을 위한 물 재이용 활성화로 정책 실효성 제고 필요<br />
• 물관리 계획(국가수도기본계획, 국가 및 유역하수도종합계획, 하천유역수자원관리계획 등)과의 연계성 확보 → 통합적인 관점에서 물이용 체계 구축 필요
<div className='mt-8'>
<div className='mx-[20px] mb-8'>
<p className='text-[24px] font-semibold mb-2'>주요 국가계획의 정책방향과 연계한 물 재이용 정책 필요</p>
<p className='text-[#4F4F4F]'>
국가 상위계획의 정책 방향이 기후위기 대응 등 수자원의 공급 안전성을 위한 물 재이용 활성화로 정책 실효성 제고 필요
<br />• 물관리 계획(국가수도기본계획, 국가 및 유역하수도종합계획, 하천유역수자원관리계획 등)과의 연계성 확보 → 통합적인 관점에서 물이용 체계 구축 필요
</p>
</div>
<ImgCard src={linkage} alt="국내 연계 현황" />
<ImgCard src={linkage} alt='국내 연계 현황' />
</div>
</section>
</div>
@@ -1,4 +1,4 @@
"use client";
'use client';
export default function LawIntroSection() {
const contentsHtml = `
@@ -84,10 +84,5 @@ export default function LawIntroSection() {
</div>
`;
return (
<div
className="bg-white"
dangerouslySetInnerHTML={{ __html: contentsHtml }}
/>
);
return <div className='bg-white' dangerouslySetInnerHTML={{ __html: contentsHtml }} />;
}
+47 -89
View File
@@ -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 (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
<div className='flex items-center gap-3'>
<span className='h-5 w-[3px] bg-sky-500' />
<h3 className='text-[24px] font-bold text-neutral-900'>{children}</h3>
</div>
);
}
@@ -25,7 +25,7 @@ type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
function Hr({ className = '' }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
@@ -36,14 +36,8 @@ type ImgCardProps = {
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<Image
src={src}
alt={alt}
className="mx-auto block max-w-none"
style={{ width: "auto", height: "auto" }}
priority={false}
/>
<div className='w-full overflow-x-auto'>
<Image src={src} alt={alt} className='mx-auto block max-w-none' style={{ width: 'auto', height: 'auto' }} priority={false} />
</div>
);
}
@@ -55,109 +49,73 @@ type SectionShellProps = {
className?: string;
};
function SectionShell({ id, title, children, className = "" }: SectionShellProps) {
function SectionShell({ id, title, children, className = '' }: SectionShellProps) {
return (
<section
id={id}
className={[
"scroll-mt-[140px]",
className,
].join(" ")}
>
<section id={id} className={['scroll-mt-[140px]', className].join(' ')}>
<SectionTitle>{title}</SectionTitle>
<div className="mt-8">{children}</div>
<div className='mt-8'>{children}</div>
</section>
);
}
export default function WaterReuseLawPage() {
return (
<div className="relative bg-white">
<div className='relative bg-white'>
{/* <BusinessSubNav /> */}
<SubPageHeader
title="물 재이용 법"
crumbs={[
{ label: "Home", href: "/main" },
{ label: "물 재이용 소개", href: "/intro/law" },
{ label: "물 재이용 법" },
]}
title='물 재이용 법'
crumbs={[{ label: 'Home', href: '/main' }, { label: '물 재이용 소개', href: '/intro/law' }, { label: '물 재이용 법' }]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
wrapClassName='!pb-0'
/>
<div className="pr-0">
<Hr className="my-7" />
<div className='pr-0'>
<Hr className='my-7' />
<SectionShell
id="law-plan"
title="법률 제·개정 및 기본계획 수립"
className="mt-6"
>
<ImgCard src={lawTimeline} alt="법률 제·개정 및 기본계획 수립" />
<SectionShell id='law-plan' title='법률 제·개정 및 기본계획 수립' className='mt-6'>
<ImgCard src={lawTimeline} alt='법률 제·개정 및 기본계획 수립' />
</SectionShell>
<Hr className="my-11" />
<Hr className='my-11' />
<SectionShell
id="plan-performance"
title="제2차 계획 대비 실적 현황"
>
<ImgCard src={performanceLeft} alt="제2차 계획 대비 실적 현황" />
<SectionShell id='plan-performance' title='제2차 계획 대비 실적 현황'>
<ImgCard src={performanceLeft} alt='제2차 계획 대비 실적 현황' />
</SectionShell>
<Hr className="my-11" />
<Hr className='my-11' />
<SectionShell
id="domestic-change"
title="국내 여건 변화"
>
<ImgCard src={domesticTextbox} alt="국내 여건 변화" />
<SectionShell id='domestic-change' title='국내 여건 변화'>
<ImgCard src={domesticTextbox} alt='국내 여건 변화' />
</SectionShell>
<Hr className="my-11" />
<Hr className='my-11' />
<SectionShell
id="policy-history"
title="물 재이용 정책 변화 추이"
>
<ImgCard src={policyTimeline} alt="물 재이용 정책 변화 추이" />
<SectionShell id='policy-history' title='물 재이용 정책 변화 추이'>
<ImgCard src={policyTimeline} alt='물 재이용 정책 변화 추이' />
</SectionShell>
<Hr className="my-11" />
<Hr className='my-11' />
<SectionShell
id="law-relation"
title="타법령과의 관계 검토"
>
<ImgCard src={relNote} alt="타법령과의 관계 검토" />
<SectionShell id='law-relation' title='타법령과의 관계 검토'>
<ImgCard src={relNote} alt='타법령과의 관계 검토' />
</SectionShell>
<Hr className="my-11" />
<Hr className='my-11' />
<SectionShell
id="domestic-linkage"
title="국내 연계 현황"
className="pb-[250px]"
>
<div className="mx-[20px] mb-8">
<p className="mb-2 text-[24px] font-semibold">
주요 국가계획의 정책방향과 연계한 물 재이용 정책 필요
</p>
<p className="text-[#4F4F4F]">
국가 상위계획의 정책 방향이 기후위기 대응 등 수자원의 공급 안전성을 위한
물 재이용 활성화로 정책 실효성 제고 필요
<br />
• 물관리 계획(국가수도기본계획, 국가 및 유역하수도종합계획,
하천유역수자원관리계획 등)과의 연계성 확보 → 통합적인 관점에서
물이용 체계 구축 필요
<SectionShell id='domestic-linkage' title='국내 연계 현황' className='pb-[250px]'>
<div className='mx-[20px] mb-8'>
<p className='mb-2 text-[24px] font-semibold'>주요 국가계획의 정책방향과 연계한 물 재이용 정책 필요</p>
<p className='text-[#4F4F4F]'>
국가 상위계획의 정책 방향이 기후위기 대응 등 수자원의 공급 안전성을 위한 물 재이용 활성화로 정책 실효성 제고 필요
<br />• 물관리 계획(국가수도기본계획, 국가 및 유역하수도종합계획, 하천유역수자원관리계획 등)과의 연계성 확보 → 통합적인 관점에서 물이용 체계 구축 필요
</p>
</div>
<ImgCard src={linkage} alt="국내 연계 현황" />
<ImgCard src={linkage} alt='국내 연계 현황' />
</SectionShell>
</div>
</div>
);
}
}
@@ -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 (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
<div className='flex items-center gap-3'>
<span className='h-5 w-[3px] bg-sky-500' />
<h3 className='text-[24px] font-bold text-neutral-900'>{children}</h3>
</div>
);
}
// 구분선
function Hr({ className = "" }: { className?: string }) {
function Hr({ className = '' }: { className?: string }) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
@@ -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 (
<div className="mb-[70px] px-[160px]">
<div className="grid grid-cols-[1fr_400px] items-center gap-[60px]">
<div className='mb-[70px] px-[160px]'>
<div className='grid grid-cols-[1fr_400px] items-center gap-[60px]'>
{/* 좌측 */}
<div>
<h3 className="text-[40px] font-extrabold leading-[1.35] tracking-[-0.04em] text-neutral-900">
<h3 className='text-[40px] font-extrabold leading-[1.35] tracking-[-0.04em] text-neutral-900'>
기후위기 시대의 수자원 확보,
<br />
<span className="inline-block bg-gradient-to-b from-[#6DBB4F] to-[#01ACE5] bg-clip-text text-transparent">
빗물 재이용
</span>
이 앞장섭니다.
<span className='inline-block bg-gradient-to-b from-[#6DBB4F] to-[#01ACE5] bg-clip-text text-transparent'>빗물 재이용</span>이 앞장섭니다.
</h3>
<p className="mt-5 text-[16px] leading-[1.8] tracking-[-0.04em] text-neutral-600">
<p className='mt-5 text-[16px] leading-[1.8] tracking-[-0.04em] text-neutral-600'>
2023년 기준 전국 3,500여 개 시설에서
<br />
연간 약 1,394만m³의 빗물을 소중한 자원으로 다시 활용하고 있습니다.
</p>
<div className="mt-10 space-y-8">
<div className='mt-10 space-y-8'>
{items.map((item) => (
<div key={item.title} className="flex gap-3 items-center">
<div className="flex h-12 w-12 shrink-0 items-center justify-center">
<Image
src={item.icon}
alt=""
width={48}
height={48}
className="object-contain"
/>
<div key={item.title} className='flex gap-3 items-center'>
<div className='flex h-12 w-12 shrink-0 items-center justify-center'>
<Image src={item.icon} alt='' width={48} height={48} className='object-contain' />
</div>
<div>
<strong className="block text-[18px] font-extrabold text-neutral-900">
{item.title}
</strong>
<p className="mt-2 text-[16px] leading-[1.7] text-neutral-600 tracking-tighter">
{item.desc}
</p>
<strong className='block text-[18px] font-extrabold text-neutral-900'>{item.title}</strong>
<p className='mt-2 text-[16px] leading-[1.7] text-neutral-600 tracking-tighter'>{item.desc}</p>
</div>
</div>
))}
@@ -95,15 +76,8 @@ function RainIntroSummary() {
</div>
{/* 우측 이미지 */}
<div className="flex items-center justify-center">
<Image
src="/images/landing/rain_status_img.png"
alt="빗물 재이용 개념도"
width={400}
height={343}
className="h-auto w-full max-w-[400px] object-contain"
priority
/>
<div className='flex items-center justify-center'>
<Image src='/images/landing/rain_status_img.png' alt='빗물 재이용 개념도' width={400} height={343} className='h-auto w-full max-w-[400px] object-contain' priority />
</div>
</div>
</div>
@@ -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 (
<p className="mt-7 text-center text-[13px] text-neutral-500">
자료: 하수도정보시스템 {selectYear} 하수도 통계
</p>
);
return <p className='mt-7 text-center text-[13px] text-neutral-500'>자료: 하수도정보시스템 {selectYear} 하수도 통계</p>;
}
useEffect(() => {
@@ -171,32 +121,22 @@ export default function RainStatusSection() {
const renderPartHeaders = (headers: string[], headerColors: string[]) => {
return headers.map((header, index) => (
<th
key={header}
className={`${tableStyles.th} ${headerColors[index] ?? "bg-[#81C784]"} text-white`}
style={{ width: "140px" }}
>
<th key={header} className={`${tableStyles.th} ${headerColors[index] ?? 'bg-[#81C784]'} text-white`} style={{ width: '140px' }}>
{header}
</th>
));
};
const renderBuildingUsageCells = (
row: DmStatusBildTpYearsType | DmStatusUsgYearsType | DmStatusUsgYearsType,
headers: TableHeaderType[]
) => {
const renderBuildingUsageCells = (row: DmStatusBildTpYearsType | DmStatusUsgYearsType | DmStatusUsgYearsType, headers: TableHeaderType[]) => {
const rowData = row as Record<string, string | number>;
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 (
<td
key={`${rowKey}-${header.id}`}
className={`${tableStyles.td} ${index === 0 ? "!font-bold !text-neutral-900" : ""} ${[1, 3, 5].includes(index) ? "bg-[#EAF5F5]" : ""}`}
>
<td key={`${rowKey}-${header.id}`} className={`${tableStyles.td} ${index === 0 ? '!font-bold !text-neutral-900' : ''} ${[1, 3, 5].includes(index) ? 'bg-[#EAF5F5]' : ''}`}>
{index === 0 ? value : formatUsage(value as string | number, 1000, 1)}
</td>
);
@@ -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 (
<td
key={`${row.USG_NM}-${header.id}`}
className={`${tableStyles.td} ${index === 0 ? "!font-bold !text-neutral-900" : ""} ${[1, 2].includes(index) ? "bg-[#EAF5F5]" : ""}`}
>
{displayValue == 0.0 ? "-" : displayValue}
<td key={`${row.USG_NM}-${header.id}`} className={`${tableStyles.td} ${index === 0 ? '!font-bold !text-neutral-900' : ''} ${[1, 2].includes(index) ? 'bg-[#EAF5F5]' : ''}`}>
{displayValue == 0.0 ? '-' : displayValue}
</td>
);
});
};
const title = "빗물이용시설(현황)";
const title = '빗물이용시설(현황)';
return (
<div className="bg-white">
<div className='bg-white'>
{/* 상단 타이틀 */}
<div className="inline-block">
<h2 className="relative inline-block px-2 text-[28px] font-extrabold tracking-tight leading-none text-slate-900">
<span className="relative z-10">{title}</span>
<span className="absolute left-0 bottom-[-4px] z-0 h-3 w-full bg-gradient-to-r from-sky-300 to-green-300" />
<div className='inline-block'>
<h2 className='relative inline-block px-2 text-[28px] font-extrabold tracking-tight leading-none text-slate-900'>
<span className='relative z-10'>{title}</span>
<span className='absolute left-0 bottom-[-4px] z-0 h-3 w-full bg-gradient-to-r from-sky-300 to-green-300' />
</h2>
</div>
<div className="mt-[70px] pb-[120px]">
<div className='mt-[70px] pb-[120px]'>
{/* 1) 빗물이용시설의 건축물별 빗물이용량 현황 */}
<section>
{/* 인트로 영역 */}
<RainIntroSummary />
<div id="status-rain-building" className="scroll-mt-[120px]">
<div id='status-rain-building' className='scroll-mt-[120px]'>
<SectionTitle>빗물이용시설의 건축물별 빗물이용량 현황</SectionTitle>
<div className="mt-10">
<div className={tableStyles.container}>
<div className='mt-10'>
<div className={tableStyles.container}>
<table className={tableStyles.table}>
<thead>
<thead>
<tr>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: "160px" }}>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: '160px' }}>
구분
</th>
<th colSpan={rainyYears.length || 6} className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}>
</th>
<th colSpan={rainyYears.length || 6} className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}>
빗물이용량 (천m³/년)
</th>
</th>
</tr>
<tr>
{renderPartHeaders(rainyYears, headerColors6)}
</tr>
</thead>
<tbody>
<tr>{renderPartHeaders(rainyYears, headerColors6)}</tr>
</thead>
<tbody>
{rainyByBildTpYears.map((row, idx) => (
<tr key={idx} className="bg-white">
<tr key={idx} className='bg-white'>
{renderBuildingUsageCells(row, rainyByBildTpYearsHeader)}
</tr>
</tr>
))}
</tbody>
</tbody>
</table>
</div>
</div>
<SourceText />
<SourceText />
</div>
</div>
</section>
<Hr className="my-11" />
<Hr className='my-11' />
{/* 2) 빗물이용시설의 용도별 분포 (xxxx년 기준) */}
<section
id="status-rain-purpose-distribution"
className="scroll-mt-[120px]"
>
<section id='status-rain-purpose-distribution' className='scroll-mt-[120px]'>
<SectionTitle>빗물이용시설의 용도별 분포 ({selectYear}년 기준)</SectionTitle>
<div className="mt-8">
<div className='mt-8'>
<div className={tableStyles.container}>
<table className={tableStyles.table}>
<thead>
<tr>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: "160px" }}>용도</th>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: "84px" }}>개소수<br />(개소)</th>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: "84px" }}>저류용량<br />(천m³)</th>
<th colSpan={8} className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}>빗물이용량 (천m³/년)</th>
</tr>
<tr>
{renderPartHeaders(partBildTpHeader, headerColors8)}
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: '160px' }}>
용도
</th>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: '84px' }}>
개소수
<br />
(개소)
</th>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: '84px' }}>
저류조용량
<br />
(천m³)
</th>
<th colSpan={8} className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}>
빗물이용량 (천m³/년)
</th>
</tr>
<tr>{renderPartHeaders(partBildTpHeader, headerColors8)}</tr>
</thead>
<tbody>
{rainyByUsgBildTp.map((row, idx) => (
<tr key={`${row.USG_NM}-${idx}`} className="bg-white">
<tr key={`${row.USG_NM}-${idx}`} className='bg-white'>
{renderRainyByUsgBildTpCells(row)}
</tr>
))}
@@ -313,30 +250,29 @@ export default function RainStatusSection() {
</div>
</section>
<Hr className="my-11" />
<Hr className='my-11' />
{/* 3) 빗물이용시설의 용도별 빗물이용량 현황 */}
<section
id="status-rain-purpose-usage"
className="scroll-mt-[120px]"
>
<section id='status-rain-purpose-usage' className='scroll-mt-[120px]'>
<SectionTitle>빗물이용시설의 용도별 빗물이용량 현황</SectionTitle>
<div className="mt-8">
<div className='mt-8'>
<div className={tableStyles.container}>
<table className={tableStyles.table}>
<thead>
<tr>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: "160px" }}>용도</th>
<th colSpan={6} className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}>빗물이용량 (천m³/년)</th>
</tr>
<tr>
{renderPartHeaders(rainyYears, headerColors6)}
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: '160px' }}>
용도
</th>
<th colSpan={6} className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}>
빗물이용량 (천m³/년)
</th>
</tr>
<tr>{renderPartHeaders(rainyYears, headerColors6)}</tr>
</thead>
<tbody>
{rainyByUsgYears.map((row, idx) => (
<tr key={idx} className="bg-white">
<tr key={idx} className='bg-white'>
{renderBuildingUsageCells(row, rainyByUsgYearsHeader)}
</tr>
))}
@@ -350,4 +286,4 @@ export default function RainStatusSection() {
</div>
</div>
);
}
}
@@ -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 (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
<div className='flex items-center gap-3'>
<span className='h-5 w-[3px] bg-sky-500' />
<h3 className='text-[24px] font-bold text-neutral-900'>{children}</h3>
</div>
);
}
function Hr({ className = "" }: { className?: string }) {
function Hr({ className = '' }: { className?: string }) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
function SewageIntroSummary() {
const items = [
{
icon: "/images/landing/icon_cycle_water.png",
title: "안정적인 재이용률 유지",
icon: '/images/landing/icon_cycle_water.png',
title: '안정적인 재이용률 유지',
desc: (
<>
<>
연간 약 1,136백만m³의 하수처리수가 재이용되고 있으며,
<br/>
<br />
약 15%에 달하는 높은 재이용률을 통해
<br/>
<br />
수자원 선순환 체계를 구축하고 있습니다.
</>
</>
),
},
{
icon: "/images/landing/icon_water_flow.png",
title: "메마른 하천을 살리는 물",
icon: '/images/landing/icon_water_flow.png',
title: '메마른 하천을 살리는 물',
desc: (
<>
<>
재이용되는 물의 가장 많은 부분이 하천유지용수로 사용되어
<br/>
<br />
도심 속 하천의 생태계를 보호하고 쾌적한 수변 환경을 조성합니다.
</>
</>
),
},
{
icon: "/images/landing/icon_location.png",
title: "지역별 균형 있는 활용",
icon: '/images/landing/icon_location.png',
title: '지역별 균형 있는 활용',
desc: (
<>
<>
전국적으로 고르게 시설이 확충되고 있으며, 특히 경기 지역을 중심으로
<br/>
<br />
대규모 공업용수 공급망을 구축하여
<br/>
<br />
지역 경제와 환경 보호를 동시에 실현하고 있습니다.
</>
</>
),
},
];
return (
<div className="mb-[70px] px-[160px]">
<div className="grid grid-cols-[1fr_400px] items-center gap-0">
<div className='mb-[70px] px-[160px]'>
<div className='grid grid-cols-[1fr_400px] items-center gap-0'>
{/* 좌측 */}
<div>
<h3 className="text-[40px] font-extrabold leading-[1.35] tracking-[-0.14em] text-neutral-900">
<h3 className='text-[40px] font-extrabold leading-[1.35] tracking-[-0.14em] text-neutral-900'>
버려지는 하수에서 찾는 새로운 가치,
<br />
<span className="inline-block bg-gradient-to-b from-[#6DBB4F] to-[#01ACE5] bg-clip-text text-transparent">
하수처리수 재이용
</span>
이 도시를 숨쉬게 합니다.
<span className='inline-block bg-gradient-to-b from-[#6DBB4F] to-[#01ACE5] bg-clip-text text-transparent'>하수처리수 재이용</span>이 도시를 숨쉬게 합니다.
</h3>
<p className="mt-5 text-[16px] leading-[1.8] text-neutral-600">
<p className='mt-5 text-[16px] leading-[1.8] text-neutral-600'>
하수처리장에서 정화된 물은 하천의 수량을 조절하고
<br />
산업 현장에 용수를 공급하는 소중한 자원입니다.
</p>
<div className="mt-10 space-y-8">
<div className='mt-10 space-y-8'>
{items.map((item) => (
<div key={item.title} className="flex gap-4 items-center">
<div className="mt-1 flex h-12 w-12 shrink-0 items-center justify-center">
<Image
src={item.icon}
alt=""
width={48}
height={48}
className="object-contain"
/>
<div key={item.title} className='flex gap-4 items-center'>
<div className='mt-1 flex h-12 w-12 shrink-0 items-center justify-center'>
<Image src={item.icon} alt='' width={48} height={48} className='object-contain' />
</div>
<div>
<strong className="block text-[18px] font-extrabold text-neutral-900">
{item.title}
</strong>
<p className="mt-2 text-[16px] leading-[1.7] text-neutral-600">
{item.desc}
</p>
<strong className='block text-[18px] font-extrabold text-neutral-900'>{item.title}</strong>
<p className='mt-2 text-[16px] leading-[1.7] text-neutral-600'>{item.desc}</p>
</div>
</div>
))}
@@ -118,15 +99,8 @@ function SewageIntroSummary() {
</div>
{/* 우측 이미지 */}
<div className="flex items-center justify-center">
<Image
src="/images/landing/sewage_status_img.png"
alt="하수처리수 재이용 개념도"
width={420}
height={360}
className="h-auto w-full max-w-[420px] object-contain"
priority
/>
<div className='flex items-center justify-center'>
<Image src='/images/landing/sewage_status_img.png' alt='하수처리수 재이용 개념도' width={420} height={360} className='h-auto w-full max-w-[420px] object-contain' priority />
</div>
</div>
</div>
@@ -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 (
<p className="mt-7 text-center text-[13px] text-neutral-500">
자료: 하수도정보시스템 {selectYear} 하수도 통계
</p>
);
return <p className='mt-7 text-center text-[13px] text-neutral-500'>자료: 하수도정보시스템 {selectYear} 하수도 통계</p>;
}
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) => (
<th
key={header}
className={`${tableStyles.th} ${headerColors[index] ?? "bg-[#81C784]"} text-white`}
>
<th key={header} className={`${tableStyles.th} ${headerColors[index] ?? 'bg-[#81C784]'} text-white`}>
{header}
</th>
));
@@ -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<string, string | number>;
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 (
<td
key={`${rowIdx}-${header.id}`}
rowSpan={getRowSpan()}
className={`${tableStyles.td} !font-bold !text-neutral-900`}
>
<td key={`${rowIdx}-${header.id}`} rowSpan={getRowSpan()} className={`${tableStyles.td} !font-bold !text-neutral-900`}>
{value}
</td>
);
} else if (header.id === "COL2") {
return (
<td
key={`${rowIdx}-${header.id}`}
className={`${tableStyles.td} !font-bold !text-neutral-900`}
dangerouslySetInnerHTML={{ __html: Col2Name[value as string] ?? "" }}
/>
);
} else if (header.id === 'COL2') {
return <td key={`${rowIdx}-${header.id}`} className={`${tableStyles.td} !font-bold !text-neutral-900`} dangerouslySetInnerHTML={{ __html: Col2Name[value as string] ?? '' }} />;
}
return (
<td
key={`${rowIdx}-${header.id}`}
className={`${tableStyles.td} ${[0, 2, 4].includes(index) ? "bg-[#EAF5F5]" : ""}`}
>
{(String(rowData["COL2"]).indexOf("CNT") > -1)
? Number(value).toLocaleString("ko-KR")
: ((String(rowData["COL2"]).indexOf("REUSERT") > -1)
<td key={`${rowIdx}-${header.id}`} className={`${tableStyles.td} ${[0, 2, 4].includes(index) ? 'bg-[#EAF5F5]' : ''}`}>
{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)}
</td>
);
});
};
const renderPbnstUsgCells = (
rowIdx: number,
row: DmStatusBildTpYearsType | DmStatusUsgYearsType,
headers: TableHeaderType[]
) => {
const renderPbnstUsgCells = (rowIdx: number, row: DmStatusBildTpYearsType | DmStatusUsgYearsType, headers: TableHeaderType[]) => {
const rowData = row as Record<string, string | number>;
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 (
<td key={`${rowIdx}-${header.id}`} className={`${tableStyles.td} relative`}>
{isSubItem ? (
<>
<div
className={`absolute left-[1px] w-[16px] bg-white z-10 top-[-2px] ${
isLastInGroup ? "bottom-[0px]" : "bottom-[-2px]"
}`}
/>
<div
className={`absolute left-[16px] w-[4px] z-20 ${
type === "red-item" ? "bg-[#FF8B8B]" : "bg-[#4ECBFF]"
} top-[-2px] ${
isLastInGroup ? "bottom-[0px]" : "bottom-[-2px]"
}`}
/>
<span className="text-[#4f4f4f] font-bold block w-full text-center relative z-30 translate-x-[8px]">
{value}
</span>
<div className={`absolute left-[1px] w-[16px] bg-white z-10 top-[-2px] ${isLastInGroup ? 'bottom-[0px]' : 'bottom-[-2px]'}`} />
<div className={`absolute left-[16px] w-[4px] z-20 ${type === 'red-item' ? 'bg-[#FF8B8B]' : 'bg-[#4ECBFF]'} top-[-2px] ${isLastInGroup ? 'bottom-[0px]' : 'bottom-[-2px]'}`} />
<span className='text-[#4f4f4f] font-bold block w-full text-center relative z-30 translate-x-[8px]'>{value}</span>
</>
) : (
<span className="font-bold text-neutral-900 block w-full text-center">
{value}
</span>
<span className='font-bold text-neutral-900 block w-full text-center'>{value}</span>
)}
</td>
);
}
);
}
return (
<td
key={`${rowIdx}-${header.id}`}
className={`${tableStyles.td} ${index === 0 ? "!font-bold !text-neutral-900" : ""} ${[0, 2, 4].includes(index) ? "bg-[#EAF5F5]" : ""}`}
>
<td key={`${rowIdx}-${header.id}`} className={`${tableStyles.td} ${index === 0 ? '!font-bold !text-neutral-900' : ''} ${[0, 2, 4].includes(index) ? 'bg-[#EAF5F5]' : ''}`}>
{formatUsage(value as string | number, 1000, 1)}
</td>
);
});
};
const renderSidoUsgCells = (
rowIdx: number,
row: DmStatusSidoUsgType,
headers: TableHeaderType[]
) => {
const renderSidoUsgCells = (rowIdx: number, row: DmStatusSidoUsgType, headers: TableHeaderType[]) => {
const rowData = row as Record<string, string | number>;
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 (
<td
key={`${rowIdx}-${header.id}`}
className={`${tableStyles.td} ${index === 0 ? "!font-bold !text-neutral-900" : ""} ${[1, 9].includes(index) ? "bg-[#EAF5F5]" : ""}`}
>
<td key={`${rowIdx}-${header.id}`} className={`${tableStyles.td} ${index === 0 ? '!font-bold !text-neutral-900' : ''} ${[1, 9].includes(index) ? 'bg-[#EAF5F5]' : ''}`}>
{value}
</td>
);
});
};
const title = "하수처리수 재이용시설(현황)";
const title = '하수처리수 재이용시설(현황)';
return (
<div className="bg-white">
<div className='bg-white'>
{/* 상단 타이틀 */}
<div className="inline-block">
<h2 className="relative inline-block px-2 text-[28px] font-extrabold tracking-tight leading-none text-slate-900">
<span className="relative z-10">{title}</span>
<span className="absolute left-0 bottom-[-4px] z-0 h-3 w-full bg-gradient-to-r from-sky-300 to-green-300" />
<div className='inline-block'>
<h2 className='relative inline-block px-2 text-[28px] font-extrabold tracking-tight leading-none text-slate-900'>
<span className='relative z-10'>{title}</span>
<span className='absolute left-0 bottom-[-4px] z-0 h-3 w-full bg-gradient-to-r from-sky-300 to-green-300' />
</h2>
</div>
<div className="mt-[70px] pb-[120px]">
<div className='mt-[70px] pb-[120px]'>
{/* 1) 연도별 하수처리수 재이용 현황 */}
<section>
{/* 인트로 */}
<SewageIntroSummary />
{/* 인트로 */}
<SewageIntroSummary />
<div id="status-treated-year" className="scroll-mt-[120px]">
<SectionTitle>연도별 하수처리수 재이용 현황</SectionTitle>
<div className="mt-10">
<div className={tableStyles.container}>
<table className={tableStyles.table}>
<colgroup>
<col style={{ width: "130px" }} />
<col style={{ width: "180px" }} />
<col />
<col />
<col />
<col />
<col />
<col />
</colgroup>
<thead>
<tr>
<th
colSpan={2}
className={`${tableStyles.th} ${tableStyles.headerBg}`}
>
구분
</th>
{renderPartHeaders(pbnstYears, headerColors6)}
</tr>
</thead>
<tbody>
{pbnstByLawYears.map((row, idx, arr) => (
<tr key={idx}>
{renderCells(idx, row, arr, pbnstByLawYearsHeader, 1000)}
</tr>
))}
</tbody>
</table>
</div>
<div id='status-treated-year' className='scroll-mt-[120px]'>
<SectionTitle>연도별 하수처리수 재이용 현황</SectionTitle>
<div className='mt-10'>
<div className={tableStyles.container}>
<table className={tableStyles.table}>
<colgroup>
<col style={{ width: '130px' }} />
<col style={{ width: '180px' }} />
<col />
<col />
<col />
<col />
<col />
<col />
</colgroup>
<thead>
<tr>
<th colSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
구분
</th>
{renderPartHeaders(pbnstYears, headerColors6)}
</tr>
</thead>
<tbody>
{pbnstByLawYears.map((row, idx, arr) => (
<tr key={idx}>{renderCells(idx, row, arr, pbnstByLawYearsHeader, 1000)}</tr>
))}
</tbody>
</table>
</div>
<SourceText />
</div>
<SourceText />
</div>
</div>
</section>
<Hr className="my-11" />
<Hr className='my-11' />
{/* 2) 용도별 하수처리수 재이용 현황 */}
<section id="status-treated-usage" className="scroll-mt-[120px]">
<section id='status-treated-usage' className='scroll-mt-[120px]'>
<SectionTitle>용도별 하수처리수 재이용 현황</SectionTitle>
<div className="mt-8">
<div className='mt-8'>
<div className={tableStyles.container}>
<div className="mb-2 text-right text-[13px] text-neutral-600">
(단위: 백만m³/일)
</div>
<div className='mb-2 text-right text-[13px] text-neutral-600'>(단위: 백만m³/년)</div>
<table className={tableStyles.table}>
<colgroup>
<col style={{ width: "180px" }} />
<col style={{ width: '180px' }} />
<col />
<col />
<col />
@@ -434,17 +344,15 @@ export default function SewageStatusSection() {
</colgroup>
<thead>
<tr>
<th className={`${tableStyles.th} ${tableStyles.headerBg}`}>
구분
</th>
<th className={`${tableStyles.th} ${tableStyles.headerBg}`}>구분</th>
{renderPartHeaders(pbnstYears, headerColors6)}
</tr>
</thead>
<tbody>
{pbnstByUsgYears.map((row, idx) => (
<tr key={idx} className="bg-white">
<tr key={idx} className='bg-white'>
{renderPbnstUsgCells(idx, row, pbnstByUsgYearsHeader)}
</tr>
</tr>
))}
</tbody>
</table>
@@ -454,18 +362,18 @@ export default function SewageStatusSection() {
</div>
</section>
<Hr className="my-11" />
<Hr className='my-11' />
{/* 3) 하수처리시설 설치 및 이용 현황 */}
<section id="status-treated-facility" className="scroll-mt-[120px]">
<section id='status-treated-facility' className='scroll-mt-[120px]'>
<SectionTitle>하수처리시설 설치 및 이용 현황</SectionTitle>
<div className="mt-8">
<div className='mt-8'>
<div className={tableStyles.container}>
<table className={tableStyles.table}>
<colgroup>
<col style={{ width: "130px" }} />
<col style={{ width: "160px" }} />
<col style={{ width: '130px' }} />
<col style={{ width: '160px' }} />
<col />
<col />
<col />
@@ -475,10 +383,7 @@ export default function SewageStatusSection() {
</colgroup>
<thead>
<tr>
<th
colSpan={2}
className={`${tableStyles.th} ${tableStyles.headerBg}`}
>
<th colSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
구분
</th>
{renderPartHeaders(pbnstYears, headerColors6)}
@@ -486,9 +391,7 @@ export default function SewageStatusSection() {
</thead>
<tbody>
{pbnstByCpcYears.map((row, idx, arr) => (
<tr key={idx}>
{renderCells(idx, row, arr, pbnstByCpcYearsHeader, 10000)}
</tr>
<tr key={idx}>{renderCells(idx, row, arr, pbnstByCpcYearsHeader, 10000)}</tr>
))}
</tbody>
</table>
@@ -498,24 +401,19 @@ export default function SewageStatusSection() {
</div>
</section>
<Hr className="my-11" />
<Hr className='my-11' />
{/* 4) 시도별 하수처리수 재이용 현황 */}
<section
id="status-treated-region"
className="scroll-mt-[120px]"
>
<section id='status-treated-region' className='scroll-mt-[120px]'>
<SectionTitle>시도별 하수처리수 재이용 현황</SectionTitle>
<div className="mt-8">
<div className='mt-8'>
<div className={tableStyles.container}>
<div className="mb-2 text-right text-[13px] text-neutral-600">
(단위: 백만m³/일)
</div>
<div className='mb-2 text-right text-[13px] text-neutral-600'>(단위: 백만m³/년)</div>
<table className={tableStyles.table}>
<colgroup>
<col style={{ width: "160px" }} />
<col style={{ width: '160px' }} />
<col />
<col />
<col />
@@ -529,54 +427,53 @@ export default function SewageStatusSection() {
</colgroup>
<thead>
<tr>
<th rowSpan={3} className={`${tableStyles.th} ${tableStyles.headerBg}`}> 시도 </th>
<th rowSpan={3} className={`${tableStyles.th} ${tableStyles.headerBg}`} >
연간<br />하수<br />처리량
<th rowSpan={3} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
{' '}
시도{' '}
</th>
<th colSpan={7} className={`${tableStyles.th} ${tableStyles.headerBg} py-2`} >
<th rowSpan={3} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
연간
<br />
하수
<br />
처리량
</th>
<th colSpan={7} className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}>
재이용현황
</th>
<th rowSpan={3} className={`${tableStyles.th} ${tableStyles.headerBg}`} >
재이용률<br />(%)
<th rowSpan={3} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
재이용률
<br />
(%)
</th>
<th rowSpan={3} className={`${tableStyles.th} ${tableStyles.headerBg}`} >
<th rowSpan={3} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
비고
</th>
</tr>
<tr>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} >
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
계
</th>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} >
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
장내
</th>
<th colSpan={5} className={`${tableStyles.th} ${tableStyles.headerBg} py-1`} >
<th colSpan={5} className={`${tableStyles.th} ${tableStyles.headerBg} py-1`}>
장외
</th>
</tr>
<tr>
<th className={`${tableStyles.th} bg-[#01ACE5] text-white py-2 text-[16px]`}>
소계
</th>
<th className={`${tableStyles.th} bg-[#1FB1C2] text-white py-2 text-[13px]`}>
하천유지용수
</th>
<th className={`${tableStyles.th} bg-[#3EB59F] text-white py-2 text-[16px]`}>
공업용수
</th>
<th className={`${tableStyles.th} bg-[#5DB97C] text-white py-2 text-[16px]`}>
농업용수
</th>
<th className={`${tableStyles.th} bg-[#6DBB4F] text-white py-2 text-[16px]`}>
기타용수
</th>
<th className={`${tableStyles.th} bg-[#01ACE5] text-white py-2 text-[16px]`}>소계</th>
<th className={`${tableStyles.th} bg-[#1FB1C2] text-white py-2 text-[13px]`}>하천유지용수</th>
<th className={`${tableStyles.th} bg-[#3EB59F] text-white py-2 text-[16px]`}>공업용수</th>
<th className={`${tableStyles.th} bg-[#5DB97C] text-white py-2 text-[16px]`}>농업용수</th>
<th className={`${tableStyles.th} bg-[#6DBB4F] text-white py-2 text-[16px]`}>기타용수</th>
</tr>
</thead>
<tbody>
{pbnstBySido.map((row, idx) => (
<tr key={idx} className="bg-white">
<tr key={idx} className='bg-white'>
{renderSidoUsgCells(idx, row, pbnstBySidoHeader)}
</tr>
</tr>
))}
</tbody>
</table>
+69 -113
View File
@@ -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 (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
<div className='flex items-center gap-3'>
<span className='h-5 w-[3px] bg-sky-500' />
<h3 className='text-[24px] font-bold text-neutral-900'>{children}</h3>
</div>
);
}
// 구분선
function Hr({ className = "" }: { className?: string }) {
function Hr({ className = '' }: { className?: string }) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
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 (
<p className="mt-7 text-center text-[13px] text-neutral-500">
자료: 환경부(2024), 「{selectYear} 하수도 통계」
</p>
);
return <p className='mt-7 text-center text-[13px] text-neutral-500'>자료: 환경부(2024), 「{selectYear} 하수도 통계」</p>;
}
useEffect(() => {
@@ -91,32 +59,22 @@ export default function RainwaterStatusPage() {
const renderPartHeaders = (headers: string[], headerColors: string[]) => {
return headers.map((header, index) => (
<th
key={header}
className={`${tableStyles.th} ${headerColors[index] ?? "bg-[#81C784]"} text-white`}
style={{ width: "140px" }}
>
<th key={header} className={`${tableStyles.th} ${headerColors[index] ?? 'bg-[#81C784]'} text-white`} style={{ width: '140px' }}>
{header}
</th>
));
};
const renderBuildingUsageCells = (
row: DmStatusBildTpYearsType | DmStatusUsgYearsType | DmStatusUsgYearsType,
headers: TableHeaderType[]
) => {
const renderBuildingUsageCells = (row: DmStatusBildTpYearsType | DmStatusUsgYearsType | DmStatusUsgYearsType, headers: TableHeaderType[]) => {
const rowData = row as Record<string, string | number>;
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 (
<td
key={`${rowKey}-${header.id}`}
className={`${tableStyles.td} ${index === 0 ? "!font-bold !text-neutral-900" : ""} ${[1, 3, 5].includes(index) ? "bg-[#EAF5F5]" : ""}`}
>
<td key={`${rowKey}-${header.id}`} className={`${tableStyles.td} ${index === 0 ? '!font-bold !text-neutral-900' : ''} ${[1, 3, 5].includes(index) ? 'bg-[#EAF5F5]' : ''}`}>
{index === 0 ? value : formatUsage(value as string | number, 1000, 1)}
</td>
);
@@ -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 (
<td
key={`${row.USG_NM}-${header.id}`}
className={`${tableStyles.td} ${index === 0 ? "!font-bold !text-neutral-900" : ""} ${[1, 2].includes(index) ? "bg-[#EAF5F5]" : ""}`}
>
{displayValue == 0.0 ? "-" : displayValue}
<td key={`${row.USG_NM}-${header.id}`} className={`${tableStyles.td} ${index === 0 ? '!font-bold !text-neutral-900' : ''} ${[1, 2].includes(index) ? 'bg-[#EAF5F5]' : ''}`}>
{displayValue == 0.0 ? '-' : displayValue}
</td>
);
});
};
return (
<div className="bg-white">
<div className='bg-white'>
<SubPageHeader
title="빗물이용시설(현황)"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 현황", href: "/reuse/cases/rain" },
{ label: "빗물이용시설(현황)" },
]}
title='빗물이용시설(현황)'
crumbs={[{ label: 'Home', href: '/reuse' }, { label: '물 재이용 현황', href: '/reuse/cases/rain' }, { label: '빗물이용시설(현황)' }]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
wrapClassName='!pb-0'
/>
<div className="pr-[100px]">
<Hr className="my-7" />
<div className='pr-[100px]'>
<Hr className='my-7' />
{/* 1) 빗물이용시설의 건축물별 빗물이용량 현황 */}
<section id="status-rain-building" className="mt-10 scroll-mt-[120px]">
<section id='status-rain-building' className='mt-10 scroll-mt-[120px]'>
<SectionTitle>빗물이용시설의 건축물별 빗물이용량 현황</SectionTitle>
<div className="mt-10">
<div className='mt-10'>
<div className={tableStyles.container}>
<table className={tableStyles.table}>
<thead>
<tr>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: "160px" }}>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: '160px' }}>
구분
</th>
<th colSpan={rainyYears.length || 6} className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}>
빗물이용량 (천m³/년)
</th>
</tr>
<tr>
{renderPartHeaders(rainyYears, headerColors6)}
</tr>
<tr>{renderPartHeaders(rainyYears, headerColors6)}</tr>
</thead>
<tbody>
{rainyByBildTpYears.map((row, idx) => (
<tr key={idx} className="bg-white">
<tr key={idx} className='bg-white'>
{renderBuildingUsageCells(row, rainyByBildTpYearsHeader)}
</tr>
))}
@@ -193,28 +137,38 @@ export default function RainwaterStatusPage() {
</div>
</section>
<Hr className="my-11" />
<Hr className='my-11' />
{/* 2) 빗물이용시설의 용도별 분포 (2023년 기준) */}
<section id="status-rain-purpose-distribution" className="scroll-mt-[120px]">
<section id='status-rain-purpose-distribution' className='scroll-mt-[120px]'>
<SectionTitle>빗물이용시설의 용도별 분포 ({selectYear}년 기준)</SectionTitle>
<div className="mt-8">
<div className='mt-8'>
<div className={tableStyles.container}>
<table className={tableStyles.table}>
<thead>
<tr>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: "160px" }}>용도</th>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: "84px" }}>개소수<br />(개소)</th>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: "84px" }}>저류용량<br />(천m³)</th>
<th colSpan={8} className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}>빗물이용량 (천m³/년)</th>
</tr>
<tr>
{renderPartHeaders(partBildTpHeader, headerColors8)}
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: '160px' }}>
용도
</th>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: '84px' }}>
개소수
<br />
(개소)
</th>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: '84px' }}>
저류조용량
<br />
(천m³)
</th>
<th colSpan={8} className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}>
빗물이용량 (천m³/년)
</th>
</tr>
<tr>{renderPartHeaders(partBildTpHeader, headerColors8)}</tr>
</thead>
<tbody>
{rainyByUsgBildTp.map((row, idx) => (
<tr key={`${row.USG_NM}-${idx}`} className="bg-white">
<tr key={`${row.USG_NM}-${idx}`} className='bg-white'>
{renderRainyByUsgBildTpCells(row)}
</tr>
))}
@@ -225,26 +179,28 @@ export default function RainwaterStatusPage() {
</div>
</section>
<Hr className="my-11" />
<Hr className='my-11' />
{/* 3) 빗물이용시설의 용도별 빗물이용량 현황 */}
<section id="status-rain-purpose-usage" className="pb-[200px] scroll-mt-[120px]">
<section id='status-rain-purpose-usage' className='pb-[200px] scroll-mt-[120px]'>
<SectionTitle>빗물이용시설의 용도별 빗물이용량 현황</SectionTitle>
<div className="mt-8">
<div className='mt-8'>
<div className={tableStyles.container}>
<table className={tableStyles.table}>
<thead>
<tr>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: "160px" }}>용도</th>
<th colSpan={6} className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}>빗물이용량 (천m³/년)</th>
</tr>
<tr>
{renderPartHeaders(rainyYears, headerColors6)}
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: '160px' }}>
용도
</th>
<th colSpan={6} className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}>
빗물이용량 (천m³/년)
</th>
</tr>
<tr>{renderPartHeaders(rainyYears, headerColors6)}</tr>
</thead>
<tbody>
{rainyByUsgYears.map((row, idx) => (
<tr key={idx} className="bg-white">
<tr key={idx} className='bg-white'>
{renderBuildingUsageCells(row, rainyByUsgYearsHeader)}
</tr>
))}
+372 -362
View File
@@ -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 (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
<div className='flex items-center gap-3'>
<span className='h-5 w-[3px] bg-sky-500' />
<h3 className='text-[24px] font-bold text-neutral-900'>{children}</h3>
</div>
);
}
// 구분선
function Hr({ className = "" }: { className?: string }) {
function Hr({ className = '' }: { className?: string }) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
// 출처 텍스트
function SourceText() {
return (
<p className="mt-7 text-center text-[13px] text-neutral-500">
자료: 환경부(2024), 「2023 하수도 통계」
</p>
);
return <p className='mt-7 text-center text-[13px] text-neutral-500'>자료: 환경부(2024), 「2023 하수도 통계」</p>;
}
/**
@@ -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 (
<div className="relative bg-white">
<div className='relative bg-white'>
{/* <BusinessSubNav /> */}
<SubPageHeader
title="하수처리수 재이용시설(현황)"
crumbs={[
{ label: "Home", href: "/main" },
{ label: "물 재이용 현황", href: "/status/rain" },
{ label: "하수처리수 재이용시설(현황)" },
]}
title='하수처리수 재이용시설(현황)'
crumbs={[{ label: 'Home', href: '/main' }, { label: '물 재이용 현황', href: '/status/rain' }, { label: '하수처리수 재이용시설(현황)' }]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
wrapClassName='!pb-0'
/>
<div className="pr-0">
<Hr className="my-7" />
<div className='pr-0'>
<Hr className='my-7' />
{/* 1) 연도별 하수처리수 재이용 현황 */}
<section className="mt-10">
<SectionTitle>연도별 하수처리수 재이용 현황</SectionTitle>
<div className="mt-10">
<div className={tableStyles.container}>
<table className={tableStyles.table}>
<colgroup>
<col style={{ width: '130px' }} />
<col style={{ width: '180px' }} />
<col />
<col />
<col />
<col />
<col />
<col />
</colgroup>
<thead>
<tr>
<th colSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`}>구분</th>
<th className={`${tableStyles.th} bg-[#01ACE5] text-white`}>2018</th>
<th className={`${tableStyles.th} bg-[#14AFCD] text-white`}>2019</th>
<th className={`${tableStyles.th} bg-[#28B2B4] text-white`}>2020</th>
<th className={`${tableStyles.th} bg-[#3CB59A] text-white`}>2021</th>
<th className={`${tableStyles.th} bg-[#50B880] text-white`}>2022</th>
<th className={`${tableStyles.th} bg-[#6DBB4F] text-white`}>2023</th>
</tr>
</thead>
<tbody>
{[
{
group: "전체",
rows: [
["개소수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>", "671", "681", "698", "704", "714", "717"],
["하수처리량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>", "7,163.7", "7,140.4", "7,375.6", "7,335.8", "7,378.9", "7,537.2"],
["재이용량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>", "1,112.7", "1,148.7", "1,148.7", "1,127.0", "1,136.8", "1,137.5"],
["재이용률 <span class='text-[13px] font-normal text-[#767676]'>(%)</span>", "17.4", "16.5", "15.5", "14.1", "14.3", "14.4"],
]
},
{
group: "법적 대상",
rows: [
["개소수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>", "68", "82", "94", "97", "101", "118"],
["하수처리량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>", "761.2", "839.0", "935.3", "1,158.7", "1,220.6", "1,473.6"],
["재이용량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>", "162.2", "190.6", "218.5", "244.6", "247.2", "324.9"],
["재이용률 <span class='text-[13px] font-normal text-[#767676]'>(%)</span>", "24.7", "25.4", "27.8", "25.7", "24.2", "24.1"],
]
},
{
group: "법적 미대상",
rows: [
["개소수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>", "603", "599", "604", "607", "613", "599"],
["하수처리량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>", "6,402.5", "6,301.4", "6,440.3", "6,177.1", "6,158.3", "6,063.5"],
["재이용량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>", "950.6", "958.1", "926.2", "882.4", "889.5", "812.7"],
["재이용률 <span class='text-[13px] font-normal text-[#767676]'>(%)</span>", "16.5", "15.3", "13.6", "12.2", "12.6", "12.5"],
]
}
].map((section, sIdx) => (
<React.Fragment key={sIdx}>
{section.rows.map((row, rIdx) => (
<tr key={`${sIdx}-${rIdx}`} className="bg-white">
{rIdx === 0 && (
<td
rowSpan={section.rows.length}
className={`${tableStyles.td} ${tableStyles.headerBg} !text-[18px] !font-bold !text-neutral-900`}
>
{section.group}
</td>
)}
{row.map((val, i) => {
const isFirstCol = i === 0;
const isHighlightedCol = [1, 3, 5].includes(i);
return (
<td
key={i}
className={`${tableStyles.td} ${isFirstCol ? "!font-bold !text-neutral-900" : ""} ${isHighlightedCol ? "bg-[#EAF5F5]" : ""}`}
dangerouslySetInnerHTML={{ __html: val }}
/>
);
})}
</tr>
))}
</React.Fragment>
))}
</tbody>
</table>
{/* 1) 연도별 하수처리수 재이용 현황 */}
<section className='mt-10'>
<SectionTitle>연도별 하수처리수 재이용 현황</SectionTitle>
<div className='mt-10'>
<div className={tableStyles.container}>
<table className={tableStyles.table}>
<colgroup>
<col style={{ width: '130px' }} />
<col style={{ width: '180px' }} />
<col />
<col />
<col />
<col />
<col />
<col />
</colgroup>
<thead>
<tr>
<th colSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
구분
</th>
<th className={`${tableStyles.th} bg-[#01ACE5] text-white`}>2018</th>
<th className={`${tableStyles.th} bg-[#14AFCD] text-white`}>2019</th>
<th className={`${tableStyles.th} bg-[#28B2B4] text-white`}>2020</th>
<th className={`${tableStyles.th} bg-[#3CB59A] text-white`}>2021</th>
<th className={`${tableStyles.th} bg-[#50B880] text-white`}>2022</th>
<th className={`${tableStyles.th} bg-[#6DBB4F] text-white`}>2023</th>
</tr>
</thead>
<tbody>
{[
{
group: '전체',
rows: [
["개소수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>", '671', '681', '698', '704', '714', '717'],
["하수처리량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>", '7,163.7', '7,140.4', '7,375.6', '7,335.8', '7,378.9', '7,537.2'],
["재이용량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>", '1,112.7', '1,148.7', '1,148.7', '1,127.0', '1,136.8', '1,137.5'],
["재이용률 <span class='text-[13px] font-normal text-[#767676]'>(%)</span>", '17.4', '16.5', '15.5', '14.1', '14.3', '14.4'],
],
},
{
group: '법적 대상',
rows: [
["개소수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>", '68', '82', '94', '97', '101', '118'],
["하수처리량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>", '761.2', '839.0', '935.3', '1,158.7', '1,220.6', '1,473.6'],
["재이용량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>", '162.2', '190.6', '218.5', '244.6', '247.2', '324.9'],
["재이용률 <span class='text-[13px] font-normal text-[#767676]'>(%)</span>", '24.7', '25.4', '27.8', '25.7', '24.2', '24.1'],
],
},
{
group: '법적 미대상',
rows: [
["개소수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>", '603', '599', '604', '607', '613', '599'],
["하수처리량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>", '6,402.5', '6,301.4', '6,440.3', '6,177.1', '6,158.3', '6,063.5'],
["재이용량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>", '950.6', '958.1', '926.2', '882.4', '889.5', '812.7'],
["재이용률 <span class='text-[13px] font-normal text-[#767676]'>(%)</span>", '16.5', '15.3', '13.6', '12.2', '12.6', '12.5'],
],
},
].map((section, sIdx) => (
<React.Fragment key={sIdx}>
{section.rows.map((row, rIdx) => (
<tr key={`${sIdx}-${rIdx}`} className='bg-white'>
{rIdx === 0 && (
<td rowSpan={section.rows.length} className={`${tableStyles.td} ${tableStyles.headerBg} !text-[18px] !font-bold !text-neutral-900`}>
{section.group}
</td>
)}
{row.map((val, i) => {
const isFirstCol = i === 0;
const isHighlightedCol = [1, 3, 5].includes(i);
return (
<td
key={i}
className={`${tableStyles.td} ${isFirstCol ? '!font-bold !text-neutral-900' : ''} ${isHighlightedCol ? 'bg-[#EAF5F5]' : ''}`}
dangerouslySetInnerHTML={{ __html: val }}
/>
);
})}
</tr>
))}
</React.Fragment>
))}
</tbody>
</table>
</div>
<SourceText />
</div>
<SourceText />
</div>
</section>
</section>
<Hr className="my-11" />
<Hr className='my-11' />
{/* 2) 용도별 하수처리수 재이용 현황 */}
<section>
<SectionTitle>용도별 하수처리수 재이용 현황</SectionTitle>
<div className="mt-8">
<div className={tableStyles.container}>
<table className={tableStyles.table}>
<colgroup>
<col style={{ width: '180px' }} />
<col />
<col />
<col />
<col />
<col />
<col />
</colgroup>
<thead>
<tr>
<th className={`${tableStyles.th} ${tableStyles.headerBg}`}>구분</th>
<th className={`${tableStyles.th} bg-[#01ACE5] text-white`}>2018</th>
<th className={`${tableStyles.th} bg-[#14AFCD] text-white`}>2019</th>
<th className={`${tableStyles.th} bg-[#28B2B4] text-white`}>2020</th>
<th className={`${tableStyles.th} bg-[#3CB59A] text-white`}>2021</th>
<th className={`${tableStyles.th} bg-[#50B880] text-white`}>2022</th>
<th className={`${tableStyles.th} bg-[#6DBB4F] text-white`}>2023</th>
</tr>
</thead>
<tbody>
{[
{ 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) 용도별 하수처리수 재이용 현황 */}
<section>
<SectionTitle>용도별 하수처리수 재이용 현황</SectionTitle>
<div className='mt-8'>
<div className={tableStyles.container}>
<table className={tableStyles.table}>
<colgroup>
<col style={{ width: '180px' }} />
<col />
<col />
<col />
<col />
<col />
<col />
</colgroup>
<thead>
<tr>
<th className={`${tableStyles.th} ${tableStyles.headerBg}`}>구분</th>
<th className={`${tableStyles.th} bg-[#01ACE5] text-white`}>2018</th>
<th className={`${tableStyles.th} bg-[#14AFCD] text-white`}>2019</th>
<th className={`${tableStyles.th} bg-[#28B2B4] text-white`}>2020</th>
<th className={`${tableStyles.th} bg-[#3CB59A] text-white`}>2021</th>
<th className={`${tableStyles.th} bg-[#50B880] text-white`}>2022</th>
<th className={`${tableStyles.th} bg-[#6DBB4F] text-white`}>2023</th>
</tr>
</thead>
<tbody>
{[
{ 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 (
<tr key={idx} className="bg-white">
<td className={`${tableStyles.td} relative`}>
{isSubItem ? (
<>
<div className={`absolute left-[1px] w-[16px] bg-white z-10 top-[-2px] ${isLastInGroup ? 'bottom-[0px]' : 'bottom-[-2px]'}`} />
<div className={`absolute left-[16px] w-[4px] z-20 ${row.type === 'red-item' ? 'bg-[#FF8B8B]' : 'bg-[#4ECBFF]'} top-[-2px] ${isLastInGroup ? 'bottom-[0px]' : 'bottom-[-2px]'}`} />
<span className="text-[#4f4f4f] font-bold block w-full text-center relative z-30 translate-x-[8px]">{row.label}</span>
</>
) : (
<span className="font-bold text-neutral-900 block w-full text-center">{row.label}</span>
)}
</td>
{row.values.map((val, i) => (
<td key={i} className={`${tableStyles.td} ${[0, 2, 4].includes(i) ? "bg-[#EAF5F5]" : ""}`}>
return (
<tr key={idx} className='bg-white'>
<td className={`${tableStyles.td} relative`}>
{isSubItem ? (
<>
<div className={`absolute left-[1px] w-[16px] bg-white z-10 top-[-2px] ${isLastInGroup ? 'bottom-[0px]' : 'bottom-[-2px]'}`} />
<div
className={`absolute left-[16px] w-[4px] z-20 ${row.type === 'red-item' ? 'bg-[#FF8B8B]' : 'bg-[#4ECBFF]'} top-[-2px] ${isLastInGroup ? 'bottom-[0px]' : 'bottom-[-2px]'}`}
/>
<span className='text-[#4f4f4f] font-bold block w-full text-center relative z-30 translate-x-[8px]'>{row.label}</span>
</>
) : (
<span className='font-bold text-neutral-900 block w-full text-center'>{row.label}</span>
)}
</td>
{row.values.map((val, i) => (
<td key={i} className={`${tableStyles.td} ${[0, 2, 4].includes(i) ? 'bg-[#EAF5F5]' : ''}`}>
{val}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
<SourceText />
</div>
</section>
<Hr className='my-11' />
{/* 3) 하수처리시설 설치 및 이용 현황 */}
<section>
<SectionTitle>하수처리시설 설치 및 이용 현황</SectionTitle>
<div className='mt-8'>
<div className={tableStyles.container}>
<table className={tableStyles.table}>
<colgroup>
<col style={{ width: '130px' }} />
<col style={{ width: '160px' }} />
<col />
<col />
<col />
<col />
<col />
<col />
</colgroup>
<thead>
<tr>
<th colSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
구분
</th>
<th className={`${tableStyles.th} bg-[#01ACE5] text-white`}>2018</th>
<th className={`${tableStyles.th} bg-[#14AFCD] text-white`}>2019</th>
<th className={`${tableStyles.th} bg-[#28B2B4] text-white`}>2020</th>
<th className={`${tableStyles.th} bg-[#3CB59A] text-white`}>2021</th>
<th className={`${tableStyles.th} bg-[#50B880] text-white`}>2022</th>
<th className={`${tableStyles.th} bg-[#6DBB4F] text-white`}>2023</th>
</tr>
</thead>
<tbody>
{[
{
group: '전체',
rows: [
["시설수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>", '671', '681', '698', '704', '714', '717'],
["시설용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>", '25,846', '26,067', '26,244', '26,492', '26,586', '26,766'],
["처리용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>", '1,112.7', '1,148.7', '1,144.7', '1,127.0', '1,136.8', '1,137.5'],
],
},
{
group: '법적 대상',
rows: [
["시설수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>", '68', '82', '94', '97', '101', '118'],
["시설용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>", '3,088.9', '3,359.2', '3,612.6', '4,412.2', '4,692.3', '5,503.8'],
["처리용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>", '162.2', '190.6', '218.5', '244.6', '247.2', '324.9'],
],
},
{
group: '법적 미대상',
rows: [
["시설수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>", '603', '599', '604', '607', '613', '599'],
["시설용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>", '22,757', '22,708', '22,631', '22,080', '21,894', '21,262'],
["처리용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>", '950.6', '958.1', '926.2', '882.4', '889.5', '812.7'],
],
},
].map((section, sIdx) => (
<React.Fragment key={sIdx}>
{section.rows.map((row, rIdx) => (
<tr key={`${sIdx}-${rIdx}`} className='bg-white'>
{rIdx === 0 && (
<td rowSpan={section.rows.length} className={`${tableStyles.td} ${tableStyles.headerBg} !text-[18px] !font-bold !text-neutral-900`}>
{section.group}
</td>
)}
{row.map((val, i) => {
const isFirstCol = i === 0;
const isHighlightedCol = [1, 3, 5].includes(i);
return (
<td
key={i}
className={`${tableStyles.td} ${isFirstCol ? '!font-bold !text-neutral-900' : ''} ${isHighlightedCol ? 'bg-[#EAF5F5]' : ''}`}
dangerouslySetInnerHTML={{ __html: val }}
/>
);
})}
</tr>
))}
</React.Fragment>
))}
</tbody>
</table>
</div>
<SourceText />
</div>
</section>
<Hr className='my-11' />
{/* 4) 시도별 하수처리수 재이용 현황 */}
<section className='pb-[200px]'>
<SectionTitle>시도별 하수처리수 재이용 현황</SectionTitle>
<div className='mt-8'>
<div className={tableStyles.container}>
<div className='text-right text-[13px] mb-2 text-neutral-600'>(단위: 백만m³/년)</div>
<table className={tableStyles.table}>
<colgroup>
<col style={{ width: '160px' }} />
<col />
<col />
<col />
<col />
<col />
<col />
<col />
<col />
<col />
<col />
</colgroup>
<thead>
<tr>
<th rowSpan={3} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
시도
</th>
<th rowSpan={3} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
연간
<br />
하수
<br />
처리량
</th>
<th colSpan={7} className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}>
재이용현황
</th>
<th rowSpan={3} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
재이용률
<br />
(%)
</th>
<th rowSpan={3} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
비고
</th>
</tr>
<tr>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
계
</th>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
장내
</th>
<th colSpan={5} className={`${tableStyles.th} ${tableStyles.headerBg} py-1`}>
장외
</th>
</tr>
<tr>
{/* 💡 하천유지용수는 14px, 나머지 하위 헤더는 16px로 적용 */}
<th className={`${tableStyles.th} bg-[#01ACE5] text-white py-2 text-[16px]`}>소계</th>
<th className={`${tableStyles.th} bg-[#1FB1C2] text-white py-2 text-[13px]`}>하천유지용수</th>
<th className={`${tableStyles.th} bg-[#3EB59F] text-white py-2 text-[16px]`}>공업용수</th>
<th className={`${tableStyles.th} bg-[#5DB97C] text-white py-2 text-[16px]`}>농업용수</th>
<th className={`${tableStyles.th} bg-[#6DBB4F] text-white py-2 text-[16px]`}>기타용수</th>
</tr>
</thead>
<tbody>
{[
['합계', '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) => (
<tr key={idx} className='bg-white'>
{/* 💡 연간하수처리량(index 1), 장내(index 3), 재이용률(index 9) 컬러 하이라이트 적용 */}
{row.map((val, i) => (
<td key={i} className={`${tableStyles.td} ${i === 0 ? '!font-bold !text-neutral-900' : ''} ${[1, 3, 9].includes(i) ? 'bg-[#EAF5F5]' : ''}`}>
{val}
</td>
))}
</tr>
);
})}
</tbody>
</table>
))}
</tbody>
</table>
</div>
<SourceText />
</div>
<SourceText />
</div>
</section>
<Hr className="my-11" />
{/* 3) 하수처리시설 설치 및 이용 현황 */}
<section>
<SectionTitle>하수처리시설 설치 및 이용 현황</SectionTitle>
<div className="mt-8">
<div className={tableStyles.container}>
<table className={tableStyles.table}>
<colgroup>
<col style={{ width: '130px' }} />
<col style={{ width: '160px' }} />
<col />
<col />
<col />
<col />
<col />
<col />
</colgroup>
<thead>
<tr>
<th colSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`}>구분</th>
<th className={`${tableStyles.th} bg-[#01ACE5] text-white`}>2018</th>
<th className={`${tableStyles.th} bg-[#14AFCD] text-white`}>2019</th>
<th className={`${tableStyles.th} bg-[#28B2B4] text-white`}>2020</th>
<th className={`${tableStyles.th} bg-[#3CB59A] text-white`}>2021</th>
<th className={`${tableStyles.th} bg-[#50B880] text-white`}>2022</th>
<th className={`${tableStyles.th} bg-[#6DBB4F] text-white`}>2023</th>
</tr>
</thead>
<tbody>
{[
{
group: "전체",
rows: [
["시설수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>", "671", "681", "698", "704", "714", "717"],
["시설용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>", "25,846", "26,067", "26,244", "26,492", "26,586", "26,766"],
["처리용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>", "1,112.7", "1,148.7", "1,144.7", "1,127.0", "1,136.8", "1,137.5"],
]
},
{
group: "법적 대상",
rows: [
["시설수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>", "68", "82", "94", "97", "101", "118"],
["시설용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>", "3,088.9", "3,359.2", "3,612.6", "4,412.2", "4,692.3", "5,503.8"],
["처리용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>", "162.2", "190.6", "218.5", "244.6", "247.2", "324.9"],
]
},
{
group: "법적 미대상",
rows: [
["시설수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>", "603", "599", "604", "607", "613", "599"],
["시설용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>", "22,757", "22,708", "22,631", "22,080", "21,894", "21,262"],
["처리용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>", "950.6", "958.1", "926.2", "882.4", "889.5", "812.7"],
]
}
].map((section, sIdx) => (
<React.Fragment key={sIdx}>
{section.rows.map((row, rIdx) => (
<tr key={`${sIdx}-${rIdx}`} className="bg-white">
{rIdx === 0 && (
<td
rowSpan={section.rows.length}
className={`${tableStyles.td} ${tableStyles.headerBg} !text-[18px] !font-bold !text-neutral-900`}
>
{section.group}
</td>
)}
{row.map((val, i) => {
const isFirstCol = i === 0;
const isHighlightedCol = [1, 3, 5].includes(i);
return (
<td
key={i}
className={`${tableStyles.td} ${isFirstCol ? "!font-bold !text-neutral-900" : ""} ${isHighlightedCol ? "bg-[#EAF5F5]" : ""}`}
dangerouslySetInnerHTML={{ __html: val }}
/>
);
})}
</tr>
))}
</React.Fragment>
))}
</tbody>
</table>
</div>
<SourceText />
</div>
</section>
<Hr className="my-11" />
{/* 4) 시도별 하수처리수 재이용 현황 */}
<section className="pb-[200px]">
<SectionTitle>시도별 하수처리수 재이용 현황</SectionTitle>
<div className="mt-8">
<div className={tableStyles.container}>
<div className="text-right text-[13px] mb-2 text-neutral-600">(단위: 백만m³/일)</div>
<table className={tableStyles.table}>
<colgroup>
<col style={{ width: '160px' }} />
<col />
<col />
<col />
<col />
<col />
<col />
<col />
<col />
<col />
<col />
</colgroup>
<thead>
<tr>
<th rowSpan={3} className={`${tableStyles.th} ${tableStyles.headerBg}`}>시도</th>
<th rowSpan={3} className={`${tableStyles.th} ${tableStyles.headerBg}`}>연간<br/>하수<br/>처리량</th>
<th colSpan={7} className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}>재이용현황</th>
<th rowSpan={3} className={`${tableStyles.th} ${tableStyles.headerBg}`}>재이용률<br/>(%)</th>
<th rowSpan={3} className={`${tableStyles.th} ${tableStyles.headerBg}`}>비고</th>
</tr>
<tr>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`}>계</th>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`}>장내</th>
<th colSpan={5} className={`${tableStyles.th} ${tableStyles.headerBg} py-1`}>장외</th>
</tr>
<tr>
{/* 💡 하천유지용수는 14px, 나머지 하위 헤더는 16px로 적용 */}
<th className={`${tableStyles.th} bg-[#01ACE5] text-white py-2 text-[16px]`}>소계</th>
<th className={`${tableStyles.th} bg-[#1FB1C2] text-white py-2 text-[13px]`}>하천유지용수</th>
<th className={`${tableStyles.th} bg-[#3EB59F] text-white py-2 text-[16px]`}>공업용수</th>
<th className={`${tableStyles.th} bg-[#5DB97C] text-white py-2 text-[16px]`}>농업용수</th>
<th className={`${tableStyles.th} bg-[#6DBB4F] text-white py-2 text-[16px]`}>기타용수</th>
</tr>
</thead>
<tbody>
{[
["합계", "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) => (
<tr key={idx} className="bg-white">
{/* 💡 연간하수처리량(index 1), 장내(index 3), 재이용률(index 9) 컬러 하이라이트 적용 */}
{row.map((val, i) => (
<td
key={i}
className={`${tableStyles.td} ${i === 0 ? "!font-bold !text-neutral-900" : ""} ${[1, 3, 9].includes(i) ? "bg-[#EAF5F5]" : ""}`}
>
{val}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<SourceText />
</div>
</section>
</section>
</div>
</div>
);
}
}
+21 -22
View File
@@ -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<Record<FieldKey, string>> = {};
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({
사업자 등록번호
</label>
<input
value={formatBusinessNumber(bizNo)}
value={bizNo}
onChange={(e) => {
setBizNo(e.target.value.replace(/\D/g, "").slice(0, 10));
setBizNo(formatBusinessNumber(e.target.value));
setErrors((p) => ({ ...p, bizNo: undefined }));
}}
onBlur={() => {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 317 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 284 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 24 KiB

+2 -1
View File
@@ -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
NEXT_PUBLIC_COOKIE_COMAIN=.mind-one.co.kr
NEXT_PUBLIC_NODE_ENV = "development"
+2 -1
View File
@@ -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
NEXT_PUBLIC_COOKIE_COMAIN=.mind-one.co.kr
NEXT_PUBLIC_NODE_ENV = "development"
+22 -1
View File
@@ -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
+1 -5
View File
@@ -9,9 +9,5 @@ export default function AuthLayout({
}: {
children: React.ReactNode;
}) {
return (
<html lang="ko">
<body>{children}</body>
</html>
);
return children;
}
@@ -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<InputSectionProps> = ({ title, data, sectionKey, onInputChange }) => {
const InputSection: React.FC<InputSectionProps> = ({
title,
data,
sectionKey,
onInputChange,
}) => {
return (
<div>
{/* 섹션 제목 */}
<div className="card-header">
<h3 className="text-2xl mx-3 font-medium text-label whitespace-nowrap">{title}</h3>
<h3 className="mx-3 whitespace-nowrap text-2xl font-medium text-label">
{title}
</h3>
</div>
{/* 테이블 */}
<div className="card !m-[1.2rem] !p-0r">
<div className="card !p-0r !m-[1.2rem]">
<Table variant="secondary" className="w-full">
<TableHeader>
<TableRow>
<TableHead className="border border-gray-300 px-2 py-2 text-sm w-auto whitespace-nowrap">구분</TableHead>
<TableHead className="border border-gray-300 px-2 py-2 text-sm w-[80px] whitespace-nowrap">진단연도</TableHead>
<TableHead className="border border-gray-300 px-2 py-2 text-sm w-auto whitespace-nowrap">사업명</TableHead>
<TableHead className="border border-gray-300 px-2 py-2 text-sm w-auto whitespace-nowrap">유형</TableHead>
<TableHead className="w-auto whitespace-nowrap border border-border px-2 py-2 text-sm">
구분
</TableHead>
<TableHead className="w-[80px] whitespace-nowrap border border-border px-2 py-2 text-sm">
진단연도
</TableHead>
<TableHead className="w-auto whitespace-nowrap border border-border px-2 py-2 text-sm">
사업명
</TableHead>
<TableHead className="w-auto whitespace-nowrap border border-border px-2 py-2 text-sm">
유형
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((row, index) => (
<TableRow key={index}>
<TableCell className="border border-gray-300 px-2 py-2 text-sm text-center leading-normal w-auto whitespace-nowrap">
<TableCell className="w-auto whitespace-nowrap border border-border px-2 py-2 text-center text-sm leading-normal">
{row.type}
</TableCell>
<TableCell className="border border-gray-300 px-2 py-2 text-sm w-[80px] whitespace-nowrap">
<TableCell className="w-[80px] whitespace-nowrap border border-border px-2 py-2 text-sm">
<input
type="text"
value={row.year}
onChange={(e) => 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"
/>
</TableCell>
<TableCell className="border border-gray-300 px-2 py-2 text-sm text-center leading-normal w-auto">
<TableCell className="w-auto border border-border px-2 py-2 text-center text-sm leading-normal">
{row.bizInfoNm}
</TableCell>
<TableCell className="border border-gray-300 px-2 py-2 text-sm text-center leading-normal w-auto whitespace-nowrap">
<TableCell className="w-auto whitespace-nowrap border border-border px-2 py-2 text-center text-sm leading-normal">
{row.bizType}
</TableCell>
</TableRow>
@@ -55,4 +84,4 @@ const InputSection: React.FC<InputSectionProps> = ({ title, data, sectionKey, on
);
};
export default InputSection;
export default InputSection;
@@ -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 = void> = T | Promise<T>;
@@ -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 = <span className="h-2 w-2 shrink-0 rounded-full bg-[#243b93]" />;
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 = (
<span className="h-2 w-2 shrink-0 rounded-full bg-primary" />
);
const settingRow = (
<div className="flex min-h-[25px] items-center gap-1 whitespace-nowrap text-[1.05rem] font-medium text-slate-700">
<div className="flex min-h-[25px] items-center gap-1 whitespace-nowrap text-[1.05rem] font-medium text-label">
<div className="flex min-w-0 items-center gap-1">
{indicator}
<label htmlFor="aged-pipe-elapsed-years" className="shrink-0">노후관로 경과년도(년)</label>
<label htmlFor="aged-pipe-elapsed-years" className="shrink-0">
노후관로 경과년도(년)
</label>
</div>
<div className="ml-auto flex shrink-0 items-center gap-1">
<input
@@ -85,7 +96,7 @@ export default function AgedPipeLayerSettingPanel({
if (compact) {
return (
<div className="w-full rounded-[6px] border border-[#dbe7f5] bg-white px-[6px] py-2 shadow-[0_2px_8px_rgba(15,23,42,0.05)]">
<div className="w-full rounded-[6px] border border-border bg-background px-[6px] py-2 shadow-blur">
{settingRow}
</div>
);
@@ -93,23 +104,21 @@ export default function AgedPipeLayerSettingPanel({
return (
<div className="w-full overflow-hidden rounded-sm border border-red-100 bg-red-50/35">
<div className="relative flex min-h-[30px] items-center justify-center border-b border-red-100 bg-white px-8">
<div className="relative flex min-h-[30px] items-center justify-center border-b border-red-100 bg-background px-8">
<button
type="button"
className="absolute left-2 top-1/2 h-[18px] w-[18px] -translate-y-1/2 bg-transparent text-[1.2rem] leading-none text-[#555]"
className="absolute left-2 top-1/2 h-[18px] w-[18px] -translate-y-1/2 bg-transparent text-[1.2rem] leading-none text-muted-foreground"
onClick={() => setIsCollapsed((value) => !value)}
title={isCollapsed ? "펼치기" : "접기"}
>
{isCollapsed ? "+" : "-"}
</button>
<span className="text-[1.2rem] font-semibold text-red-700">노후관로 설정</span>
<span className="text-[1.2rem] font-semibold text-red-700">
노후관로 설정
</span>
</div>
{!isCollapsed && (
<div className="px-2 py-2">
{settingRow}
</div>
)}
{!isCollapsed && <div className="px-2 py-2">{settingRow}</div>}
</div>
);
}
@@ -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<AgedPipeFilters>(() =>
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<AgedPipeRow[]>([]);
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<string, unknown>, index: number) =>
const total = Number(response?.count ?? 0);
const rows = responseRows.map(
(item: Record<string, unknown>, 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 (
<main className="sn-popup-page w-full max-w-full overflow-x-hidden overflow-y-hidden">
<StandardPopupLayout
title="노후관로 목록"
actions={
<Button type="button" color="white" onClick={handleMinimize}>
최소화
</Button>
}
>
<LoadingOverlay isLoading={isLoading} />
<header className="sn-popup-header">
<h1 className="sn-popup-title">노후관로 현황</h1>
<div className="flex items-center gap-2">
<Button
type="button"
color="white"
size="xs"
className="sn-popup-header-button"
onClick={handleMinimize}
>
최소화
</Button>
</div>
</header>
{downloadProgressText ? (
<div className="sn-popup-success-bar">
<div className="rounded-sm border border-primary bg-primary-transparent px-4 py-3 text-[1.3rem] text-primary">
{downloadProgressText}
</div>
) : null}
<section className="flex min-h-0 flex-1 flex-col overflow-x-hidden px-4 pb-3 pt-3">
<div className="sn-popup-filter-panel-compact grid grid-cols-1 gap-3 md:grid-cols-[140px_minmax(0,1fr)_minmax(0,1fr)_auto] md:items-end">
<label className="sn-popup-field">
<div className="sn-popup-field-label">경과년도</div>
<Input
className="sn-popup-input !text-[1.1rem]"
type="text"
inputMode="numeric"
pattern="[0-9]*"
autoComplete="off"
value={String(filters.elapsedYears ?? "")}
onChange={(event) => {
const digits = event.target.value.replace(/[^0-9]/g, "");
setFilters({
...filters,
elapsedYears: digits === "" ? ("" as unknown as number) : Number(digits),
});
}}
onKeyDown={(event) => {
if (event.key === "Enter") void handleSearch(1);
}}
/>
</label>
<label className="sn-popup-field">
<div className="sn-popup-field-label">시도</div>
<select
className="sn-popup-input !text-[1.1rem]"
value={filters.ctpvCd}
onChange={(event) =>
setFilters({
...filters,
ctpvCd: event.target.value,
sggCd: "0",
})
}
>
<option value="0">전체</option>
{ctpv.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="sn-popup-field">
<div className="sn-popup-field-label">시군구</div>
<select
className="sn-popup-input !text-[1.1rem]"
value={filters.sggCd}
onChange={(event) => setFilters({ ...filters, sggCd: event.target.value })}
>
<option value="0">전체</option>
{sggOptions.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<div className="sn-popup-actions">
<Button
type="button"
size="sm"
color="primary"
className="sn-popup-primary-button !text-[1.1rem]"
onClick={() => void handleSearch(1)}
>
조회
</Button>
<Button
type="button"
size="sm"
color="primary"
className="sn-popup-primary-button !text-[1.1rem]"
onClick={handleDownload}
>
다운로드
</Button>
</div>
</div>
<div className="aged-pipe-popup-table min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto pt-2">
<UiTable<AgedPipeRow>
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"
<PopupSearchPanel contentClassName="flex flex-wrap items-end gap-x-5 gap-y-3">
<label className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.3rem] text-label">
경과년도
</span>
<Input
className="w-[12rem]"
type="text"
inputMode="numeric"
pattern="[0-9]*"
autoComplete="off"
value={String(filters.elapsedYears ?? "")}
onChange={(event) => {
const digits = event.target.value.replace(/[^0-9]/g, "");
setFilters({
...filters,
elapsedYears:
digits === "" ? ("" as unknown as number) : Number(digits),
});
}}
onKeyDown={(event) => {
if (event.key === "Enter") void handleSearch(1);
}}
/>
</label>
<label className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.3rem] text-label">
시도
</span>
<select
className="h-[3.2rem] w-[18rem] rounded-md border border-input bg-field px-3 text-[1.3rem]"
value={filters.ctpvCd}
onChange={(event) =>
setFilters({
...filters,
ctpvCd: event.target.value,
sggCd: "0",
})
}
>
<option value="0">전체</option>
{ctpv.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.3rem] text-label">
시군구
</span>
<select
className="h-[3.2rem] w-[18rem] rounded-md border border-input bg-field px-3 text-[1.3rem]"
value={filters.sggCd}
onChange={(event) =>
setFilters({ ...filters, sggCd: event.target.value })
}
>
<option value="0">전체</option>
{sggOptions.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<div className="ml-auto flex items-center gap-2">
<Button
type="button"
color="primary"
onClick={() => void handleSearch(1)}
>
조회
</Button>
<Button
type="button"
color="white"
onClick={() => {
const resetFilters = normalizeAgedPipeFilters(
AGED_PIPE_DEFAULT_FILTERS,
);
setFilters(resetFilters);
void loadRows(1, resetFilters);
}}
>
초기화
</Button>
</div>
</section>
<style jsx global>{`
.aged-pipe-popup-table .card-header {
margin-bottom: 0.6rem;
}
</PopupSearchPanel>
.aged-pipe-popup-table .card-header h3 {
font-size: 1.7rem;
line-height: 1.25;
<MapPopupDataTable
rows={rows as Array<AgedPipeRow & Record<string, unknown>>}
headlist={AGED_PIPE_LIST_HEADLIST}
title="노후관로 목록"
total={total}
page={currentPage}
pageSize={AGED_PIPE_PAGE_SIZE}
onPageChange={(page) => void handleSearch(page)}
onRowClick={handleRowClick}
rowId={(row, index) => String(row.fid ?? row.fcltMngNo ?? index)}
actions={
<Button type="button" onClick={handleDownload}>
엑셀다운로드
</Button>
}
.aged-pipe-popup-table .card {
overflow-x: hidden;
}
.aged-pipe-popup-table .table-ui {
width: 100%;
max-width: 100%;
table-layout: fixed;
}
.aged-pipe-popup-table .table-ui th {
padding: 0.8rem 0.45rem;
font-size: 1.1rem;
line-height: 1.2;
word-break: keep-all;
}
.aged-pipe-popup-table .table-ui td {
padding: 0.85rem 0.4rem;
font-size: 1.1rem;
line-height: 1.25;
overflow-wrap: anywhere;
word-break: break-word;
}
.aged-pipe-popup-table .table-ui td:nth-child(2),
.aged-pipe-popup-table .table-ui td:nth-child(3),
.aged-pipe-popup-table .table-ui td:nth-child(8) {
word-break: keep-all;
}
`}</style>
</main>
/>
</StandardPopupLayout>
);
}
@@ -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 },
@@ -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<AgedPipeStatusByCtpvFilters>(() =>
normalizeAgedPipeStatusByCtpvFilters({
elapsedYears: AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS,
}),
);
// 서버 응답을 화면 그리드에 맞게 평탄화한 row 목록이다.
const [rows, setRows] = useState<AgedPipeStatusByCtpvRow[]>([]);
// 동적 컬럼 생성을 위한 기준년도 목록이다.
const [years, setYears] = useState<number[]>([]);
// 조회/다운로드 중 로딩 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 (
<StandardPopupLayout
title={windowPopupDefinitions.agedPipeStatusByCtpvPopup.title}
bodyClassName="overflow-x-hidden"
>
<LoadingOverlay isLoading={isLoading} />
{downloadProgressText ? (
<div className="rounded-sm border border-primary bg-primary-transparent px-4 py-3 text-[1.3rem] text-primary">
{downloadProgressText}
</div>
) : null}
<PopupSearchPanel contentClassName="flex flex-wrap items-end gap-x-5 gap-y-3">
<label className="flex shrink-0 items-center gap-2">
<span className="whitespace-nowrap text-xl font-medium text-label">
기준년도 시작
</span>
<select
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl"
value={filters.startYear}
onChange={(event) =>
setFilters((current) =>
normalizeAgedPipeStatusByCtpvFilters({
...current,
startYear: Number(event.target.value),
}),
)
}
>
{yearOptions.map((year) => (
<option key={year} value={year}>
{year}
</option>
))}
</select>
</label>
<label className="flex shrink-0 items-center gap-2">
<span className="whitespace-nowrap text-xl font-medium text-label">
기준년도 종료
</span>
<select
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl"
value={filters.endYear}
onChange={(event) =>
setFilters((current) => ({
...current,
endYear: Number(event.target.value),
}))
}
>
{yearOptions.map((year) => (
<option key={year} value={year}>
{year}
</option>
))}
</select>
</label>
<label className="flex shrink-0 items-center gap-2">
<span className="whitespace-nowrap text-xl font-medium text-label">
노후관로 기준(년)
</span>
<Input
type="text"
inputMode="numeric"
value={String(filters.elapsedYears)}
onChange={(event) =>
setFilters((current) => ({
...current,
elapsedYears: Number(event.target.value.replace(/[^0-9]/g, "")),
}))
}
className="w-[12rem]"
onKeyDown={(event) => {
if (event.key === "Enter") void handleSearch();
}}
/>
</label>
<div className="flex min-w-fit flex-1 items-center justify-end gap-2">
<Button onClick={handleSearch} color="primary">
조회
</Button>
<Button
color="white"
onClick={() => {
const resetFilters = normalizeAgedPipeStatusByCtpvFilters({
elapsedYears: AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS,
});
setFilters(resetFilters);
void loadRows(resetFilters);
}}
>
초기화
</Button>
</div>
</PopupSearchPanel>
<MapPopupDataTable
rows={
pageRows as Array<AgedPipeStatusByCtpvRow & Record<string, unknown>>
}
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={
<Button type="button" onClick={handleDownload}>
엑셀다운로드
</Button>
}
/>
</StandardPopupLayout>
);
}
@@ -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;
};
@@ -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<AgedPipeStatusByCtpvResponse> | 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>,
): 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<AgedPipeStatusByCtpvResponse> | 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;
};
@@ -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<WindowPopupKey | "infoSearch" | null>(initialActiveKey);
const [activeKey, setActiveKey] = useState<
WindowPopupKey | "infoSearch" | null
>(initialActiveKey);
useEffect(() => {
if (!isGisEditActive && activeKey === "gisEditPopup") {
@@ -97,87 +139,91 @@ export default function AnalysisPopupMenu({
}, [initialActiveKey]);
return (
<div className="absolute right-[0.55rem] top-[20px] z-[45]">
<div className="relative">
{isOpen ? (
<div className="absolute right-[28px] w-[154px] overflow-hidden rounded-[6px] border border-border bg-background shadow-[0_8px_18px_rgba(15,23,42,0.14)]">
<div className="flex h-[36px] items-center gap-2 border-b border-border bg-secondary px-[10px] text-[13px] font-bold text-foreground">
<div className="flex items-center gap-2">
<PanelsTopLeft className="h-4 w-4 text-primary" />
<span>분석도구</span>
</div>
</div>
<div className="absolute right-[15px] top-[8px] z-[45]">
<div className="flex w-[154px] flex-col overflow-hidden rounded-[6px] border border-border bg-background shadow-[0_8px_18px_rgba(15,23,42,0.14)]">
<div className="flex h-[36px] shrink-0 items-center justify-between gap-2 border-b border-border bg-secondary pl-[10px] pr-[4px] text-[13px] font-bold text-foreground">
<div className="flex items-center gap-2">
<PanelsTopLeft className="h-4 w-4 text-primary" />
<span>분석도구</span>
</div>
<div className="flex flex-col gap-1 p-[6px]">
{ANALYSIS_POPUP_ITEMS.map((item) => {
const Icon = item.icon;
const isActive = item.key === "gisEditPopup"
<button
type="button"
className="flex h-[28px] w-[28px] shrink-0 items-center justify-center rounded-[4px] text-label transition hover:bg-accent hover:text-primary"
onClick={() => setIsOpen((value) => !value)}
title={isOpen ? "분석 메뉴 숨기기" : "분석 메뉴 보이기"}
aria-label={isOpen ? "분석 메뉴 숨기기" : "분석 메뉴 보이기"}
aria-expanded={isOpen}
>
{isOpen ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
</div>
{isOpen ? (
<div className="flex flex-col gap-[3px] p-[6px]">
{ANALYSIS_POPUP_ITEMS.map((item) => {
const Icon = item.icon;
const isActive =
item.key === "gisEditPopup"
? isGisEditActive
: activeKey === item.key;
return (
<button
key={item.key}
type="button"
className={`relative flex h-[34px] items-center gap-[7px] rounded-[5px] border px-[8px] pl-[10px] text-left text-[12px] font-semibold transition ${
isActive
? "border-primary bg-secondary font-bold text-primary before:absolute before:left-0 before:top-[7px] before:bottom-[7px] before:w-[3px] before:rounded-r-[2px] before:bg-primary"
: "border-transparent bg-background text-foreground hover:border-border hover:bg-accent hover:text-primary"
}`}
onClick={() => {
if (item.key === "gisEditPopup" && onToggleGisEdit) {
setActiveKey(item.key);
onToggleGisEdit();
return;
}
return (
<button
key={item.key}
type="button"
className={`relative flex h-[33px] items-center gap-[7px] rounded-[5px] border px-[8px] pl-[10px] text-left text-[12px] font-semibold transition ${
isActive
? "border-primary bg-secondary font-bold text-primary before:absolute before:bottom-[7px] before:left-0 before:top-[7px] before:w-[3px] before:rounded-r-[2px] before:bg-primary"
: "border-transparent bg-background text-foreground hover:border-border hover:bg-accent hover:text-primary"
}`}
onClick={() => {
if (item.key === "gisEditPopup" && onToggleGisEdit) {
setActiveKey(item.key);
onToggleGisEdit();
return;
}
if (item.key === "infoSearch") {
onOpenInfoSearch();
return;
}
setActiveKey(item.key);
const popupWindow = onOpenPopup(item.key);
if (!popupWindow) {
setActiveKey(null);
return;
}
if (item.key === "infoSearch") {
onOpenInfoSearch();
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 ? (
<Icon
className={`h-[15px] w-[15px] shrink-0 ${
isActive ? "text-primary" : "text-label"
}`}
/>
) : null}
<span className="whitespace-nowrap">{item.label}</span>
</button>
);
})}
</div>
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 ? (
<Icon
className={`h-[15px] w-[15px] shrink-0 ${
isActive ? "text-primary" : "text-label"
}`}
/>
) : null}
<span className="whitespace-nowrap">{item.label}</span>
</button>
);
})}
</div>
) : null}
<button
type="button"
className="absolute right-0 top-0 flex h-[36px] w-[20px] items-center justify-center rounded-[4px] border border-border bg-background text-label shadow-[0_8px_18px_rgba(15,23,42,0.14)] transition hover:bg-accent hover:text-primary"
onClick={() => setIsOpen((value) => !value)}
title={isOpen ? "분석 메뉴 접기" : "분석 메뉴 펼치기"}
aria-label={isOpen ? "분석 메뉴 접기" : "분석 메뉴 펼치기"}
>
{isOpen ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
</button>
</div>
</div>
);
@@ -17,10 +17,11 @@ type LayerComponentProps = {
chmbrYdnts: string;
gisObject?: Record<string, any>; // GIS 객체 추가
rawFeatureData?: any;
surveyResultContent?: React.ReactNode;
};
type LayerContentMap = {
[key: string]: {
component?: React.FC<LayerComponentProps>;
component?: React.ComponentType<any>;
props?: any;
};
};
@@ -45,7 +46,7 @@ export const getLayerContentComponent = (layerNm: string): React.FC<LayerCompone
// component가 없으면 null 반환
return () => null;
}
return component.component
return component.component as React.FC<LayerComponentProps>
};
// 사용자 입력 필드 (보수 및 갱신, 청소 및 준설, 점검 및 진단)
export const handleInputChange = (section: string,
@@ -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'
];
@@ -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 (
<div className="w-full rounded-[6px] border border-border bg-background px-2 py-[9px] shadow-blur">
<div className="mb-2 flex items-center gap-1">
{/* 관로/맨홀 조사유형을 구분하는 지도 범례 의미색이므로 고정색을 유지한다. */}
<span
className={`h-2 w-2 rounded-full ${jobType === "PPL" ? "bg-[#facc15]" : "bg-[#8b5cf6]"}`}
/>
<span className="text-[1.1rem] font-semibold text-label">
{jobType === "PPL" ? "관로조사" : "맨홀조사"}
</span>
</div>
<div className="space-y-1.5">
<label className="flex min-h-[25px] items-center justify-between gap-2">
<span className={labelClass}>사업차수</span>
<input
className={selectClass}
type="text"
inputMode="numeric"
maxLength={10}
placeholder="전체"
value={filters.bizCycl}
onChange={(event) => updateFilter("bizCycl", event.target.value)}
/>
</label>
<label className="flex min-h-[25px] items-center justify-between gap-2">
<span className={labelClass}>구조적 상태등급</span>
<select
className={selectClass}
value={filters.structuralGrade}
onChange={(event) =>
updateFilter("structuralGrade", event.target.value)
}
>
{GRADE_OPTIONS.map((grade) => (
<option key={grade || "all"} value={grade}>
{grade || "전체"}
</option>
))}
</select>
</label>
<label className="flex min-h-[25px] items-center justify-between gap-2">
<span className={labelClass}>운영적 상태등급</span>
<select
className={selectClass}
value={filters.operationalGrade}
onChange={(event) =>
updateFilter("operationalGrade", event.target.value)
}
>
{GRADE_OPTIONS.map((grade) => (
<option key={grade || "all"} value={grade}>
{grade || "전체"}
</option>
))}
</select>
</label>
</div>
</div>
);
}
@@ -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 (
<section className="border-b border-border bg-background">
<div className="card-header flex items-center justify-between">
<h3 className="mx-3 text-2xl font-medium text-label">조사 결과</h3>
{jobType === "PPL" && (
<Button
onClick={openPipeReport}
disabled={!canOpenPipeReport}
className="hover:bg-primary/90 mr-3 rounded-sm bg-primary px-4 py-2 text-sm font-medium text-primary-foreground disabled:cursor-not-allowed disabled:opacity-50"
>
조사보고서 보기
</Button>
)}
</div>
<div className="card !m-[1.2rem] !p-0">
<Table variant="secondary">
<TableBody className="text-center">
{rows.map(([label, value]) => (
<TableRow key={label}>
<TableCell className="w-[120px] whitespace-nowrap bg-secondary text-xs font-bold text-label">
{label}
</TableCell>
<TableCell className="break-all text-left text-xs">
{displayValue(value ?? "")}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</section>
);
}
@@ -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;
};
@@ -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<URLSearchParams, "get">,
): 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,
};
};
@@ -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]>) => (
<table className="w-full table-fixed border-collapse">
<colgroup>
<col className="w-[42%]" />
<col className="w-[58%]" />
</colgroup>
<tbody>
{rows.map(([label, value], index) => (
<tr key={`${label}-${index}`}>
<th className="border border-border bg-secondary px-3 py-[7px] text-left text-[1.02rem] font-semibold text-label">
{label}
</th>
<td className="border border-border bg-background px-3 py-[7px] text-[1.02rem] text-foreground">
{value}
</td>
</tr>
))}
</tbody>
</table>
);
export default function FldngHstryDetailPanel({
detail,
onClose,
position,
}: Props) {
// 지도에서 침수흔적도 polygon을 클릭했을 때, 상세정보는 업무 의미 기준으로 3개 탭에 나눠 보여준다.
const [activeTab, setActiveTab] = useState<DetailTab>("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 (
<div
className="absolute z-[70] w-[248px] overflow-hidden rounded-[8px] border border-border bg-background shadow-blur"
style={{
left: `${position.left}px`,
top: `${position.top}px`,
}}
>
<div className="flex h-[28px] items-center justify-between border-b border-border bg-secondary px-[10px]">
<div className="text-[10px] font-bold tracking-[-0.01em] text-label">
상세정보
</div>
<button
type="button"
className="rounded-[4px] px-[4px] text-[13px] leading-none text-muted-foreground transition hover:bg-accent hover:text-accent-foreground"
onClick={onClose}
>
×
</button>
</div>
<div className="border-b border-border bg-secondary px-[6px] pt-[6px]">
<div className="flex gap-[4px]">
{[
["year", "년도탭"],
["disaster", "재해명탭"],
["rainfall", "침수발생강우탭"],
].map(([tabId, label]) => {
const isActive = activeTab === tabId;
return (
<button
key={tabId}
type="button"
className={`h-[24px] flex-1 rounded-t-[6px] border px-[6px] text-[9px] font-semibold leading-none transition ${
isActive
? "border-primary border-b-background bg-background text-primary"
: "border-border bg-secondary text-muted-foreground hover:bg-accent"
}`}
onClick={() => setActiveTab(tabId as DetailTab)}
>
{label}
</button>
);
})}
</div>
</div>
<div className="bg-background p-[6px]">
<div className="overflow-hidden rounded-[6px] border border-border bg-background">
{renderRows(currentRows)}
</div>
</div>
</div>
);
}
@@ -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 = <T,>(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<FldngHstryFilters>(
FLDNG_HSTRY_DEFAULT_FILTERS,
);
const [appliedFilters, setAppliedFilters] = useState<FldngHstryFilters>(
FLDNG_HSTRY_DEFAULT_FILTERS,
);
const [rows, setRows] = useState<FldngHstryTableRow[]>([]);
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 (
<StandardPopupLayout title="침수발생이력 목록">
<LoadingOverlay isLoading={isLoading} />
<PopupSearchPanel contentClassName="flex flex-wrap items-end gap-x-5 gap-y-3">
<label className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.3rem] text-label">
시도
</span>
<select
className="h-[3.2rem] w-[16rem] rounded-md border border-input bg-field px-3 text-[1.3rem]"
value={filters.ctpvCd}
onChange={(event) =>
setFilters((current) => ({
...current,
ctpvCd: event.target.value,
sggCd: "0",
}))
}
>
<option value="0">전체</option>
{ctpv.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.3rem] text-label">
시군구
</span>
<select
className="h-[3.2rem] w-[16rem] rounded-md border border-input bg-field px-3 text-[1.3rem]"
value={filters.sggCd}
onChange={(event) =>
setFilters((current) => ({
...current,
sggCd: event.target.value,
}))
}
>
<option value="0">전체</option>
{sggOptions.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.3rem] text-label">
침수발생년도
</span>
<select
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-[1.3rem]"
value={filters.floodYear}
onChange={(event) =>
setFilters((current) => ({
...current,
floodYear: event.target.value,
}))
}
>
<option value="">전체</option>
{fldngYearOptions.map((year) => (
<option key={year} value={year}>
{year}년
</option>
))}
</select>
</label>
<label className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.3rem] text-label">
침수재해명
</span>
<Input
className="w-[20rem]"
type="text"
autoComplete="off"
value={filters.disasterName}
onChange={(event) =>
setFilters((current) => ({
...current,
disasterName: event.target.value,
}))
}
onKeyDown={(event) => {
if (event.key === "Enter") void handleSearch(1);
}}
/>
</label>
<div className="ml-auto flex items-center gap-2">
<Button
type="button"
color="primary"
onClick={() => void handleSearch(1)}
>
조회
</Button>
<Button
type="button"
color="white"
onClick={() => {
setFilters(FLDNG_HSTRY_DEFAULT_FILTERS);
void loadRows(1, FLDNG_HSTRY_DEFAULT_FILTERS);
}}
>
초기화
</Button>
</div>
</PopupSearchPanel>
<MapPopupDataTable
rows={rows as Array<FldngHstryTableRow & Record<string, unknown>>}
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={
<Button type="button" onClick={() => void handleDownload()}>
엑셀다운로드
</Button>
}
/>
<div className="rounded-sm bg-secondary px-4 py-3 text-[1.2rem] text-muted-foreground">
행을 클릭하면 메인 지도에서 해당 침수흔적을 표시합니다.
</div>
</StandardPopupLayout>
);
}
@@ -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<string, string> = {
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;
@@ -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<never>((_, 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<FloodDngrMapFilters>(
FLOOD_DNGR_MAP_DEFAULT_FILTERS,
);
const [appliedFilters, setAppliedFilters] = useState<FloodDngrMapFilters>(
FLOOD_DNGR_MAP_DEFAULT_FILTERS,
);
const [items, setItems] = useState<FloodDngrMapApiItem[]>([]);
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 (
<StandardPopupLayout title="도시침수지도 목록">
<LoadingOverlay isLoading={isLoading} />
<PopupSearchPanel contentClassName="flex flex-wrap items-center gap-x-5 gap-y-3">
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
시도
</div>
<select
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl text-foreground"
value={filters.ctpvCd}
onChange={(event) =>
setFilters({
...filters,
ctpvCd: event.target.value,
sggCd: "0",
})
}
>
<option value="0">전체</option>
{ctpvOptions.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
시군구
</div>
<select
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl text-foreground"
value={filters.sggCd}
onChange={(event) =>
setFilters({ ...filters, sggCd: event.target.value })
}
>
<option value="0">전체</option>
{sggOptions.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
침수심도
</div>
<select
className="h-[3.2rem] w-[18rem] rounded-md border border-input bg-field px-3 text-xl text-foreground"
value={filters.sbmrgDpthlCd}
onChange={(event) =>
setFilters({ ...filters, sbmrgDpthlCd: event.target.value })
}
>
<option value="0">전체</option>
{FLOOD_DNGR_MAP_DEPTH_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
침수빈도
</div>
<select
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl text-foreground"
value={filters.sbmrgFrqExpln}
onChange={(event) =>
setFilters({ ...filters, sbmrgFrqExpln: event.target.value })
}
>
<option value="0">전체</option>
{FLOOD_DNGR_MAP_FREQUENCY_OPTIONS.map((value) => (
<option key={value} value={value}>
{Number(value)}년
</option>
))}
</select>
</label>
<div className="flex min-w-fit flex-1 justify-end gap-2">
<Button
type="button"
size="default"
color="primary"
onClick={handleSearch}
>
조회
</Button>
<Button
type="button"
size="default"
color="white"
onClick={() => {
setFilters(FLOOD_DNGR_MAP_DEFAULT_FILTERS);
void loadRows(1, FLOOD_DNGR_MAP_DEFAULT_FILTERS);
}}
>
초기화
</Button>
</div>
</PopupSearchPanel>
{errorMessage ? (
<div
role="alert"
className="mt-2 rounded border border-red-200 bg-red-50 px-3 py-2 text-[1rem] text-red-700"
>
{errorMessage}
</div>
) : null}
<MapPopupDataTable
rows={rows as Array<FloodDngrMapTableRow & Record<string, unknown>>}
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"
/>
</StandardPopupLayout>
);
}
@@ -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<string, string> = {
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, "");
@@ -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]>) => (
<table className="w-full table-fixed border-collapse">
<colgroup>
<col className="w-[42%]" />
<col className="w-[58%]" />
</colgroup>
<tbody>
{rows.map(([label, value], index) => (
<tr key={`${label}-${index}`}>
<th className="border border-[#d8e3ee] bg-[#f8fbfe] px-3 py-[7px] text-left text-[1.02rem] font-semibold text-[#36506b]">
{label}
</th>
<td className="border border-[#d8e3ee] bg-white px-3 py-[7px] text-[1.02rem] text-[#1f2937]">
{value}
</td>
</tr>
))}
</tbody>
</table>
);
export default function FloodHistoryDetailPanel({ detail, onClose, position }: Props) {
// 지도에서 침수흔적도 polygon을 클릭했을 때, 상세정보는 업무 의미 기준으로 3개 탭에 나눠 보여준다.
const [activeTab, setActiveTab] = useState<DetailTab>("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 (
<div
className="absolute z-[70] w-[248px] overflow-hidden rounded-[8px] border border-[#cfdbe8] bg-[rgba(255,255,255,0.98)] shadow-[0_14px_28px_rgba(15,23,42,0.18)]"
style={{
left: `${position.left}px`,
top: `${position.top}px`,
}}
>
<div className="flex h-[28px] items-center justify-between border-b border-[#d8e3ee] bg-[linear-gradient(180deg,#ffffff_0%,#f6faff_100%)] px-[10px]">
<div className="text-[10px] font-bold tracking-[-0.01em] text-[#243b53]">상세정보</div>
<button
type="button"
className="rounded-[4px] px-[4px] text-[13px] leading-none text-[#7b8ba1] transition hover:bg-[#eef5fb] hover:text-[#35597d]"
onClick={onClose}
>
×
</button>
</div>
<div className="border-b border-[#d8e3ee] bg-[#f8fbfe] px-[6px] pt-[6px]">
<div className="flex gap-[4px]">
{[
["year", "년도탭"],
["disaster", "재해명탭"],
["rainfall", "침수발생강우탭"],
].map(([tabId, label]) => {
const isActive = activeTab === tabId;
return (
<button
key={tabId}
type="button"
className={`h-[24px] flex-1 rounded-t-[6px] border px-[6px] text-[9px] font-semibold leading-none transition ${
isActive
? "border-[#b8d4f0] border-b-white bg-white text-[#1f5d99]"
: "border-[#d8e3ee] bg-[#f3f7fb] text-[#5f7388] hover:bg-[#eef5fb]"
}`}
onClick={() => setActiveTab(tabId as DetailTab)}
>
{label}
</button>
);
})}
</div>
</div>
<div className="bg-white p-[6px]">
<div className="overflow-hidden rounded-[6px] border border-[#d8e3ee] bg-white">
{renderRows(currentRows)}
</div>
</div>
</div>
);
}
@@ -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<FloodHistoryFilters>(FLOOD_HISTORY_DEFAULT_FILTERS);
// 적용된 조회조건과 선택행은 다운로드/페이지 이동에도 같은 기준을 유지하기 위해 분리한다.
const [appliedFilters, setAppliedFilters] = useState<FloodHistoryFilters>(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 (
<main className="sn-popup-page">
<header className="sn-popup-header">
<h1 className="sn-popup-title">침수흔적도 목록</h1>
<div />
</header>
<section className="sn-popup-section">
<div className="sn-popup-filter-panel-compact">
<div className="grid grid-cols-1 gap-3 md:grid-cols-[180px_180px_180px_minmax(0,1fr)_auto] md:items-end">
<label className="sn-popup-field">
<div className="sn-popup-field-label">시도</div>
<select
className="sn-popup-input !text-[1.1rem]"
value={filters.ctpvCd}
onChange={(event) =>
setFilters((current) => ({
...current,
ctpvCd: event.target.value,
sggCd: "0",
}))
}
>
<option value="0">전체</option>
{ctpv.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="sn-popup-field">
<div className="sn-popup-field-label">시군구</div>
<select
className="sn-popup-input !text-[1.1rem]"
value={filters.sggCd}
onChange={(event) =>
setFilters((current) => ({
...current,
sggCd: event.target.value,
}))
}
>
<option value="0">전체</option>
{sggOptions.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="sn-popup-field">
<div className="sn-popup-field-label">침수발생년도</div>
<select
className="sn-popup-input !text-[1.1rem]"
value={filters.floodYear}
onChange={(event) =>
setFilters((current) => ({
...current,
floodYear: event.target.value,
}))
}
>
<option value="">전체</option>
{floodYearOptions.map((year) => (
<option key={year} value={year}>
{year}년
</option>
))}
</select>
</label>
<label className="sn-popup-field">
<div className="sn-popup-field-label">침수재해명</div>
<Input
className="sn-popup-input !text-[1.1rem]"
type="text"
autoComplete="off"
value={filters.disasterName}
onChange={(event) =>
setFilters((current) => ({
...current,
disasterName: event.target.value,
}))
}
onKeyDown={(event) => {
if (event.key === "Enter") handleSearch();
}}
/>
</label>
<div className="sn-popup-actions md:pr-3">
<Button
type="button"
size="sm"
color="primary"
className="sn-popup-primary-button !text-[1.1rem]"
onClick={handleSearch}
>
조회
</Button>
<Button
type="button"
size="sm"
color="primary"
className="sn-popup-primary-button !text-[1.1rem]"
onClick={() => void handleDownload()}
>
다운로드
</Button>
</div>
</div>
</div>
<div className="mt-3 flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">
침수흔적도 조회 결과 {filteredRows.length.toLocaleString("ko-KR")}건
</h3>
</div>
<div className="card !m-[1.2rem] !mt-0 min-h-0 flex-1 overflow-x-auto overflow-y-auto">
<Table variant="secondary" className="w-max min-w-full">
<colgroup>
{GRID_COLUMNS.map((head) => (
<col key={head.id} style={{ width: head.width }} />
))}
</colgroup>
<TableHeader className="font-bold">
<TableRow>
<TableHead rowSpan={2} className="whitespace-nowrap">NO</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">시도</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">시군구</TableHead>
<TableHead colSpan={4} className="whitespace-nowrap">강우 분석정보</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">침수재해명</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">침수년도</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">침수시간</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">침수지구명</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">침수원인상세코드</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">침수원인상세</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">평균침수위</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">침수면적</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">조사년도</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">피해금액</TableHead>
</TableRow>
<TableRow>
<TableHead className="whitespace-nowrap">침수시작일자</TableHead>
<TableHead className="whitespace-nowrap">침수종료일자</TableHead>
<TableHead className="whitespace-nowrap">침수시작시간</TableHead>
<TableHead className="whitespace-nowrap">침수종료시간</TableHead>
</TableRow>
</TableHeader>
<TableBody className="text-center">
{pagedRows.length > 0 ? (
pagedRows.map((row) => {
const isSelected = selectedHistoryId === row.historyId;
return (
<TableRow
key={row.historyId}
className={`cursor-pointer ${isSelected ? "sn-popup-grid-row-selected" : "sn-popup-grid-row-hover"}`}
onClick={() => handleRowClick(row)}
onDoubleClick={() => handleRowDoubleClick(row)}
>
<TableCell>{row.rowNo}</TableCell>
<TableCell>{row.ctpvNm}</TableCell>
<TableCell>{row.sggNm}</TableCell>
<TableCell>{row.startDateDisplay}</TableCell>
<TableCell>{row.endDateDisplay}</TableCell>
<TableCell>{row.startTimeDisplay}</TableCell>
<TableCell>{row.endTimeDisplay}</TableCell>
<TableCell className="text-left">{row.disasterName}</TableCell>
<TableCell>{row.floodYearDisplay}</TableCell>
<TableCell>{row.floodHourDisplay}</TableCell>
<TableCell className="text-left">{row.districtNameDisplay}</TableCell>
<TableCell>{row.causeCodeDisplay}</TableCell>
<TableCell className="text-left">{row.causeDetailDisplay}</TableCell>
<TableCell className="text-right">{row.avgFloodDepthDisplay}</TableCell>
<TableCell className="text-right">{row.floodAreaDisplay}</TableCell>
<TableCell>{row.exmnYearDisplay}</TableCell>
<TableCell className="text-right">{row.damageAmountDisplay}</TableCell>
</TableRow>
);
})
) : (
<TableRow>
<TableCell colSpan={GRID_COLUMNS.length} className="sn-popup-empty-cell h-24 text-center">
조회된 데이터가 없습니다.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{filteredRows.length > 0 ? (
<UiPageNations
total={filteredRows.length}
cur={currentPage}
pageSize={FLOOD_HISTORY_PAGE_SIZE}
onPageChange={setCurrentPage}
/>
) : null}
<div className="sn-popup-help-text">
행 클릭 시 메인 지도에 미리 표시되고, 더블클릭하면 팝업을 닫습니다. 상세정보는 지도 위 침수흔적 영역을 클릭해 확인합니다.
</div>
</div>
</section>
</main>
);
}
@@ -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<string, string> = {
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<FloodHistoryItem, "areaRing" | "depthZones"> & {
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;
};
@@ -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<string, any>, field: { id: string; sourceId?: string }) =>
values[field.id] ?? (field.sourceId ? values[field.sourceId] : undefined);
const getObjectValue = (
values: Record<string, any>,
field: { id: string; sourceId?: string },
) => values[field.id] ?? (field.sourceId ? values[field.sourceId] : undefined);
const pickFields = (values: Record<string, any>, fieldIds: string[]) =>
fieldIds.reduce<Record<string, any>>((acc, fieldId) => {
@@ -116,13 +205,17 @@ const normalizeBasicFormValues = (values: Record<string, any>) => {
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<Record<string, any>>({});
const [inputInfoTableData, setInputInfoTableData] = useState<Record<string, any>>(
buildInitialInputDraft(change),
const baseFeatureValues = useMemo(
() => buildBaseFeatureValues(featureData),
[featureData],
);
const [basicInfoTableData, setBasicInfoTableData] = useState<
Record<string, any>
>({});
const [inputInfoTableData, setInputInfoTableData] = useState<
Record<string, any>
>(buildInitialInputDraft(change));
const [mngInstOptions, setMngInstOptions] = useState<MngInstOption[]>([]);
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<string, any>,
nextInputInfoTableData: Record<string, any>,
): 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 (
<div id="popup" className="absolute top-0 right-0 w-[350px] h-[calc(100vh-5rem)] bg-white border-r border-gray-300 flex flex-col overflow-hidden transition-all duration-300 z-[50]">
<div className="card-header bg-primary flex items-center justify-between relative p-4">
<div className="flex items-center space-x-4 flex-1">
<h3 className="text-2xl font-medium text-white text-label whitespace-nowrap">
<div
id="popup"
className="absolute right-0 top-0 z-[50] flex h-[calc(100vh-5rem)] w-[350px] flex-col overflow-hidden border-r border-border bg-background transition-all duration-300"
>
<div className="card-header relative flex items-center justify-between bg-primary p-4">
<div className="flex flex-1 items-center space-x-4">
<h3 className="whitespace-nowrap text-2xl font-medium text-primary-foreground">
하수관로
{basicInfoTableData?.usgCd ? `(${getPipeUsageLabel(basicInfoTableData.usgCd)})` : ""}
{" "}정보 <br />관리번호 : {displayManagementNo || "신규"}
{basicInfoTableData?.usgCd
? `(${getPipeUsageLabel(basicInfoTableData.usgCd)})`
: ""}{" "}
정보 <br />
관리번호 : {displayManagementNo || "신규"}
</h3>
</div>
<Button
onClick={onClose}
className="flex items-center space-x-4 text-white hover:text-gray-300 focus:outline-none"
className="text-primary-foreground/80 flex items-center space-x-4 hover:text-primary-foreground focus:outline-none"
size="xs"
>
✕
</Button>
</div>
<div className="overflow-y-auto overflow-x-hidden flex-1">
<div className="flex-1 overflow-y-auto overflow-x-hidden">
<VerticalTable
headName="기본정보"
tableData={basicInfoTableRows}
@@ -1,5 +1,7 @@
"use client";
import { Button } from "@common/components";
import type { GisEditSessionSnapshot, GisEditTool } from "./gisEditTypes";
type Props = GisEditSessionSnapshot & {
@@ -20,20 +22,23 @@ const TOOL_LABELS: Record<GisEditTool, string> = {
};
const buildChangeItemClassName = (isFocused: boolean) =>
`block w-full rounded border px-2 py-2 text-left text-[12px] transition ${
`block w-full rounded-md border px-3 py-2.5 text-left text-[1.1rem] transition ${
isFocused
? "border-[#5a9bd5] bg-[#eef7ff] shadow-[inset_0_0_0_1px_rgba(90,155,213,0.2)]"
: "border-[#d8dee8] bg-[#fbfcfe] hover:border-[#bfd2e5] hover:bg-white"
? "border-primary bg-primary-transparent shadow-[inset_0_0_0_1px_var(--primary-transparent)]"
: "border-border bg-background hover:border-primary hover:bg-background"
}`;
const getChangeLabel = (change: GisEditSessionSnapshot["changes"][number]) =>
change.attributeOnly ? "상세정보수정" : TOOL_LABELS[change.editType];
const getChangeSummary = (change: GisEditSessionSnapshot["changes"][number]) => {
const getChangeSummary = (
change: GisEditSessionSnapshot["changes"][number],
) => {
if (change.attributeOnly) return "변경한 상세정보를 확인하세요.";
if (change.editType === "ADD") return "추가한 객체 위치를 확인하세요.";
if (change.editType === "MOVE") return "객체를 드래그해 위치를 조정하세요.";
if (change.editType === "VERTEX_EDIT") return "점을 옮기거나 선분을 끌어 추가하세요.";
if (change.editType === "VERTEX_EDIT")
return "점을 옮기거나 선분을 끌어 추가하세요.";
return "삭제할 객체가 맞는지 확인하세요.";
};
@@ -43,33 +48,41 @@ type ChangeListProps = {
onFocusChange: (changeId: string) => void;
};
const ChangeList = ({ changes, focusedChangeId, onFocusChange }: ChangeListProps) => (
const ChangeList = ({
changes,
focusedChangeId,
onFocusChange,
}: ChangeListProps) => (
<>
{changes.map((change, index) => (
<button
key={change.changeId}
type="button"
className={buildChangeItemClassName(focusedChangeId === change.changeId)}
className={buildChangeItemClassName(
focusedChangeId === change.changeId,
)}
onClick={() => onFocusChange(change.changeId)}
>
<div className="flex items-center gap-1">
<div className="shrink-0 whitespace-nowrap text-[11px] font-semibold leading-4 text-[#173f67]">
<div className="shrink-0 whitespace-nowrap text-[1.1rem] font-semibold leading-4 text-label">
{index + 1}. {getChangeLabel(change)} / {change.objectLabel}
</div>
<div className="ml-auto flex shrink-0 items-center gap-1">
{change.basicInfoSavedAt && focusedChangeId !== change.changeId ? (
<span className="whitespace-nowrap rounded-full border border-[#a9d9bd] bg-white px-1.5 py-0.5 text-[9px] font-semibold text-[#16834f]">
<span className="whitespace-nowrap rounded-full border border-emerald-200 bg-background px-1.5 py-0.5 text-[0.9rem] font-semibold text-emerald-700">
정보저장
</span>
) : null}
{focusedChangeId === change.changeId ? (
<span className="whitespace-nowrap rounded-full border border-[#b8d3ef] bg-white px-1.5 py-0.5 text-[9px] font-semibold text-[#176db8]">
지도 미리보기 선택
<span className="whitespace-nowrap rounded-full border border-primary bg-background px-1.5 py-0.5 text-[0.9rem] font-semibold text-primary">
선택됨
</span>
) : null}
</div>
</div>
<div className="mt-1 truncate text-[11px] text-[#526173]">{getChangeSummary(change)}</div>
<div className="mt-1 truncate text-[1rem] text-muted-foreground">
{getChangeSummary(change)}
</div>
</button>
))}
</>
@@ -94,63 +107,81 @@ export default function GisEditSessionPanel({
return (
<div
className={`flex min-h-0 flex-col overflow-hidden rounded-lg border border-[#d8dee8] bg-[rgba(255,255,255,0.98)] text-sm text-[#304255] shadow-[0_10px_26px_rgba(23,42,70,0.14)] ${isCompactInline ? "h-[58px] p-2" : "p-4"} ${className}`.trim()}
className={`flex min-h-0 flex-col overflow-hidden rounded-lg border border-border bg-[rgba(255,255,255,0.98)] text-label shadow-[0_10px_26px_rgba(23,42,70,0.16)] ${isCompactInline ? "min-h-[68px]" : "p-3"} ${className}`.trim()}
>
<div className={`flex shrink-0 justify-between gap-3 ${isCompactInline ? "mb-0 items-center" : "mb-3 items-start"}`}>
<div
className={`flex shrink-0 justify-between gap-2 ${isCompactInline ? "items-center bg-container px-3 py-2.5" : "mb-3 items-start rounded-md bg-container px-3 py-2.5"}`}
>
<div className="min-w-0">
{variant === "inline" ? (
<div className="mb-1 text-[14px] font-bold text-[#173f67]">편집 세션</div>
<div className="mb-1 flex items-center gap-2 text-[1.3rem] font-semibold text-label">
<span>편집 세션</span>
<span className="rounded-full bg-primary px-2 py-0.5 text-[0.9rem] font-semibold text-primary-foreground">
{changes.length}건
</span>
</div>
) : null}
<div className="truncate text-[12px] text-[#667485]">
레이어: {selectedLayerName || "미선택"} / 모드: {isEditMode ? (activeTool ? TOOL_LABELS[activeTool] : "대기") : "편집 모드 종료"}
<div className="truncate text-[1.05rem] text-muted-foreground">
{selectedLayerName || "레이어 미선택"} ·{" "}
{isEditMode
? activeTool
? TOOL_LABELS[activeTool]
: "대기"
: "편집 모드 종료"}
</div>
</div>
{onOpenPopup ? (
<button
<Button
type="button"
className="flex shrink-0 items-center gap-1 rounded border border-[#b8d3ef] bg-white px-2.5 py-1.5 text-[11px] font-semibold text-[#176db8] transition hover:bg-[#eef7ff]"
color="white"
onClick={onOpenPopup}
title="편집 세션을 기존 윈도우 팝업으로 열기"
>
<span aria-hidden="true" className="text-[12px] leading-none">↗</span>
<span aria-hidden="true" className="text-[1.1rem] leading-none">
↗
</span>
<span>팝업</span>
</button>
</Button>
) : null}
</div>
<div className={`${isCompactInline ? "hidden" : "mb-3 flex"} shrink-0 flex-wrap items-center gap-2`}>
<button
<div
className={`${isCompactInline ? "hidden" : "mb-3 flex"} shrink-0 flex-wrap items-center gap-2 rounded-md border border-border bg-background p-2`}
>
<Button
type="button"
className="rounded border border-[#d8dee8] bg-white px-3 py-1.5 text-[12px] font-semibold text-[#304255] transition hover:bg-[#f8fafc] disabled:cursor-not-allowed disabled:text-[#9aa8b7]"
color="white"
disabled={!focusedChangeId}
onClick={onRemoveFocusedChange}
>
취소
</button>
<button
선택 취소
</Button>
<Button
type="button"
className="rounded border border-[#d8dee8] bg-white px-3 py-1.5 text-[12px] font-semibold text-[#304255] transition hover:bg-[#f8fafc] disabled:cursor-not-allowed disabled:text-[#9aa8b7]"
color="white"
disabled={!canUndo}
onClick={onRemoveLastChange}
>
뒤로가기
</button>
<button
최근 취소
</Button>
<Button
type="button"
className="rounded border border-[#f3d0d0] bg-white px-3 py-1.5 text-[12px] font-semibold text-[#b42318] transition hover:bg-[#fff5f5] disabled:cursor-not-allowed disabled:text-[#d0a5a5]"
color="white"
disabled={!canUndo}
onClick={onClearChanges}
>
초기화
</button>
<div className="ml-auto rounded-full border border-[#d8dee8] bg-white px-2.5 py-1 text-[11px] font-semibold text-[#526173]">
전체 초기화
</Button>
<div className="ml-auto rounded-full border border-border bg-background px-2.5 py-1 text-[1rem] font-semibold text-muted-foreground">
총 {changes.length}건
</div>
</div>
<div className={`${isCompactInline ? "hidden" : "min-h-0 flex-1"} overflow-hidden`}>
<div
className={`${isCompactInline ? "hidden" : "min-h-0 flex-1"} overflow-hidden`}
>
{changes.length === 0 ? (
<div className="rounded border border-dashed border-[#d8dee8] bg-[#fbfcfe] px-3 py-4 text-[12px] text-[#667485]">
<div className="rounded-md border border-dashed border-border bg-background px-3 py-5 text-center text-[1.1rem] text-muted-foreground">
저장할 편집 변경분이 없습니다.
</div>
) : (
@@ -1,13 +1,6 @@
"use client";
import {
Check,
Move,
PenTool,
Plus,
Trash2,
X,
} from "lucide-react";
import { Check, Move, PenTool, Plus, Trash2, X } from "lucide-react";
import { Button } from "@common/components/ui";
import type { GisEditTool } from "./gisEditTypes";
@@ -45,7 +38,7 @@ export default function GisEditToolbar({
canUseTools,
canSave,
isSaving = false,
statusMessage: _statusMessage,
statusMessage,
allowedTools = [],
onClose,
onToggleSnap,
@@ -55,26 +48,41 @@ export default function GisEditToolbar({
if (!isEditMode) return null;
return (
<div data-testid="gis-edit-toolbar" className="absolute bottom-12 left-1/2 z-[60] flex w-[min(960px,calc(100%-2rem))] -translate-x-1/2 flex-col">
<div className="card card-border flex !bg-white py-[1px]">
<div className="flex w-full items-center gap-2 overflow-hidden">
<div className="flex w-[120px] shrink-0 items-center justify-start">
<span className="whitespace-nowrap text-[1.2rem] font-bold leading-5 text-[#123b62]">하수관로 편집</span>
<div
data-testid="gis-edit-toolbar"
className="absolute bottom-4 left-1/2 z-[60] flex w-[min(980px,calc(100%-2rem))] -translate-x-1/2 flex-col"
>
<div className="card card-border !m-0 flex !bg-background !p-[0.8rem] shadow-[0_8px_24px_rgba(23,42,70,0.18)]">
<div className="flex w-full items-center gap-3 overflow-hidden">
<div className="flex w-[168px] shrink-0 flex-col justify-center border-r border-border pr-3">
<span className="whitespace-nowrap text-[1.3rem] font-semibold leading-5 text-label">
하수관로 편집
</span>
<span
className="mt-0.5 truncate text-[1.05rem] text-muted-foreground"
title={statusMessage}
>
{statusMessage || "편집 도구를 선택하세요."}
</span>
</div>
<div className="flex min-w-0 flex-1 justify-center">
<div className="flex items-center gap-1">
<div className="flex min-w-0 flex-1 items-center justify-center gap-3">
<div className="flex items-center gap-1 rounded-md bg-container p-1">
{TOOL_BUTTONS.map(({ tool, label, icon: Icon }) => {
const isActive = activeTool === tool;
const isToolEnabled =
canUseTools && allowedTools.includes(tool);
if (tool === "VERTEX_EDIT") {
return (
<div key="vertex-edit-with-snap" className="flex shrink-0 items-center">
<div
key="vertex-edit-with-snap"
className="flex shrink-0 items-center"
>
<Button
type="button"
color={activeTool === "VERTEX_EDIT" ? "primary" : "white"}
size="sm"
className="mx-0.5 mt-1 min-w-[98px] px-2"
color={
activeTool === "VERTEX_EDIT" ? "primary" : "white"
}
className="min-w-[98px] px-2"
disabled={!isToolEnabled}
onClick={() => onSelectTool("VERTEX_EDIT")}
>
@@ -83,20 +91,6 @@ export default function GisEditToolbar({
<span>{label}</span>
</span>
</Button>
<Button
type="button"
color={isSnapEnabled ? "primary" : "white"}
size="sm"
className="mx-0.5 mt-1 min-w-[82px] px-2"
disabled={!canUseTools}
onClick={onToggleSnap}
title="이동과 꼭지점편집에 스냅핑 적용"
>
<span className="flex items-center justify-center gap-2 whitespace-nowrap">
<Check className="h-4 w-4" />
<span>스냅핑</span>
</span>
</Button>
</div>
);
}
@@ -105,8 +99,7 @@ export default function GisEditToolbar({
key={tool}
type="button"
color={isActive ? "primary" : "white"}
size="sm"
className="mx-0.5 mt-1 min-w-[74px] shrink-0 px-2"
className="min-w-[74px] shrink-0 px-2"
disabled={!isToolEnabled}
onClick={() => onSelectTool(tool)}
>
@@ -118,14 +111,27 @@ export default function GisEditToolbar({
);
})}
</div>
<div className="h-8 w-px shrink-0 bg-border" />
<Button
type="button"
color={isSnapEnabled ? "primary" : "white"}
className="min-w-[82px] shrink-0 px-2"
disabled={!canUseTools}
onClick={onToggleSnap}
title="이동과 꼭지점편집에 스냅핑 적용"
>
<span className="flex items-center justify-center gap-2 whitespace-nowrap">
<Check className="h-4 w-4" />
<span>스냅핑</span>
</span>
</Button>
</div>
<div className="flex w-[132px] shrink-0 items-center justify-end gap-1">
<div className="flex w-[140px] shrink-0 items-center justify-end gap-2 border-l border-border pl-3">
<Button
type="button"
color="primary"
size="sm"
className="mt-1 min-w-[58px] shrink-0 px-2"
className="min-w-[64px] shrink-0 px-2"
disabled={!canSave || isSaving}
onClick={onSave}
>
@@ -135,14 +141,13 @@ export default function GisEditToolbar({
<Button
type="button"
color="white"
size="sm"
className="mt-1 min-w-[58px] shrink-0 px-2"
className="min-w-[64px] shrink-0 px-2"
onClick={onClose}
title="하수관로 편집 닫기"
title="하수관로 편집 취소"
>
<span className="flex items-center justify-center gap-2 whitespace-nowrap">
<X className="h-4 w-4" />
<span>닫기</span>
<span>취소</span>
</span>
</Button>
</div>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,573 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { FeatureLike } from "ol/Feature";
import GeoJSON from "ol/format/GeoJSON";
import Map from "ol/Map";
import View from "ol/View";
import { Stroke, Style } from "ol/style";
import TileLayer from "ol/layer/Tile";
import VectorLayer from "ol/layer/Vector";
import VectorSource from "ol/source/Vector";
import TileWMTS from "ol/source/WMTS";
import WMTSTileGrid from "ol/tilegrid/WMTS";
import { getTopLeft } from "ol/extent";
import { get as getProjection } from "ol/proj";
import { register } from "ol/proj/proj4";
import proj4 from "proj4";
import { getRawJson } from "@/components/lib/api";
type GisUploadPreviewMapProps = {
uploadId: string;
mngInstCd?: string | null;
};
type LayerSetting = {
id?: string;
lyrSn?: string;
lyrNm?: string;
lyrTyNm?: string;
lyrTypeNm?: string;
lyr_type_nm?: string;
flterSql?: string | null;
minViewSize?: string | null;
minZoom?: string | null;
lnColor?: string | null;
lineColr?: string | null;
lnWdth?: string | null;
lineWdth?: string | null;
drawOrdr?: string | null;
exprsYn?: string | null;
useYn?: string | null;
actlLyrYn?: string | null;
children?: LayerSetting[];
};
const MAP_SRS = "EPSG:5179";
const INITIAL_CENTER_5179 = [965000, 1900000];
const INITIAL_ZOOM = 8;
const FALLBACK_PIPE_MIN_ZOOM = 13;
// OpenLayers는 minZoom 경계값에서 레이어를 숨기므로 자동 포커스는 임계값보다 소폭 크게 설정한다.
const MIN_ZOOM_VISIBILITY_OFFSET = 0.01;
const NETWORK_LAYER_GROUP = "SN_NETWORK";
const PIPE_LAYER_TYPE = "tb_ms_swage_ppl";
const NGII_WMTS_URL = `/ngii-server/openapi/Gettile.do?apikey=${process.env.NEXT_PUBLIC_NGII_PARAM ?? ""}`;
const NGII_RESOLUTIONS = [
1044.48, 522.24, 261.12, 130.56, 65.28, 32.64, 16.32, 8.16, 4.08, 2.04, 1.02,
0.51, 0.255,
];
const NGII_MATRIX_IDS = [
"L06",
"L07",
"L08",
"L09",
"L10",
"L11",
"L12",
"L13",
"L14",
"L15",
"L16",
"L17",
"L18",
];
let isProjectionRegistered = false;
const registerMapProjection = () => {
if (isProjectionRegistered) return;
proj4.defs(
MAP_SRS,
"+proj=tmerc +lat_0=38 +lon_0=127.5 +k=0.9996 +x_0=1000000 +y_0=2000000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs",
);
register(proj4);
const projection = getProjection(MAP_SRS);
projection?.setExtent([-200000.0, -28024123.62, 31824123.62, 4000000.0]);
isProjectionRegistered = true;
};
const createNgiiBaseLayer = () => {
registerMapProjection();
const projection = getProjection(MAP_SRS);
const tileGrid = new WMTSTileGrid({
origin: getTopLeft(
projection?.getExtent() ?? [
-200000.0, -28024123.62, 31824123.62, 4000000.0,
],
),
resolutions: NGII_RESOLUTIONS,
matrixIds: NGII_MATRIX_IDS,
tileSize: [256, 256],
});
const source = new TileWMTS({
projection: MAP_SRS,
tileGrid,
crossOrigin: "anonymous",
requestEncoding: "KVP",
layer: "korean_map",
matrixSet: MAP_SRS,
format: "image/png",
style: "korean",
});
source.setTileUrlFunction((tileCoord) => {
const [z, x, y] = tileCoord;
return NGII_WMTS_URL.replace(
"{z}",
NGII_MATRIX_IDS[z ?? 0] ?? NGII_MATRIX_IDS[0] ?? "L06",
)
.replace("{x}", String(x))
.replace("{y}", String(y));
});
return new TileLayer({
source,
zIndex: 0,
});
};
const flattenLayerSettings = (items: LayerSetting[] = []): LayerSetting[] =>
items.flatMap((item) => [item, ...flattenLayerSettings(item.children ?? [])]);
const getLayerTypeName = (layer: LayerSetting) =>
String(layer.lyrTyNm ?? layer.lyrTypeNm ?? layer.lyr_type_nm ?? "").trim();
const toFiniteNumber = (value: unknown, fallback: number) => {
if (value === null || value === undefined || String(value).trim() === "")
return fallback;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
};
const normalizeColor = (value: unknown, fallback: string) => {
const color = String(value ?? "").trim();
return color || fallback;
};
const getLineColor = (layer?: LayerSetting | null) =>
normalizeColor(layer?.lnColor ?? layer?.lineColr, "#f97316");
const getLayerMinZoom = (
layer: LayerSetting | null | undefined,
fallback: number,
) => toFiniteNumber(layer?.minViewSize ?? layer?.minZoom, fallback);
const getNetworkPipeLineWidth = (layer?: LayerSetting | null) =>
Math.max(1, toFiniteNumber(layer?.lnWdth ?? layer?.lineWdth, 1));
const getHighlightedPipeLineWidth = (layer?: LayerSetting | null) =>
Math.max(3, getNetworkPipeLineWidth(layer) + 2) + 3;
const pipePreviewStyle =
(layerSetting?: LayerSetting | null) => (feature: FeatureLike) => {
const status = feature.get("previewStatus");
if (status !== "CREATED" && status !== "UPDATED") {
return new Style({
stroke: new Stroke({
color: getLineColor(layerSetting),
width: getNetworkPipeLineWidth(layerSetting),
}),
});
}
const baseColor = status === "CREATED" ? "#16a34a" : "#f97316";
const width = getHighlightedPipeLineWidth(layerSetting);
return [
new Style({
stroke: new Stroke({
color: "#ffffff",
width: width + 3,
}),
}),
new Style({
stroke: new Stroke({
color: baseColor,
width,
}),
}),
];
};
const unwrapFeatureCollection = (response: any) => response?.data ?? response;
const refreshMapSize = (map: Map) => {
window.requestAnimationFrame(() => {
map.updateSize();
});
};
const getFeatureExtent = (features: ReturnType<GeoJSON["readFeatures"]>) => {
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
features.forEach((feature) => {
const geometry = feature.getGeometry();
if (!geometry) return;
const extent = geometry.getExtent();
minX = Math.min(minX, extent[0]!);
minY = Math.min(minY, extent[1]!);
maxX = Math.max(maxX, extent[2]!);
maxY = Math.max(maxY, extent[3]!);
});
if (![minX, minY, maxX, maxY].every(Number.isFinite)) {
return null;
}
return [minX, minY, maxX, maxY] as [number, number, number, number];
};
const getFeatureExtentCenter = (
features: ReturnType<GeoJSON["readFeatures"]>,
) => {
const extent = getFeatureExtent(features);
if (!extent) return null;
const targetCenter: [number, number] = [
(extent[0] + extent[2]) / 2,
(extent[1] + extent[3]) / 2,
];
return features.reduce<[number, number] | null>((nearestCenter, feature) => {
const featureExtent = feature.getGeometry()?.getExtent();
if (!featureExtent) return nearestCenter;
const featureCenter: [number, number] = [
(featureExtent[0]! + featureExtent[2]!) / 2,
(featureExtent[1]! + featureExtent[3]!) / 2,
];
if (!nearestCenter) return featureCenter;
const nearestDistance =
(nearestCenter[0] - targetCenter[0]) ** 2 +
(nearestCenter[1] - targetCenter[1]) ** 2;
const featureDistance =
(featureCenter[0] - targetCenter[0]) ** 2 +
(featureCenter[1] - targetCenter[1]) ** 2;
return featureDistance < nearestDistance ? featureCenter : nearestCenter;
}, null);
};
export default function GisUploadPreviewMap({
uploadId,
mngInstCd,
}: GisUploadPreviewMapProps) {
const mapElementRef = useRef<HTMLDivElement | null>(null);
const mapRef = useRef<Map | null>(null);
const pipeSourceRef = useRef(new VectorSource());
const pipeLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const loadTimerRef = useRef<number | null>(null);
const didFitPreviewRef = useRef(false);
const [layerSettings, setLayerSettings] = useState<LayerSetting[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [featureCount, setFeatureCount] = useState(0);
const [deletedCount, setDeletedCount] = useState(0);
const [truncated, setTruncated] = useState(false);
const [message, setMessage] = useState("");
const flatLayerSettings = useMemo(
() => flattenLayerSettings(layerSettings),
[layerSettings],
);
const pipeLayerSetting = useMemo(
() =>
flatLayerSettings.find(
(layer) => getLayerTypeName(layer) === PIPE_LAYER_TYPE,
) ?? null,
[flatLayerSettings],
);
const pipeMinZoom = getLayerMinZoom(pipeLayerSetting, FALLBACK_PIPE_MIN_ZOOM);
useEffect(() => {
let mounted = true;
const loadLayerSettings = async () => {
try {
const networkSettings = await getRawJson(
`/sewerage-service/api/v1/map/lyrStngs/${NETWORK_LAYER_GROUP}`,
);
const filtered = flattenLayerSettings(
Array.isArray(networkSettings) ? networkSettings : [],
)
.filter((layer) => getLayerTypeName(layer) === PIPE_LAYER_TYPE)
.filter(
(layer) =>
(layer.useYn ?? "Y") === "Y" &&
(layer.exprsYn ?? "Y") === "Y" &&
(layer.actlLyrYn ?? "Y") === "Y",
);
if (mounted) {
setLayerSettings(filtered);
}
} catch (error) {
console.error("GIS 업로드 미리보기 레이어 설정 조회 실패:", error);
if (mounted) {
setLayerSettings([]);
}
}
};
void loadLayerSettings();
return () => {
mounted = false;
};
}, []);
useEffect(() => {
let disposed = false;
let initializeTimer: number | null = null;
const initializeMap = () => {
const mapElement = mapElementRef.current;
if (disposed || mapRef.current) return;
if (
!mapElement ||
mapElement.clientWidth <= 0 ||
mapElement.clientHeight <= 0
) {
initializeTimer = window.setTimeout(initializeMap, 100);
return;
}
registerMapProjection();
const pipeLayer = new VectorLayer({
source: pipeSourceRef.current,
style: pipePreviewStyle(pipeLayerSetting),
minZoom: pipeMinZoom,
zIndex: toFiniteNumber(pipeLayerSetting?.drawOrdr, 300),
});
const map = new Map({
target: mapElement,
layers: [createNgiiBaseLayer(), pipeLayer],
view: new View({
center: INITIAL_CENTER_5179,
zoom: INITIAL_ZOOM,
projection: MAP_SRS,
}),
controls: [],
});
pipeLayerRef.current = pipeLayer;
mapRef.current = map;
refreshMapSize(map);
};
initializeMap();
return () => {
disposed = true;
if (initializeTimer) {
window.clearTimeout(initializeTimer);
}
abortControllerRef.current?.abort();
if (loadTimerRef.current) {
window.clearTimeout(loadTimerRef.current);
}
mapRef.current?.setTarget(undefined);
mapRef.current = null;
pipeLayerRef.current = null;
pipeSourceRef.current.clear();
};
}, []);
useEffect(() => {
pipeLayerRef.current?.setStyle(pipePreviewStyle(pipeLayerSetting));
pipeLayerRef.current?.setMinZoom(pipeMinZoom);
}, [pipeLayerSetting, pipeMinZoom]);
useEffect(() => {
const map = mapRef.current;
const mapElement = mapElementRef.current;
if (!map || !mapElement) return;
const resizeObserver = new ResizeObserver(() => {
refreshMapSize(map);
});
resizeObserver.observe(mapElement);
const updateTimer = window.setTimeout(() => {
map.updateSize();
}, 0);
return () => {
window.clearTimeout(updateTimer);
resizeObserver.disconnect();
};
}, []);
useEffect(() => {
didFitPreviewRef.current = false;
}, [mngInstCd, uploadId]);
const loadPreview = useCallback(
async function loadPreviewRequest() {
const map = mapRef.current;
if (!map || !uploadId) return;
map.updateSize();
let size = map.getSize();
if (!size) {
map.updateSize();
size = map.getSize();
}
if (!size || size[0]! <= 0 || size[1]! <= 0) {
if (loadTimerRef.current) {
window.clearTimeout(loadTimerRef.current);
}
loadTimerRef.current = window.setTimeout(() => {
void loadPreviewRequest();
}, 150);
return;
}
const extent = map.getView().calculateExtent(size);
const previewParams = new URLSearchParams({
bbox: extent.join(","),
srs: MAP_SRS,
});
if (mngInstCd) {
previewParams.set("mngInstCd", mngInstCd);
}
abortControllerRef.current?.abort();
const abortController = new AbortController();
abortControllerRef.current = abortController;
setIsLoading(true);
setMessage("");
pipeSourceRef.current.clear();
setFeatureCount(0);
setDeletedCount(0);
setTruncated(false);
try {
const previewResponse = await getRawJson(
`v1/map/gisUploads/${encodeURIComponent(uploadId)}/preview?${previewParams.toString()}`,
{ signal: abortController.signal },
);
const previewCollection = unwrapFeatureCollection(previewResponse);
const pipeFeatures = new GeoJSON({
dataProjection: MAP_SRS,
featureProjection: MAP_SRS,
}).readFeatures(previewCollection);
pipeSourceRef.current.addFeatures(pipeFeatures);
if (!didFitPreviewRef.current && pipeFeatures.length > 0) {
const changedFeatures = pipeFeatures.filter(
(feature) => feature.get("previewStatus") !== "LATEST",
);
const center = getFeatureExtentCenter(
changedFeatures.length > 0 ? changedFeatures : pipeFeatures,
);
if (center) {
map.getView().setCenter(center);
map.getView().setZoom(pipeMinZoom + MIN_ZOOM_VISIBILITY_OFFSET);
didFitPreviewRef.current = true;
}
}
setFeatureCount(pipeFeatures.length);
setDeletedCount(Number(previewCollection?.meta?.deletedCount ?? 0));
setTruncated(Boolean(previewCollection?.meta?.truncated));
} catch (error: any) {
if (error?.name === "AbortError") return;
console.error("GIS 업로드 미리보기 조회 실패:", error);
setMessage("미리보기 데이터를 불러오지 못했습니다.");
} finally {
if (!abortController.signal.aborted) {
setIsLoading(false);
}
}
},
[mngInstCd, pipeMinZoom, uploadId],
);
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const scheduleLoad = () => {
if (loadTimerRef.current) {
window.clearTimeout(loadTimerRef.current);
}
loadTimerRef.current = window.setTimeout(() => {
void loadPreview();
}, 250);
};
map.on("moveend", scheduleLoad);
scheduleLoad();
return () => {
map.un("moveend", scheduleLoad);
};
}, [loadPreview]);
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-2xl font-medium text-label">
업로드 이력 미리보기
</h3>
<p className="mt-1 text-[1rem] text-label">
변경 관로는 업로드 이력 기준으로 강조하고, 변경 없는 관로는 관망도와
동일한 스타일로 표시합니다.
</p>
</div>
</div>
<div className="mb-2 flex flex-wrap gap-3 text-[0.95rem] text-label">
<span>
<b className="text-green-600">신규</b> /{" "}
<b className="text-orange-500">수정</b> /{" "}
<b style={{ color: getLineColor(pipeLayerSetting) }}>관망도 기본</b>
</span>
<span>관로 표출 줌 {pipeMinZoom} 이상</span>
<span>관로 {featureCount.toLocaleString("ko-KR")}건</span>
<span>삭제 미표시 {deletedCount.toLocaleString("ko-KR")}건</span>
{truncated ? (
<span className="text-red-600">조회 상한 초과</span>
) : null}
{isLoading ? <span>불러오는 중...</span> : null}
</div>
{message ? (
<div className="mb-2 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-[1rem] text-red-700">
{message}
</div>
) : null}
<div
ref={mapElementRef}
className="gis-upload-preview-map min-h-0 flex-1 overflow-hidden rounded-md border border-border bg-secondary"
/>
<style jsx global>{`
.gis-upload-preview-map {
position: relative;
}
.gis-upload-preview-map .ol-viewport {
position: relative;
overflow: hidden;
width: 100%;
height: 100%;
touch-action: none;
}
.gis-upload-preview-map .ol-unselectable {
user-select: none;
}
.gis-upload-preview-map .ol-layers,
.gis-upload-preview-map .ol-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.gis-upload-preview-map canvas {
display: block;
}
`}</style>
</div>
);
}
@@ -1,14 +1,23 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { DateMonthPicker, UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
import { DateMonthPicker } from "@common/business_components/ui/date_month_picker";
import {
StandardPopupLayout,
type UiHeadProps,
} from "@common/business_components";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { useAlertStore } from "@/shared/lib";
import { Button } from "@common/components";
import { getData } from "@/components/lib/api";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue";
import useStore from "@/store/useStore";
import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefinitions";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import LoadingOverlay from "@/components/ui/LoadingBar";
import {
INFTWT_RESULT_ROW_SELECT_MESSAGE,
@@ -18,8 +27,8 @@ import {
const INFTWT_RESULT_PAGE_SIZE = 10;
const INFTWT_RESULT_HEADLIST = [
{ id: "rowNumber", title: "일련번호" },
const INFTWT_RESULT_HEADLIST: UiHeadProps[] = [
{ id: "rowNumber", title: "순번" },
{ id: "ctpvNm", title: "시도" },
{ id: "sggNm", title: "시군구" },
{ id: "swtrmtFcltNm", title: "하수처리시설" },
@@ -29,7 +38,7 @@ const INFTWT_RESULT_HEADLIST = [
{ id: "regDt", title: "분석일자" },
{ id: "rgtrNm", title: "분석자" },
{ id: "scsYn", title: "분석성공여부" },
] as const;
];
type InftwtResultFilters = {
ctpvCd: string;
@@ -82,6 +91,7 @@ const postRowSelectMessage = (row: InftwtResultRow) => {
};
export default function InftwtResultPopupPage() {
const alert = useAlertStore((state) => state.setMessage);
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const [filters, setFilters] = useState<InftwtResultFilters>(DEFAULT_FILTERS);
const [selectedSwagePrcsFclt, setSelectedSwagePrcsFclt] = useState("0");
@@ -89,7 +99,6 @@ export default function InftwtResultPopupPage() {
const [rows, setRows] = useState<InftwtResultRow[]>([]);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
@@ -102,27 +111,39 @@ export default function InftwtResultPopupPage() {
const queryParams = new URLSearchParams({
page: String(page - 1),
size: String(INFTWT_RESULT_PAGE_SIZE),
sort: "inftwtAnlsSn,desc",
scsYn: "Y",
});
queryParams.append("sort", "inftwtAnlsSn,desc");
if (nextFilters.aplcnYmFrom) queryParams.set("aplcnYmFrom", nextFilters.aplcnYmFrom);
if (nextFilters.aplcnYmTo) queryParams.set("aplcnYmTo", nextFilters.aplcnYmTo);
if (nextFilters.ctpvCd !== "0") queryParams.set("ctpvCd", nextFilters.ctpvCd);
if (nextFilters.sggCd !== "0") queryParams.set("sggCd", nextFilters.sggCd);
if (nextFilters.swtrmtFcltNm.trim()) queryParams.set("swtrmtFcltNm", nextFilters.swtrmtFcltNm.trim());
if (nextFilters.aplcnYmFrom)
queryParams.set("aplcnYmFrom", nextFilters.aplcnYmFrom);
if (nextFilters.aplcnYmTo)
queryParams.set("aplcnYmTo", nextFilters.aplcnYmTo);
if (nextFilters.ctpvCd !== "0")
queryParams.set("ctpvCd", nextFilters.ctpvCd);
if (nextFilters.sggCd !== "0")
queryParams.set("sggCd", nextFilters.sggCd);
if (nextFilters.swtrmtFcltNm.trim())
queryParams.set("swtrmtFcltNm", nextFilters.swtrmtFcltNm.trim());
const response = await getData(`v1/inftwtAnls?${queryParams.toString()}`);
const content = Array.isArray(response?.content) ? response.content : [];
const totalElements = Number(response?.totalElements ?? content.length);
const nextRows = content
.map((item: Record<string, unknown>, index: number) => {
const formattedItem = formatDisplayData(item);
const ctpvOption = ctpv.find((code) => code.val.trim() === String(item?.ctpvCd ?? "").trim());
const ctpvOption = ctpv.find(
(code) => code.val.trim() === String(item?.ctpvCd ?? "").trim(),
);
const sggOption = sgg.find((code) => code.val === item?.sggCd);
return {
...(formattedItem as Record<string, unknown>),
rowNumber: (page - 1) * INFTWT_RESULT_PAGE_SIZE + index + 1,
rowNumber: Math.max(
totalElements - ((page - 1) * INFTWT_RESULT_PAGE_SIZE + index),
0,
),
ctpvNm: ctpvOption ? ctpvOption.text : String(item?.ctpvCd ?? "-"),
sggNm: sggOption ? sggOption.text : String(item?.sggCd ?? "-"),
scsYn: String(item?.scsYn ?? "N"),
@@ -131,7 +152,7 @@ export default function InftwtResultPopupPage() {
.filter((item: InftwtResultRow) => item.scsYn === "Y");
setRows(nextRows);
setTotal(Number(response?.totalElements ?? nextRows.length));
setTotal(totalElements);
setCurrentPage(page);
},
[ctpv, sgg],
@@ -151,7 +172,7 @@ export default function InftwtResultPopupPage() {
setIsLoading(false);
}
},
[fetchRows, filters],
[alert, fetchRows, filters],
);
useEffect(() => {
@@ -160,17 +181,23 @@ export default function InftwtResultPopupPage() {
}, []);
const handleSearch = async (page = 1) => {
setSelectedIndex(-1);
setSelectedRow(null);
await loadRows(page, filters);
};
/** CS 검색 카드와 동일하게 조건과 선택 결과를 기본값으로 되돌린다. */
const handleReset = async () => {
setFilters(DEFAULT_FILTERS);
setSelectedSwagePrcsFclt("0");
setSelectedRow(null);
await loadRows(1, DEFAULT_FILTERS);
};
const handleRowClick = (index?: number) => {
if (typeof index !== "number") return;
const row = rows[index];
if (!row) return;
setSelectedIndex(index);
setSelectedRow(row);
postRowSelectMessage(row);
};
@@ -183,8 +210,12 @@ export default function InftwtResultPopupPage() {
try {
setIsLoading(true);
const response = await getData(`v1/inftwtAnlsRslt/ppl/${selectedRow.inftwtAnlsSn}`);
const excelRows = Array.isArray(response?.content) ? response.content : [];
const response = await getData(
`v1/inftwtAnlsRslt/ppl/${selectedRow.inftwtAnlsSn}`,
);
const excelRows = Array.isArray(response?.content)
? response.content
: [];
await downloadExcelWithKoreanHeaders({
data: excelRows as JSON[],
@@ -192,7 +223,7 @@ export default function InftwtResultPopupPage() {
sheetNameArray: ["침입수 분석결과"],
headerRange: "A1:D1",
headerMapping: {
inftwtAnlsSn: "침입수분석\n일련번호",
inftwtAnlsSn: "침입수분석\n분석번호",
pplMngNo: "관로\n관리번호",
anlsTm: "분석시각",
predcOilq: "예측유량\n(m³/h)",
@@ -207,139 +238,138 @@ export default function InftwtResultPopupPage() {
};
return (
<main className="sn-popup-page">
<StandardPopupLayout
title={windowPopupDefinitions.inftwtResultPopup.title}
bodyClassName="overflow-x-hidden"
>
<LoadingOverlay isLoading={isLoading} />
<header className="sn-popup-header">
<h1 className="sn-popup-title">침입수 분석결과</h1>
</header>
<section className="sn-popup-section overflow-y-auto">
<div className="rounded-md border border-border bg-secondary p-4">
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.05rem] font-medium text-label">상수사용기간</span>
<DateMonthPicker
field={{
value: filters.aplcnYmFrom,
onChange: (value: string) => {
setFilters((current) => ({
...current,
aplcnYmFrom: value,
}));
},
}}
selectClass="w-[145px]"
startYear={1900}
/>
<span className="text-[1.1rem] font-semibold text-muted-foreground">~</span>
<DateMonthPicker
field={{
value: filters.aplcnYmTo,
onChange: (value: string) => {
setFilters((current) => ({
...current,
aplcnYmTo: value,
}));
},
}}
selectClass="w-[145px]"
startYear={1900}
/>
</div>
<div className="min-w-0 flex-1 overflow-x-auto">
<CtpvSggPrcsFcltSelectBox
sido={filters.ctpvCd}
sigungu={filters.sggCd}
sggName={filters.sggName}
onSidoChange={(value) => {
setSelectedSwagePrcsFclt("0");
setFilters((current) => ({
...current,
ctpvCd: value,
sggCd: "0",
sggName: "",
swtrmtFcltNm: "",
}));
}}
onSigunguChange={(value) => {
setSelectedSwagePrcsFclt("0");
setFilters((current) => ({
...current,
sggCd: value,
swtrmtFcltNm: "",
}));
}}
setSggName={(value) => {
setSelectedSwagePrcsFclt("0");
setFilters((current) => ({
...current,
sggName: value ?? "",
swtrmtFcltNm: "",
}));
}}
selectedSwagePrcsFclt={selectedSwagePrcsFclt}
onSwagePrcsFcltChange={setSelectedSwagePrcsFclt}
setSwtrmtFcltNm={(value) =>
setFilters((current) => ({
...current,
swtrmtFcltNm: value ?? "",
}))
}
setIsLoading={setIsLoading}
/>
</div>
<div className="ml-auto flex items-center gap-2">
<Button
type="button"
size="sm"
color="primary"
className="h-10 px-5 !text-[1.1rem]"
onClick={() => void handleSearch(1)}
>
조회
</Button>
<Button
type="button"
color="green"
size="sm"
className="h-10 px-5 !text-[1.1rem]"
onClick={() => void handleDownload()}
>
다운로드
</Button>
</div>
</div>
</div>
<div className="mt-4">
<UiTable<InftwtResultRow>
headName={<>{`분석이력 총 ${total.toLocaleString()}건`}</>}
tableData={rows}
headlist={INFTWT_RESULT_HEADLIST as unknown as { id: string; title: string }[]}
pageSize={INFTWT_RESULT_PAGE_SIZE}
total={total}
cellClick={(index) => handleRowClick(index)}
cellClassName={(_, headId) =>
headId === "anlsInfo"
? "whitespace-normal break-all leading-[1.35]"
: ""
}
onPageChange={(page) => void handleSearch(page)}
curPage={currentPage}
index={selectedIndex}
widthFixed={true}
<PopupSearchPanel contentClassName="flex flex-wrap items-center gap-x-5 gap-y-3">
<div className="flex shrink-0 items-center gap-2">
<span className="whitespace-nowrap text-[1.3rem] font-medium text-label">
상수사용기간
</span>
<DateMonthPicker
field={{
value: filters.aplcnYmFrom,
onChange: (value: string) => {
setFilters((current) => ({
...current,
aplcnYmFrom: value,
}));
},
}}
selectClass="w-[145px] !text-[1.3rem]"
startYear={1900}
/>
<span className="text-[1.1rem] font-semibold text-muted-foreground">
~
</span>
<DateMonthPicker
field={{
value: filters.aplcnYmTo,
onChange: (value: string) => {
setFilters((current) => ({
...current,
aplcnYmTo: value,
}));
},
}}
selectClass="w-[145px] !text-[1.3rem]"
startYear={1900}
/>
</div>
{selectedRow ? (
<div className="mt-3 rounded-md border border-border bg-secondary px-4 py-3 text-[1rem] text-label">
{"선택 분석: "}
<span className="font-semibold text-foreground">{selectedRow.anlsInfo}</span>
</div>
) : null}
</section>
</main>
<div className="min-w-0 shrink-0">
<CtpvSggPrcsFcltSelectBox
compact
className="!justify-start"
selectValueClassName="!text-[1.3rem]"
sido={filters.ctpvCd}
sigungu={filters.sggCd}
sggName={filters.sggName}
onSidoChange={(value) => {
setSelectedSwagePrcsFclt("0");
setFilters((current) => ({
...current,
ctpvCd: value,
sggCd: "0",
sggName: "",
swtrmtFcltNm: "",
}));
}}
onSigunguChange={(value) => {
setSelectedSwagePrcsFclt("0");
setFilters((current) => ({
...current,
sggCd: value,
swtrmtFcltNm: "",
}));
}}
setSggName={(value) => {
setSelectedSwagePrcsFclt("0");
setFilters((current) => ({
...current,
sggName: value ?? "",
swtrmtFcltNm: "",
}));
}}
selectedSwagePrcsFclt={selectedSwagePrcsFclt}
onSwagePrcsFcltChange={setSelectedSwagePrcsFclt}
setSwtrmtFcltNm={(value) =>
setFilters((current) => ({
...current,
swtrmtFcltNm: value ?? "",
}))
}
setIsLoading={setIsLoading}
/>
</div>
<div className="ml-auto flex shrink-0 items-center justify-end gap-2">
<Button
type="button"
color="primary"
onClick={() => void handleSearch(1)}
>
조회
</Button>
<Button
type="button"
color="white"
onClick={() => void handleReset()}
>
초기화
</Button>
</div>
</PopupSearchPanel>
<MapPopupDataTable
rows={rows as Array<InftwtResultRow & Record<string, unknown>>}
headlist={INFTWT_RESULT_HEADLIST}
title="침입수 분석결과 목록"
total={total}
page={currentPage}
pageSize={INFTWT_RESULT_PAGE_SIZE}
onPageChange={(page) => void handleSearch(page)}
onRowClick={(_, index) => handleRowClick(index)}
rowId={(row) => String(row.inftwtAnlsSn)}
scrollMode="horizontal"
minTableWidth="110rem"
actions={
<Button type="button" onClick={() => void handleDownload()}>
엑셀다운로드
</Button>
}
/>
{selectedRow ? (
<div className="rounded-sm border border-border bg-secondary px-4 py-3 text-[1rem] text-label">
{"선택 분석: "}
<span className="font-semibold text-foreground">
{selectedRow.anlsInfo}
</span>
</div>
) : null}
</StandardPopupLayout>
);
}
@@ -11,6 +11,7 @@ import { intersects as intersectsExtent } from "ol/extent";
import { getRawJson } from "@/components/lib/api";
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
import { useAlertStore } from "@/shared/lib";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import { getSigunguCenter, moveMapToCenter, sidoCenter } from "@common/business_components/ui/map/searchBar/utils";
@@ -194,6 +195,7 @@ export const useInftwtResultMap = ({
setIsOpen,
setIsSearchModalOpen,
}: UseInftwtResultMapParams) => {
const alert = useAlertStore((state) => state.setMessage);
const activeResultRowRef = useRef<InftwtResultRow | null>(null);
const zoneLayerRef = useRef<VectorLayer<any> | null>(null);
const zoneSourceRef = useRef<VectorSource | null>(null);
@@ -433,10 +435,10 @@ export const useInftwtResultMap = ({
void renderMapView(mapView);
} catch (error) {
console.error("移⑥엯??遺꾩꽍寃곌낵 吏€???곌퀎 以??ㅻ쪟:", error);
alert("移⑥엯??遺꾩꽍寃곌낵瑜?吏€?꾩뿉 ?곌퀎?섏? 紐삵뻽?듬땲??");
alert("침입수 분석결과를 지도에 연계하지 못했습니다.");
}
},
[renderMapView, setFeatureData, setIsOpen, setIsSearchModalOpen],
[alert, renderMapView, setFeatureData, setIsOpen, setIsSearchModalOpen],
);
useEffect(() => {
@@ -494,11 +496,11 @@ export const useInftwtResultMap = ({
});
if (!popupWindow) {
alert("?앹뾽??李⑤떒?섏뿀?듬땲?? 釉뚮씪?곗? ?앹뾽 ?덉슜 ?ㅼ젙???뺤씤?댁<?몄슂.");
alert("팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.");
}
return true;
}, [activeMapView]);
}, [activeMapView, alert]);
return {
activeMapView,
@@ -3,11 +3,15 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Button, Input } from "@common/components";
import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
import { StandardPopupLayout } from "@common/business_components";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { getData } from "@/components/lib/api";
import LoadingOverlay from "@/components/ui/LoadingBar";
import useStore from "@/store/useStore";
import { useAlertStore } from "@/shared/lib";
import { useUserStore } from "@/shared/lib/use-user-store";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import {
PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS,
PIPE_ANALYSIS_DETAIL_EXCEL_HEADERS,
@@ -28,6 +32,7 @@ import {
} from "./pipeAnalysisUtils";
type DetailViewRow = ReturnType<typeof toPipeAnalysisDetailViewRows>[number];
const PIPE_ANALYSIS_DETAIL_PAGE_SIZE = 10;
const PIPE_ANALYSIS_DETAIL_EXPORT_KEYS = [
"rowNo",
"ctpvNm",
@@ -41,11 +46,15 @@ const PIPE_ANALYSIS_DETAIL_EXPORT_KEYS = [
"prlgVl",
"materialName",
] as const;
const PIPE_ANALYSIS_DETAIL_STANDARD_HEADLIST = PIPE_ANALYSIS_DETAIL_HEADLIST;
const resolveErrorMessage = (error: unknown, fallbackMessage: string) =>
error instanceof Error && error.message.trim() ? error.message : fallbackMessage;
error instanceof Error && error.message.trim()
? error.message
: fallbackMessage;
export default function PipeAnalysisDetailPopupPage() {
const alert = useAlertStore((state) => state.setMessage);
const searchParams = useSearchParams();
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const userInfo = useUserStore((state) => state.userInfo);
@@ -58,18 +67,42 @@ export default function PipeAnalysisDetailPopupPage() {
applyScopeToDetailFilters(
{
...PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS,
ctpvCd: searchParams.get("ctpvCd") ?? PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.ctpvCd,
sggCd: searchParams.get("sggCd") ?? PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.sggCd,
swtrmtZoneNm: searchParams.get("swtrmtZoneNm") ?? PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.swtrmtZoneNm,
ctpvCd:
searchParams.get("ctpvCd") ??
PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.ctpvCd,
sggCd:
searchParams.get("sggCd") ??
PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.sggCd,
swtrmtZoneNm:
searchParams.get("swtrmtZoneNm") ??
PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.swtrmtZoneNm,
materialCode: baseMaterialCode,
},
scope,
),
);
const [appliedFilters, setAppliedFilters] =
useState<PipeAnalysisDetailFilters>(() =>
applyScopeToDetailFilters(
{
...PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS,
ctpvCd:
searchParams.get("ctpvCd") ??
PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.ctpvCd,
sggCd:
searchParams.get("sggCd") ??
PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.sggCd,
swtrmtZoneNm:
searchParams.get("swtrmtZoneNm") ??
PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.swtrmtZoneNm,
materialCode: baseMaterialCode,
},
scope,
),
);
const [rows, setRows] = useState<DetailViewRow[]>([]);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [isLoading, setIsLoading] = useState(false);
const initializedRef = useRef(false);
@@ -80,10 +113,15 @@ export default function PipeAnalysisDetailPopupPage() {
useEffect(() => {
setFilters((current) => applyScopeToDetailFilters(current, scope));
setAppliedFilters((current) => applyScopeToDetailFilters(current, scope));
}, [scope]);
const fetchRows = useCallback(
async (page: number, nextFilters: PipeAnalysisDetailFilters, size: number) => {
async (
page: number,
nextFilters: PipeAnalysisDetailFilters,
size: number,
) => {
const params = new URLSearchParams({
analysisType: baseAnalysisType,
page: String(page - 1),
@@ -92,19 +130,30 @@ export default function PipeAnalysisDetailPopupPage() {
if (nextFilters.ctpvCd !== "0") params.set("ctpvCd", nextFilters.ctpvCd);
if (nextFilters.sggCd !== "0") params.set("sggCd", nextFilters.sggCd);
if (nextFilters.swtrmtZoneNm !== "0") params.set("swtrmtZoneNm", nextFilters.swtrmtZoneNm);
if (baseElapsedYearBucket) params.set("elapsedYearBucket", baseElapsedYearBucket);
if (nextFilters.materialCode !== "0") params.set("materialCode", nextFilters.materialCode);
if (nextFilters.swtrmtZoneNm !== "0")
params.set("swtrmtZoneNm", nextFilters.swtrmtZoneNm);
if (baseElapsedYearBucket)
params.set("elapsedYearBucket", baseElapsedYearBucket);
if (nextFilters.materialCode !== "0")
params.set("materialCode", nextFilters.materialCode);
if (nextFilters.useCd !== "0") params.set("useCd", nextFilters.useCd);
if (nextFilters.diameterFrom) params.set("diameterFrom", nextFilters.diameterFrom);
if (nextFilters.diameterTo) params.set("diameterTo", nextFilters.diameterTo);
if (nextFilters.diameterFrom)
params.set("diameterFrom", nextFilters.diameterFrom);
if (nextFilters.diameterTo)
params.set("diameterTo", nextFilters.diameterTo);
if (scope.regionCode) params.set("scopeRegionCode", scope.regionCode);
const response = await getData(`v1/fclts/ppl-analysis/details?${params.toString()}`);
const response = await getData(
`v1/fclts/ppl-analysis/details?${params.toString()}`,
);
const responseRows = Array.isArray(response?.data) ? response.data : [];
return {
rows: toPipeAnalysisDetailViewRows(responseRows as PipeAnalysisDetailRow[], ctpv, sgg),
rows: toPipeAnalysisDetailViewRows(
responseRows as PipeAnalysisDetailRow[],
ctpv,
sgg,
),
total: Number(response?.count ?? 0),
};
},
@@ -115,40 +164,48 @@ export default function PipeAnalysisDetailPopupPage() {
async (page = 1, nextFilters = filters) => {
setIsLoading(true);
try {
const response = await fetchRows(page, nextFilters, 20);
const response = await fetchRows(
page,
nextFilters,
PIPE_ANALYSIS_DETAIL_PAGE_SIZE,
);
setRows(response.rows);
setTotal(response.total);
setCurrentPage(page);
setAppliedFilters(nextFilters);
} catch (error) {
console.error("관로현황 조회 중 오류:", error);
alert(resolveErrorMessage(error, "관로현황 조회 중 오류가 발생했습니다."));
alert(
resolveErrorMessage(error, "관로현황 조회 중 오류가 발생했습니다."),
);
setRows([]);
setTotal(0);
} finally {
setIsLoading(false);
}
},
[fetchRows, filters],
[alert, fetchRows, filters],
);
useEffect(() => {
if (initializedRef.current) return;
if (ctpv.length === 0 || sgg.length === 0) return;
initializedRef.current = true;
void loadRows(1, applyScopeToDetailFilters(filters, scope));
const initialFilters = applyScopeToDetailFilters(filters, scope);
void loadRows(1, initialFilters);
}, [ctpv.length, filters, loadRows, scope, sgg.length]);
const handleSearch = (page = 1) => {
setSelectedIndex(-1);
void loadRows(page, applyScopeToDetailFilters(filters, scope));
const handleSearch = () => {
const nextFilters = applyScopeToDetailFilters(filters, scope);
void loadRows(1, nextFilters);
};
const handleRowClick = (index?: number) => {
if (typeof index !== "number") return;
const row = rows[index];
if (!row) return;
/** Keeps the last searched filters when the user moves pages. */
const handlePageChange = (page: number) => {
void loadRows(page, appliedFilters);
};
setSelectedIndex(index);
const handleRowClick = (row: DetailViewRow) => {
const targetWindow = window.opener?.opener ?? window.opener;
targetWindow?.postMessage(
{
@@ -167,7 +224,11 @@ export default function PipeAnalysisDetailPopupPage() {
try {
setIsLoading(true);
const response = await fetchRows(1, filters, Math.max(total, 1000));
const response = await fetchRows(
1,
appliedFilters,
Math.max(total, 1000),
);
const exportRows = response.rows.map((row) => ({
rowNo: row.rowNo,
ctpvNm: row.ctpvNm,
@@ -179,7 +240,8 @@ export default function PipeAnalysisDetailPopupPage() {
useNm: row.useNm,
pdmtVl: row.pdmtVl,
prlgVl: row.prlgVl,
materialName: resolveMaterialKoreanName(row.materialCode) || row.materialName,
materialName:
resolveMaterialKoreanName(row.materialCode) || row.materialName,
}));
const detailHeaderRange = `A1:${String.fromCharCode(64 + PIPE_ANALYSIS_DETAIL_EXPORT_KEYS.length)}1`;
await downloadExcelWithKoreanHeaders({
@@ -191,7 +253,9 @@ export default function PipeAnalysisDetailPopupPage() {
});
} catch (error) {
console.error("관로현황 다운로드 중 오류:", error);
alert(resolveErrorMessage(error, "관로현황 다운로드 중 오류가 발생했습니다."));
alert(
resolveErrorMessage(error, "관로현황 다운로드 중 오류가 발생했습니다."),
);
} finally {
setIsLoading(false);
}
@@ -228,142 +292,187 @@ export default function PipeAnalysisDetailPopupPage() {
const titleSuffix = useMemo(() => {
const locationParts: string[] = [];
const resolvedCtpvName = rows[0]?.ctpvNm || resolveRegionName(filters.ctpvCd, ctpv, 2);
const resolvedSggName = rows[0]?.sggNm || resolveRegionName(filters.sggCd, sgg, 5);
const resolvedCtpvName =
rows[0]?.ctpvNm || resolveRegionName(filters.ctpvCd, ctpv, 2);
const resolvedSggName =
rows[0]?.sggNm || resolveRegionName(filters.sggCd, sgg, 5);
const resolvedZoneName =
(filters.swtrmtZoneNm && filters.swtrmtZoneNm !== "0" ? filters.swtrmtZoneNm : rows[0]?.swtrmtZoneNm) || "";
(filters.swtrmtZoneNm && filters.swtrmtZoneNm !== "0"
? filters.swtrmtZoneNm
: rows[0]?.swtrmtZoneNm) || "";
if (resolvedCtpvName && resolvedCtpvName !== "-") locationParts.push(resolvedCtpvName);
if (resolvedSggName && resolvedSggName !== "-") locationParts.push(resolvedSggName);
if (resolvedZoneName && resolvedZoneName !== "-" && resolvedZoneName !== "0") locationParts.push(resolvedZoneName);
if (resolvedCtpvName && resolvedCtpvName !== "-")
locationParts.push(resolvedCtpvName);
if (resolvedSggName && resolvedSggName !== "-")
locationParts.push(resolvedSggName);
if (
resolvedZoneName &&
resolvedZoneName !== "-" &&
resolvedZoneName !== "0"
)
locationParts.push(resolvedZoneName);
const parts = [];
if (locationParts.length > 0) parts.push(locationParts.join(" "));
const analysisTypeLabel = resolvePipeAnalysisTypeLabel(baseAnalysisType);
if (analysisTypeLabel) parts.push(analysisTypeLabel);
if (baseElapsedYearBucket) parts.push(resolveElapsedYearBucketLabel(baseElapsedYearBucket));
if (baseMaterialCode && baseMaterialCode !== "0") parts.push(resolveMaterialName(baseMaterialCode));
if (baseElapsedYearBucket)
parts.push(resolveElapsedYearBucketLabel(baseElapsedYearBucket));
if (baseMaterialCode && baseMaterialCode !== "0")
parts.push(resolveMaterialName(baseMaterialCode));
return parts.length > 0 ? ` (${parts.join(" / ")})` : "";
}, [baseAnalysisType, baseElapsedYearBucket, baseMaterialCode, ctpv, filters.ctpvCd, filters.sggCd, filters.swtrmtZoneNm, rows, sgg]);
}, [
baseAnalysisType,
baseElapsedYearBucket,
baseMaterialCode,
ctpv,
filters.ctpvCd,
filters.sggCd,
filters.swtrmtZoneNm,
rows,
sgg,
]);
return (
<main className="sn-popup-page">
<StandardPopupLayout
title={`관로현황 목록${titleSuffix}`}
bodyClassName="overflow-x-hidden"
>
<LoadingOverlay isLoading={isLoading} />
<header className="sn-popup-header">
<h1 className="sn-popup-title">관로현황{titleSuffix}</h1>
</header>
<section className="sn-popup-section overflow-y-auto">
<div className="grid grid-cols-1 gap-3 border-b border-border bg-secondary pb-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_110px_110px_auto] md:items-end">
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-label">용도</div>
<select
className="sn-popup-input !text-[1.1rem]"
value={filters.useCd}
onChange={(event) =>
setFilters((current) => ({
...current,
useCd: event.target.value,
}))
}
>
<option value="0">전체</option>
<option value="SBA001">합류관</option>
<option value="SBA002">차집관</option>
<option value="SBA003">우수관</option>
<option value="SBA004">오수관</option>
</select>
</label>
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-label">관재질</div>
<select
className="sn-popup-input !text-[1.1rem]"
value={filters.materialCode}
onChange={(event) =>
setFilters((current) => ({
...current,
materialCode: event.target.value,
}))
}
>
{materialOptions.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</label>
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-label">관경 시작</div>
<Input
className="sn-popup-input !text-[1.1rem]"
value={filters.diameterFrom}
onChange={(event) =>
setFilters((current) => ({
...current,
diameterFrom: normalizeNumberInput(event.target.value),
}))
}
onKeyDown={(event) => {
if (event.key === "Enter") handleSearch(1);
}}
/>
</label>
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-label">관경 종료</div>
<Input
className="sn-popup-input !text-[1.1rem]"
value={filters.diameterTo}
onChange={(event) =>
setFilters((current) => ({
...current,
diameterTo: normalizeNumberInput(event.target.value),
}))
}
onKeyDown={(event) => {
if (event.key === "Enter") handleSearch(1);
}}
/>
</label>
<div className="flex flex-wrap justify-end gap-2 md:pr-3">
<Button
type="button"
size="sm"
color="primary"
className="h-10 px-5 !text-[1.1rem]"
onClick={() => handleSearch(1)}
>
조회
</Button>
<Button
type="button"
size="sm"
color="green"
className="h-10 px-5 !text-[1.1rem]"
onClick={() => void handleDownload()}
>
다운로드
</Button>
<PopupSearchPanel contentClassName="flex flex-wrap items-center gap-x-5 gap-y-3">
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
용도
</div>
</div>
<select
className="sn-popup-input w-[14rem] !h-[3.2rem] !text-xl"
value={filters.useCd}
onChange={(event) =>
setFilters((current) => ({
...current,
useCd: event.target.value,
}))
}
>
<option value="0">전체</option>
<option value="SBA001">합류관</option>
<option value="SBA002">차집관</option>
<option value="SBA003">우수관</option>
<option value="SBA004">오수관</option>
</select>
</label>
<UiTable<DetailViewRow>
headName={<>관로현황 조회 결과 총 {total.toLocaleString()}건</>}
tableData={rows}
headlist={PIPE_ANALYSIS_DETAIL_HEADLIST}
pageSize={20}
total={total}
cellClick={(index) => handleRowClick(index)}
onPageChange={(page) => handleSearch(page)}
curPage={currentPage}
index={selectedIndex}
widthFixed={true}
/>
</section>
</main>
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
관재질
</div>
<select
className="sn-popup-input w-[14rem] !h-[3.2rem] !text-xl"
value={filters.materialCode}
onChange={(event) =>
setFilters((current) => ({
...current,
materialCode: event.target.value,
}))
}
>
{materialOptions.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</label>
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
관경 시작
</div>
<Input
className="sn-popup-input w-[12rem] !h-[3.2rem] !text-xl"
value={filters.diameterFrom}
onChange={(event) =>
setFilters((current) => ({
...current,
diameterFrom: normalizeNumberInput(event.target.value),
}))
}
onKeyDown={(event) => {
if (event.key === "Enter") handleSearch();
}}
/>
</label>
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
관경 종료
</div>
<Input
className="sn-popup-input w-[12rem] !h-[3.2rem] !text-xl"
value={filters.diameterTo}
onChange={(event) =>
setFilters((current) => ({
...current,
diameterTo: normalizeNumberInput(event.target.value),
}))
}
onKeyDown={(event) => {
if (event.key === "Enter") handleSearch();
}}
/>
</label>
<div className="flex min-w-fit flex-1 items-center justify-end gap-2">
<Button type="button" color="primary" onClick={handleSearch}>
조회
</Button>
<Button
type="button"
color="white"
onClick={() => {
const resetFilters = applyScopeToDetailFilters(
{
...PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS,
ctpvCd:
searchParams.get("ctpvCd") ??
PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.ctpvCd,
sggCd:
searchParams.get("sggCd") ??
PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.sggCd,
swtrmtZoneNm:
searchParams.get("swtrmtZoneNm") ??
PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.swtrmtZoneNm,
materialCode: baseMaterialCode,
},
scope,
);
setFilters(resetFilters);
void loadRows(1, resetFilters);
}}
>
초기화
</Button>
</div>
</PopupSearchPanel>
<MapPopupDataTable
rows={rows as Array<DetailViewRow & Record<string, unknown>>}
headlist={PIPE_ANALYSIS_DETAIL_STANDARD_HEADLIST}
title="관로현황 목록"
total={total}
page={currentPage}
pageSize={PIPE_ANALYSIS_DETAIL_PAGE_SIZE}
onPageChange={handlePageChange}
onRowClick={handleRowClick}
rowId={(row, index) => String(row.fid ?? row.fcltMngNo ?? index)}
scrollMode="horizontal"
minTableWidth="150rem"
actions={
<Button type="button" onClick={() => void handleDownload()}>
엑셀다운로드
</Button>
}
/>
</StandardPopupLayout>
);
}
@@ -1,14 +1,27 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button, Table, TableBody, TableCell, TableHeader, TableHead, TableRow } from "@common/components";
import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
import {
Button,
Table,
TableBody,
TableCell,
TableHeader,
TableHead,
TableRow,
} from "@common/components";
import { StandardPopupLayout } from "@common/business_components";
import UiPageNations from "@common/business_components/ui/table/uiPageNations";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { getData } from "@/components/lib/api";
import LoadingOverlay from "@/components/ui/LoadingBar";
import useStore from "@/store/useStore";
import { useAlertStore } from "@/shared/lib";
import { useUserStore } from "@/shared/lib/use-user-store";
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefinitions";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import {
PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS,
PIPE_ANALYSIS_TYPE_OPTIONS,
@@ -60,21 +73,30 @@ const formatGridNumber = (value: unknown) => {
};
const resolveErrorMessage = (error: unknown, fallbackMessage: string) =>
error instanceof Error && error.message.trim() ? error.message : fallbackMessage;
error instanceof Error && error.message.trim()
? error.message
: fallbackMessage;
export default function PipeAnalysisPopupPage() {
const alert = useAlertStore((state) => state.setMessage);
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const userInfo = useUserStore((state) => state.userInfo);
const scope = useMemo(() => buildPipeAnalysisScope(userInfo), [userInfo]);
const [draftFilters, setDraftFilters] = useState<PipeAnalysisSummaryFilters>(() =>
applyScopeToSummaryFilters(PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS, scope),
const [draftFilters, setDraftFilters] = useState<PipeAnalysisSummaryFilters>(
() =>
applyScopeToSummaryFilters(PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS, scope),
);
const [appliedFilters, setAppliedFilters] = useState<PipeAnalysisSummaryFilters>(() =>
applyScopeToSummaryFilters(PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS, scope),
);
const [materialColumns, setMaterialColumns] = useState<PipeAnalysisMaterialColumn[]>([]);
const [appliedFilters, setAppliedFilters] =
useState<PipeAnalysisSummaryFilters>(() =>
applyScopeToSummaryFilters(PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS, scope),
);
const [materialColumns, setMaterialColumns] = useState<
PipeAnalysisMaterialColumn[]
>([]);
const [rows, setRows] = useState<PipeAnalysisSummaryViewRow[]>([]);
const [zoneOptions, setZoneOptions] = useState<Array<{ value: string; label: string }>>([]);
const [zoneOptions, setZoneOptions] = useState<
Array<{ value: string; label: string }>
>([]);
const [summaryCurrentPage, setSummaryCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const initializedRef = useRef(false);
@@ -121,8 +143,10 @@ export default function PipeAnalysisPopupPage() {
if (nextFilters.ctpvCd !== "0") params.set("ctpvCd", nextFilters.ctpvCd);
if (nextFilters.sggCd !== "0") params.set("sggCd", nextFilters.sggCd);
if (nextFilters.swtrmtZoneNm !== "0") params.set("swtrmtZoneNm", nextFilters.swtrmtZoneNm);
if (nextFilters.scopeRegionCode) params.set("scopeRegionCode", nextFilters.scopeRegionCode);
if (nextFilters.swtrmtZoneNm !== "0")
params.set("swtrmtZoneNm", nextFilters.swtrmtZoneNm);
if (nextFilters.scopeRegionCode)
params.set("scopeRegionCode", nextFilters.scopeRegionCode);
const response = (await getData(
`v1/fclts/ppl-analysis/summaries?${params.toString()}`,
@@ -148,14 +172,19 @@ export default function PipeAnalysisPopupPage() {
await fetchSummary(scopedFilters);
} catch (error) {
console.error("관로분석 집계 조회 중 오류:", error);
alert(resolveErrorMessage(error, "관로분석 집계 조회 중 오류가 발생했습니다."));
alert(
resolveErrorMessage(
error,
"관로분석 집계 조회 중 오류가 발생했습니다.",
),
);
setRows([]);
setMaterialColumns([]);
} finally {
setIsLoading(false);
}
},
[draftFilters, fetchSummary, scope],
[alert, draftFilters, fetchSummary, scope],
);
useEffect(() => {
@@ -178,81 +207,98 @@ export default function PipeAnalysisPopupPage() {
materialColumns,
);
const headerRange = `A1:${toExcelColumnName(exportHeadList.length)}1`;
const mergeRanges = appliedFilters.analysisType === "YEAR_MATERIAL"
? (() => {
const ranges: string[] = [];
let currentRow = 2;
const mergeRanges =
appliedFilters.analysisType === "YEAR_MATERIAL"
? (() => {
const ranges: string[] = [];
let currentRow = 2;
yearMaterialGroups.forEach((group) => {
const rowSpan = group.rows.length;
if (rowSpan > 1) {
["A", "B", "C", "D", "E"].forEach((column) => {
ranges.push(`${column}${currentRow}:${column}${currentRow + rowSpan - 1}`);
orderedYearMaterialGroups.forEach((group) => {
const rowSpan = group.rows.length;
if (rowSpan > 1) {
["A", "B", "C", "D", "E"].forEach((column) => {
ranges.push(
`${column}${currentRow}:${column}${currentRow + rowSpan - 1}`,
);
});
}
currentRow += rowSpan;
});
}
currentRow += rowSpan;
});
return ranges;
})()
: [];
const exportRows = appliedFilters.analysisType === "YEAR_MATERIAL"
? yearMaterialGroups.flatMap((group) =>
group.rows.map((yearRow, rowIndex) => {
const exportRow: Record<string, unknown> = {
rowNo: rowIndex === 0 ? group.rowNo : "",
ctpvNm: rowIndex === 0 ? group.ctpvNm : "",
sggNm: rowIndex === 0 ? group.sggNm : "",
swtrmtZoneNm: rowIndex === 0 ? group.swtrmtZoneNm : "",
totalLengthKm: rowIndex === 0 ? group.totalLengthKm : "",
elapsedYearLabel: yearRow.elapsedYearLabel,
};
return ranges;
})()
: [];
const exportRows =
appliedFilters.analysisType === "YEAR_MATERIAL"
? orderedYearMaterialGroups.flatMap((group) =>
group.rows.map((yearRow, rowIndex) => {
const exportRow: Record<string, unknown> = {
rowNo: rowIndex === 0 ? group.rowNo : "",
ctpvNm: rowIndex === 0 ? group.ctpvNm : "",
sggNm: rowIndex === 0 ? group.sggNm : "",
swtrmtZoneNm: rowIndex === 0 ? group.swtrmtZoneNm : "",
totalLengthKm: rowIndex === 0 ? group.totalLengthKm : "",
elapsedYearLabel: yearRow.elapsedYearLabel,
};
materialColumns.forEach((column) => {
exportRow[column.key] = yearRow.metrics[column.key] ?? 0;
materialColumns.forEach((column) => {
exportRow[column.key] = yearRow.metrics[column.key] ?? 0;
});
return exportRow;
}),
)
: rows.map((row) => {
const exportRow: Record<string, unknown> = {};
exportHeadList.forEach((head) => {
exportRow[head.id] = row[head.id];
});
return exportRow;
});
return exportRow;
}),
)
: rows.map((row) => {
const exportRow: Record<string, unknown> = {};
exportHeadList.forEach((head) => {
exportRow[head.id] = row[head.id];
});
return exportRow;
});
await downloadExcelWithKoreanHeaders({
data: exportRows as unknown as JSON[],
fileName: `${SUMMARY_FILE_PREFIX[appliedFilters.analysisType]}_${buildPipeAnalysisFileDate()}`,
sheetNameArray: ["관로분석 현황"],
headerRange,
headerMapping: buildPipeAnalysisSummaryDownloadHeaders(appliedFilters.analysisType, materialColumns),
headerMapping: buildPipeAnalysisSummaryDownloadHeaders(
appliedFilters.analysisType,
materialColumns,
),
mergeRanges,
});
} catch (error) {
console.error("관로분석 집계 다운로드 중 오류:", error);
alert(resolveErrorMessage(error, "관로분석 집계 다운로드 중 오류가 발생했습니다."));
alert(
resolveErrorMessage(
error,
"관로분석 집계 다운로드 중 오류가 발생했습니다.",
),
);
}
};
const handleCellClick = (index?: number, columnId?: string) => {
if (typeof index !== "number" || !columnId) return;
const row = rows[index];
if (!row) return;
const handleCellClick = (
row: PipeAnalysisSummaryViewRow,
columnId: string,
) => {
if (row.__isNoZone || row.__isSummaryTotal) return;
const cellFilter = row.__cellFilters[columnId];
if (!cellFilter) return;
openWindowPopup("pipeAnalysisDetailPopup", {
const popup = openWindowPopup("pipeAnalysisDetailPopup", {
analysisType: appliedFilters.analysisType,
ctpvCd: String(row.__base.ctpvCd || appliedFilters.ctpvCd),
sggCd: String(row.__base.sggCd || appliedFilters.sggCd || "0"),
swtrmtZoneNm: String(row.__base.swtrmtZoneNm || appliedFilters.swtrmtZoneNm),
swtrmtZoneNm: String(
row.__base.swtrmtZoneNm || appliedFilters.swtrmtZoneNm,
),
elapsedYearBucket: cellFilter.elapsedYearBucket ?? "",
materialCode: cellFilter.materialCode ?? "",
scopeRegionCode: scope.regionCode,
});
if (!popup) {
alert("팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.");
}
};
const handleYearMaterialCellClick = (
@@ -263,302 +309,374 @@ export default function PipeAnalysisPopupPage() {
const cellFilter = row.cellFilters[columnId];
if (!cellFilter) return;
openWindowPopup("pipeAnalysisDetailPopup", {
const popup = openWindowPopup("pipeAnalysisDetailPopup", {
analysisType: appliedFilters.analysisType,
ctpvCd: String(group.base.ctpvCd || appliedFilters.ctpvCd),
sggCd: String(group.base.sggCd || appliedFilters.sggCd || "0"),
swtrmtZoneNm: String(group.base.swtrmtZoneNm || appliedFilters.swtrmtZoneNm),
swtrmtZoneNm: String(
group.base.swtrmtZoneNm || appliedFilters.swtrmtZoneNm,
),
elapsedYearBucket: cellFilter.elapsedYearBucket ?? "",
materialCode: cellFilter.materialCode ?? "",
scopeRegionCode: scope.regionCode,
});
if (!popup) {
alert("팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.");
}
};
const headList = useMemo(
() => buildPipeAnalysisSummaryHeadList(appliedFilters.analysisType, materialColumns),
() =>
buildPipeAnalysisSummaryHeadList(
appliedFilters.analysisType,
materialColumns,
),
[appliedFilters.analysisType, materialColumns],
);
const yearMaterialGroups = useMemo(
() => appliedFilters.analysisType === "YEAR_MATERIAL"
? buildPipeAnalysisYearMaterialGroups(rows, materialColumns)
: [],
() =>
appliedFilters.analysisType === "YEAR_MATERIAL"
? buildPipeAnalysisYearMaterialGroups(rows, materialColumns)
: [],
[appliedFilters.analysisType, materialColumns, rows],
);
const orderedYearMaterialGroups = useMemo(
() =>
yearMaterialGroups.map((group, index) => ({
...group,
rowNo: yearMaterialGroups.length - index,
})),
[yearMaterialGroups],
);
const useHorizontalScrollTable = appliedFilters.analysisType !== "YEAR";
const summaryPageOffset = (summaryCurrentPage - 1) * PIPE_ANALYSIS_SUMMARY_PAGE_SIZE;
const summaryPageOffset =
(summaryCurrentPage - 1) * PIPE_ANALYSIS_SUMMARY_PAGE_SIZE;
const pagedRows = useMemo(
() =>
appliedFilters.analysisType === "YEAR_MATERIAL"
? rows
: rows.slice(summaryPageOffset, summaryPageOffset + PIPE_ANALYSIS_SUMMARY_PAGE_SIZE),
: rows.slice(
summaryPageOffset,
summaryPageOffset + PIPE_ANALYSIS_SUMMARY_PAGE_SIZE,
),
[appliedFilters.analysisType, rows, summaryPageOffset],
);
const clickableHeadIds = useMemo(
() =>
headList
.filter((head) =>
rows.some((row) => Boolean(row.__cellFilters[head.id])),
)
.map((head) => head.id),
[headList, rows],
);
const visibleYearMaterialGroups = useMemo(
() =>
yearMaterialGroups.slice(
orderedYearMaterialGroups.slice(
summaryPageOffset,
summaryPageOffset + PIPE_ANALYSIS_SUMMARY_PAGE_SIZE,
),
[summaryPageOffset, yearMaterialGroups],
[orderedYearMaterialGroups, summaryPageOffset],
);
const summaryTotalCount = appliedFilters.analysisType === "YEAR_MATERIAL"
? yearMaterialGroups.length
: rows.length;
const sggOptions = useMemo(
() =>
draftFilters.ctpvCd === "0"
? sgg
: sgg.filter((item) => String(item.val).startsWith(draftFilters.ctpvCd.slice(0, 2))),
: sgg.filter((item) =>
String(item.val).startsWith(draftFilters.ctpvCd.slice(0, 2)),
),
[draftFilters.ctpvCd, sgg],
);
return (
<main className="sn-popup-page">
<StandardPopupLayout
title={windowPopupDefinitions.pipeAnalysisPopup.title}
bodyClassName="overflow-x-hidden"
>
<LoadingOverlay isLoading={isLoading} />
<header className="sn-popup-header">
<h1 className="sn-popup-title">관로분석</h1>
</header>
<PopupSearchPanel contentClassName="flex flex-wrap items-center gap-x-5 gap-y-3">
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
시도
</div>
<select
className="sn-popup-input !h-[3.2rem] w-[14rem] !text-xl"
value={draftFilters.ctpvCd}
disabled={scope.ctpvLocked}
onChange={(event) =>
setDraftFilters((current) => ({
...current,
ctpvCd: event.target.value,
sggCd: "0",
swtrmtZoneNm: "0",
}))
}
>
<option value="0">전체</option>
{ctpv.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<section className="sn-popup-section overflow-y-auto">
<div className="grid grid-cols-1 gap-3 border-b border-border bg-secondary pb-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_auto] md:items-end">
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-label">시도</div>
<select
className="sn-popup-input !text-[1.1rem]"
value={draftFilters.ctpvCd}
disabled={scope.ctpvLocked}
onChange={(event) =>
setDraftFilters((current) => ({
...current,
ctpvCd: event.target.value,
sggCd: "0",
swtrmtZoneNm: "0",
}))
}
>
<option value="0">전체</option>
{ctpv.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
시군구
</div>
<select
className="sn-popup-input !h-[3.2rem] w-[14rem] !text-xl"
value={draftFilters.sggCd}
disabled={scope.sggLocked}
onChange={(event) =>
setDraftFilters((current) => ({
...current,
sggCd: event.target.value,
swtrmtZoneNm: "0",
}))
}
>
<option value="0">전체</option>
{sggOptions.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-label">시군구</div>
<select
className="sn-popup-input !text-[1.1rem]"
value={draftFilters.sggCd}
disabled={scope.sggLocked}
onChange={(event) =>
setDraftFilters((current) => ({
...current,
sggCd: event.target.value,
swtrmtZoneNm: "0",
}))
}
>
<option value="0">전체</option>
{sggOptions.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
처리구역
</div>
<select
className="sn-popup-input !h-[3.2rem] w-[14rem] !text-xl"
value={draftFilters.swtrmtZoneNm}
onChange={(event) =>
setDraftFilters((current) => ({
...current,
swtrmtZoneNm: event.target.value,
}))
}
>
{zoneOptions.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</label>
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-label">처리구역</div>
<select
className="sn-popup-input !text-[1.1rem]"
value={draftFilters.swtrmtZoneNm}
onChange={(event) =>
setDraftFilters((current) => ({
...current,
swtrmtZoneNm: event.target.value,
}))
}
>
{zoneOptions.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</label>
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
분석구분
</div>
<select
className="sn-popup-input !h-[3.2rem] w-[18rem] !text-xl"
value={draftFilters.analysisType}
onChange={(event) =>
setDraftFilters((current) => ({
...current,
analysisType: event.target
.value as PipeAnalysisSummaryFilters["analysisType"],
}))
}
>
{PIPE_ANALYSIS_TYPE_OPTIONS.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</label>
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-label">분석구분</div>
<select
className="sn-popup-input !text-[1.1rem]"
value={draftFilters.analysisType}
onChange={(event) =>
setDraftFilters((current) => ({
...current,
analysisType: event.target.value as PipeAnalysisSummaryFilters["analysisType"],
}))
}
>
{PIPE_ANALYSIS_TYPE_OPTIONS.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</label>
<div className="flex min-w-fit flex-1 items-center justify-end gap-2">
<Button
type="button"
color="primary"
onClick={() => void handleSearch()}
>
조회
</Button>
<Button
type="button"
color="white"
onClick={() => {
const resetFilters = applyScopeToSummaryFilters(
PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS,
scope,
);
setDraftFilters(resetFilters);
void handleSearch(resetFilters);
}}
>
초기화
</Button>
</div>
</PopupSearchPanel>
<div className="flex flex-wrap justify-end gap-2 md:pr-3">
<Button
type="button"
size="sm"
color="primary"
className="h-10 px-5 !text-[1.1rem]"
onClick={() => void handleSearch()}
>
조회
</Button>
<Button
type="button"
size="sm"
color="green"
className="h-10 px-5 !text-[1.1rem]"
onClick={() => void handleDownload()}
>
다운로드
{appliedFilters.analysisType === "YEAR_MATERIAL" ? (
<div className="card card-border !m-0 min-w-0 overflow-hidden !p-0">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">
{resolvePipeAnalysisTypeLabel(appliedFilters.analysisType)} 목록
</h3>
<Button type="button" onClick={() => void handleDownload()}>
엑셀다운로드
</Button>
</div>
</div>
{appliedFilters.analysisType === "YEAR_MATERIAL" ? (
<>
<div className="card-header">
<h3 className="text-2xl font-medium text-label">
{resolvePipeAnalysisTypeLabel(appliedFilters.analysisType)} 관로현황 {summaryTotalCount.toLocaleString("ko-KR")}건
</h3>
</div>
<div className="card !p-0r !m-[1.2rem] overflow-x-auto">
<Table variant="secondary" className="w-max min-w-full caption-bottom text-sm">
<TableHeader className="font-bold">
<TableRow>
{headList.map((head) => (
<TableHead
key={head.id}
className={`border border-border bg-secondary px-4 py-[1.2rem] text-[1.3rem] font-medium text-label ${
head.align === "right" ? "text-right" : "text-center"
}`}
<div className="card !m-[1.2rem] overflow-x-auto !p-0">
<Table
variant="secondary"
className="w-max min-w-full caption-bottom text-sm"
>
<TableHeader className="font-bold">
<TableRow>
{headList.map((head) => (
<TableHead
key={head.id}
className="border border-border bg-primary px-4 py-[1.2rem] text-center text-[1.3rem] font-medium text-primary-foreground"
>
{head.id === "rowNo" ? "순번" : head.title}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody className="text-center">
{visibleYearMaterialGroups.length > 0 ? (
visibleYearMaterialGroups.flatMap((group) =>
group.rows.map((yearRow, rowIndex) => (
<TableRow
key={`${group.base.ctpvCd}-${group.base.sggCd}-${group.base.swtrmtZoneNm}-${yearRow.elapsedYearBucket}`}
>
{head.title}
</TableHead>
))}
{rowIndex === 0 ? (
<>
<TableCell
rowSpan={group.rows.length}
className="border border-border px-2 py-[1.2rem] text-center text-[1.3rem]"
>
{group.rowNo}
</TableCell>
<TableCell
rowSpan={group.rows.length}
className="border border-border px-2 py-[1.2rem] text-center text-[1.3rem]"
>
{group.ctpvNm}
</TableCell>
<TableCell
rowSpan={group.rows.length}
className="border border-border px-2 py-[1.2rem] text-center text-[1.3rem]"
>
{group.sggNm}
</TableCell>
<TableCell
rowSpan={group.rows.length}
className="border border-border px-2 py-[1.2rem] text-center text-[1.3rem]"
>
{group.swtrmtZoneNm}
</TableCell>
<TableCell
rowSpan={group.rows.length}
className="border border-border px-2 py-[1.2rem] text-right text-[1.3rem]"
>
{formatGridNumber(group.totalLengthKm)}
</TableCell>
</>
) : null}
<TableCell className="border border-border px-2 py-[1.2rem] text-center text-[1.3rem]">
{yearRow.elapsedYearLabel}
</TableCell>
{materialColumns.map((column) => {
const value = Number(
yearRow.metrics[column.key] ?? 0,
);
const isClickable =
value > 0 &&
Boolean(yearRow.cellFilters[column.key]);
return (
<TableCell
key={`${group.base.ctpvCd}-${group.base.sggCd}-${group.base.swtrmtZoneNm}-${yearRow.elapsedYearBucket}-${column.key}`}
className={`border border-border px-2 py-[1.2rem] text-right text-[1.3rem] ${
isClickable
? "cursor-pointer font-semibold text-primary underline underline-offset-2"
: ""
}`}
onClick={() => {
if (isClickable) {
handleYearMaterialCellClick(
group,
yearRow,
column.key,
);
}
}}
>
{formatGridNumber(value)}
</TableCell>
);
})}
</TableRow>
)),
)
) : (
<TableRow>
<TableCell
colSpan={headList.length}
className="h-24 border border-border text-center text-[1.3rem] text-label"
>
조회 결과가 없습니다.
</TableCell>
</TableRow>
</TableHeader>
<TableBody className="text-center">
{visibleYearMaterialGroups.length > 0 ? (
visibleYearMaterialGroups.flatMap((group) =>
group.rows.map((yearRow, rowIndex) => (
<TableRow key={`${group.base.ctpvCd}-${group.base.sggCd}-${group.base.swtrmtZoneNm}-${yearRow.elapsedYearBucket}`}>
{rowIndex === 0 ? (
<>
<TableCell rowSpan={group.rows.length} className="border border-border px-2 py-[1.2rem] text-center text-[1.3rem]">
{group.rowNo}
</TableCell>
<TableCell rowSpan={group.rows.length} className="border border-border px-2 py-[1.2rem] text-center text-[1.3rem]">
{group.ctpvNm}
</TableCell>
<TableCell rowSpan={group.rows.length} className="border border-border px-2 py-[1.2rem] text-center text-[1.3rem]">
{group.sggNm}
</TableCell>
<TableCell rowSpan={group.rows.length} className="border border-border px-2 py-[1.2rem] text-center text-[1.3rem]">
{group.swtrmtZoneNm}
</TableCell>
<TableCell rowSpan={group.rows.length} className="border border-border px-2 py-[1.2rem] text-right text-[1.3rem]">
{formatGridNumber(group.totalLengthKm)}
</TableCell>
</>
) : null}
<TableCell className="border border-border px-2 py-[1.2rem] text-center text-[1.3rem]">
{yearRow.elapsedYearLabel}
</TableCell>
{materialColumns.map((column) => {
const value = Number(yearRow.metrics[column.key] ?? 0);
const isClickable = value > 0 && Boolean(yearRow.cellFilters[column.key]);
return (
<TableCell
key={`${group.base.ctpvCd}-${group.base.sggCd}-${group.base.swtrmtZoneNm}-${yearRow.elapsedYearBucket}-${column.key}`}
className={`border border-border px-2 py-[1.2rem] text-right text-[1.3rem] ${
isClickable ? "cursor-pointer font-semibold text-primary underline underline-offset-2" : ""
}`}
onClick={() => {
if (isClickable) {
handleYearMaterialCellClick(group, yearRow, column.key);
}
}}
>
{formatGridNumber(value)}
</TableCell>
);
})}
</TableRow>
))
)
) : (
<TableRow>
<TableCell colSpan={headList.length} className="h-24 border border-border text-center text-[1.3rem] text-label">
조회 결과가 없습니다.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</TableBody>
</Table>
</div>
{orderedYearMaterialGroups.length >
PIPE_ANALYSIS_SUMMARY_PAGE_SIZE ? (
<div className="mb-[1.2rem] flex justify-center">
<UiPageNations
total={orderedYearMaterialGroups.length}
cur={summaryCurrentPage}
pageSize={PIPE_ANALYSIS_SUMMARY_PAGE_SIZE}
onPageChange={setSummaryCurrentPage}
/>
</div>
{yearMaterialGroups.length > PIPE_ANALYSIS_SUMMARY_PAGE_SIZE ? (
<div className="flex justify-center">
<UiPageNations
total={yearMaterialGroups.length}
cur={summaryCurrentPage}
pageSize={PIPE_ANALYSIS_SUMMARY_PAGE_SIZE}
onPageChange={setSummaryCurrentPage}
/>
</div>
) : null}
</>
) : null}
) : null}
</div>
) : null}
<UiTable<PipeAnalysisSummaryViewRow>
className={appliedFilters.analysisType === "YEAR_MATERIAL"
? "hidden"
: useHorizontalScrollTable
? "overflow-x-auto overflow-y-visible [&_table]:min-w-full [&_table]:w-max"
: ""}
headName={
appliedFilters.analysisType === "YEAR_MATERIAL"
? `경과년도&재질 관로현황 ${summaryTotalCount.toLocaleString("ko-KR")}건`
: `관로분석현황 ${summaryTotalCount.toLocaleString("ko-KR")}건`
{appliedFilters.analysisType !== "YEAR_MATERIAL" ? (
<MapPopupDataTable
rows={
pagedRows as Array<
PipeAnalysisSummaryViewRow & Record<string, unknown>
>
}
tableData={pagedRows}
headlist={headList}
pageSize={PIPE_ANALYSIS_SUMMARY_PAGE_SIZE}
title={`${resolvePipeAnalysisTypeLabel(appliedFilters.analysisType)} 목록`}
total={rows.length}
cellClick={(index, columnId) =>
handleCellClick(
typeof index === "number" ? index + summaryPageOffset : index,
columnId,
)}
page={summaryCurrentPage}
pageSize={PIPE_ANALYSIS_SUMMARY_PAGE_SIZE}
onPageChange={setSummaryCurrentPage}
curPage={summaryCurrentPage}
widthFixed={!useHorizontalScrollTable}
isHideCount={true}
cellClassName={(row, columnId) =>
!row.__isNoZone && row.__cellFilters[columnId]
? "cursor-pointer font-semibold text-blue-700 underline underline-offset-2"
: ""
scrollMode={useHorizontalScrollTable ? "horizontal" : "none"}
minTableWidth={useHorizontalScrollTable ? "160rem" : "100%"}
clickableHeadIds={clickableHeadIds}
onCellClick={(row, _rowIndex, columnId) =>
handleCellClick(row, columnId)
}
rowId={(row, index) =>
`${String(row.__base.ctpvCd)}-${String(row.__base.sggCd)}-${String(row.__base.swtrmtZoneNm)}-${index}`
}
actions={
<Button type="button" onClick={() => void handleDownload()}>
엑셀다운로드
</Button>
}
/>
</section>
</main>
) : null}
</StandardPopupLayout>
);
}
@@ -2,12 +2,10 @@
import type { SelectDataType } from "@common/business_components";
import type { IUserInfo } from "@/shared/store";
import {
MS_SWAGE_PPL_CODES,
MS_SWAGE_PPL_USE_CODES,
} from "../common/table";
import { MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_USE_CODES } from "../common/table";
export const PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE = "SN_PIPE_ANALYSIS_DETAIL_ROW_SELECT";
export const PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE =
"SN_PIPE_ANALYSIS_DETAIL_ROW_SELECT";
export type PipeAnalysisType = "YEAR" | "MATERIAL" | "YEAR_MATERIAL";
@@ -126,22 +124,30 @@ export type PipeAnalysisYearMaterialGroup = {
rows: PipeAnalysisYearMaterialGroupRow[];
};
const materialMap = new Map(MS_SWAGE_PPL_CODES.map((item) => [item.val.trim(), item.text]));
const useMap = new Map(MS_SWAGE_PPL_USE_CODES.map((item) => [item.val.trim(), item.text]));
const materialMap = new Map(
MS_SWAGE_PPL_CODES.map((item) => [item.val.trim(), item.text]),
);
const useMap = new Map(
MS_SWAGE_PPL_USE_CODES.map((item) => [item.val.trim(), item.text]),
);
export const PIPE_ANALYSIS_TYPE_OPTIONS: Array<{ value: PipeAnalysisType; label: string }> = [
export const PIPE_ANALYSIS_TYPE_OPTIONS: Array<{
value: PipeAnalysisType;
label: string;
}> = [
{ value: "YEAR", label: "경과년도별" },
{ value: "MATERIAL", label: "재질별" },
{ value: "YEAR_MATERIAL", label: "경과년도&재질" },
];
export const PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS: PipeAnalysisSummaryFilters = {
analysisType: "YEAR",
ctpvCd: "0",
sggCd: "0",
swtrmtZoneNm: "0",
scopeRegionCode: "",
};
export const PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS: PipeAnalysisSummaryFilters =
{
analysisType: "YEAR",
ctpvCd: "0",
sggCd: "0",
swtrmtZoneNm: "0",
scopeRegionCode: "",
};
export const PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS: PipeAnalysisDetailFilters = {
ctpvCd: "0",
@@ -172,7 +178,7 @@ const elapsedYearBucketLabelMap: Record<string, string> = {
const YEAR_MATERIAL_BASE_BUCKETS = ["LT10", "Y10_20", "Y20_30", "GE30"];
export const PIPE_ANALYSIS_DETAIL_HEADLIST = [
{ id: "rowNo", title: "NO", widthPercent: 6 },
{ id: "rowNo", title: "순번", widthPercent: 6 },
{ id: "ctpvNm", title: "시도", widthPercent: 9 },
{ id: "sggNm", title: "시군구", widthPercent: 10 },
{ id: "fcltMngNo", title: "시설관리번호", widthPercent: 15 },
@@ -185,7 +191,9 @@ export const PIPE_ANALYSIS_DETAIL_HEADLIST = [
{ id: "materialName", title: "관재질", widthPercent: 12 },
];
export const buildPipeAnalysisScope = (userInfo: IUserInfo | null): PipeAnalysisScope => {
export const buildPipeAnalysisScope = (
userInfo: IUserInfo | null,
): PipeAnalysisScope => {
const admin = userInfo?.adminRoleYn === "Y";
if (admin) {
return {
@@ -263,11 +271,16 @@ export const buildPipeAnalysisSummaryHeadList = (
materialColumns: PipeAnalysisMaterialColumn[],
) => {
const common = [
{ id: "rowNo", title: "NO", widthPercent: 6 },
{ id: "rowNo", title: "순번", widthPercent: 6 },
{ id: "ctpvNm", title: "시도", widthPercent: 10 },
{ id: "sggNm", title: "시군구", widthPercent: 10 },
{ id: "swtrmtZoneNm", title: "처리구역", widthPercent: 14 },
{ id: "totalLengthKm", title: "합계(km)", widthPercent: 10, align: "right" },
{
id: "totalLengthKm",
title: "합계(km)",
widthPercent: 10,
align: "right",
},
];
if (analysisType === "YEAR") {
@@ -309,12 +322,17 @@ export const buildPipeAnalysisSummaryViewRows = (
return response.data.map((row) => {
const isNoZone = String(row.swtrmtZoneNm ?? "").trim() === "NO_ZONE";
const isSummaryTotal = !String(row.ctpvCd ?? "").trim()
&& String(row.swtrmtZoneNm ?? "").trim() === "-";
const isSummaryTotal =
!String(row.ctpvCd ?? "").trim() &&
String(row.swtrmtZoneNm ?? "").trim() === "-";
const viewRow: PipeAnalysisSummaryViewRow = {
rowNo: row.rowNo,
ctpvNm: isNoZone ? "전체" : row.ctpvNm || resolveRegionName(row.ctpvCd, ctpvOptions, 2),
sggNm: isNoZone ? "-" : row.sggNm || resolveRegionName(row.sggCd, sggOptions, 5),
ctpvNm: isNoZone
? "전체"
: row.ctpvNm || resolveRegionName(row.ctpvCd, ctpvOptions, 2),
sggNm: isNoZone
? "-"
: row.sggNm || resolveRegionName(row.sggCd, sggOptions, 5),
swtrmtZoneNm: isNoZone ? "-" : row.swtrmtZoneNm || "-",
totalLengthKm: row.totalLengthKm ?? 0,
elapsedYearBucket: row.elapsedYearBucket ?? "",
@@ -357,16 +375,18 @@ export const buildPipeAnalysisSummaryViewRows = (
return viewRow;
}
const columns = response.analysisType === "YEAR_MATERIAL"
? materialCodes
: materialCodes;
const columns =
response.analysisType === "YEAR_MATERIAL" ? materialCodes : materialCodes;
columns.forEach((columnKey) => {
const value = Number(row.metrics?.[columnKey] ?? 0);
viewRow[columnKey] = value;
if (!isNoZone && !isSummaryTotal && value > 0) {
viewRow.__cellFilters[columnKey] = {
elapsedYearBucket: response.analysisType === "YEAR_MATERIAL" ? row.elapsedYearBucket : undefined,
elapsedYearBucket:
response.analysisType === "YEAR_MATERIAL"
? row.elapsedYearBucket
: undefined,
materialCode: columnKey === "OTHER" ? "" : columnKey,
};
}
@@ -416,14 +436,19 @@ export const buildPipeAnalysisYearMaterialGroups = (
group.rows.push({
elapsedYearBucket,
elapsedYearLabel: String(row.elapsedYearLabel ?? resolveElapsedYearBucketLabel(elapsedYearBucket)),
elapsedYearLabel: String(
row.elapsedYearLabel ??
resolveElapsedYearBucketLabel(elapsedYearBucket),
),
metrics,
cellFilters: row.__cellFilters,
});
});
return Array.from(groupMap.values()).map((group) => {
const rowByBucket = new Map(group.rows.map((row) => [row.elapsedYearBucket, row]));
const rowByBucket = new Map(
group.rows.map((row) => [row.elapsedYearBucket, row]),
);
const orderedBuckets = [...YEAR_MATERIAL_BASE_BUCKETS];
if (rowByBucket.has("UNKNOWN")) {
@@ -474,7 +499,7 @@ export const buildPipeAnalysisSummaryDownloadHeaders = (
materialColumns: PipeAnalysisMaterialColumn[],
) => {
const headers: Record<string, string> = {
rowNo: "NO",
rowNo: "순번",
ctpvNm: "시도",
sggNm: "시군구",
swtrmtZoneNm: "처리구역",
@@ -499,7 +524,7 @@ export const buildPipeAnalysisSummaryDownloadHeaders = (
};
export const PIPE_ANALYSIS_DETAIL_EXCEL_HEADERS = {
rowNo: "NO",
rowNo: "순번",
ctpvNm: "시도",
sggNm: "시군구",
fcltMngNo: "시설관리번호",
@@ -548,7 +573,9 @@ const toKoreanMaterialLabel = (value?: string) => {
const parenthesisMatches = Array.from(normalized.matchAll(/\(([^)]*)\)/g))
.map((match) => String(match[1] ?? "").trim())
.filter(Boolean);
const koreanParenthesisText = parenthesisMatches.find((text) => /[가-힣]/.test(text));
const koreanParenthesisText = parenthesisMatches.find((text) =>
/[가-힣]/.test(text),
);
if (koreanParenthesisText) {
const simplifiedParenthesisText = koreanParenthesisText
@@ -561,7 +588,9 @@ const toKoreanMaterialLabel = (value?: string) => {
return normalized;
};
export const resolveMaterialColumnName = (column: PipeAnalysisMaterialColumn) => {
export const resolveMaterialColumnName = (
column: PipeAnalysisMaterialColumn,
) => {
const rawName = String(column.materialName ?? "").trim();
const rawCode = String(column.materialCode ?? "").trim();
const rawKey = String(column.key ?? "").trim();
@@ -592,8 +621,8 @@ export const resolveElapsedYearBucketLabel = (bucket?: string) => {
export const resolvePipeAnalysisTypeLabel = (analysisType?: string) => {
const normalized = String(analysisType ?? "").trim();
return (
PIPE_ANALYSIS_TYPE_OPTIONS.find((option) => option.value === normalized)?.label
?? normalized
PIPE_ANALYSIS_TYPE_OPTIONS.find((option) => option.value === normalized)
?.label ?? normalized
);
};
@@ -611,12 +640,16 @@ export const resolveRegionName = (
const normalized = String(code ?? "").trim();
if (!normalized) return "-";
const prefix = normalized.slice(0, prefixLength);
return options.find((item) => String(item.val).trim() === prefix)?.text ?? prefix;
return (
options.find((item) => String(item.val).trim() === prefix)?.text ?? prefix
);
};
export const normalizeNumberInput = (value: string) => value.replace(/[^0-9]/g, "");
export const normalizeNumberInput = (value: string) =>
value.replace(/[^0-9]/g, "");
export const buildPipeAnalysisFileDate = () => new Date().toISOString().slice(0, 10);
export const buildPipeAnalysisFileDate = () =>
new Date().toISOString().slice(0, 10);
export const fetchPipeAnalysisZoneOptions = async ({
ctpvCd,
@@ -34,7 +34,15 @@ const inputColumn = [
type operationDataType = { year: string; type: string, bizInfoNm: string, bizType: string };
const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClick?: () => void }) => {
const MsSwagePpl = ({
featureData,
onSaveClick,
surveyResultContent,
}: {
featureData: any;
onSaveClick?: () => void;
surveyResultContent?: React.ReactNode;
}) => {
const [formData, setFormData] = useState<any>({});
const [repairData, setRepairData] = useState<operationDataType[]>([]);
const [cleaningData, setCleaningData] = useState<operationDataType[]>([]);
@@ -48,6 +56,8 @@ const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClic
const { BIZ_SE_CD, BIZ_TY_CD } = useStore();
const requestMngInstCd = String(featureData?.mng_inst_cd ?? featureData?.mngInstCd ?? "").trim();
const requestFcltMngNo = String(featureData?.fclt_mng_no ?? featureData?.fcltMngNo ?? "").trim();
// 조사관리 전용 탭은 기본·입력정보와 조사결과만 표시하고 일반 시설 유지관리 이력은 숨긴다.
const showOperationSections = !surveyResultContent;
// 마운트 및 업데이트 시 API 호출
@@ -58,7 +68,7 @@ const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClic
}, [featureData]);
useEffect(() => {
if (!requestMngInstCd || !requestFcltMngNo) {
if (!showOperationSections || !requestMngInstCd || !requestFcltMngNo) {
setInspectionData([]);
setCleaningData([]);
setRepairData([]);
@@ -121,7 +131,7 @@ const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClic
setCleaningData([]);
setRepairData([]);
});
}, [BIZ_SE_CD, BIZ_TY_CD, requestFcltMngNo, requestMngInstCd]);
}, [BIZ_SE_CD, BIZ_TY_CD, requestFcltMngNo, requestMngInstCd, showOperationSections]);
// 폼 데이터가 변경될 때 호출될 핸들러
const handleFormDataChange = (newFormData: any) => {
@@ -154,24 +164,29 @@ const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClic
onSaveClick={handleSaveClick}
onFormDataChange={handleFormDataChange} // 폼 데이터 변경 콜백 추가
/>
<InputSection
title="보수 및 갱신"
data={repairData}
sectionKey="repair"
onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value, dataMap)}
/>
<InputSection
title="청소 및 준설"
data={cleaningData}
sectionKey="cleaning"
onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value, dataMap)}
/>
<InputSection
title="점검 및 진단"
data={inspectionData}
sectionKey="inspection"
onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value, dataMap)}
/>
{surveyResultContent}
{showOperationSections && (
<>
<InputSection
title="보수 및 갱신"
data={repairData}
sectionKey="repair"
onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value, dataMap)}
/>
<InputSection
title="청소 및 준설"
data={cleaningData}
sectionKey="cleaning"
onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value, dataMap)}
/>
<InputSection
title="점검 및 진단"
data={inspectionData}
sectionKey="inspection"
onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value, dataMap)}
/>
</>
)}
</>
)
}
@@ -1,6 +1,6 @@
/**
* ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (우수토실)
*
*
* 📄 Ssc
* 우수토실 정보 컴포넌트
*
@@ -8,53 +8,78 @@
* - 중점우수토실 운영정보 모달 표시 (강우/측정 정보, 수량/수질측정 정보)
* - VerticalTable을 통한 입력 폼 렌더링
* - 저장 버튼을 통한 데이터 저장 기능
*
*
* 🔗 사용 API
* - 위경도로 강우시 미처리 하수 목록 조회: GET /api/v1/onsemiro/getSwgRainsByGeomety (sewerage-service)
* - 강우시 월류 하수 관리 모니터링 조회: GET /api/v1/onsemiro/swgRain/{adstDstrCd}/{manageNo}/mntrngs (sewerage-service)
*/
'use client';
import { UiTable } from '@common/business_components/ui';
import { Button, TableHead, TableRow } from '@common/components/ui';
import React, { useState, useEffect } from 'react';
import { untrtSwgRainCol3, untrtSwgRainCol4, untrtSwgRainCol5 } from '../common/table';
import { VerticalTable } from '@/components/ui/verticalTable';
"use client";
import { StandardPopupLayout } from "@common/business_components/ui";
import { Button } from "@common/components/ui";
import React, { useState, useEffect } from "react";
import {
untrtSwgRainCol3,
untrtSwgRainCol4,
untrtSwgRainCol5,
} from "../common/table";
import { VerticalTable } from "@/components/ui/verticalTable";
import { getRawJson } from "@/components/lib/api";
import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue';
import { Label } from '@radix-ui/react-label';
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue";
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
import { useAlertStore } from "@/shared/lib";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
const inputColumn = [
{ id: "prrtymngYn", title: "중점여부", type: "select", options: ["우수토실", "중점우수토실"] },
{
id: "prrtymngYn",
title: "중점여부",
type: "select",
options: ["우수토실", "중점우수토실"],
},
{ id: "swtrmtZoneNm", title: "처리구역", type: "input" },
];
const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick, renderMode = 'inline' }: {
featureData: any,
isMainSsc?: boolean,
chmbrXcnts?: string,
chmbrYdnts?: string,
onSaveClick?: () => void,
renderMode?: 'inline' | 'popup'
const toStandardHeadList = (headlist: typeof untrtSwgRainCol3) =>
headlist.map((head) => ({
...head,
title: ["no", "rowNo", "rowNumber"].includes(head.id) ? "순번" : head.title,
}));
const Ssc = ({
featureData,
isMainSsc,
chmbrXcnts,
chmbrYdnts,
onSaveClick,
renderMode = "inline",
}: {
featureData: any;
isMainSsc?: boolean;
chmbrXcnts?: string;
chmbrYdnts?: string;
onSaveClick?: () => void;
renderMode?: "inline" | "popup";
}) => {
const alert = useAlertStore((state) => state.setMessage);
const [formData, setFormData] = useState<any>({});
const [rainMeasureData, setRainMeasureData] = useState<any>({}); // 강우, 측정 정보
const [rainMeasureData, setRainMeasureData] = useState<any[]>([]); // 강우, 측정 정보
const [mntrngsData, setMntrngsData] = useState<any[]>([]); // 수량/수질측정 정보
const [totalCount, setTotalCount] = useState<number>(0);
const [totalDetailCount, setTotalDetailCount] = useState<number>(0);
const [selectedRowIndex, setSelectedRowIndex] = useState<number>(-1);
const [curPage, setCurPage] = useState<number>(1);
const [curDetailPage, setCurDetailPage] = useState<number>(1);
const [isResetPage, setIsResetPage] = useState<boolean>(true);
const [isResetDetailPage, setIsResetDetailPage] = useState<boolean>(true);
const [isResetSelection, setIsResetSelection] = useState<boolean>(false);
// 강우, 측정 정보 API 호출 함수
const fetchRainMeasureData = async (chmbrXcnts: string, chmbrYdnts: string) => {
setIsResetDetailPage(true);
setIsResetSelection(true);
const fetchRainMeasureData = async (
chmbrXcnts: string,
chmbrYdnts: string,
) => {
if (!chmbrXcnts || !chmbrYdnts) {
console.warn('추가 데이터 호출 실패: 필수 매개변수 누락', { chmbrXcnts, chmbrYdnts });
console.warn("추가 데이터 호출 실패: 필수 매개변수 누락", {
chmbrXcnts,
chmbrYdnts,
});
return [];
}
@@ -63,17 +88,21 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick, rend
chmbrXcnts: chmbrXcnts,
chmbrYdnts: chmbrYdnts,
page: `${curPage - 1}`,
size: '3',
size: "3",
});
const result = await getRawJson(`/sewerage-service/api/v1/onsemiro/getSwgRainsByGeomety?${queryParams.toString()}`);
const result = await getRawJson(
`/sewerage-service/api/v1/onsemiro/getSwgRainsByGeomety?${queryParams.toString()}`,
);
// helper: "YYYY-MM-DD HH:MM:SS" -> "MM-DD HH:MM"
const formatNoYearNoSec = (val: any) => {
if (!val && val !== 0) return "-";
const s = String(val);
// 정규식으로 안전하게 매칭 후 변환
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})(?::\d{2})?$/);
const m = s.match(
/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})(?::\d{2})?$/,
);
if (m) {
const [, , mm, dd, hh, min] = m;
return `${mm}-${dd} ${hh}:${min}`;
@@ -86,45 +115,70 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick, rend
const processedDataArray: any[] = result.content.map((data: any) => {
// 🎯 formatDisplayData로 기본 포맷팅 적용
const formattedItem = formatDisplayData(data);
const flwmtrInstTp = data.flwmtrInstTp === 'R' ? '이동식' : data.flwmtrInstTp === 'S' ? '고정식' : '-';
const flwmtrMesureTp = data.flwmtrMesureTp === 'R' ? '레이더' : data.flwmtrMesureTp === 'P' ? '플륨'
: data.flwmtrMesureTp === 'U' ? '초음파' : data.flwmtrMesureTp === 'E' ? '기타' : '-';
const flwmtrInstTp =
data.flwmtrInstTp === "R"
? "이동식"
: data.flwmtrInstTp === "S"
? "고정식"
: "-";
const flwmtrMesureTp =
data.flwmtrMesureTp === "R"
? "레이더"
: data.flwmtrMesureTp === "P"
? "플륨"
: data.flwmtrMesureTp === "U"
? "초음파"
: data.flwmtrMesureTp === "E"
? "기타"
: "-";
const flwmtrCombined = `${flwmtrInstTp}, ${flwmtrMesureTp}`;
// 🎯 추가 데이터 조합
return {
...formattedItem,
rainInfo: `${data.rainInfo === '0' ? '강우량계' : data.observatory || '-'}`, // 강우정보 0: 강우량계,1: 관측기관 이면 OBSERVATORY 이값사용
mesureIntrvl: `${data.mesureIntrvlGbn === '1' ? '1분' : (data.mesureIntrvlGbn === '0' ? data.mesureIntrvl + '분' : '-')}`, // 측정간격
flwmtrCombined: `${flwmtrCombined} / ${data.flwmtrKnd || '-'}`, // 유량계종류 및 모델명
mesureBeginDt: data.mesureBeginDt ? formatNoYearNoSec(data.mesureBeginDt) : '-', // 수량 측정시작일
mesureEndDt: data.mesureEndDt ? formatNoYearNoSec(data.mesureEndDt) : '-', // 수량 측정종료일
qltwtrBeginDt: data.qltwtrBeginDt ? formatNoYearNoSec(data.qltwtrBeginDt) : '-', // 수질 측정시작일(채수시작일)
qltwtrEndDt: data.qltwtrEndDt ? formatNoYearNoSec(data.qltwtrEndDt) : '-', // 수질 측정종료일(채수종료일)
rainInfo: `${data.rainInfo === "0" ? "강우량계" : data.observatory || "-"}`, // 강우정보 0: 강우량계,1: 관측기관 이면 OBSERVATORY 이값사용
mesureIntrvl: `${data.mesureIntrvlGbn === "1" ? "1분" : data.mesureIntrvlGbn === "0" ? data.mesureIntrvl + "분" : "-"}`, // 측정간격
flwmtrCombined: `${flwmtrCombined} / ${data.flwmtrKnd || "-"}`, // 유량계종류 및 모델명
mesureBeginDt: data.mesureBeginDt
? formatNoYearNoSec(data.mesureBeginDt)
: "-", // 수량 측정시작일
mesureEndDt: data.mesureEndDt
? formatNoYearNoSec(data.mesureEndDt)
: "-", // 수량 측정종료일
qltwtrBeginDt: data.qltwtrBeginDt
? formatNoYearNoSec(data.qltwtrBeginDt)
: "-", // 수질 측정시작일(채수시작일)
qltwtrEndDt: data.qltwtrEndDt
? formatNoYearNoSec(data.qltwtrEndDt)
: "-", // 수질 측정종료일(채수종료일)
};
});
setRainMeasureData(processedDataArray);
setTotalCount(result?.totalElements || 0);
} catch (error) {
console.error('추가 데이터 로딩 실패:', error);
console.error("추가 데이터 로딩 실패:", error);
return [];
}
};
// 수량/수질측정 정보 API 호출 함수
const fetchMntrngsData = async (adstDstrCd: string, manageNo: string) => {
setIsResetDetailPage(true);
if (!adstDstrCd || !manageNo) {
console.warn('추가 데이터 호출 실패: 필수 매개변수 누락', { adstDstrCd, manageNo });
console.warn("추가 데이터 호출 실패: 필수 매개변수 누락", {
adstDstrCd,
manageNo,
});
return [];
}
try {
const queryParams = new URLSearchParams({
page: `${curDetailPage - 1}`,
size: '3',
size: "3",
});
const result = await getRawJson(`/sewerage-service/api/v1/onsemiro/swgRain/${adstDstrCd}/${manageNo}/mntrngs?${queryParams.toString()}`);
const result = await getRawJson(
`/sewerage-service/api/v1/onsemiro/swgRain/${adstDstrCd}/${manageNo}/mntrngs?${queryParams.toString()}`,
);
// 각 아이템 전처리
const processedDataArray = result.content.map((data: any) => {
@@ -138,17 +192,16 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick, rend
setMntrngsData(processedDataArray);
setTotalDetailCount(result?.totalElements || 0);
} catch (error) {
console.error('추가 데이터 로딩 실패:', error);
console.error("추가 데이터 로딩 실패:", error);
return [];
}
};
// 🎯 추가 데이터는 별도 useEffect로 비동기 로딩
useEffect(() => {
// 강우/측정 정보 데이터 호출
if (isMainSsc && chmbrXcnts && chmbrYdnts) {
fetchRainMeasureData(chmbrXcnts, chmbrYdnts)
fetchRainMeasureData(chmbrXcnts, chmbrYdnts);
}
}, [chmbrXcnts, chmbrYdnts]);
@@ -160,7 +213,7 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick, rend
});
if (!popupWindow) {
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
alert("팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.");
}
};
@@ -175,14 +228,17 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick, rend
// featureData가 변경될 때 formData 초기화
useEffect(() => {
// showTable이 true일 때만 fetch (최초 렌더 방지)
setIsResetDetailPage(false);
setSelectedRowIndex(-1);
if (chmbrXcnts && chmbrYdnts) fetchRainMeasureData(chmbrXcnts, chmbrYdnts);
}, [curPage]);
// 페이지 변경 시마다 데이터 fetch
useEffect(() => {
if (chmbrXcnts && chmbrYdnts && selectedRowIndex !== -1) fetchMntrngsData(rainMeasureData[selectedRowIndex].adstDstrCd, rainMeasureData[selectedRowIndex].manageNo);
if (chmbrXcnts && chmbrYdnts && selectedRowIndex !== -1)
fetchMntrngsData(
rainMeasureData[selectedRowIndex].adstDstrCd,
rainMeasureData[selectedRowIndex].manageNo,
);
}, [curDetailPage]);
// 폼 데이터가 변경될 때 호출될 핸들러
@@ -199,86 +255,72 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick, rend
};
// 테이블 셀 클릭 핸들러 추가
const handleCellClick = async (index: number | undefined, headId: string) => {
if (index !== undefined && rainMeasureData) {
await fetchMntrngsData(rainMeasureData[index].adstDstrCd, rainMeasureData[index].manageNo);
const handleCellClick = async (
_row: Record<string, unknown>,
index: number,
) => {
if (rainMeasureData[index]) {
await fetchMntrngsData(
rainMeasureData[index].adstDstrCd,
rainMeasureData[index].manageNo,
);
setSelectedRowIndex(index);
}
};
const renderModalBody = () => (
<div className='grid grid-cols-1 gap-4 p-4 mr-4 mt-4'>
<div className='border rounded-lg pt-4 bg-white'>
<Label className='text-2xl font-bold text-label ml-4'>강우/측정 정보</Label>
<hr className="my-4 ml-4 mr-4 border-t border-gray-300" />
<div className="grid grid-cols-1 gap-4">
<div className="space-y-4">
{/* 강우정보 테이블 */}
<UiTable
headName=''
tableData={rainMeasureData}
headlist={untrtSwgRainCol3}
<MapPopupDataTable
title="강우정보 목록"
rows={rainMeasureData}
headlist={toStandardHeadList(untrtSwgRainCol3)}
pageSize={3}
total={0}
cellClick={handleCellClick}
total={totalCount}
page={curPage}
onCellClick={handleCellClick}
onPageChange={setCurPage}
resetPageNo={isResetPage}
resetSelection={isResetSelection}
index={selectedRowIndex}
viewPagination={false}
scrollMode="horizontal"
minTableWidth="72rem"
/>
{/* 측정정보 테이블 */}
<UiTable
headName=''
tableData={rainMeasureData}
headlist={untrtSwgRainCol4}
<MapPopupDataTable
title="측정정보 목록"
rows={rainMeasureData}
headlist={toStandardHeadList(untrtSwgRainCol4)}
pageSize={3}
total={totalCount}
cellClick={handleCellClick}
page={curPage}
onCellClick={handleCellClick}
onPageChange={setCurPage}
resetPageNo={isResetPage}
resetSelection={isResetSelection}
index={selectedRowIndex}
>
{/* 커스텀 헤더 */}
<TableRow>
<TableHead colSpan={5} className="text-center bg-blue-100 font-bold">수량</TableHead>
<TableHead colSpan={3} className="text-center bg-blue-100 font-bold">수질</TableHead>
</TableRow>
<TableRow>
<TableHead className="text-center">측정시작일시</TableHead>
<TableHead className="text-center">측정종료일시</TableHead>
<TableHead className="text-center">측정간격(분)</TableHead>
<TableHead className="text-center">측정기기설치위치</TableHead>
<TableHead className="text-center">유량계 종류 및 모델명</TableHead>
<TableHead className="text-center">채수시작일시</TableHead>
<TableHead className="text-center">채수종료일시</TableHead>
<TableHead className="text-center">채수횟수</TableHead>
</TableRow>
</UiTable>
scrollMode="horizontal"
minTableWidth="88rem"
/>
</div>
<div className='border rounded-lg pt-4 bg-white'>
<div>
{/* 수량/수질측정 정보 테이블 */}
<Label className='text-2xl font-bold text-label ml-4'>수량/수질측정 정보</Label>
<hr className="my-4 ml-4 mr-4 border-t border-gray-300" />
<UiTable
headName=''
tableData={mntrngsData}
headlist={untrtSwgRainCol5}
<MapPopupDataTable
title="수량·수질측정 목록"
rows={mntrngsData}
headlist={toStandardHeadList(untrtSwgRainCol5)}
pageSize={3}
total={totalDetailCount}
cellClick={undefined}
page={curDetailPage}
onPageChange={setCurDetailPage}
resetPageNo={isResetDetailPage}
/>
</div>
</div>
)
);
return (
<div>
{!isMainSsc && (
<VerticalTable
headName='입력정보'
headName="입력정보"
tableData={featureData ? [formData] : []}
headlist={inputColumn}
pageSize={0}
@@ -286,7 +328,7 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick, rend
cellClick={undefined}
isEditable={true}
onPageChange={function (): void {
throw new Error('Function not implemented.');
throw new Error("Function not implemented.");
}}
showSaveButton={true}
onSaveClick={handleSaveClick}
@@ -294,13 +336,16 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick, rend
/>
)}
{isMainSsc && renderMode !== 'popup' && (
{isMainSsc && renderMode !== "popup" && (
<div>
<div className="card-header">
<h3 className="text-2xl mx-3 font-medium text-label">중점우수토실 운영정보</h3>
<h3 className="text-2xl mx-3 font-medium text-label">
중점우수토실 운영정보
</h3>
</div>
<Button
className='mx-auto block w-64 mt-3 py-2 text-white rounded-sm text-sm font-medium hover:bg-blue-700 bg-blue-600'
className="mx-auto mt-3 block w-64"
color="white"
onClick={openDetailPopup}
>
상세보기
@@ -308,14 +353,13 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick, rend
</div>
)}
{isMainSsc && renderMode === 'popup' && (
<div className="min-h-dvh bg-white p-4">
<h1 className="mb-3 text-2xl font-semibold text-gray-900">중점우수토실 운영정보</h1>
{isMainSsc && renderMode === "popup" && (
<StandardPopupLayout title="중점우수토실 운영정보">
{renderModalBody()}
</div>
</StandardPopupLayout>
)}
</div>
)
}
);
};
export default Ssc
export default Ssc;
@@ -13,6 +13,7 @@ import React from 'react'
import { VerticalTable } from '@/components/ui/verticalTable';
import { Button } from '@common/components/ui';
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
import { useAlertStore } from '@/shared/lib';
// 하수처리시설
const headList = [
@@ -20,6 +21,7 @@ const headList = [
];
const SwtrmtFclt = ({ gisObject }: { gisObject: any }) => {
const alert = useAlertStore((state) => state.setMessage);
const ctpvNm = gisObject?.ctpv_nm;
const sggNm = gisObject?.sgg_nm;
@@ -1,23 +1,72 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import { Table, TableBody, TableCell, TableRow } from "@common/components";
import { fetchSwtrmtPrcsZoneAnalysis } from "./swtrmtPrcsZoneAnalysisApi";
import type { SwtrmtPrcsZoneAnalysisMetric, SwtrmtPrcsZoneAnalysisResponse } from "./swtrmtPrcsZoneAnalysisApi";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
Button,
Table,
TableBody,
TableCell,
TableRow,
} from "@common/components";
import { useGisContext } from "@common/business_components/ui/map/Gis";
import Feature from "ol/Feature.js";
import GeoJSON from "ol/format/GeoJSON.js";
import VectorLayer from "ol/layer/Vector.js";
import VectorSource from "ol/source/Vector.js";
import Stroke from "ol/style/Stroke.js";
import Style from "ol/style/Style.js";
const analysisRows: { key: keyof NonNullable<SwtrmtPrcsZoneAnalysisResponse["analysis"]>; title: string }[] = [
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import { useAlertStore } from "@/shared/lib";
import {
fetchSwtrmtPrcsZoneAnalysis,
getSwtrmtPrcsZoneId,
} from "./swtrmtPrcsZoneAnalysisApi";
import type {
RepairHistoryRow,
SwtrmtPrcsZoneAnalysisMetric,
SwtrmtPrcsZoneAnalysisResponse,
} from "./swtrmtPrcsZoneAnalysisApi";
const analysisRows: {
key: keyof NonNullable<SwtrmtPrcsZoneAnalysisResponse["analysis"]>;
title: string;
}[] = [
{ key: "total", title: "전체" },
{ key: "combined", title: "합류관" },
{ key: "rain", title: "우수관" },
{ key: "sewage", title: "오수관" },
];
const formatValue = (value: unknown, unit = "") => {
if (value === null || value === undefined || value === "") return "데이터 없음";
const repairHistoryStyle = new Style({
stroke: new Stroke({ color: "rgba(0, 229, 255, 0.95)", width: 5 }),
zIndex: 10020,
});
const numberValue = typeof value === "number" ? value : Number(String(value).replaceAll(",", ""));
const selectedRepairHistoryStyle = new Style({
stroke: new Stroke({ color: "rgba(255, 82, 82, 1)", width: 8 }),
zIndex: 10021,
});
const formatValue = (value: unknown, unit = "") => {
if (value === null || value === undefined || value === "")
return "데이터 없음";
const numberValue =
typeof value === "number"
? value
: Number(String(value).replaceAll(",", ""));
if (!Number.isNaN(numberValue) && Number.isFinite(numberValue)) {
const formatted = numberValue.toLocaleString("ko-KR", { maximumFractionDigits: 2 });
const formatted = numberValue.toLocaleString("ko-KR", {
maximumFractionDigits: 2,
});
return unit ? `${formatted}${unit}` : formatted;
}
@@ -26,7 +75,7 @@ const formatValue = (value: unknown, unit = "") => {
const getMetric = (
analysis: SwtrmtPrcsZoneAnalysisResponse["analysis"] | undefined,
key: keyof NonNullable<SwtrmtPrcsZoneAnalysisResponse["analysis"]>
key: keyof NonNullable<SwtrmtPrcsZoneAnalysisResponse["analysis"]>,
): SwtrmtPrcsZoneAnalysisMetric => analysis?.[key] ?? {};
const metricGroups: {
@@ -42,25 +91,25 @@ const MetricVerticalGrid = ({
title,
field,
analysis,
isLoading,
}: {
title: string;
field: keyof SwtrmtPrcsZoneAnalysisMetric;
analysis: SwtrmtPrcsZoneAnalysisResponse["analysis"] | undefined;
isLoading?: boolean;
}) => (
<div className="mb-4 last:mb-0">
<div className="text-xl mx-3 mb-2 mt-1 font-medium text-label">{title}</div>
<div className="mx-3 mb-2 mt-1 text-xl font-medium text-label">{title}</div>
<Table variant="secondary">
<TableBody className="text-center">
{analysisRows.map((row) => {
const metric = getMetric(analysis, row.key);
return (
<TableRow key={`${title}-${row.key}`}>
<TableCell className="font-bold bg-blue-100 text-[13px] w-[120px] whitespace-nowrap">
<TableCell className="w-[120px] whitespace-nowrap bg-secondary text-[13px] font-bold text-label">
{row.title}
</TableCell>
<TableCell className="text-[13px]">{isLoading ? "" : formatValue(metric[field])}</TableCell>
<TableCell className="text-[13px]">
{formatValue(metric[field])}
</TableCell>
</TableRow>
);
})}
@@ -69,6 +118,7 @@ const MetricVerticalGrid = ({
</div>
);
/** 처리구역 분석 패널과 보수이력 윈도우 팝업의 부모 지도 연계를 담당한다. */
const SwtrmtPrcsZoneAnalysis = ({
featureData,
rawFeatureData,
@@ -76,9 +126,16 @@ const SwtrmtPrcsZoneAnalysis = ({
featureData: any;
rawFeatureData?: any;
}) => {
const [analysisData, setAnalysisData] = useState<SwtrmtPrcsZoneAnalysisResponse | null>(null);
const alert = useAlertStore((state) => state.setMessage);
const { gisMap } = useGisContext() as unknown as { gisMap: any };
// 기존 처리구역 관로분석 정보의 조회 상태다.
const [analysisData, setAnalysisData] =
useState<SwtrmtPrcsZoneAnalysisResponse | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
// 보수이력 지도 레이어는 React 렌더링 대상이 아니므로 ref로 보관해 팝업 종료·구역 전환 때 제거한다.
const repairLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
const repairPopupRef = useRef<Window | null>(null);
const requestKey = useMemo(() => {
const rawValues = rawFeatureData?.values_ ?? rawFeatureData ?? {};
@@ -87,68 +144,237 @@ const SwtrmtPrcsZoneAnalysis = ({
rawFeatureData?.id_,
rawFeatureData?.fid,
rawValues?.fid,
rawValues?.swtrmt_zone_sn,
rawValues?.swtrmt_fclt_cd,
featureData?.fid,
featureData?.swtrmtZoneSn,
featureData?.swtrmt_zone_sn,
featureData?.swtrmtFcltCd,
rawValues?.swtrmt_fclt_cd,
rawValues?.swtrmtFcltCd,
featureData?.swtrmt_fclt_cd,
].filter(Boolean).join("|");
featureData?.swtrmtFcltCd,
]
.filter(Boolean)
.join("|");
}, [featureData, rawFeatureData]);
const zoneId = useMemo(
() => getSwtrmtPrcsZoneId(rawFeatureData, featureData),
[featureData, rawFeatureData, requestKey],
);
/** 이전 보수이력 조회에서 만든 임시 벡터 레이어를 제거한다. */
const clearRepairLayer = useCallback(() => {
const layer = repairLayerRef.current;
if (layer && gisMap) gisMap.removeLayer(layer);
layer?.getSource()?.clear();
repairLayerRef.current = null;
}, [gisMap]);
/** 윈도우 팝업 조회 결과 전체를 청록색 관로 레이어로 교체한다. */
const replaceRepairLayer = useCallback(
(rows: RepairHistoryRow[]) => {
if (!gisMap) return;
clearRepairLayer();
const format = new GeoJSON();
const projection = gisMap.getView()?.getProjection();
const features = rows.flatMap((row) => {
if (!row.geometryJson) return [];
try {
const geometry = format.readGeometry(JSON.parse(row.geometryJson), {
dataProjection: "EPSG:3857",
featureProjection: projection,
});
const feature = new Feature({ geometry, repairFid: row.fid });
feature.setId(`repair-history-${row.fid}`);
return [feature];
} catch (error) {
console.warn("보수이력 관로 도형을 읽지 못했습니다.", row.fid, error);
return [];
}
});
if (features.length === 0) return;
const layer = new VectorLayer({
source: new VectorSource({ features }),
style: (feature) =>
feature.get("repairSelected")
? selectedRepairHistoryStyle
: repairHistoryStyle,
});
layer.set("repairHistoryLayer", true);
layer.setZIndex(10020);
gisMap.addLayer(layer);
repairLayerRef.current = layer;
},
[clearRepairLayer, gisMap],
);
/** 팝업에서 클릭한 관로를 붉게 선택하고 부모 지도를 해당 범위로 이동한다. */
const focusRepairFeature = useCallback(
(fid: string) => {
const features = repairLayerRef.current?.getSource()?.getFeatures() ?? [];
const selectedFeature = features.find(
(feature) => String(feature.get("repairFid")) === String(fid),
);
if (!selectedFeature || !gisMap) return;
features.forEach((feature) =>
feature.set("repairSelected", feature === selectedFeature),
);
repairLayerRef.current?.changed();
const extent = selectedFeature.getGeometry()?.getExtent();
if (extent) {
gisMap.getView().fit(extent, {
padding: [100, 100, 100, 460],
duration: 350,
maxZoom: 18,
});
}
},
[gisMap],
);
useEffect(() => {
let isMounted = true;
setIsLoading(true);
setErrorMessage("");
const fetchAnalysis = async () => {
setIsLoading(true);
setErrorMessage("");
setAnalysisData(null);
const loadAnalysis = async () => {
try {
const data = await fetchSwtrmtPrcsZoneAnalysis(rawFeatureData, featureData);
const data = await fetchSwtrmtPrcsZoneAnalysis(
rawFeatureData,
featureData,
);
if (!isMounted) return;
if (!data) {
setErrorMessage("분석정보 조회에 필요한 처리구역 식별값이 없습니다.");
setAnalysisData(null);
setErrorMessage(
"처리구역 식별값이 없어 관로분석정보를 조회하지 못했습니다.",
);
return;
}
setAnalysisData(data);
} catch (error) {
if (!isMounted) return;
console.error("하수처리구역 분석정보 로딩 중 오류:", error);
setErrorMessage("분석정보를 불러오지 못했습니다.");
setAnalysisData(null);
setErrorMessage(
error instanceof Error
? error.message
: "관로분석정보를 불러오지 못했습니다.",
);
} finally {
if (isMounted) setIsLoading(false);
}
};
fetchAnalysis();
void loadAnalysis();
return () => {
isMounted = false;
};
}, [featureData, rawFeatureData, requestKey]);
useEffect(() => {
const handleRepairPopupMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
const data = event.data;
const messageZoneId = String(data?.payload?.zoneId ?? "").trim();
if (!messageZoneId || messageZoneId !== zoneId) return;
if (
data?.type === WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SHOW_RESULTS
) {
replaceRepairLayer(
Array.isArray(data.payload?.rows) ? data.payload.rows : [],
);
return;
}
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SELECT_PIPE) {
focusRepairFeature(String(data.payload?.fid ?? ""));
return;
}
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_CLEAR) {
clearRepairLayer();
}
};
window.addEventListener("message", handleRepairPopupMessage);
return () =>
window.removeEventListener("message", handleRepairPopupMessage);
}, [clearRepairLayer, focusRepairFeature, replaceRepairLayer, zoneId]);
useEffect(() => {
clearRepairLayer();
}, [clearRepairLayer, requestKey]);
useEffect(() => () => clearRepairLayer(), [clearRepairLayer]);
useEffect(() => {
const timerId = window.setInterval(() => {
if (repairPopupRef.current?.closed) {
repairPopupRef.current = null;
clearRepairLayer();
}
}, 500);
return () => window.clearInterval(timerId);
}, [clearRepairLayer]);
/** 선택 처리구역을 고정한 기존 공통 윈도우 팝업을 연다. */
const handleOpenRepairHistory = () => {
if (!zoneId) {
setErrorMessage("처리구역 식별값이 없어 보수이력을 열 수 없습니다.");
return;
}
clearRepairLayer();
const popup = openWindowPopup("repairHistoryPopup", {
zoneId,
zoneName:
analysisData?.zone?.swtrmtZoneNm ??
featureData?.swtrmt_zone_nm ??
featureData?.swtrmtZoneNm,
});
if (!popup) {
alert("팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.");
return;
}
repairPopupRef.current = popup;
};
return (
<>
<div className="card-header flex items-center justify-between">
<h3 className="text-2xl mx-3 font-medium text-label">처리구역내 관로분석정보</h3>
<h3 className="mx-3 text-2xl font-medium text-label">
처리구역내 관로분석정보
</h3>
<Button
type="button"
className="hover:bg-primary/90 mr-3 bg-primary text-primary-foreground"
onClick={handleOpenRepairHistory}
data-testid="repair-history-open"
>
보수이력
</Button>
</div>
<div className="card !mx-[1.2rem] !mb-[1.2rem] !mt-1 !p-0">
<div className="px-3 pb-3 pt-1">
{!isLoading && errorMessage && (
<div className="py-6 text-center text-[13px] text-gray-500">{errorMessage}</div>
)}
{(isLoading || !errorMessage) && (
{errorMessage ? (
<div className="mb-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-[13px] text-red-700">
{errorMessage}
</div>
) : null}
{isLoading ? (
<div
role="status"
className="flex min-h-[12rem] items-center justify-center rounded-md border border-border bg-background text-[1.2rem] text-muted-foreground"
>
관로분석정보를 조회하고 있습니다.
</div>
) : (
metricGroups.map((group) => (
<MetricVerticalGrid
key={group.field}
key={group.title}
title={group.title}
field={group.field}
analysis={analysisData?.analysis}
isLoading={isLoading}
/>
))
)}
@@ -1,18 +1,21 @@
'use client'
"use client";
import React, { useEffect, useMemo, useState } from 'react';
import moment from 'moment';
import React, { useEffect, useMemo, useState } from "react";
import moment from "moment";
import {
CHART_FORM,
type ChartConfig,
MainChart,
} from '@common/business_components';
import { SelectBox } from '@common/business_components/ui';
import { Button, Input, Label } from '@common/components';
StandardFormField,
StandardFormSection,
StandardPopupLayout,
} from "@common/business_components";
import { SelectBox } from "@common/business_components/ui";
import { Button, Input } from "@common/components";
import type { TInftwtAnls, TInftwtAnlsRslt, TOilqData } from './aplcnYms';
import * as fn from './aplcnYms';
import type { TInftwtAnls, TInftwtAnlsRslt, TOilqData } from "./aplcnYms";
import * as fn from "./aplcnYms";
type ModalOpenProp = {
pplMngNo?: string;
@@ -27,43 +30,46 @@ type DataType = {
val: number;
};
const INITIAL_ANALYSIS_OPTIONS = [{ val: 0, text: '선택' }];
const INITIAL_ANALYSIS_OPTIONS = [{ val: 0, text: "선택" }];
const InfilChart = ({
pplMngNo = '',
pplMngNo = "",
inftwtAnlsSn = 0,
aplcnYm = '',
analysisInfo = '',
aplcnYm = "",
analysisInfo = "",
resultOnly = false,
}: ModalOpenProp) => {
const [selectedAplcnYm, setSelectedAplcnYm] = useState('');
const [selectedAplcnYm, setSelectedAplcnYm] = useState("");
const [inftwtAnlss, setInftwtAnlss] = useState<TInftwtAnls[]>([]);
const [inftwtAnlsInfos, setInftwtAnlsInfos] = useState<{ text: string; val: number; hidden?: boolean }[]>(
INITIAL_ANALYSIS_OPTIONS,
);
const [inftwtAnlsInfos, setInftwtAnlsInfos] = useState<
{ text: string; val: number; hidden?: boolean }[]
>(INITIAL_ANALYSIS_OPTIONS);
const [inftwtAnls, setInftwtAnls] = useState<TInftwtAnls | null>(null);
const [selectedInftwtAnls, setSelectedInftwtAnls] = useState<number>(0);
const [chartData, setChartData] = useState<{ chartData1: DataType[]; chartData2: DataType[] }>({
const [chartData, setChartData] = useState<{
chartData1: DataType[];
chartData2: DataType[];
}>({
chartData1: [],
chartData2: [],
});
const [inftwtQty, setInftwtQty] = useState<string>('0');
const [inftwtQty, setInftwtQty] = useState<string>("0");
const aplcnYms = fn.useAplcnYms('inftwtAnlsRslt');
const normalizedPplMngNo = pplMngNo ? `L${pplMngNo}` : '';
const isResultOnlyMode = resultOnly && inftwtAnlsSn > 0 && Boolean(normalizedPplMngNo);
const aplcnYms = fn.useAplcnYms("inftwtAnlsRslt");
const normalizedPplMngNo = pplMngNo ? `L${pplMngNo}` : "";
const isResultOnlyMode =
resultOnly && inftwtAnlsSn > 0 && Boolean(normalizedPplMngNo);
const chartForm = CHART_FORM.LINE;
const chartConfig = {
chartData1: { label: '순오수량', color: 'red' },
chartData2: { label: '실측하수량', color: 'orange' },
chartData1: { label: "순오수량", color: "red" },
chartData2: { label: "실측하수량", color: "orange" },
} as ChartConfig;
const hasChartData = useMemo(() => {
return chartData.chartData1.length > 0 || chartData.chartData2.length > 0;
}, [chartData]);
const calculateFallbackInftwtQty = (
inftwtAnlsRslt: TInftwtAnlsRslt[],
oilqRows: TOilqData[],
@@ -79,26 +85,28 @@ const InfilChart = ({
return 0;
}
const avgPredc = predcValues.reduce((sum, value) => sum + value, 0) / predcValues.length;
const avgOilq = oilqValues.reduce((sum, value) => sum + value, 0) / oilqValues.length;
const avgPredc =
predcValues.reduce((sum, value) => sum + value, 0) / predcValues.length;
const avgOilq =
oilqValues.reduce((sum, value) => sum + value, 0) / oilqValues.length;
const dailyQty = (avgOilq - avgPredc) * 24;
return Math.round(dailyQty / 10) * 10;
};
const resetState = () => {
setSelectedAplcnYm('');
setSelectedAplcnYm("");
setInftwtAnlss([]);
setInftwtAnlsInfos(INITIAL_ANALYSIS_OPTIONS);
setInftwtAnls(null);
setSelectedInftwtAnls(0);
setChartData({ chartData1: [], chartData2: [] });
setInftwtQty('0');
setInftwtQty("0");
};
const loadResultChart = async (targetInftwtAnlsSn: number) => {
if (!targetInftwtAnlsSn || !normalizedPplMngNo) {
setChartData({ chartData1: [], chartData2: [] });
setInftwtQty('0');
setInftwtQty("0");
return;
}
@@ -106,13 +114,15 @@ const InfilChart = ({
const [inftwtAnlsRslt, oilq, qty] = await Promise.all([
fn.selectInftwtAnlsRslt(targetInftwtAnlsSn, normalizedPplMngNo),
fn.selectOilq(targetInftwtAnlsSn, normalizedPplMngNo).catch((err) => {
console.error('selectOilq 에러:', err);
console.error("selectOilq 에러:", err);
return [];
}),
fn.selectPplInftwtQty(targetInftwtAnlsSn, normalizedPplMngNo).catch((err) => {
console.error('selectPplInftwtQty 에러:', err);
return 0;
}),
fn
.selectPplInftwtQty(targetInftwtAnlsSn, normalizedPplMngNo)
.catch((err) => {
console.error("selectPplInftwtQty 에러:", err);
return 0;
}),
]);
setChartData({
@@ -120,26 +130,37 @@ const InfilChart = ({
chartData2: fn.getChartDataOilq(oilq ?? []),
});
const fallbackQty = calculateFallbackInftwtQty(inftwtAnlsRslt ?? [], oilq ?? []);
const fallbackQty = calculateFallbackInftwtQty(
inftwtAnlsRslt ?? [],
oilq ?? [],
);
const normalizedQty = Number(qty ?? Number.NaN);
const resolvedQty = Number.isFinite(normalizedQty) && !(normalizedQty === 0 && fallbackQty !== 0)
? normalizedQty
: fallbackQty;
const resolvedQty =
Number.isFinite(normalizedQty) &&
!(normalizedQty === 0 && fallbackQty !== 0)
? normalizedQty
: fallbackQty;
if (!Number.isFinite(normalizedQty) || (normalizedQty === 0 && fallbackQty !== 0)) {
console.warn('Inftwt qty API returned empty or zero; using chart-data fallback qty.', {
apiQty: qty,
fallbackQty,
resultCount: inftwtAnlsRslt?.length ?? 0,
oilqCount: oilq?.length ?? 0,
});
if (
!Number.isFinite(normalizedQty) ||
(normalizedQty === 0 && fallbackQty !== 0)
) {
console.warn(
"Inftwt qty API returned empty or zero; using chart-data fallback qty.",
{
apiQty: qty,
fallbackQty,
resultCount: inftwtAnlsRslt?.length ?? 0,
oilqCount: oilq?.length ?? 0,
},
);
}
setInftwtQty(resolvedQty.toLocaleString('ko-KR'));
setInftwtQty(resolvedQty.toLocaleString("ko-KR"));
} catch (error) {
console.error('침입수 분석결과 차트 조회 중 오류:', error);
console.error("침입수 분석결과 차트 조회 중 오류:", error);
setChartData({ chartData1: [], chartData2: [] });
setInftwtQty('0');
setInftwtQty("0");
}
};
@@ -159,10 +180,10 @@ const InfilChart = ({
setInftwtAnlss([]);
setInftwtAnls(null);
setSelectedInftwtAnls(0);
setInftwtQty('0');
setInftwtQty("0");
setChartData({ chartData1: [], chartData2: [] });
if (!selectedValue || selectedValue === '0') {
if (!selectedValue || selectedValue === "0") {
setInftwtAnlsInfos(INITIAL_ANALYSIS_OPTIONS);
return;
}
@@ -173,7 +194,7 @@ const InfilChart = ({
setInftwtAnlss(list);
setInftwtAnlsInfos(fn.getOptionsInftwtAnlsInfo(list));
} catch (error) {
console.error('selectInftwtAnls 에러:', error);
console.error("selectInftwtAnls 에러:", error);
setInftwtAnlss([]);
setInftwtAnlsInfos(INITIAL_ANALYSIS_OPTIONS);
}
@@ -184,37 +205,37 @@ const InfilChart = ({
setSelectedInftwtAnls(selectedValue);
setChartData({ chartData1: [], chartData2: [] });
setInftwtQty('0');
setInftwtQty("0");
if (selectedValue === 0) {
setInftwtAnls(null);
return;
}
const selectedAnls = inftwtAnlss.find((anls) => anls.inftwtAnlsSn === selectedValue) ?? null;
const selectedAnls =
inftwtAnlss.find((anls) => anls.inftwtAnlsSn === selectedValue) ?? null;
setInftwtAnls(selectedAnls);
};
const infilRsltChart = async () => {
if (!selectedAplcnYm || selectedInftwtAnls === 0 || !normalizedPplMngNo) {
setChartData({ chartData1: [], chartData2: [] });
setInftwtQty('0');
setInftwtQty("0");
return;
}
await loadResultChart(selectedInftwtAnls);
};
const renderInfoInput = (label: string, value?: string | number | null) => (
<div className="grid grid-cols-[minmax(0,32%)_minmax(0,68%)] items-center gap-3">
<Label className="w-auto text-[14px] font-semibold text-[#5c6470] whitespace-nowrap">{label}</Label>
<Input
className="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white px-3 text-xl md:text-xl text-[#2b3440]"
placeholder=""
value={value ?? ''}
readOnly
/>
</div>
/** 분석 메타데이터를 공통 표형 폼의 읽기 전용 셀로 표시한다. */
const renderInfoField = (
label: string,
value?: string | number | null,
span: 1 | 2 = 1,
) => (
<StandardFormField label={label} columns={2} span={span}>
<Input className="w-full" value={value ?? ""} readOnly />
</StandardFormField>
);
const handleReset = () => {
@@ -222,116 +243,122 @@ const InfilChart = ({
};
const renderResultPanel = () => (
<section className="relative rounded-[12px] border border-[#b1b1b1] bg-white p-4">
<h3 className="mb-4 text-[20px] font-extrabold tracking-[-0.02em] text-black">침입수 분석 결과</h3>
<div className="mb-4 grid grid-cols-2 gap-4">
<div className="flex min-h-[98px] items-center justify-between rounded-[14px] border border-[#4f88c6] bg-white px-6 py-4">
<div className="flex items-center gap-2 text-[16px] font-extrabold text-[#ef4444]">
<span>상수사용량 적용연월</span>
<section className="card card-border !m-0 min-w-0 overflow-hidden !p-[1.2rem]">
<StandardFormSection title="침입수 분석결과" columns={2}>
<StandardFormField label="상수사용량 적용연월" columns={2}>
<div className="flex min-h-[3.2rem] items-center justify-end text-[1.35rem] font-semibold tabular-nums text-foreground">
{selectedAplcnYm
? moment(selectedAplcnYm, "YYYYMM").format("YYYY-MM")
: "-"}
</div>
<div className="text-[20px] font-extrabold text-[#222]">
{selectedAplcnYm ? moment(selectedAplcnYm, 'YYYYMM').format('YYYY-MM') : '-'}
</div>
</div>
<div className="flex min-h-[98px] items-center justify-between rounded-[14px] border border-[#4f88c6] bg-white px-6 py-4">
<div className="flex items-center gap-2 text-[16px] font-extrabold text-[#ef4444]">
<span>침입수량 산정결과</span>
</div>
<div className="flex items-end gap-3 whitespace-nowrap text-[#222]">
<span className="text-[16px] text-[#666]">침입수량</span>
<div className="flex items-end justify-start gap-2 whitespace-nowrap">
<span className="text-[20px] font-extrabold">{inftwtQty}</span>
<span className="pb-[2px] text-[16px]">m³/d</span>
</StandardFormField>
<StandardFormField label="침입수량 산정결과" columns={2}>
<div className="flex min-h-[3.2rem] items-center justify-end gap-3 whitespace-nowrap text-foreground">
<span className="text-[1.15rem] text-muted-foreground">
침입수량
</span>
<div className="flex items-baseline gap-2">
<strong className="text-[1.5rem] font-semibold tabular-nums">
{inftwtQty}
</strong>
<span className="text-[1.1rem]">m³/d</span>
</div>
</div>
</div>
</div>
</StandardFormField>
</StandardFormSection>
<div className="rounded-[10px] bg-white px-2 pb-2 pt-3">
{hasChartData ? (
<MainChart
chartConfig={chartConfig}
chartForm={chartForm}
chartData={chartData}
chartTitle={'침입수량 그래프'}
yFieldText="유량(m³/h)"
yFieldText2=""
/>
) : (
<div className="flex h-[464px] items-center justify-center text-[18px] text-[#2c2f33]">
데이터가 없습니다.
</div>
)}
</div>
<section className="mt-[1.2rem] overflow-hidden rounded-sm border border-border bg-background">
<h4 className="border-b border-border bg-container px-4 py-3 text-[1.4rem] font-semibold text-label">
침입수량 그래프
</h4>
<div className="min-h-[30rem] p-3">
{hasChartData ? (
<MainChart
chartConfig={chartConfig}
chartForm={chartForm}
chartData={chartData}
chartTitle=""
yFieldText="유량(m³/h)"
yFieldText2=""
/>
) : (
<div
className="flex min-h-[30rem] items-center justify-center text-[1.3rem] text-muted-foreground"
role="status"
>
조회된 데이터가 없습니다.
</div>
)}
</div>
</section>
</section>
);
const renderDefaultBody = () => (
<div className="text-black">
<div className="grid min-h-[68vh] grid-cols-[minmax(0,38%)_minmax(0,62%)] gap-5">
<section className="relative rounded-[12px] border border-[#b1b1b1] bg-white p-5">
<h3 className="mb-6 text-[20px] font-extrabold tracking-[-0.02em] text-black">
침입수 분석 선택 조건
</h3>
<div className="flex min-h-0 flex-1 flex-col gap-[1.2rem]">
<StandardFormSection title="침입수 분석 조건" columns={2}>
<StandardFormField label="상수사용월" columns={2}>
<SelectBox
className="w-full"
selectClass="w-full"
label=""
selectArray={aplcnYms}
value={selectedAplcnYm}
onSelectValue={handleAplcnYmChange}
/>
</StandardFormField>
<StandardFormField label="분석정보" columns={2}>
<SelectBox
className="w-full"
selectClass="w-full"
label=""
selectArray={inftwtAnlsInfos}
value={selectedInftwtAnls}
onSelectValue={handleInftwtAnlsSnChange}
/>
</StandardFormField>
{renderInfoField("오수전환율", inftwtAnls?.drtwSwtcRt ?? "")}
{renderInfoField(
"시간패턴 파일명",
inftwtAnls?.atflHrPttnNm ?? "",
)}
{renderInfoField("수용가-맨홀 매칭", inftwtAnls?.atflMpngNm ?? "")}
{renderInfoField("관망도 파일명", inftwtAnls?.atflInpNm ?? "")}
{renderInfoField("유량 파일명", inftwtAnls?.atflOilqNm ?? "", 2)}
</StandardFormSection>
<div className="rounded-[6px] border border-[#d9dde3] bg-[#eff1f4] px-4 py-6">
<div className="space-y-3">
<div className="grid grid-cols-[minmax(0,32%)_minmax(0,68%)] items-center gap-3">
<Label className="w-auto text-[14px] font-semibold text-[#5c6470] whitespace-nowrap">
상수사용월:
</Label>
<SelectBox
className="w-full"
selectClass="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white text-xl"
label=""
selectArray={aplcnYms}
value={selectedAplcnYm}
onSelectValue={handleAplcnYmChange}
/>
</div>
{renderResultPanel()}
</div>
);
<div className="grid grid-cols-[minmax(0,32%)_minmax(0,68%)] items-center gap-3">
<Label className="w-auto text-[14px] font-semibold text-[#5c6470] whitespace-nowrap">
분석정보:
</Label>
<SelectBox
className="w-full"
selectClass="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white text-xl"
label=""
selectArray={inftwtAnlsInfos}
value={selectedInftwtAnls}
onSelectValue={handleInftwtAnlsSnChange}
/>
</div>
{renderInfoInput('오수전환율:', inftwtAnls?.drtwSwtcRt ?? '')}
{renderInfoInput('시간패턴 파일명:', inftwtAnls?.atflHrPttnNm ?? '')}
{renderInfoInput('수용가-맨홀 매칭:', inftwtAnls?.atflMpngNm ?? '')}
{renderInfoInput('관망도 파일명:', inftwtAnls?.atflInpNm ?? '')}
{renderInfoInput('유량 파일명:', inftwtAnls?.atflOilqNm ?? '')}
</div>
</div>
<div className="absolute inset-x-0 bottom-0 mb-[40px] flex justify-center">
<Button size="lg" onClick={infilRsltChart} >
조회
</Button>&nbsp;&nbsp;
<Button size="lg" color="gray" onClick={handleReset} >
초기화
</Button>
</div>
</section>
{renderResultPanel()}
</div>
/** 조회·초기화 뒤에 창 닫기 성격의 취소 액션을 항상 마지막에 배치한다. */
const renderFooterActions = () => (
<div className="flex w-full items-center justify-center gap-2">
{!isResultOnlyMode ? (
<>
<Button type="button" onClick={infilRsltChart}>
조회
</Button>
<Button type="button" color="white" onClick={handleReset}>
초기화
</Button>
</>
) : null}
<Button type="button" color="white" onClick={() => window.close()}>
취소
</Button>
</div>
);
return (
<div className="min-h-dvh bg-white p-3">
<StandardPopupLayout
title={isResultOnlyMode ? "침입수 분석결과" : "침입수 분석"}
bodyClassName="overflow-x-hidden"
footer={renderFooterActions()}
showDefaultCancelAction={false}
>
{isResultOnlyMode ? renderResultPanel() : renderDefaultBody()}
</div>
</StandardPopupLayout>
);
};
@@ -7,9 +7,12 @@ import {
CHART_FORM,
type ChartConfig,
MainChart,
StandardFormField,
StandardFormSection,
StandardPopupLayout,
} from "@common/business_components";
import { SelectBox } from "@common/business_components/ui";
import { Button, Input, Label } from "@common/components";
import { Button, Input } from "@common/components";
import type { TRdiiAnls } from "./aplcnYms";
import * as fn from "./aplcnYms";
@@ -45,18 +48,20 @@ const RdiiChart = ({
}: ModalOpenProp) => {
const [selectedAplcnYm, setSelectedAplcnYm] = useState("");
const [rdiiAnlss, setRdiiAnlss] = useState<TRdiiAnls[]>([]);
const [rdiiAnlsInfos, setRdiiAnlsInfos] = useState<{ text: string; val: number; hidden?: boolean }[]>(
INITIAL_ANALYSIS_OPTIONS,
);
const [rdiiAnlsInfos, setRdiiAnlsInfos] = useState<
{ text: string; val: number; hidden?: boolean }[]
>(INITIAL_ANALYSIS_OPTIONS);
const [rdiiAnls, setRdiiAnls] = useState<TRdiiAnls | null>(null);
const [selectedRainInfwtAnlsSn, setSelectedRainInfwtAnlsSn] = useState<number>(0);
const [selectedRainInfwtAnlsSn, setSelectedRainInfwtAnlsSn] =
useState<number>(0);
const [chartData, setChartData] = useState<LineBarChartData[]>([]);
const [rdiiQty, setRdiiQty] = useState<string>("0");
const [rainTotal, setRainTotal] = useState<string>("0");
const [totalVolume, setTotalVolume] = useState<string>("0");
const normalizedPplMngNo = normalizePplMngNo(pplMngNo);
const isResultOnlyMode = resultOnly && rainInfwtAnlsSn > 0 && Boolean(normalizedPplMngNo);
const isResultOnlyMode =
resultOnly && rainInfwtAnlsSn > 0 && Boolean(normalizedPplMngNo);
const aplcnYms = fn.useAplcnYms(isResultOnlyMode ? "" : "rdiiAnlsRslt");
const chartForm = CHART_FORM.LINEBAR2;
@@ -103,7 +108,9 @@ const RdiiChart = ({
const rsltDataFormatted = fn.getChartDataRdiiAnlsRslt(rsltData ?? []);
const rainDataFormatted = fn.getChartDataRainData(rainData ?? []);
const rainMap = new Map(rainDataFormatted.map((item) => [item.label, item.barVal0]));
const rainMap = new Map(
rainDataFormatted.map((item) => [item.label, item.barVal0]),
);
rsltDataFormatted.forEach((item) => {
const matchedVal = rainMap.get(item.label);
@@ -118,7 +125,9 @@ const RdiiChart = ({
const appliedRainfall = Number(qtyData?.raintotal ?? 0);
const rdiiPerRainfall =
qtyData?.raintotal && qtyData?.raintotal !== 0
? Math.round(avgRdii * Number(qtyData?.sustaintime ?? 0) / qtyData.raintotal)
? Math.round(
(avgRdii * Number(qtyData?.sustaintime ?? 0)) / qtyData.raintotal,
)
: 0;
setRdiiQty(Math.round(avgRdii * 24).toLocaleString("ko-KR"));
@@ -179,7 +188,8 @@ const RdiiChart = ({
return;
}
const selectedAnls = rdiiAnlss.find((anls) => anls.rainInfwtAnlsSn === selectedValue) ?? null;
const selectedAnls =
rdiiAnlss.find((anls) => anls.rainInfwtAnlsSn === selectedValue) ?? null;
setRdiiAnls(selectedAnls);
};
@@ -195,136 +205,140 @@ const RdiiChart = ({
await loadResultChart(selectedRainInfwtAnlsSn);
};
const renderInfoInput = (label: string, value?: string | number | null) => (
<div className="grid grid-cols-[minmax(0,32%)_minmax(0,68%)] items-center gap-3">
<Label className="w-auto whitespace-nowrap text-[14px] font-semibold text-[#5c6470]">
{label}
</Label>
<Input
className="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white px-3 text-xl text-[#2b3440]"
placeholder=""
value={value ?? ""}
readOnly
/>
</div>
/** 분석 메타데이터를 공통 표형 폼의 읽기 전용 셀로 표시한다. */
const renderInfoField = (
label: string,
value?: string | number | null,
span: 1 | 2 = 1,
) => (
<StandardFormField label={label} columns={2} span={span}>
<Input className="w-full" value={value ?? ""} readOnly />
</StandardFormField>
);
const renderResultPanel = () => (
<section className="relative rounded-[12px] border border-[#b1b1b1] bg-white p-4">
<h3 className="mb-4 text-[20px] font-extrabold tracking-[-0.02em] text-black">RDII 분석 결과</h3>
<div className="mb-4 grid grid-cols-2 gap-4">
<div className="flex min-h-[98px] items-center justify-between rounded-[14px] border border-[#4f88c6] bg-white px-6 py-4">
<div className="flex items-center gap-2 text-[16px] font-extrabold text-[#ef4444]">
<span>상수사용 적용연월</span>
<section className="card card-border !m-0 min-w-0 overflow-hidden !p-[1.2rem]">
<StandardFormSection title="RDII 분석결과" columns={2}>
<StandardFormField label="상수사용 적용연월" columns={2}>
<div className="flex min-h-[3.2rem] items-center justify-end text-[1.35rem] font-semibold tabular-nums text-foreground">
{selectedAplcnYm
? moment(selectedAplcnYm, "YYYYMM").format("YYYY-MM")
: "-"}
</div>
<div className="text-[20px] font-extrabold text-[#222]">
{selectedAplcnYm ? moment(selectedAplcnYm, "YYYYMM").format("YYYY-MM") : "-"}
</StandardFormField>
<StandardFormField label="강우유입수량 분석결과" columns={2}>
<div className="grid min-h-[3.2rem] grid-cols-[minmax(0,1fr)_7rem_5rem] items-center gap-x-3 gap-y-1 text-[1.1rem] text-foreground">
<span className="text-right text-muted-foreground">평균 RDII</span>
<strong className="text-right text-[1.35rem] font-semibold tabular-nums">
{rdiiQty}
</strong>
<span>m³/d</span>
<span className="text-right text-muted-foreground">적용 강우량</span>
<strong className="text-right text-[1.35rem] font-semibold tabular-nums">
{rainTotal}
</strong>
<span>mm</span>
<span className="text-right text-muted-foreground">
강우량 대비 RDII
</span>
<strong className="text-right text-[1.35rem] font-semibold tabular-nums">
{totalVolume}
</strong>
<span>m³/mm</span>
</div>
</div>
</StandardFormField>
</StandardFormSection>
<div className="flex min-h-[98px] items-center justify-between rounded-[14px] border border-[#4f88c6] bg-white px-6 py-4">
<div className="shrink-0 whitespace-nowrap text-[14px] font-extrabold text-[#ef4444]">
<span>강우유입수량 분석결과</span>
</div>
<div className="ml-4 flex min-w-0 basis-[60%] flex-col gap-[2px]">
<div className="flex min-w-0 items-center gap-3">
<div className="min-w-0 basis-[48%] text-right text-[15px] text-[#555]">평균RDII</div>
<div className="min-w-0 basis-[32%] text-right text-[18px] font-extrabold text-[#111] tabular-nums">{rdiiQty}</div>
<div className="min-w-0 basis-[20%] text-left text-[14px] text-[#111]">m³/d</div>
<section className="mt-[1.2rem] overflow-hidden rounded-sm border border-border bg-background">
<h4 className="border-b border-border bg-container px-4 py-3 text-[1.4rem] font-semibold text-label">
RDII 그래프
</h4>
<div className="min-h-[30rem] p-3">
{hasChartData ? (
<MainChart
chartConfig={chartConfig}
chartForm={chartForm}
chartData={chartData}
chartTitle=""
yFieldText2="강수량(mm)"
yFieldText="유량(m³/h)"
/>
) : (
<div
className="flex min-h-[30rem] items-center justify-center text-[1.3rem] text-muted-foreground"
role="status"
>
조회된 데이터가 없습니다.
</div>
<div className="flex min-w-0 items-center gap-3">
<div className="min-w-0 basis-[48%] text-right text-[15px] text-[#555]">적용 강우량</div>
<div className="min-w-0 basis-[32%] text-right text-[18px] font-extrabold text-[#111] tabular-nums">{rainTotal}</div>
<div className="min-w-0 basis-[20%] text-left text-[14px] text-[#111]">mm</div>
</div>
<div className="flex min-w-0 items-center gap-3">
<div className="min-w-0 basis-[48%] text-right text-[15px] text-[#555]">강우량 대비 RDII</div>
<div className="min-w-0 basis-[32%] text-right text-[18px] font-extrabold text-[#111] tabular-nums">{totalVolume}</div>
<div className="min-w-0 basis-[20%] text-left text-[14px] text-[#111]">m³/mm</div>
</div>
</div>
)}
</div>
</div>
<div className="rounded-[10px] bg-white px-2 pb-2 pt-3">
{hasChartData ? (
<MainChart
chartConfig={chartConfig}
chartForm={chartForm}
chartData={chartData}
chartTitle={"RDII 그래프"}
yFieldText2="강수량(mm)"
yFieldText="유량(m³/h)"
/>
) : (
<div className="flex h-[464px] items-center justify-center text-[18px] text-[#2c2f33]">데이터가 없습니다.</div>
)}
</div>
</section>
</section>
);
const renderDefaultBody = () => (
<div className="text-black">
<div className="grid min-h-[68vh] grid-cols-[minmax(0,36%)_minmax(0,64%)] gap-5">
<section className="relative rounded-[12px] border border-[#b1b1b1] bg-white p-5">
<h3 className="mb-6 text-[20px] font-extrabold tracking-[-0.02em] text-black">강우유입수량 선택 조건</h3>
<div className="flex min-h-0 flex-1 flex-col gap-[1.2rem]">
<StandardFormSection title="RDII 분석 조건" columns={2}>
<StandardFormField label="상수사용월" columns={2}>
<SelectBox
className="w-full"
selectClass="w-full"
label=""
selectArray={aplcnYms}
value={selectedAplcnYm}
onSelectValue={handleAplcnYmChange}
/>
</StandardFormField>
<StandardFormField label="분석정보" columns={2}>
<SelectBox
className="w-full"
selectClass="w-full"
label=""
selectArray={rdiiAnlsInfos}
value={selectedRainInfwtAnlsSn}
onSelectValue={handleRdiiAnlsSnChange}
/>
</StandardFormField>
{renderInfoField("오수전환율", rdiiAnls?.drtwSwtcRt ?? "")}
{renderInfoField("수용가-맨홀 매칭", rdiiAnls?.atflMpngNm ?? "")}
{renderInfoField("관망도 파일명", rdiiAnls?.atflInpNm ?? "")}
{renderInfoField("강우 파일명", rdiiAnls?.atflRainNm ?? "")}
{renderInfoField("RTK계수 출처", rdiiAnls?.srcNm ?? "", 2)}
</StandardFormSection>
<div className="rounded-[6px] border border-[#d9dde3] bg-[#eff1f4] px-4 py-6">
<div className="space-y-3">
<div className="grid grid-cols-[minmax(0,32%)_minmax(0,68%)] items-center gap-3">
<Label className="w-auto whitespace-nowrap text-[14px] font-semibold text-[#5c6470]">상수사용월</Label>
<SelectBox
className="w-full"
selectClass="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white text-xl"
label=""
selectArray={aplcnYms}
value={selectedAplcnYm}
onSelectValue={handleAplcnYmChange}
/>
</div>
<div className="grid grid-cols-[minmax(0,32%)_minmax(0,68%)] items-center gap-3">
<Label className="w-auto whitespace-nowrap text-[14px] font-semibold text-[#5c6470]">분석정보:</Label>
<SelectBox
className="w-full"
selectClass="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white text-xl"
label=""
selectArray={rdiiAnlsInfos}
value={selectedRainInfwtAnlsSn}
onSelectValue={handleRdiiAnlsSnChange}
/>
</div>
{renderInfoInput("오수전환율", rdiiAnls?.drtwSwtcRt ?? "")}
{renderInfoInput("수용가-맨홀 매칭:", rdiiAnls?.atflMpngNm ?? "")}
{renderInfoInput("관망도 파일명", rdiiAnls?.atflInpNm ?? "")}
{renderInfoInput("강우 파일명", rdiiAnls?.atflRainNm ?? "")}
{renderInfoInput("RTK계수 출처:", rdiiAnls?.srcNm ?? "")}
</div>
</div>
<div className="absolute inset-x-0 bottom-0 mb-[40px] flex justify-center">
<Button size="lg" onClick={fetchFilteredData}>조회</Button>
&nbsp;&nbsp;
<Button size="lg" color="gray" onClick={resetState}>초기화</Button>
</div>
</section>
{renderResultPanel()}
</div>
{renderResultPanel()}
</div>
);
if (isResultOnlyMode) {
return <div className="min-h-dvh bg-white p-3">{renderResultPanel()}</div>;
}
/** 조회·초기화 뒤에 창 닫기 성격의 취소 액션을 항상 마지막에 배치한다. */
const renderFooterActions = () => (
<div className="flex w-full items-center justify-center gap-2">
{!isResultOnlyMode ? (
<>
<Button type="button" onClick={fetchFilteredData}>
조회
</Button>
<Button type="button" color="white" onClick={resetState}>
초기화
</Button>
</>
) : null}
<Button type="button" color="white" onClick={() => window.close()}>
취소
</Button>
</div>
);
return (
<div className="min-h-dvh bg-white p-4">
<h1 className="mb-3 text-2xl font-semibold text-gray-900">RDII 분석</h1>
{renderDefaultBody()}
</div>
<StandardPopupLayout
title={isResultOnlyMode ? "RDII 분석결과" : "RDII 분석"}
bodyClassName="overflow-x-hidden"
footer={renderFooterActions()}
showDefaultCancelAction={false}
>
{isResultOnlyMode ? renderResultPanel() : renderDefaultBody()}
</StandardPopupLayout>
);
};
export default React.memo(RdiiChart);
export default React.memo(RdiiChart);
File diff suppressed because it is too large Load Diff
@@ -13,23 +13,27 @@
* - 상류관로 조회: GET /api/v1/fcltmhl/upperPpls/{fcltMngNo} (sewerage-service)
*/
import { VerticalTable } from '@/components/ui/verticalTable';
import { useEffect, useState } from 'react';
import { type ReactNode, useEffect, useState } from 'react';
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import { getData } from '@/components/lib/api';
// 상류관로 보기 함수 (FeatureClickEventPopup에서 사용)
export async function searchUpperPpls(gisMap: any, fcltMngNo: any) {
export async function searchUpperPpls(
gisMap: any,
fcltMngNo: any,
notify: (message: string) => void,
) {
let data: any = null;
try {
data = await getData(`v1/fcltmhl/upperPpls/${fcltMngNo}`);
} catch (error) {
console.error("JSON parsing error:", error);
alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`);
notify(`해당 맨홀의 상류관로를 찾을 수 없습니다.`);
return;
}
if (!data) {
alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`);
notify(`해당 맨홀의 상류관로를 찾을 수 없습니다.`);
return;
}
@@ -59,7 +63,7 @@ export async function searchUpperPpls(gisMap: any, fcltMngNo: any) {
if (foundFeature) select?.selectMultiFeature?.(foundFeature, true);
});
if (!foundFeature) alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`);
if (!foundFeature) notify(`해당 맨홀의 상류관로를 찾을 수 없습니다.`);
const geometry = foundFeature?.getGeometry();
if (geometry) {
@@ -80,7 +84,15 @@ const inputColumn = [
{ id: "mltvDtdvNm", title: "배수분구", type: "input" },
{ id: "layCd", title: "매설위치", type: "select", options: ["도로", "하상"] },
];
const Smh = ({ featureData, onSaveClick }: { featureData: any, onSaveClick?: () => void }) => {
const Smh = ({
featureData,
onSaveClick,
surveyResultContent,
}: {
featureData: any;
onSaveClick?: () => void;
surveyResultContent?: ReactNode;
}) => {
const [formData, setFormData] = useState<any>({});
// featureData가 변경될 때 formData 초기화
@@ -104,6 +116,7 @@ const Smh = ({ featureData, onSaveClick }: { featureData: any, onSaveClick?: ()
};
return (
<>
<VerticalTable
headName='입력정보'
tableData={featureData ? [formData] : []}
@@ -120,6 +133,8 @@ const Smh = ({ featureData, onSaveClick }: { featureData: any, onSaveClick?: ()
onSaveClick={handleSaveClick}
onFormDataChange={handleFormDataChange} // 폼 데이터 변경 콜백 추가
/>
{surveyResultContent}
</>
)
}
@@ -1,4 +1,4 @@
import { getData } from "@/components/lib/api";
import { getBlobDataWithHeaders, getData } from "@/components/lib/api";
export type SwtrmtPrcsZoneAnalysisMetric = {
avgInstlYear?: number | string | null;
@@ -32,7 +32,43 @@ export type SwtrmtPrcsZoneAnalysisResponse = {
type ZoneAnalysisSource = Record<string, any> | null | undefined;
export type RepairHistoryFilters = {
useCd?: string;
materialCode?: string;
diameterFrom?: string;
diameterTo?: string;
};
export type RepairHistoryRow = {
rowNo: number;
fid: string;
lyrTypeNm?: string | null;
fcltMngNo?: string | null;
repairCount: number;
latestRepairYmd?: string | null;
latestRepairYear?: number | null;
elapsedYearsSinceRepair?: number | null;
useCd?: string | null;
useNm?: string | null;
installationYear?: number | null;
burialDepth?: number | null;
materialCode?: string | null;
materialName?: string | null;
pdmtVl?: number | null;
prlgVl?: number | null;
geometryJson?: string | null;
};
export type RepairHistoryResponse = {
totalElements: number;
totalPages: number;
page: number;
size: number;
data: RepairHistoryRow[];
};
const ANALYSIS_ENDPOINT_PREFIX = "v1/swagePrcsZones";
const REPAIR_HISTORY_PAGE_SIZE = 200;
const firstValue = (...values: unknown[]) => {
const value = values.find((item) => {
@@ -97,3 +133,71 @@ export const fetchSwtrmtPrcsZoneAnalysis = async (
return (await getData(path)) ?? {};
};
const appendRepairHistoryFilters = (params: URLSearchParams, filters: RepairHistoryFilters) => {
Object.entries(filters).forEach(([key, value]) => {
const normalized = String(value ?? "").trim();
if (normalized) params.append(key, normalized);
});
};
const getRepairHistoryPath = (
zoneId: string,
filters: RepairHistoryFilters,
suffix = "",
extraParams: Record<string, string> = {},
) => {
const params = new URLSearchParams();
appendRepairHistoryFilters(params, filters);
Object.entries(extraParams).forEach(([key, value]) => {
const normalized = value.trim();
if (normalized) params.append(key, normalized);
});
const query = params.toString();
return `${ANALYSIS_ENDPOINT_PREFIX}/${encodeURIComponent(zoneId)}/repairHistories${suffix}${query ? `?${query}` : ""}`;
};
/** 윈도우 팝업과 부모 지도가 같은 전체 결과를 사용하도록 서버 페이지를 끝까지 합친다. */
export const fetchAllRepairHistoriesByZoneId = async (
zoneId: string,
filters: RepairHistoryFilters,
): Promise<RepairHistoryResponse> => {
if (!zoneId.trim()) throw new Error("처리구역 식별값이 없습니다.");
const firstParams = new URLSearchParams();
appendRepairHistoryFilters(firstParams, filters);
firstParams.set("page", "0");
firstParams.set("size", String(REPAIR_HISTORY_PAGE_SIZE));
const first = (await getData(
`${ANALYSIS_ENDPOINT_PREFIX}/${encodeURIComponent(zoneId)}/repairHistories?${firstParams.toString()}`,
)) as RepairHistoryResponse;
const pages: RepairHistoryResponse[] = [first];
for (let page = 1; page < (first.totalPages ?? 0); page += 1) {
const params = new URLSearchParams(firstParams);
params.set("page", String(page));
pages.push((await getData(
`${ANALYSIS_ENDPOINT_PREFIX}/${encodeURIComponent(zoneId)}/repairHistories?${params.toString()}`,
)) as RepairHistoryResponse);
}
const data = pages.flatMap((page) => page?.data ?? []);
return {
...first,
totalElements: data.length,
totalPages: 1,
page: 0,
size: data.length,
data,
};
};
/** 현재 조회 조건의 화면 목록 항목 파일을 내려받는다. */
export const downloadRepairHistoriesByZoneId = async (
zoneId: string,
filters: RepairHistoryFilters,
zoneName: string,
) => {
if (!zoneId.trim()) throw new Error("처리구역 식별값이 없습니다.");
return getBlobDataWithHeaders(getRepairHistoryPath(zoneId, filters, "/download", { zoneName }));
};
@@ -1,14 +1,23 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { DateMonthPicker, UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
import { DateMonthPicker } from "@common/business_components/ui/date_month_picker";
import {
StandardPopupLayout,
type UiHeadProps,
} from "@common/business_components";
import { Button } from "@common/components";
import { getData } from "@/components/lib/api";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { useAlertStore } from "@/shared/lib";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue";
import useStore from "@/store/useStore";
import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefinitions";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import LoadingOverlay from "@/components/ui/LoadingBar";
import {
RDII_RESULT_ROW_SELECT_MESSAGE,
@@ -18,8 +27,8 @@ import {
const RDII_RESULT_PAGE_SIZE = 10;
const RDII_RESULT_HEADLIST = [
{ id: "rowNumber", title: "일련번호" },
const RDII_RESULT_HEADLIST: UiHeadProps[] = [
{ id: "rowNumber", title: "순번" },
{ id: "ctpvNm", title: "시도" },
{ id: "sggNm", title: "시군구" },
{ id: "swtrmtFcltNm", title: "하수처리시설" },
@@ -31,7 +40,7 @@ const RDII_RESULT_HEADLIST = [
{ id: "regDt", title: "분석일자" },
{ id: "rgtrNm", title: "분석자" },
{ id: "scsYn", title: "분석성공여부" },
] as const;
];
type RdiiResultFilters = {
ctpvCd: string;
@@ -84,6 +93,7 @@ const postRowSelectMessage = (row: RdiiResultRow) => {
};
export default function RdiiResultPopupPage() {
const alert = useAlertStore((state) => state.setMessage);
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const [filters, setFilters] = useState<RdiiResultFilters>(DEFAULT_FILTERS);
const [selectedSwagePrcsFclt, setSelectedSwagePrcsFclt] = useState("0");
@@ -91,7 +101,6 @@ export default function RdiiResultPopupPage() {
const [rows, setRows] = useState<RdiiResultRow[]>([]);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
@@ -104,27 +113,39 @@ export default function RdiiResultPopupPage() {
const queryParams = new URLSearchParams({
page: String(page - 1),
size: String(RDII_RESULT_PAGE_SIZE),
sort: "rainInfwtAnlsSn,desc",
scsYn: "Y",
});
queryParams.append("sort", "rainInfwtAnlsSn,desc");
if (nextFilters.aplcnYmFrom) queryParams.set("aplcnYmFrom", nextFilters.aplcnYmFrom);
if (nextFilters.aplcnYmTo) queryParams.set("aplcnYmTo", nextFilters.aplcnYmTo);
if (nextFilters.ctpvCd !== "0") queryParams.set("ctpvCd", nextFilters.ctpvCd);
if (nextFilters.sggCd !== "0") queryParams.set("sggCd", nextFilters.sggCd);
if (nextFilters.swtrmtFcltNm.trim()) queryParams.set("swtrmtFcltNm", nextFilters.swtrmtFcltNm.trim());
if (nextFilters.aplcnYmFrom)
queryParams.set("aplcnYmFrom", nextFilters.aplcnYmFrom);
if (nextFilters.aplcnYmTo)
queryParams.set("aplcnYmTo", nextFilters.aplcnYmTo);
if (nextFilters.ctpvCd !== "0")
queryParams.set("ctpvCd", nextFilters.ctpvCd);
if (nextFilters.sggCd !== "0")
queryParams.set("sggCd", nextFilters.sggCd);
if (nextFilters.swtrmtFcltNm.trim())
queryParams.set("swtrmtFcltNm", nextFilters.swtrmtFcltNm.trim());
const response = await getData(`v1/rdiiAnls?${queryParams.toString()}`);
const content = Array.isArray(response?.content) ? response.content : [];
const totalElements = Number(response?.totalElements ?? content.length);
const nextRows = content
.map((item: Record<string, unknown>, index: number) => {
const formattedItem = formatDisplayData(item);
const ctpvOption = ctpv.find((code) => code.val.trim() === String(item?.ctpvCd ?? "").trim());
const ctpvOption = ctpv.find(
(code) => code.val.trim() === String(item?.ctpvCd ?? "").trim(),
);
const sggOption = sgg.find((code) => code.val === item?.sggCd);
return {
...(formattedItem as Record<string, unknown>),
rowNumber: (page - 1) * RDII_RESULT_PAGE_SIZE + index + 1,
rowNumber: Math.max(
totalElements - ((page - 1) * RDII_RESULT_PAGE_SIZE + index),
0,
),
ctpvNm: ctpvOption ? ctpvOption.text : String(item?.ctpvCd ?? "-"),
sggNm: sggOption ? sggOption.text : String(item?.sggCd ?? "-"),
scsYn: String(item?.scsYn ?? "N"),
@@ -133,7 +154,7 @@ export default function RdiiResultPopupPage() {
.filter((item: RdiiResultRow) => item.scsYn === "Y");
setRows(nextRows);
setTotal(Number(response?.totalElements ?? nextRows.length));
setTotal(totalElements);
setCurrentPage(page);
},
[ctpv, sgg],
@@ -153,7 +174,7 @@ export default function RdiiResultPopupPage() {
setIsLoading(false);
}
},
[fetchRows, filters],
[alert, fetchRows, filters],
);
useEffect(() => {
@@ -162,17 +183,23 @@ export default function RdiiResultPopupPage() {
}, []);
const handleSearch = async (page = 1) => {
setSelectedIndex(-1);
setSelectedRow(null);
await loadRows(page, filters);
};
/** CS 검색 카드와 동일하게 조건과 선택 결과를 기본값으로 되돌린다. */
const handleReset = async () => {
setFilters(DEFAULT_FILTERS);
setSelectedSwagePrcsFclt("0");
setSelectedRow(null);
await loadRows(1, DEFAULT_FILTERS);
};
const handleRowClick = (index?: number) => {
if (typeof index !== "number") return;
const row = rows[index];
if (!row) return;
setSelectedIndex(index);
setSelectedRow(row);
postRowSelectMessage(row);
};
@@ -185,8 +212,12 @@ export default function RdiiResultPopupPage() {
try {
setIsLoading(true);
const response = await getData(`v1/rdiiAnlsRslt/ppl/${selectedRow.rainInfwtAnlsSn}`);
const excelRows = Array.isArray(response?.content) ? response.content : [];
const response = await getData(
`v1/rdiiAnlsRslt/ppl/${selectedRow.rainInfwtAnlsSn}`,
);
const excelRows = Array.isArray(response?.content)
? response.content
: [];
await downloadExcelWithKoreanHeaders({
data: excelRows as JSON[],
@@ -194,7 +225,7 @@ export default function RdiiResultPopupPage() {
sheetNameArray: ["RDII 분석결과"],
headerRange: "A1:D1",
headerMapping: {
rainInfwtAnlsSn: "RDII분석\n일련번호",
rainInfwtAnlsSn: "RDII분석\n분석번호",
pplMngNo: "관로\n관리번호",
anlsTm: "분석시각",
predcOilq: "예측유량\n(m³/h)",
@@ -209,139 +240,138 @@ export default function RdiiResultPopupPage() {
};
return (
<main className="sn-popup-page">
<StandardPopupLayout
title={windowPopupDefinitions.rdiiResultPopup.title}
bodyClassName="overflow-x-hidden"
>
<LoadingOverlay isLoading={isLoading} />
<header className="sn-popup-header">
<h1 className="sn-popup-title">RDII 분석결과</h1>
</header>
<section className="sn-popup-section overflow-y-auto">
<div className="rounded-md border border-border bg-secondary p-4">
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.05rem] font-medium text-label">상수사용기간</span>
<DateMonthPicker
field={{
value: filters.aplcnYmFrom,
onChange: (value: string) => {
setFilters((current) => ({
...current,
aplcnYmFrom: value,
}));
},
}}
selectClass="w-[145px]"
startYear={1900}
/>
<span className="text-[1.1rem] font-semibold text-muted-foreground">~</span>
<DateMonthPicker
field={{
value: filters.aplcnYmTo,
onChange: (value: string) => {
setFilters((current) => ({
...current,
aplcnYmTo: value,
}));
},
}}
selectClass="w-[145px]"
startYear={1900}
/>
</div>
<div className="min-w-0 flex-1 overflow-x-auto">
<CtpvSggPrcsFcltSelectBox
sido={filters.ctpvCd}
sigungu={filters.sggCd}
sggName={filters.sggName}
onSidoChange={(value) => {
setSelectedSwagePrcsFclt("0");
setFilters((current) => ({
...current,
ctpvCd: value,
sggCd: "0",
sggName: "",
swtrmtFcltNm: "",
}));
}}
onSigunguChange={(value) => {
setSelectedSwagePrcsFclt("0");
setFilters((current) => ({
...current,
sggCd: value,
swtrmtFcltNm: "",
}));
}}
setSggName={(value) => {
setSelectedSwagePrcsFclt("0");
setFilters((current) => ({
...current,
sggName: value ?? "",
swtrmtFcltNm: "",
}));
}}
selectedSwagePrcsFclt={selectedSwagePrcsFclt}
onSwagePrcsFcltChange={setSelectedSwagePrcsFclt}
setSwtrmtFcltNm={(value) =>
setFilters((current) => ({
...current,
swtrmtFcltNm: value ?? "",
}))
}
setIsLoading={setIsLoading}
/>
</div>
<div className="ml-auto flex items-center gap-2">
<Button
type="button"
size="sm"
color="primary"
className="h-10 px-5 !text-[1.1rem]"
onClick={() => void handleSearch(1)}
>
조회
</Button>
<Button
type="button"
color="green"
size="sm"
className="h-10 px-5 !text-[1.1rem]"
onClick={() => void handleDownload()}
>
다운로드
</Button>
</div>
</div>
</div>
<div className="mt-4">
<UiTable<RdiiResultRow>
headName={<>{`분석이력 총 ${total.toLocaleString()}건`}</>}
tableData={rows}
headlist={RDII_RESULT_HEADLIST as unknown as { id: string; title: string }[]}
pageSize={RDII_RESULT_PAGE_SIZE}
total={total}
cellClick={(index) => handleRowClick(index)}
cellClassName={(_, headId) =>
headId === "anlsInfo"
? "whitespace-normal break-all leading-[1.35]"
: ""
}
onPageChange={(page) => void handleSearch(page)}
curPage={currentPage}
index={selectedIndex}
widthFixed={true}
<PopupSearchPanel contentClassName="flex flex-wrap items-center gap-x-5 gap-y-3">
<div className="flex shrink-0 items-center gap-2">
<span className="whitespace-nowrap text-[1.3rem] font-medium text-label">
상수사용기간
</span>
<DateMonthPicker
field={{
value: filters.aplcnYmFrom,
onChange: (value: string) => {
setFilters((current) => ({
...current,
aplcnYmFrom: value,
}));
},
}}
selectClass="w-[145px] !text-[1.3rem]"
startYear={1900}
/>
<span className="text-[1.1rem] font-semibold text-muted-foreground">
~
</span>
<DateMonthPicker
field={{
value: filters.aplcnYmTo,
onChange: (value: string) => {
setFilters((current) => ({
...current,
aplcnYmTo: value,
}));
},
}}
selectClass="w-[145px] !text-[1.3rem]"
startYear={1900}
/>
</div>
{selectedRow ? (
<div className="mt-3 rounded-md border border-border bg-secondary px-4 py-3 text-[1rem] text-label">
{"선택 분석: "}
<span className="font-semibold text-foreground">{selectedRow.anlsInfo}</span>
</div>
) : null}
</section>
</main>
<div className="min-w-0 shrink-0">
<CtpvSggPrcsFcltSelectBox
compact
className="!justify-start"
selectValueClassName="!text-[1.3rem]"
sido={filters.ctpvCd}
sigungu={filters.sggCd}
sggName={filters.sggName}
onSidoChange={(value) => {
setSelectedSwagePrcsFclt("0");
setFilters((current) => ({
...current,
ctpvCd: value,
sggCd: "0",
sggName: "",
swtrmtFcltNm: "",
}));
}}
onSigunguChange={(value) => {
setSelectedSwagePrcsFclt("0");
setFilters((current) => ({
...current,
sggCd: value,
swtrmtFcltNm: "",
}));
}}
setSggName={(value) => {
setSelectedSwagePrcsFclt("0");
setFilters((current) => ({
...current,
sggName: value ?? "",
swtrmtFcltNm: "",
}));
}}
selectedSwagePrcsFclt={selectedSwagePrcsFclt}
onSwagePrcsFcltChange={setSelectedSwagePrcsFclt}
setSwtrmtFcltNm={(value) =>
setFilters((current) => ({
...current,
swtrmtFcltNm: value ?? "",
}))
}
setIsLoading={setIsLoading}
/>
</div>
<div className="ml-auto flex shrink-0 items-center justify-end gap-2">
<Button
type="button"
color="primary"
onClick={() => void handleSearch(1)}
>
조회
</Button>
<Button
type="button"
color="white"
onClick={() => void handleReset()}
>
초기화
</Button>
</div>
</PopupSearchPanel>
<MapPopupDataTable
rows={rows as Array<RdiiResultRow & Record<string, unknown>>}
headlist={RDII_RESULT_HEADLIST}
title="RDII 분석결과 목록"
total={total}
page={currentPage}
pageSize={RDII_RESULT_PAGE_SIZE}
onPageChange={(page) => void handleSearch(page)}
onRowClick={(_, index) => handleRowClick(index)}
rowId={(row) => String(row.rainInfwtAnlsSn)}
scrollMode="horizontal"
minTableWidth="125rem"
actions={
<Button type="button" onClick={() => void handleDownload()}>
엑셀다운로드
</Button>
}
/>
{selectedRow ? (
<div className="rounded-sm border border-border bg-secondary px-4 py-3 text-[1rem] text-label">
{"선택 분석: "}
<span className="font-semibold text-foreground">
{selectedRow.anlsInfo}
</span>
</div>
) : null}
</StandardPopupLayout>
);
}
@@ -9,6 +9,7 @@ import Style from "ol/style/Style.js";
import { getRawJson } from "@/components/lib/api";
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
import { useAlertStore } from "@/shared/lib";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import { getSigunguCenter, moveMapToCenter, sidoCenter } from "@common/business_components/ui/map/searchBar/utils";
@@ -138,6 +139,7 @@ export const useRdiiResultMap = ({
setIsOpen,
setIsSearchModalOpen,
}: UseRdiiResultMapParams) => {
const alert = useAlertStore((state) => state.setMessage);
const activeResultRowRef = useRef<RdiiResultRow | null>(null);
const pipeLayerRef = useRef<VectorLayer<any> | null>(null);
const pipeSourceRef = useRef<VectorSource | null>(null);
@@ -294,7 +296,7 @@ export const useRdiiResultMap = ({
alert("RDII 분석결과를 지도에 연계하지 못했습니다.");
}
},
[renderMapView, setFeatureData, setIsOpen, setIsSearchModalOpen],
[alert, renderMapView, setFeatureData, setIsOpen, setIsSearchModalOpen],
);
useEffect(() => {
@@ -356,11 +358,11 @@ export const useRdiiResultMap = ({
}
return true;
}, [activeMapView]);
}, [activeMapView, alert]);
return {
activeMapView,
clearLayers,
handleOverlayFeatureSelect,
};
};
};
@@ -0,0 +1,484 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Input } from "@common/components";
import {
StandardPopupLayout,
type UiHeadProps,
} from "@common/business_components";
import LoadingOverlay from "@/components/ui/LoadingBar";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import { getRawJson } from "@/components/lib/api";
import {
WINDOW_POPUP_MESSAGE_TYPE,
type WindowPopupContentProps,
type WindowPopupMessageType,
} from "@/features/windowPopup/model/types";
import { MS_SWAGE_PPL_CODES } from "../common/table";
import {
downloadRepairHistoriesByZoneId,
fetchAllRepairHistoriesByZoneId,
type RepairHistoryFilters,
type RepairHistoryRow,
} from "../popupComponent/swtrmtPrcsZoneAnalysisApi";
const REPAIR_HISTORY_PAGE_SIZE = 10;
const MAP_LAYER_SETTING_PATH =
"/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK";
type PipeUseOption = {
val: string;
text: string;
};
type MapLayerSettingRow = {
lyrTyNm?: string | null;
lyrNm?: string | null;
sortOrdr?: string | number | null;
flterSql?: string | null;
};
/** 관망도 트리의 하수관로 노드에서 용도코드·명칭·표시순서를 그대로 추출한다. */
const resolvePipeUseOptions = (rows: MapLayerSettingRow[]): PipeUseOption[] =>
rows
.filter((row) => row.lyrTyNm === "tb_ms_swage_ppl")
.sort(
(left, right) => Number(left.sortOrdr ?? 0) - Number(right.sortOrdr ?? 0),
)
.flatMap((row) => {
const code = String(row.flterSql ?? "")
.match(/\busg_cd\s*=\s*['\"]([^'\"]+)['\"]/i)?.[1]
?.trim();
const text = String(row.lyrNm ?? "").trim();
return code && text ? [{ val: code, text }] : [];
});
type RepairHistoryTableRow = {
fid: string;
fcltMngNo: string;
useName: string;
repairCount: string;
latestRepairYear: string;
elapsedYearsSinceRepair: string;
materialName: string;
diameter: string;
installationYear: string;
burialDepth: string;
};
/** 부모 업무화면과 동일한 공통 표 헤더·열 규격이다. */
const REPAIR_HISTORY_HEADLIST: UiHeadProps[] = [
{ id: "rowNo", title: "순번", align: "center", widthPercent: 7 },
{ id: "fcltMngNo", title: "시설관리번호", align: "center", widthPercent: 17 },
{ id: "useName", title: "용도", align: "center", widthPercent: 10 },
{
id: "repairCount",
title: "보수이력(건)",
align: "right",
widthPercent: 10,
},
{
id: "latestRepairYear",
title: "최근보수년도",
align: "center",
widthPercent: 11,
},
{
id: "elapsedYearsSinceRepair",
title: "보수후 경과년도",
align: "right",
widthPercent: 12,
},
{ id: "materialName", title: "재질", align: "center", widthPercent: 13 },
{ id: "diameter", title: "관경(mm)", align: "right", widthPercent: 9 },
{
id: "installationYear",
title: "설치년도",
align: "center",
widthPercent: 8,
},
{ id: "burialDepth", title: "매설심도(m)", align: "right", widthPercent: 10 },
];
/** 화면 입력조건은 조회 버튼을 누른 시점의 적용조건과 분리해 다운로드 계약을 고정한다. */
const buildEmptyFilters = (): RepairHistoryFilters => ({
useCd: "",
materialCode: "",
diameterFrom: "",
diameterTo: "",
});
/** Next searchParams의 단일 값을 안전하게 읽는다. */
const firstSearchParam = (value: string | string[] | undefined) =>
Array.isArray(value)
? String(value[0] ?? "").trim()
: String(value ?? "").trim();
/** 팝업의 조회·선택·정리 이벤트를 동일 출처의 부모 지도에 전달한다. */
const postRepairMessage = (
type: WindowPopupMessageType,
payload: Record<string, unknown>,
) => {
if (typeof window === "undefined" || !window.opener) return;
window.opener.postMessage({ type, payload }, window.location.origin);
};
/** 지정한 파일명으로 Blob을 다운로드한다. */
const saveBlob = (blob: Blob, fileName: string) => {
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = fileName;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
};
const buildRepairHistoryFileName = (zoneName: string) => {
const sanitizedZoneName = zoneName.trim().replace(/[\\/:*?"<>|\r\n]+/g, "_");
return `${sanitizedZoneName || "처리구역"}_보수이력.xlsx`;
};
const formatCellValue = (value: unknown) => {
if (value === null || value === undefined || value === "") return "-";
return typeof value === "number"
? value.toLocaleString("ko-KR", { maximumFractionDigits: 2 })
: String(value);
};
const formatYear = (value: unknown) => {
if (value === null || value === undefined || value === "") return "-";
return String(value);
};
/** SFR-011-006 관로별 보수이력 조회·다운로드와 부모 지도 연계를 제공한다. */
export default function RepairHistoryPopupPage({
searchParams,
}: WindowPopupContentProps) {
const zoneId = useMemo(
() => firstSearchParam(searchParams.zoneId),
[searchParams.zoneId],
);
const zoneName = useMemo(
() => firstSearchParam(searchParams.zoneName),
[searchParams.zoneName],
);
// 입력조건과 마지막 조회조건을 분리해 다운로드가 현재 표와 동일한 범위를 사용한다.
const [filters, setFilters] =
useState<RepairHistoryFilters>(buildEmptyFilters);
const [appliedFilters, setAppliedFilters] =
useState<RepairHistoryFilters>(buildEmptyFilters);
// 전체 조회 결과는 부모 지도 강조와 팝업 내 10건 페이지 표가 함께 사용한다.
const [rows, setRows] = useState<RepairHistoryRow[]>([]);
// 용도 선택지는 별도 하드코딩하지 않고 관망도 트리 설정 API의 sortOrdr를 따른다.
const [pipeUseOptions, setPipeUseOptions] = useState<PipeUseOption[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
/** 조회 결과 전체를 부모 지도 레이어와 팝업 표에 동시에 반영한다. */
const searchRepairHistories = useCallback(
async (nextFilters: RepairHistoryFilters) => {
if (!zoneId) {
setErrorMessage("처리구역 식별값이 없습니다.");
return;
}
setIsLoading(true);
setErrorMessage("");
try {
const response = await fetchAllRepairHistoriesByZoneId(
zoneId,
nextFilters,
);
const nextRows = response.data ?? [];
setRows(nextRows);
setAppliedFilters({ ...nextFilters });
setCurrentPage(1);
postRepairMessage(
WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SHOW_RESULTS,
{
zoneId,
rows: nextRows,
},
);
} catch (error) {
setRows([]);
postRepairMessage(
WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SHOW_RESULTS,
{
zoneId,
rows: [],
},
);
setErrorMessage(
error instanceof Error
? error.message
: "보수이력을 불러오지 못했습니다.",
);
} finally {
setIsLoading(false);
}
},
[zoneId],
);
useEffect(() => {
const initialFilters = buildEmptyFilters();
setFilters(initialFilters);
setAppliedFilters(initialFilters);
void searchRepairHistories(initialFilters);
}, [searchRepairHistories]);
useEffect(() => {
let isMounted = true;
const loadPipeUseOptions = async () => {
try {
const layerSettings = (await getRawJson(
MAP_LAYER_SETTING_PATH,
)) as MapLayerSettingRow[];
if (isMounted)
setPipeUseOptions(
resolvePipeUseOptions(
Array.isArray(layerSettings) ? layerSettings : [],
),
);
} catch {
if (isMounted) setPipeUseOptions([]);
}
};
void loadPipeUseOptions();
return () => {
isMounted = false;
};
}, []);
useEffect(() => {
const clearParentLayer = () => {
postRepairMessage(WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_CLEAR, {
zoneId,
});
};
window.addEventListener("beforeunload", clearParentLayer);
return () => {
window.removeEventListener("beforeunload", clearParentLayer);
clearParentLayer();
};
}, [zoneId]);
/** 화면에서 적용한 조건과 동일한 9개 목록 항목의 XLSX를 내려받는다. */
const handleDownload = async () => {
if (!zoneId || rows.length === 0) return;
setIsLoading(true);
setErrorMessage("");
try {
const { blob } = await downloadRepairHistoriesByZoneId(
zoneId,
appliedFilters,
zoneName,
);
saveBlob(blob, buildRepairHistoryFileName(zoneName));
} catch (error) {
setErrorMessage(
error instanceof Error
? error.message
: "보수이력 Excel 다운로드에 실패했습니다.",
);
} finally {
setIsLoading(false);
}
};
/** 행을 한 번 클릭하면 부모 지도에서 해당 관로를 선택하고 범위로 이동한다. */
const handleRowClick = (row: RepairHistoryRow) => {
postRepairMessage(WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SELECT_PIPE, {
zoneId,
fid: row.fid,
});
};
const visibleRows = useMemo(
() =>
rows.slice(
(currentPage - 1) * REPAIR_HISTORY_PAGE_SIZE,
currentPage * REPAIR_HISTORY_PAGE_SIZE,
),
[currentPage, rows],
);
// 공통 표에 전달할 표시값은 원본 API 행과 분리해 지도 연계 식별값을 보존한다.
const tableRows = useMemo<RepairHistoryTableRow[]>(
() =>
visibleRows.map((row) => ({
fid: row.fid,
fcltMngNo: formatCellValue(row.fcltMngNo),
useName: formatCellValue(row.useNm),
repairCount: formatCellValue(row.repairCount),
latestRepairYear: formatYear(row.latestRepairYear),
elapsedYearsSinceRepair: formatCellValue(row.elapsedYearsSinceRepair),
materialName: formatCellValue(row.materialName),
diameter: formatCellValue(row.pdmtVl),
installationYear: formatYear(row.installationYear),
burialDepth: formatCellValue(row.burialDepth),
})),
[visibleRows],
);
return (
<StandardPopupLayout title="보수이력 목록">
<LoadingOverlay isLoading={isLoading} />
<PopupSearchPanel contentClassName="flex w-full flex-wrap items-center gap-x-5 gap-y-3">
<label className="flex items-center gap-2 whitespace-nowrap text-[1.3rem] text-label">
<span>용도</span>
<select
className="h-[3.2rem] w-[15rem] rounded-md border border-input bg-field px-3 text-[1.3rem]"
value={filters.useCd}
onChange={(event) =>
setFilters((current) => ({
...current,
useCd: event.target.value,
}))
}
data-testid="repair-use-filter"
>
<option value="">전체</option>
{pipeUseOptions.map((code) => (
<option key={code.val} value={code.val}>
{code.text}
</option>
))}
</select>
</label>
<label className="flex items-center gap-2 whitespace-nowrap text-[1.3rem] text-label">
<span>재질</span>
<select
className="h-[3.2rem] w-[18rem] rounded-md border border-input bg-field px-3 text-[1.3rem]"
value={filters.materialCode}
onChange={(event) =>
setFilters((current) => ({
...current,
materialCode: event.target.value,
}))
}
data-testid="repair-material-filter"
>
<option value="">전체</option>
{MS_SWAGE_PPL_CODES.map((code) => (
<option key={code.val} value={code.val}>
{code.text}
</option>
))}
</select>
</label>
<div className="flex items-center gap-2 whitespace-nowrap text-[1.3rem] text-label">
<span>관경(mm)</span>
<Input
type="number"
min="0"
aria-label="최소 관경(mm)"
className="w-[10rem]"
value={filters.diameterFrom}
onChange={(event) =>
setFilters((current) => ({
...current,
diameterFrom: event.target.value,
}))
}
data-testid="repair-diameter-from"
/>
<span aria-hidden="true">~</span>
<Input
type="number"
min="0"
aria-label="최대 관경(mm)"
className="w-[10rem]"
value={filters.diameterTo}
onChange={(event) =>
setFilters((current) => ({
...current,
diameterTo: event.target.value,
}))
}
data-testid="repair-diameter-to"
/>
</div>
<div className="ml-auto flex shrink-0 items-center gap-2">
<Button
type="button"
color="primary"
onClick={() => void searchRepairHistories({ ...filters })}
disabled={isLoading}
data-testid="repair-history-search"
>
조회
</Button>
<Button
type="button"
color="white"
onClick={() => {
const resetFilters: RepairHistoryFilters = {
useCd: "",
materialCode: "",
diameterFrom: "",
diameterTo: "",
};
setFilters(resetFilters);
void searchRepairHistories(resetFilters);
}}
>
초기화
</Button>
</div>
</PopupSearchPanel>
{errorMessage ? (
<div
role="alert"
className="mb-2 mt-2 border border-destructive bg-secondary px-4 py-2 text-base text-destructive"
>
{errorMessage}
</div>
) : null}
<MapPopupDataTable
rows={
(isLoading ? [] : tableRows) as Array<
RepairHistoryTableRow & Record<string, unknown>
>
}
headlist={REPAIR_HISTORY_HEADLIST}
title={`${zoneName || "처리구역"} 보수이력 목록`}
total={rows.length}
page={currentPage}
pageSize={REPAIR_HISTORY_PAGE_SIZE}
onPageChange={setCurrentPage}
onRowClick={(_, index) => {
const row = visibleRows[index];
if (row) handleRowClick(row);
}}
rowId={(row, index) => String(row.fid ?? index)}
actions={
<Button
type="button"
onClick={() => void handleDownload()}
disabled={isLoading || rows.length === 0}
data-testid="repair-history-download"
>
엑셀다운로드
</Button>
}
/>
<div className="rounded-sm bg-secondary px-4 py-3 text-[1.2rem] text-muted-foreground">
행을 클릭하면 부모 지도의 해당 관로로 이동합니다.
</div>
</StandardPopupLayout>
);
}
@@ -0,0 +1,302 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@common/components";
import { StandardPopupLayout } from "@common/business_components";
import { getRawJson } from "@/components/lib/api";
import LoadingOverlay from "@/components/ui/LoadingBar";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import { useAlertStore } from "@/shared/lib";
import useStore from "@/store/useStore";
import {
SBMRG_DNGR_MAP_DEFAULT_FILTERS,
SBMRG_DNGR_MAP_FREQUENCY_OPTIONS,
SBMRG_DNGR_MAP_HEADLIST,
SBMRG_DNGR_MAP_LAYER_TYPE,
SBMRG_DNGR_MAP_PAGE_SIZE,
SBMRG_DNGR_MAP_SCOPE_SIZE,
buildSbmrgDngrMapListQueryString,
extractAdministrativeCodes,
toSbmrgDngrMapTableRows,
type SbmrgDngrMapApiItem,
type SbmrgDngrMapFilters,
type SbmrgDngrMapTableRow,
} from "./sbmrgDngrMapTypes";
const unwrapPayload = <T,>(response: any): T =>
(response?.data ?? response) as T;
/** 시설물 레이어의 중점관리지역 침수위험지도 피처를 조건별 목록으로 제공한다. */
export default function SbmrgDngrMapPopupPage() {
const alert = useAlertStore((state) => state.setMessage);
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
// 입력 중인 조건과 실제 조회에 적용된 조건을 분리해 페이지 이동 시 조건이 흔들리지 않게 한다.
const [filters, setFilters] = useState<SbmrgDngrMapFilters>(
SBMRG_DNGR_MAP_DEFAULT_FILTERS,
);
const [appliedFilters, setAppliedFilters] = useState<SbmrgDngrMapFilters>(
SBMRG_DNGR_MAP_DEFAULT_FILTERS,
);
const [items, setItems] = useState<SbmrgDngrMapApiItem[]>([]);
const [administrativeScopeItems, setAdministrativeScopeItems] = useState<
SbmrgDngrMapApiItem[] | null
>(null);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const availableCtpvCodes = useMemo(
() =>
new Set(
(administrativeScopeItems ?? []).flatMap((item) =>
extractAdministrativeCodes(item.ctpvCd, 2),
),
),
[administrativeScopeItems],
);
const availableSggCodes = useMemo(
() =>
new Set(
(administrativeScopeItems ?? []).flatMap((item) =>
extractAdministrativeCodes(item.sggCd, 5),
),
),
[administrativeScopeItems],
);
const ctpvOptions = useMemo(
() =>
administrativeScopeItems === null
? ctpv
: ctpv.filter((item) =>
availableCtpvCodes.has(String(item.val).trim().slice(0, 2)),
),
[administrativeScopeItems, availableCtpvCodes, ctpv],
);
const sggOptions = useMemo(() => {
const scopedOptions =
administrativeScopeItems === 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)),
);
}, [administrativeScopeItems, availableSggCodes, filters.ctpvCd, sgg]);
const rows = useMemo(
() =>
toSbmrgDngrMapTableRows(
items,
currentPage - 1,
SBMRG_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]);
/** 마지막 적용 조건으로 서버 페이지를 조회한다. */
const loadRows = useCallback(
async (page: number, nextFilters: SbmrgDngrMapFilters) => {
setIsLoading(true);
try {
const queryString = buildSbmrgDngrMapListQueryString(
nextFilters,
page - 1,
SBMRG_DNGR_MAP_PAGE_SIZE,
);
const response = await getRawJson(
`/sewerage-service/api/v1/fldng/sbmrg-dngr-map?${queryString}`,
);
const payload = unwrapPayload<{
items?: SbmrgDngrMapApiItem[];
total?: number;
}>(response);
const items = Array.isArray(payload?.items) ? payload.items : [];
setItems(items);
setTotal(Number(payload?.total ?? 0));
setCurrentPage(page);
setAppliedFilters(nextFilters);
} catch (error) {
console.error("침수위험지도 목록 조회 중 오류:", error);
setItems([]);
setTotal(0);
alert(
error instanceof Error
? error.message
: "침수위험지도 목록 조회 중 오류가 발생했습니다.",
);
} finally {
setIsLoading(false);
}
},
[alert],
);
/** 전체 50개 그룹에서 실제 존재하는 행정구역 코드만 검색 선택지로 제한한다. */
const loadAdministrativeScope = useCallback(async () => {
try {
const queryString = buildSbmrgDngrMapListQueryString(
SBMRG_DNGR_MAP_DEFAULT_FILTERS,
0,
SBMRG_DNGR_MAP_SCOPE_SIZE,
);
const response = await getRawJson(
`/sewerage-service/api/v1/fldng/sbmrg-dngr-map?${queryString}`,
);
const payload = unwrapPayload<{ items?: SbmrgDngrMapApiItem[] }>(
response,
);
setAdministrativeScopeItems(
Array.isArray(payload?.items) ? payload.items : [],
);
} catch (error) {
console.error("침수위험지도 행정구역 범위 조회 중 오류:", error);
setAdministrativeScopeItems(null);
}
}, []);
useEffect(() => {
void loadRows(1, SBMRG_DNGR_MAP_DEFAULT_FILTERS);
void loadAdministrativeScope();
}, [loadAdministrativeScope, loadRows]);
/** 현재 입력 조건을 적용하고 첫 페이지부터 다시 조회한다. */
const handleSearch = () => {
void loadRows(1, filters);
};
/** 선택한 지역·빈도 그룹의 전체 fid를 부모 지도에 전달해 전체 범위 이동·강조를 실행한다. */
const handleRowClick = (row: SbmrgDngrMapTableRow) => {
if (!Array.isArray(row.fids) || row.fids.length === 0) return;
const featureIds = row.fids.map((fid) =>
String(fid).startsWith(`${SBMRG_DNGR_MAP_LAYER_TYPE}.`)
? String(fid)
: `${SBMRG_DNGR_MAP_LAYER_TYPE}.${fid}`,
);
window.opener?.postMessage(
{
type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_MULTIPLE,
payload: {
layerNm: SBMRG_DNGR_MAP_LAYER_TYPE,
fids: JSON.stringify(featureIds),
},
},
window.location.origin,
);
};
return (
<StandardPopupLayout title="침수위험지도 목록">
<LoadingOverlay isLoading={isLoading} />
<PopupSearchPanel contentClassName="flex flex-wrap items-end gap-x-5 gap-y-3">
<label className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.3rem] text-label">
시도
</span>
<select
className="h-[3.2rem] w-[18rem] rounded-md border border-input bg-field px-3 text-[1.3rem]"
value={filters.ctpvCd}
onChange={(event) =>
setFilters({
...filters,
ctpvCd: event.target.value,
sggCd: "0",
})
}
>
<option value="0">전체</option>
{ctpvOptions.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.3rem] text-label">
시군구
</span>
<select
className="h-[3.2rem] w-[18rem] rounded-md border border-input bg-field px-3 text-[1.3rem]"
value={filters.sggCd}
onChange={(event) =>
setFilters({ ...filters, sggCd: event.target.value })
}
>
<option value="0">전체</option>
{sggOptions.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.3rem] text-label">
홍수수위빈도값
</span>
<select
className="h-[3.2rem] w-[16rem] rounded-md border border-input bg-field px-3 text-[1.3rem]"
value={filters.floodWtlvFrqVl}
onChange={(event) =>
setFilters({ ...filters, floodWtlvFrqVl: event.target.value })
}
>
<option value="0">전체</option>
{SBMRG_DNGR_MAP_FREQUENCY_OPTIONS.map((value) => (
<option key={value} value={String(value)}>
{value}년
</option>
))}
</select>
</label>
<div className="ml-auto flex justify-end gap-2">
<Button type="button" color="primary" onClick={handleSearch}>
조회
</Button>
<Button
type="button"
color="white"
onClick={() => {
setFilters(SBMRG_DNGR_MAP_DEFAULT_FILTERS);
void loadRows(1, SBMRG_DNGR_MAP_DEFAULT_FILTERS);
}}
>
초기화
</Button>
</div>
</PopupSearchPanel>
<MapPopupDataTable
rows={rows as Array<SbmrgDngrMapTableRow & Record<string, unknown>>}
headlist={SBMRG_DNGR_MAP_HEADLIST}
title="침수위험지도 목록"
total={total}
page={currentPage}
pageSize={SBMRG_DNGR_MAP_PAGE_SIZE}
onPageChange={(page) => void loadRows(page, appliedFilters)}
onRowClick={handleRowClick}
rowId={(row, index) => String(row.emphsMngRgnMngNo ?? index)}
/>
</StandardPopupLayout>
);
}
@@ -0,0 +1,162 @@
"use client";
import type { UiHeadProps } from "@common/business_components/ui";
export const SBMRG_DNGR_MAP_POPUP_KEY = "sbmrgDngrMapPopup";
export const SBMRG_DNGR_MAP_LAYER_TYPE = "tb_ms_sbmrg_dngr_map";
export const SBMRG_DNGR_MAP_LAYER_NAME = "침수위험지도(중점관리지역)";
export const SBMRG_DNGR_MAP_PAGE_SIZE = 20;
export const SBMRG_DNGR_MAP_SCOPE_SIZE = 2000;
export type SbmrgDngrMapFilters = {
ctpvCd: string;
sggCd: string;
floodWtlvFrqVl: string;
};
export type SbmrgDngrMapApiItem = {
fids: string[];
ctpvCd: string;
sggCd: string;
emphsMngRgnNm: string;
emphsMngRgnMngNo: string;
floodWtlvFrqVl: number | string | null;
minElvtVl: number | string | null;
maxElvtVl: number | string | null;
};
export type SbmrgDngrMapTableRow = SbmrgDngrMapApiItem & {
rowNo: number;
ctpvNm: string;
sggNm: string;
floodWtlvFrqVlDisplay: string;
elvtVlDisplay: string;
};
type AdministrativeOption = {
val: unknown;
text: unknown;
};
export const SBMRG_DNGR_MAP_DEFAULT_FILTERS: SbmrgDngrMapFilters = {
ctpvCd: "0",
sggCd: "0",
floodWtlvFrqVl: "0",
};
export const SBMRG_DNGR_MAP_FREQUENCY_OPTIONS = [30, 50, 80, 100];
export const SBMRG_DNGR_MAP_HEADLIST: UiHeadProps[] = [
{ id: "rowNo", title: "순번", widthPercent: 7 },
{ id: "ctpvNm", title: "시도", widthPercent: 11 },
{ id: "sggNm", title: "시군구", widthPercent: 12 },
{ id: "emphsMngRgnNm", title: "중점관리지역명", widthPercent: 23 },
{ id: "emphsMngRgnMngNo", title: "중점관리지역번호", widthPercent: 15 },
{
id: "floodWtlvFrqVlDisplay",
title: "홍수수위빈도값",
widthPercent: 17,
},
{
id: "elvtVlDisplay",
title: "해발값(최소~최대)",
widthPercent: 15,
},
];
/** API가 허용하는 검색 조건과 페이징 파라미터만 쿼리 문자열로 만든다. */
export const buildSbmrgDngrMapListQueryString = (
filters: SbmrgDngrMapFilters,
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.floodWtlvFrqVl && filters.floodWtlvFrqVl !== "0") {
params.set("floodWtlvFrqVl", filters.floodWtlvFrqVl);
}
return params.toString();
};
const toDisplay = (value: unknown) => {
const normalized = String(value ?? "").trim();
return normalized || "-";
};
const toRangeDisplay = (minValue: unknown, maxValue: unknown) => {
const minDisplay = toDisplay(minValue);
const maxDisplay = toDisplay(maxValue);
if (minDisplay === "-" && maxDisplay === "-") return "-";
if (minDisplay === maxDisplay) return minDisplay;
return `${minDisplay} ~ ${maxDisplay}`;
};
/** 집계 응답에서 쉼표로 묶일 수 있는 행정구역 코드를 정규화한다. */
export const extractAdministrativeCodes = (
value: unknown,
codeLength: number,
) =>
String(value ?? "")
.split(",")
.map((code) => code.trim().slice(0, codeLength))
.filter(Boolean);
/** 쉼표로 묶인 행정구역 코드도 각각 한글명으로 변환하고 중복 명칭은 제거한다. */
const resolveAdministrativeNames = (
value: unknown,
options: AdministrativeOption[],
codeLength: number,
) => {
const names = extractAdministrativeCodes(value, codeLength)
.map((code) => {
const matched = options.find(
(option) =>
String(option.val ?? "")
.trim()
.slice(0, codeLength) === code,
);
return String(matched?.text ?? "").trim();
})
.filter(Boolean);
return names.length > 0 ? [...new Set(names)].join(", ") : "-";
};
/** 서버 페이지 번호를 화면 순번에 반영하고 숫자 표시값을 보정한다. */
export const toSbmrgDngrMapTableRows = (
items: SbmrgDngrMapApiItem[],
page: number,
pageSize = SBMRG_DNGR_MAP_PAGE_SIZE,
ctpvOptions: AdministrativeOption[] = [],
sggOptions: AdministrativeOption[] = [],
total = items.length,
): SbmrgDngrMapTableRow[] =>
items.map((item, index) => ({
...item,
rowNo: total - page * pageSize - index,
ctpvNm: resolveAdministrativeNames(item.ctpvCd, ctpvOptions, 2),
sggNm: resolveAdministrativeNames(item.sggCd, sggOptions, 5),
floodWtlvFrqVlDisplay: toDisplay(item.floodWtlvFrqVl),
elvtVlDisplay: toRangeDisplay(item.minElvtVl, item.maxElvtVl),
}));
const resolveNodeName = (node: any) =>
String(
node?.text ??
node?.data?.lyrNm ??
node?.data?.name ??
node?.lyrNm ??
node?.name ??
"",
).trim();
/** 빈도·침수심 자식 노드가 아니라 중점관리지역 위험지도 루트만 목록보기 대상으로 판별한다. */
export const isSbmrgDngrMapRootNode = (node: any) =>
resolveNodeName(node).replace(/\s/g, "") ===
SBMRG_DNGR_MAP_LAYER_NAME.replace(/\s/g, "");
@@ -74,7 +74,9 @@ export default function SinkholeDetailPanel({
onClose,
onSelectPipe,
}: Props) {
const [pipeUseCodeMap, setPipeUseCodeMap] = useState<Map<string, string>>(new Map());
const [pipeUseCodeMap, setPipeUseCodeMap] = useState<Map<string, string>>(
new Map(),
);
const canShowNearby = String(detail.coordStatus ?? "").trim() === "SUCCESS";
useEffect(() => {
@@ -83,7 +85,9 @@ export default function SinkholeDetailPanel({
loadPipeUseCodes()
.then((codes) => {
if (cancelled) return;
setPipeUseCodeMap(new Map(codes.map((item) => [item.lwpoCd, item.lwpoCdNm])));
setPipeUseCodeMap(
new Map(codes.map((item) => [item.lwpoCd, item.lwpoCdNm])),
);
})
.catch((error) => {
console.error("Failed to load OGC011 pipe use codes:", error);
@@ -148,11 +152,11 @@ export default function SinkholeDetailPanel({
return (
<div
id="popup"
className="absolute top-0 right-0 z-[50] flex h-[calc(100vh-5rem)] w-[350px] flex-col overflow-hidden border-r border-gray-300 bg-white transition-all duration-300"
className="absolute right-0 top-0 z-[50] flex h-[calc(100vh-5rem)] w-[350px] flex-col overflow-hidden border-r border-border bg-background transition-all duration-300"
>
<div className="card-header relative flex items-center justify-between bg-primary p-4">
<div className="flex-1">
<h3 className="text-2xl font-medium text-white">
<h3 className="text-2xl font-medium text-primary-foreground">
지반침하정보
<br />
사고번호 : {display(detail.acdntNo)}
@@ -160,7 +164,7 @@ export default function SinkholeDetailPanel({
</div>
<Button
onClick={onClose}
className="flex items-center space-x-4 text-white hover:text-gray-300 focus:outline-none"
className="text-primary-foreground/80 flex items-center space-x-4 hover:text-primary-foreground focus:outline-none"
size="xs"
>
✕
@@ -186,15 +190,17 @@ export default function SinkholeDetailPanel({
침하발생 반경내 관로정보
</h3>
{canShowNearby ? (
<div className="mx-3 mt-2 whitespace-nowrap text-left text-[0.95rem] leading-5 text-slate-500">
<div className="mx-3 mt-2 whitespace-nowrap text-left text-[0.95rem] leading-5 text-muted-foreground">
<span>반경 {radiusMeters}m · </span>
<span className="font-bold text-[#1d4ed8]">{nearbyPipes.length.toLocaleString()}건</span>
<span className="font-bold text-primary">
{nearbyPipes.length.toLocaleString()}건
</span>
<span>, 평균경과 </span>
<span className="font-bold text-[#1d4ed8]">
<span className="font-bold text-primary">
{displayElapsedYears(nearbyPipeElapsedSummary.average)}
</span>
<span>, 최장경과 </span>
<span className="font-bold text-[#1d4ed8]">
<span className="font-bold text-primary">
{displayElapsedYears(nearbyPipeElapsedSummary.oldest)}
</span>
</div>
@@ -210,7 +216,7 @@ export default function SinkholeDetailPanel({
) : null}
</div>
) : nearbyPipes.length === 0 ? (
<div className="card !m-[1.2rem] !mt-0 rounded-sm border border-slate-200 bg-slate-50 px-3 py-3 text-[1.05rem] text-slate-600">
<div className="card !m-[1.2rem] !mt-0 rounded-sm border border-border bg-secondary px-3 py-3 text-[1.05rem] text-label">
반경 내 하수관로 정보가 없습니다.
</div>
) : (
@@ -225,19 +231,19 @@ export default function SinkholeDetailPanel({
</colgroup>
<thead>
<tr>
<th className="border border-gray-300 bg-blue-100 px-1 py-2 text-center text-[0.95rem] font-bold text-slate-900 whitespace-nowrap">
<th className="whitespace-nowrap border border-border bg-secondary px-1 py-2 text-center text-[0.95rem] font-bold text-label">
NO
</th>
<th className="border border-gray-300 bg-blue-100 px-1 py-2 text-center text-[0.95rem] font-bold text-slate-900 whitespace-nowrap">
<th className="whitespace-nowrap border border-border bg-secondary px-1 py-2 text-center text-[0.95rem] font-bold text-label">
관로 ID
</th>
<th className="border border-gray-300 bg-blue-100 px-1 py-2 text-center text-[0.95rem] font-bold text-slate-900 whitespace-nowrap">
<th className="whitespace-nowrap border border-border bg-secondary px-1 py-2 text-center text-[0.95rem] font-bold text-label">
용도
</th>
<th className="border border-gray-300 bg-blue-100 px-1 py-2 text-center text-[0.95rem] font-bold text-slate-900 whitespace-nowrap">
<th className="whitespace-nowrap border border-border bg-secondary px-1 py-2 text-center text-[0.95rem] font-bold text-label">
경과년도
</th>
<th className="border border-gray-300 bg-blue-100 px-1 py-2 text-center text-[0.95rem] font-bold text-slate-900 whitespace-nowrap">
<th className="whitespace-nowrap border border-border bg-secondary px-1 py-2 text-center text-[0.95rem] font-bold text-label">
거리(m)
</th>
</tr>
@@ -246,22 +252,22 @@ export default function SinkholeDetailPanel({
{nearbyPipes.map((pipe, index) => (
<tr
key={`${pipe.fid}-${index}`}
className="cursor-pointer hover:bg-blue-50"
className="cursor-pointer hover:bg-accent"
onClick={() => onSelectPipe(pipe)}
>
<td className="border border-gray-300 px-1 py-2 text-center text-[0.95rem] text-slate-900">
<td className="border border-border px-1 py-2 text-center text-[0.95rem] text-foreground">
{index + 1}
</td>
<td className="border border-gray-300 px-1 py-2 text-left text-[0.95rem] font-semibold text-[#1d4ed8] underline underline-offset-2">
<td className="border border-border px-1 py-2 text-left text-[0.95rem] font-semibold text-primary underline underline-offset-2">
{display(pipe.pipeId)}
</td>
<td className="border border-gray-300 px-1 py-2 text-center text-[0.95rem] text-slate-900 whitespace-nowrap">
<td className="whitespace-nowrap border border-border px-1 py-2 text-center text-[0.95rem] text-foreground">
{getPipeUseName(pipe)}
</td>
<td className="border border-gray-300 px-1 py-2 text-right text-[0.95rem] text-slate-900">
<td className="border border-border px-1 py-2 text-right text-[0.95rem] text-foreground">
{displayElapsedYears(pipe.elapsedYears)}
</td>
<td className="border border-gray-300 px-1 py-2 text-right text-[0.95rem] text-slate-900">
<td className="border border-border px-1 py-2 text-right text-[0.95rem] text-foreground">
{displayNumber(pipe.distanceMeters)}
</td>
</tr>
@@ -2,6 +2,7 @@
import { useEffect, useState } from "react";
import { SelectBox } from "@common/business_components/ui";
import { useAlertStore } from "@/shared/lib";
import {
normalizeSinkholeYearRange,
SINKHOLE_MIN_SEARCH_YEAR,
@@ -31,6 +32,7 @@ export default function SinkholeLayerSettingPanel({
isAdmin = false,
compact = false,
}: Props) {
const alert = useAlertStore((state) => state.setMessage);
const [isCollapsed, setIsCollapsed] = useState(false);
const [radiusText, setRadiusText] = useState(String(radiusMeters));
const [yearRangeDraft, setYearRangeDraft] = useState<SinkholeYearRange>(() =>
@@ -70,45 +72,67 @@ export default function SinkholeLayerSettingPanel({
try {
await onApplyYearRange(normalized);
alert(`시작년도 ${normalized.startYear} ~ 종료년도 ${normalized.endYear} 반영되었습니다.`);
alert(
`시작년도 ${normalized.startYear} ~ 종료년도 ${normalized.endYear} 반영되었습니다.`,
);
} catch {
alert("반영 중 오류가 발생했습니다.");
}
};
const updateYearRangeDraft = (key: keyof SinkholeYearRange, value: string) => {
const updateYearRangeDraft = (
key: keyof SinkholeYearRange,
value: string,
) => {
setYearRangeDraft((current) => ({
...current,
[key]: String(value).replace(/[^0-9]/g, "").slice(0, 4),
[key]: String(value)
.replace(/[^0-9]/g, "")
.slice(0, 4),
}));
};
const currentYear = new Date().getFullYear();
const yearSelectClass = "h-[22px] w-[62px] rounded-none border border-[#ccd6df] bg-white px-[4px] text-center text-[1rem] leading-none text-[#222]";
const yearSelectClass =
"h-[22px] w-[62px] rounded-none border border-input bg-background px-[4px] text-center text-[1rem] leading-none text-foreground";
const startYearOptions = Array.from(
{ length: currentYear - SINKHOLE_MIN_SEARCH_YEAR + 1 },
(_, index) => String(currentYear - index),
)
.filter((year) => !yearRangeDraft.endYear || Number(year) <= Number(yearRangeDraft.endYear))
.filter(
(year) =>
!yearRangeDraft.endYear ||
Number(year) <= Number(yearRangeDraft.endYear),
)
.map((year) => ({ val: year, text: year }));
const endYearOptions = Array.from(
{ length: currentYear - SINKHOLE_MIN_SEARCH_YEAR + 1 },
(_, index) => String(currentYear - index),
)
.filter((year) => !yearRangeDraft.startYear || Number(year) >= Number(yearRangeDraft.startYear))
.filter(
(year) =>
!yearRangeDraft.startYear ||
Number(year) >= Number(yearRangeDraft.startYear),
)
.map((year) => ({ val: year, text: year }));
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 = <span className="h-2 w-2 shrink-0 rounded-full bg-[#243b93]" />;
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 = (
<span className="h-2 w-2 shrink-0 rounded-full bg-primary" />
);
const settingRows = (
<div className="space-y-1.5 text-[1.05rem] font-medium text-slate-700">
<div className="space-y-1.5 text-[1.05rem] font-medium text-label">
<div className="flex min-h-[25px] items-center gap-1.5 whitespace-nowrap">
<div className="flex min-w-0 items-center gap-1">
{indicator}
<label htmlFor="sinkhole-radius" className="shrink-0">지반침하반경(m)</label>
<label htmlFor="sinkhole-radius" className="shrink-0">
지반침하반경(m)
</label>
</div>
<div className="ml-auto flex shrink-0 items-center gap-1">
<input
@@ -120,7 +144,9 @@ export default function SinkholeLayerSettingPanel({
autoComplete="off"
value={radiusText}
onFocus={(event) => event.currentTarget.select()}
onChange={(event) => setRadiusText(event.target.value.replace(/[^0-9]/g, ""))}
onChange={(event) =>
setRadiusText(event.target.value.replace(/[^0-9]/g, ""))
}
onKeyDown={(event) => {
if (event.key === "Enter") void commitRadius();
}}
@@ -139,7 +165,9 @@ export default function SinkholeLayerSettingPanel({
<div className="flex min-h-[25px] items-center gap-1.5 whitespace-nowrap">
<div className="flex min-w-0 items-center gap-1">
{indicator}
<label htmlFor="sinkhole-start-year" className="shrink-0">년도설정</label>
<label htmlFor="sinkhole-start-year" className="shrink-0">
년도설정
</label>
</div>
<div className="ml-auto flex shrink-0 items-center gap-1.5">
<div id="sinkhole-start-year" className="w-[62px]">
@@ -149,12 +177,16 @@ export default function SinkholeLayerSettingPanel({
label=""
selectArray={startYearOptions}
value={yearRangeDraft.startYear}
onSelectValue={(value) => updateYearRangeDraft("startYear", String(value))}
onSelectValue={(value) =>
updateYearRangeDraft("startYear", String(value))
}
selectContentClass="z-[101]"
labelClass="hidden"
/>
</div>
<span className="px-[1px] text-[0.95rem] font-normal text-slate-500">~</span>
<span className="px-[1px] text-[0.95rem] font-normal text-muted-foreground">
~
</span>
<div id="sinkhole-end-year" className="w-[62px]">
<SelectBox
className="w-full"
@@ -162,7 +194,9 @@ export default function SinkholeLayerSettingPanel({
label=""
selectArray={endYearOptions}
value={yearRangeDraft.endYear}
onSelectValue={(value) => updateYearRangeDraft("endYear", String(value))}
onSelectValue={(value) =>
updateYearRangeDraft("endYear", String(value))
}
selectContentClass="z-[101]"
labelClass="hidden"
/>
@@ -183,7 +217,7 @@ export default function SinkholeLayerSettingPanel({
if (compact) {
return (
<div className="w-full rounded-[6px] border border-[#dbe7f5] bg-white px-2 py-[10px] shadow-[0_2px_8px_rgba(15,23,42,0.05)]">
<div className="w-full rounded-[6px] border border-border bg-background px-2 py-[10px] shadow-blur">
<div className="overflow-visible">{settingRows}</div>
</div>
);
@@ -191,16 +225,19 @@ export default function SinkholeLayerSettingPanel({
return (
<div className="w-full overflow-hidden rounded-sm border border-[#f0d7b0] bg-[#fff8ef]">
<div className="relative flex min-h-[30px] items-center justify-center border-b border-[#f2dfc3] bg-white px-8">
{/* 지반침하 상세는 지도 위험정보 경고영역이므로 주황 의미색을 유지한다. */}
<div className="relative flex min-h-[30px] items-center justify-center border-b border-[#f2dfc3] bg-background px-8">
<button
type="button"
className="absolute left-2 top-1/2 h-[18px] w-[18px] -translate-y-1/2 bg-transparent text-[1.2rem] leading-none text-[#555]"
className="absolute left-2 top-1/2 h-[18px] w-[18px] -translate-y-1/2 bg-transparent text-[1.2rem] leading-none text-muted-foreground"
onClick={() => setIsCollapsed((value) => !value)}
title={isCollapsed ? "펼치기" : "접기"}
>
{isCollapsed ? "+" : "-"}
</button>
<span className="text-[1.2rem] font-semibold text-[#d97706]">지반침하정보</span>
<span className="text-[1.2rem] font-semibold text-[#d97706]">
지반침하정보
</span>
</div>
{!isCollapsed && (
@@ -208,11 +245,14 @@ export default function SinkholeLayerSettingPanel({
<div className="overflow-visible">{settingRows}</div>
{isAdmin && lastSyncSummary ? (
<div className="mt-2 rounded-sm border border-[#f2dfc3] bg-white px-2 py-2 text-[1rem] text-[#7c5b2f]">
<div className="mt-2 rounded-sm border border-[#f2dfc3] bg-background px-2 py-2 text-[1rem] text-[#7c5b2f]">
<div className="font-semibold">최근 동기화</div>
<div className="mt-1">완료시각: {lastSyncSummary.finishedAt || "-"}</div>
<div className="mt-1">
성공 {lastSyncSummary.successCount}건 / 실패 {lastSyncSummary.failedCount}건
완료시각: {lastSyncSummary.finishedAt || "-"}
</div>
<div className="mt-1">
성공 {lastSyncSummary.successCount}건 / 실패{" "}
{lastSyncSummary.failedCount}건
</div>
</div>
) : null}
@@ -3,10 +3,17 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Button, Input } from "@common/components";
import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
import {
StandardPopupLayout,
type UiHeadProps,
} from "@common/business_components";
import { getRawJson } from "@/components/lib/api";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
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 type { SelectDataType } from "@common/business_components";
import {
buildSinkholeDefaultFilters,
@@ -24,7 +31,7 @@ import {
type SinkholeListItem,
} from "./sinkholeTypes";
const PAGE_SIZE = 20;
const PAGE_SIZE = 10;
const DOWNLOAD_BATCH_SIZE = 2000;
type SinkholeTableRow = SinkholeListItem & {
@@ -37,18 +44,19 @@ type SinkholeTableRow = SinkholeListItem & {
coordStatusDisplay: string;
};
const HEADLIST = [
{ id: "acdntNo", title: "사고번호", widthPercent: 17 },
const HEADLIST: UiHeadProps[] = [
{ id: "rowNo", title: "순번", widthPercent: 6 },
{ id: "acdntNo", title: "사고번호", widthPercent: 15 },
{ id: "ocrnYmd", title: "발생일자", widthPercent: 10 },
{ id: "fullAddress", title: "주소", widthPercent: 20 },
{ id: "sizeWidthDisplay", title: "발생규모폭", widthPercent: 8 },
{ id: "sizeLengthDisplay", title: "발생규모연장", widthPercent: 8 },
{ id: "sizeDepthDisplay", title: "발생규모깊이", widthPercent: 8 },
{ id: "sizeLengthDisplay", title: "발생규모연장", widthPercent: 9 },
{ id: "sizeDepthDisplay", title: "발생규모깊이", widthPercent: 9 },
{ id: "detailCauseDisplay", title: "상세발생원인", widthPercent: 11 },
{ id: "restoreStatus", title: "복구상태", widthPercent: 8 },
{ id: "coordStatusDisplay", title: "좌표보정상태", widthPercent: 9 },
{ id: "restoreCompletedYmdDisplay", title: "복구완료일자", widthPercent: 11 },
] as const;
];
const toDisplay = (value: unknown) => {
const normalized = String(value ?? "").trim();
@@ -56,14 +64,20 @@ const toDisplay = (value: unknown) => {
};
const toCoordStatusDisplay = (value: unknown) => {
const normalized = String(value ?? "").trim().toUpperCase();
const normalized = String(value ?? "")
.trim()
.toUpperCase();
if (normalized === "SUCCESS") return "성공";
if (normalized === "NO_ADDRESS") return "주소없음";
if (normalized === "FAILED") return "실패";
return normalized || "-";
};
const toTableRows = (items: SinkholeListItem[], page: number, pageSize = PAGE_SIZE) =>
const toTableRows = (
items: SinkholeListItem[],
page: number,
pageSize = PAGE_SIZE,
) =>
items.map((item, index) => ({
...item,
rowNo: page * pageSize + index + 1,
@@ -92,6 +106,7 @@ const unwrapPayload = <T,>(response: any): T =>
(response?.data ?? response) as T;
export default function SinkholePopupPage() {
const alert = useAlertStore((state) => state.setMessage);
const searchParams = useSearchParams();
const hasInitialFilterParams =
searchParams.has("startYmd") ||
@@ -140,13 +155,14 @@ export default function SinkholePopupPage() {
const [rows, setRows] = useState<SinkholeTableRow[]>([]);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [isLoading, setIsLoading] = useState(false);
const sggOptions = useMemo(
() =>
filters.ctpvCd === "0"
? sgg
: sgg.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2))),
: sgg.filter((item) =>
String(item.val).startsWith(filters.ctpvCd.slice(0, 2)),
),
[filters.ctpvCd, sgg],
);
@@ -172,8 +188,13 @@ export default function SinkholePopupPage() {
pageSize,
options,
);
const response = await getRawJson(`/sewerage-service/api/v1/sinkholes?${queryString}`);
const payload = unwrapPayload<{ items?: SinkholeListItem[]; total?: number }>(response);
const response = await getRawJson(
`/sewerage-service/api/v1/sinkholes?${queryString}`,
);
const payload = unwrapPayload<{
items?: SinkholeListItem[];
total?: number;
}>(response);
const items = Array.isArray(payload?.items) ? payload.items : [];
return {
rows: toTableRows(items, page - 1, pageSize),
@@ -185,11 +206,11 @@ export default function SinkholePopupPage() {
);
const loadRows = useCallback(
async (page = 1, nextFilters = filters) => {
async (page: number, nextFilters: SinkholeFilters) => {
setIsLoading(true);
try {
const normalized = normalizeSinkholeFilters(nextFilters);
const response = await fetchRows(page, normalized);
const response = await fetchRows(page, normalized, PAGE_SIZE);
setRows(response.rows);
setTotal(response.total);
setCurrentPage(page);
@@ -205,12 +226,16 @@ export default function SinkholePopupPage() {
console.error("지반침하 목록 조회 중 오류:", error);
setRows([]);
setTotal(0);
alert(error instanceof Error ? error.message : "지반침하 목록 조회 중 오류가 발생했습니다.");
alert(
error instanceof Error
? error.message
: "지반침하 목록 조회 중 오류가 발생했습니다.",
);
} finally {
setIsLoading(false);
}
},
[fetchRows, filters],
[alert, fetchRows],
);
useEffect(() => {
@@ -225,12 +250,12 @@ export default function SinkholePopupPage() {
await loadRows(page, normalized);
};
const handleRowClick = (index?: number) => {
if (typeof index !== "number") return;
const row = rows[index];
if (!row) return;
/** 페이지 이동은 입력 중인 조건이 아니라 마지막 적용 조건을 사용한다. */
const handlePageChange = async (page: number) => {
await loadRows(page, appliedFilters);
};
setSelectedIndex(index);
const handleRowClick = (row: SinkholeTableRow) => {
window.opener?.postMessage(
{
type: SINKHOLE_ROW_SELECT_MESSAGE,
@@ -253,9 +278,14 @@ export default function SinkholePopupPage() {
const totalPages = Math.max(1, Math.ceil(total / DOWNLOAD_BATCH_SIZE));
for (let page = 1; page <= totalPages; page += 1) {
const response = await fetchRows(page, normalized, DOWNLOAD_BATCH_SIZE, {
includeCoordStatus: false,
});
const response = await fetchRows(
page,
normalized,
DOWNLOAD_BATCH_SIZE,
{
includeCoordStatus: false,
},
);
downloadItems.push(...response.items);
}
@@ -274,148 +304,180 @@ export default function SinkholePopupPage() {
});
} catch (error) {
console.error("지반침하 다운로드 중 오류:", error);
alert(error instanceof Error ? error.message : "지반침하 다운로드 중 오류가 발생했습니다.");
alert(
error instanceof Error
? error.message
: "지반침하 다운로드 중 오류가 발생했습니다.",
);
} finally {
setIsLoading(false);
}
};
return (
<main className="flex h-screen flex-col overflow-hidden bg-white">
<StandardPopupLayout title="지반침하 목록">
<LoadingOverlay isLoading={isLoading} />
<header className="flex min-h-[44px] items-center justify-between border-b border-[#dbe2e6] bg-primary px-5 py-3">
<h1 className="text-[1.45rem] font-semibold text-white">지반침하발생현황</h1>
<div />
</header>
<section className="overflow-y-auto p-4">
<div className="grid grid-cols-1 gap-3 border-b border-slate-200 bg-slate-50 pb-4 md:grid-cols-[170px_170px_220px_220px_180px_180px_auto] md:items-end md:justify-between">
<label className="block md:max-w-[170px]">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">발생기간(시작)</div>
<Input
className="h-10 !text-[1.1rem]"
type="date"
min={formatYmdToDateInput(SINKHOLE_MIN_SEARCH_YMD)}
value={formatYmdToDateInput(filters.startYmd)}
onChange={(event) => setFilters({ ...filters, startYmd: formatDateInputToYmd(event.target.value) })}
/>
</label>
<label className="block md:max-w-[170px]">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">발생기간(종료)</div>
<Input
className="h-10 !text-[1.1rem]"
type="date"
min={formatYmdToDateInput(SINKHOLE_MIN_SEARCH_YMD)}
value={formatYmdToDateInput(filters.endYmd)}
onChange={(event) => setFilters({ ...filters, endYmd: formatDateInputToYmd(event.target.value) })}
/>
</label>
<label className="block md:max-w-[220px]">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시도</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
value={filters.ctpvCd}
onChange={(event) =>
setFilters({
...filters,
ctpvCd: event.target.value,
sggCd: "0",
})
}
>
<option value="0">전체</option>
{ctpv.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="block md:max-w-[220px]">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시군구</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
value={filters.sggCd}
onChange={(event) => setFilters({ ...filters, sggCd: event.target.value })}
>
<option value="0">전체</option>
{sggOptions.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="block md:max-w-[180px]">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">복구상태</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
value={filters.restoreStatus}
onChange={(event) => setFilters({ ...filters, restoreStatus: event.target.value })}
>
{SINKHOLE_RESTORE_STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block md:max-w-[180px]">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">좌표보정상태</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
value={filters.coordStatus}
onChange={(event) => setFilters({ ...filters, coordStatus: event.target.value })}
>
{SINKHOLE_COORD_STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<div className="flex flex-wrap justify-end gap-2 md:pr-3">
<Button
type="button"
size="sm"
color="primary"
className="h-10 px-5 !text-[1.1rem]"
onClick={() => void handleSearch(1)}
>
조회
</Button>
<Button
type="button"
size="sm"
color="green"
className="h-10 px-5 !text-[1.1rem]"
onClick={handleDownload}
>
다운로드
</Button>
<PopupSearchPanel contentClassName="flex flex-wrap items-end gap-x-5 gap-y-3">
<label className="block md:max-w-[170px]">
<div className="mb-1 text-[1.05rem] font-medium text-label">
발생기간(시작)
</div>
</div>
<Input
className="h-[3.2rem] !text-[1.3rem]"
type="date"
min={formatYmdToDateInput(SINKHOLE_MIN_SEARCH_YMD)}
value={formatYmdToDateInput(filters.startYmd)}
onChange={(event) =>
setFilters({
...filters,
startYmd: formatDateInputToYmd(event.target.value),
})
}
/>
</label>
<UiTable<SinkholeTableRow>
headName={<>지반침하 조회 결과 총 {total.toLocaleString()}건</>}
tableData={rows}
headlist={HEADLIST as any}
pageSize={PAGE_SIZE}
total={total}
cellClick={(index) => handleRowClick(index)}
onPageChange={(page) => void handleSearch(page)}
curPage={currentPage}
index={selectedIndex}
widthFixed={true}
isHideCount={true}
/>
</section>
</main>
<label className="block md:max-w-[170px]">
<div className="mb-1 text-[1.05rem] font-medium text-label">
발생기간(종료)
</div>
<Input
className="h-[3.2rem] !text-[1.3rem]"
type="date"
min={formatYmdToDateInput(SINKHOLE_MIN_SEARCH_YMD)}
value={formatYmdToDateInput(filters.endYmd)}
onChange={(event) =>
setFilters({
...filters,
endYmd: formatDateInputToYmd(event.target.value),
})
}
/>
</label>
<label className="block md:max-w-[220px]">
<div className="mb-1 text-[1.05rem] font-medium text-label">시도</div>
<select
className="h-[3.2rem] w-full rounded-md border border-border px-3 text-[1.3rem]"
value={filters.ctpvCd}
onChange={(event) =>
setFilters({
...filters,
ctpvCd: event.target.value,
sggCd: "0",
})
}
>
<option value="0">전체</option>
{ctpv.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="block md:max-w-[220px]">
<div className="mb-1 text-[1.05rem] font-medium text-label">
시군구
</div>
<select
className="h-[3.2rem] w-full rounded-md border border-border px-3 text-[1.3rem]"
value={filters.sggCd}
onChange={(event) =>
setFilters({ ...filters, sggCd: event.target.value })
}
>
<option value="0">전체</option>
{sggOptions.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="block md:max-w-[180px]">
<div className="mb-1 text-[1.05rem] font-medium text-label">
복구상태
</div>
<select
className="h-[3.2rem] w-full rounded-md border border-border px-3 text-[1.3rem]"
value={filters.restoreStatus}
onChange={(event) =>
setFilters({ ...filters, restoreStatus: event.target.value })
}
>
{SINKHOLE_RESTORE_STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block md:max-w-[180px]">
<div className="mb-1 text-[1.05rem] font-medium text-label">
좌표보정상태
</div>
<select
className="h-[3.2rem] w-full rounded-md border border-border px-3 text-[1.3rem]"
value={filters.coordStatus}
onChange={(event) =>
setFilters({ ...filters, coordStatus: event.target.value })
}
>
{SINKHOLE_COORD_STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<div className="ml-auto flex flex-wrap justify-end gap-2">
<Button
type="button"
color="primary"
onClick={() => void handleSearch(1)}
>
조회
</Button>
<Button
type="button"
color="white"
onClick={() => {
const resetFilters = normalizeSinkholeFilters(
buildSinkholeDefaultFilters(),
undefined,
);
setFilters(resetFilters);
void loadRows(1, resetFilters);
}}
>
초기화
</Button>
</div>
</PopupSearchPanel>
<MapPopupDataTable
rows={rows as Array<SinkholeTableRow & Record<string, unknown>>}
headlist={HEADLIST}
title="지반침하 목록"
total={total}
page={currentPage}
pageSize={PAGE_SIZE}
onPageChange={(page) => void handlePageChange(page)}
onRowClick={handleRowClick}
rowId={(row, index) => String(row.fid ?? row.acdntNo ?? index)}
scrollMode="horizontal"
minTableWidth="150rem"
actions={
<Button type="button" onClick={handleDownload}>
엑셀다운로드
</Button>
}
/>
</StandardPopupLayout>
);
}
@@ -14,7 +14,9 @@ export const SINKHOLE_DEFAULT_RADIUS = 100;
export const SINKHOLE_LAYER_ID = "SN_SINKHOLE_LAYER";
export const SINKHOLE_SELECTED_LAYER_ID = "SN_SINKHOLE_SELECTED_LAYER";
export const SINKHOLE_MIN_SEARCH_YMD = "20160101";
export const SINKHOLE_MIN_SEARCH_YEAR = Number(SINKHOLE_MIN_SEARCH_YMD.slice(0, 4));
export const SINKHOLE_MIN_SEARCH_YEAR = Number(
SINKHOLE_MIN_SEARCH_YMD.slice(0, 4),
);
export type SinkholeFilters = {
startYmd: string;
@@ -93,7 +95,9 @@ export type SinkholeYearRange = {
const toYmd = (date: Date) =>
`${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, "0")}${String(date.getDate()).padStart(2, "0")}`;
export const buildSinkholeDefaultYearRange = (baseDate = new Date()): SinkholeYearRange => {
export const buildSinkholeDefaultYearRange = (
baseDate = new Date(),
): SinkholeYearRange => {
const currentYear = baseDate.getFullYear();
return {
startYear: String(currentYear - 1),
@@ -105,8 +109,12 @@ export const normalizeSinkholeYearRange = (
range?: Partial<SinkholeYearRange> | null,
fallback: SinkholeYearRange = buildSinkholeDefaultYearRange(),
): SinkholeYearRange => ({
startYear: String(range?.startYear ?? fallback.startYear).replace(/[^0-9]/g, "").slice(0, 4),
endYear: String(range?.endYear ?? fallback.endYear).replace(/[^0-9]/g, "").slice(0, 4),
startYear: String(range?.startYear ?? fallback.startYear)
.replace(/[^0-9]/g, "")
.slice(0, 4),
endYear: String(range?.endYear ?? fallback.endYear)
.replace(/[^0-9]/g, "")
.slice(0, 4),
});
export const validateSinkholeYearRange = (
@@ -119,7 +127,10 @@ export const validateSinkholeYearRange = (
if (!/^\d{4}$/.test(range.startYear) || !/^\d{4}$/.test(range.endYear)) {
return "시작년도와 종료년도는 4자리 숫자로 입력해야 합니다.";
}
if (startYear < SINKHOLE_MIN_SEARCH_YEAR || endYear < SINKHOLE_MIN_SEARCH_YEAR) {
if (
startYear < SINKHOLE_MIN_SEARCH_YEAR ||
endYear < SINKHOLE_MIN_SEARCH_YEAR
) {
return `${SINKHOLE_MIN_SEARCH_YEAR}년 이후 범위로 입력해야 합니다.`;
}
if (startYear > endYear) {
@@ -147,7 +158,9 @@ export const buildSinkholeFiltersFromYearRange = (
};
};
export const buildSinkholeDefaultFilters = (scope?: SinkholeScope): SinkholeFilters => {
export const buildSinkholeDefaultFilters = (
scope?: SinkholeScope,
): SinkholeFilters => {
const today = new Date();
const oneYearAgo = new Date(today);
oneYearAgo.setFullYear(today.getFullYear() - 1);
@@ -162,7 +175,9 @@ export const buildSinkholeDefaultFilters = (scope?: SinkholeScope): SinkholeFilt
};
};
export const buildSinkholeScope = (userInfo: IUserInfo | null): SinkholeScope => {
export const buildSinkholeScope = (
userInfo: IUserInfo | null,
): SinkholeScope => {
const admin = userInfo?.adminRoleYn === "Y";
if (admin) {
return {
@@ -204,8 +219,12 @@ export const normalizeSinkholeFilters = (
scope?: SinkholeScope,
): SinkholeFilters => {
const next = {
startYmd: String(filters.startYmd ?? "").replace(/[^0-9]/g, "").slice(0, 8),
endYmd: String(filters.endYmd ?? "").replace(/[^0-9]/g, "").slice(0, 8),
startYmd: String(filters.startYmd ?? "")
.replace(/[^0-9]/g, "")
.slice(0, 8),
endYmd: String(filters.endYmd ?? "")
.replace(/[^0-9]/g, "")
.slice(0, 8),
ctpvCd: String(filters.ctpvCd ?? "0") || "0",
sggCd: String(filters.sggCd ?? "0") || "0",
restoreStatus: String(filters.restoreStatus ?? "0") || "0",
@@ -217,7 +236,11 @@ export const normalizeSinkholeFilters = (
}
if (scope?.sggCd && scope.sggCd !== "0") {
next.sggCd = scope.sggCd;
} else if (next.ctpvCd !== "0" && next.sggCd !== "0" && !next.sggCd.startsWith(next.ctpvCd)) {
} else if (
next.ctpvCd !== "0" &&
next.sggCd !== "0" &&
!next.sggCd.startsWith(next.ctpvCd)
) {
next.sggCd = "0";
}
@@ -230,7 +253,10 @@ export const formatYmdToDateInput = (value?: string) => {
return `${normalized.slice(0, 4)}-${normalized.slice(4, 6)}-${normalized.slice(6, 8)}`;
};
export const formatDateInputToYmd = (value?: string) => String(value ?? "").replace(/[^0-9]/g, "").slice(0, 8);
export const formatDateInputToYmd = (value?: string) =>
String(value ?? "")
.replace(/[^0-9]/g, "")
.slice(0, 8);
export const resolveRegionNameByCode = (
code: string,
@@ -274,7 +300,6 @@ export const buildSinkholeListQueryString = (
if (options?.includeCoordStatus === false) {
params.set("includeCoordStatus", "false");
}
return params.toString();
};
@@ -282,7 +307,9 @@ export const isSinkholeLayerNode = (node: any) => {
const data = node?.data ?? node;
const nodeId = String(node?.id ?? data?.lyrSn ?? "");
const layerName = String(data?.lyrNm ?? data?.name ?? "").trim();
const layerTypeName = String(data?.lyrTyNm ?? data?.lyrTypeNm ?? data?.lyr_type_nm ?? "").trim();
const layerTypeName = String(
data?.lyrTyNm ?? data?.lyrTypeNm ?? data?.lyr_type_nm ?? "",
).trim();
return (
nodeId === SINKHOLE_CONTROL_NODE_ID ||
@@ -295,10 +322,11 @@ export const isSinkholeRootNode = (node: any) => {
const data = node?.data ?? node;
const nodeId = String(node?.id ?? data?.lyrSn ?? "");
const layerName = String(data?.lyrNm ?? data?.name ?? "").trim();
return nodeId === SINKHOLE_CONTROL_NODE_ID || layerName === SINKHOLE_LAYER_NAME;
return (
nodeId === SINKHOLE_CONTROL_NODE_ID || layerName === SINKHOLE_LAYER_NAME
);
};
export const SINKHOLE_RESTORE_STATUS_OPTIONS = [
{ value: "0", label: "전체" },
{ value: "복구완료", label: "복구완료" },
@@ -313,7 +341,7 @@ export const SINKHOLE_COORD_STATUS_OPTIONS = [
] as const;
export const SINKHOLE_EXCEL_HEADERS: Record<string, string> = {
rowNo: "NO",
rowNo: "순번",
ocrnYmd: "발생일자",
fullAddress: "주소",
sizeWidth: "발생규모폭",
@@ -326,7 +354,7 @@ export const SINKHOLE_EXCEL_HEADERS: Record<string, string> = {
};
export const SINKHOLE_DOWNLOAD_EXCEL_HEADERS: Record<string, string> = {
rowNo: "NO",
rowNo: "순번",
ocrnYmd: "발생일자",
fullAddress: "주소",
sizeWidth: "발생규모폭",
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,95 @@
import { getData } from "@/app/components/lib/api";
import type { UiTableSort } from "@common/business_components/ui/table";
import type {
WaterRiskAreaOption,
WaterRiskMapView,
WaterRiskResultPage,
WaterRiskResultRow,
WaterRiskStatus,
} from "./waterRiskTypes";
export type WaterRiskResultQuery = {
startDate: string;
endDate: string;
analysisBaseAt?: string;
ctpvCd?: string;
sggCd?: string;
areaId?: string;
riskStatus?: WaterRiskStatus;
rainEventStatus?: "IN_PROGRESS" | "ENDED";
sort?: UiTableSort;
page: number;
size: number;
};
type WaterRiskApiResultRow = Omit<WaterRiskResultRow, "sidoName"> & {
ctpvName: string;
};
/** 통수위험 결과 API 응답을 기존 화면 표시 모델로 정규화한다. */
const normalizeResultRow = (row: WaterRiskApiResultRow): WaterRiskResultRow => ({
...row,
sidoName: row.ctpvName,
currentAccumRainQty: row.currentAccumRainQty ?? null,
pipeSummaries: Array.isArray(row.pipeSummaries) ? row.pipeSummaries : [],
});
/** 등록된 중점관리지역 검색조건을 조회한다. */
export const fetchWaterRiskAreas = async (): Promise<WaterRiskAreaOption[]> => {
const response = await getData("v1/waterRisk/areas");
return Array.isArray(response) ? response : [];
};
/** 기간·행정구역·지역·위험상태에 맞는 결과 페이지를 조회한다. */
export const fetchWaterRiskResults = async (
query: WaterRiskResultQuery,
): Promise<WaterRiskResultPage> => {
const params = new URLSearchParams({
startDate: query.startDate,
endDate: query.endDate,
page: String(query.page),
size: String(query.size),
});
if (query.ctpvCd) params.set("ctpvCd", query.ctpvCd);
if (query.analysisBaseAt) params.set("analysisBaseAt", query.analysisBaseAt);
if (query.sggCd) params.set("sggCd", query.sggCd);
if (query.areaId) params.set("areaId", query.areaId);
if (query.riskStatus) params.set("riskStatus", query.riskStatus);
if (query.rainEventStatus) params.set("rainEventStatus", query.rainEventStatus);
if (query.sort) params.set("sort", `${query.sort.key},${query.sort.direction}`);
const response = await getData(`v1/waterRisk/results?${params.toString()}`);
const items = Array.isArray(response?.items)
? response.items.map((row: WaterRiskApiResultRow) => normalizeResultRow(row))
: [];
return {
items,
analysisBaseAt: response?.analysisBaseAt ?? null,
total: Number(response?.total ?? 0),
page: Number(response?.page ?? query.page),
size: Number(response?.size ?? query.size),
totalPages: Number(response?.totalPages ?? 0),
};
};
/** 결과 행과 연결된 중점관리지역·유출관로 도형을 조회한다. */
export const fetchWaterRiskMapView = async (
row: WaterRiskResultRow,
): Promise<WaterRiskMapView> => {
if (!row.analysisDate || !row.ctpvCd || !row.sggCd) {
throw new Error("지도 조회에 필요한 분석일·행정구역 정보가 없습니다.");
}
const params = new URLSearchParams({
ctpvCd: row.ctpvCd,
sggCd: row.sggCd,
});
const response = await getData(
`v1/waterRisk/results/${encodeURIComponent(row.analysisDate)}/areas/${encodeURIComponent(row.areaId)}/mapView?${params.toString()}`,
);
return {
...response,
pipes: Array.isArray(response?.pipes) ? response.pipes : [],
manholes: Array.isArray(response?.manholes) ? response.manholes : [],
};
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
/**
* ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 기본정보 탭
*
*
* 📄 BasicTab Component
* Next.js App Router (app/) 기반 검색 모달의 기본정보 탭 컴포넌트
*
@@ -8,23 +8,25 @@
* - 하수관로, 하수처리시설, 하수처리구역, 하수맨홀, 하수펌프장, 우수토실 등 다양한 유형별 컴포넌트 렌더링
* - useSearchModalStore를 통해 상태 관리 및 사용자 상호작용 처리
*/
import React from 'react';
import { SelectBox } from '@common/business_components/ui';
import { Label } from '@common/components';
import SwagePpl from './swagePpl';
import SwagePrcsFclt from './swagePrcsFclt';
import SwagePrcsZone from './swagePrcsZone';
import SwageMhl from './swageMhl';
import SwagePmstn from './swagePmstn';
import Stofcbr from './stofcbr';
import useSearchModalStore from '@/store/useSearchModalStore';
import React from "react";
import { SelectBox } from "@common/business_components/ui/select";
import { Label } from "@common/components";
import SwagePpl from "./swagePpl";
import SwagePrcsFclt from "./swagePrcsFclt";
import SwagePrcsZone from "./swagePrcsZone";
import FldngWtlvMap from "./fldngWtlvMap";
import SwageMhl from "./swageMhl";
import SwagePmstn from "./swagePmstn";
import Stofcbr from "./stofcbr";
import useSearchModalStore from "@/store/useSearchModalStore";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
// 기본정보 '구분' 선택 목록
export const BASIC_TYPES = [
{ val: "tb_ms_swage_ppl", text: "하수관로" },
{ val: "tb_il_swage_prcs_fclt", text: "하수처리시설" },
{ val: "tb_ms_swage_prcs_zone", text: "하수처리구역" },
{ val: "tb_ms_fldng_wtlv_map", text: "침수흔적도" },
{ val: "tb_ms_swage_mhl", text: "하수맨홀" },
{ val: "tb_ms_swage_pmstn", text: "하수펌프장" },
{ val: "tb_ms_stofcbr", text: "우수토실" },
@@ -34,32 +36,37 @@ const BasicTab: React.FC = () => {
const { basicType, setBasicType } = useSearchModalStore();
return (
<div className="relative w-full min-h-[10vh] rounded-lg border border-border bg-background px-5 pt-5">
{/* 구분 */}
<div className="mb-3 flex items-center justify-between gap-4">
<div className="flex items-center">
<Label className="mr-2 whitespace-nowrap text-xl font-medium text-label">구분</Label>
<SelectBox
className="w-[150px] mr-3"
selectClass="rounded-lg bg-field text-foreground"
label=""
selectArray={BASIC_TYPES}
onSelectValue={(value: string | number) =>
setBasicType(String(value))
}
value={basicType}
selectContentClass="z-[101]"
/>
</div>
<PopupSearchPanel contentClassName="flex flex-wrap items-center gap-x-4 gap-y-3">
{/* 구분과 세부 조회조건은 하나의 검색 행으로 이어서 배치한다. */}
<div className="flex shrink-0 items-center">
<Label className="mr-2 whitespace-nowrap text-xl font-medium text-label">
구분
</Label>
<SelectBox
className="mr-3 w-[150px]"
selectClass="rounded-lg bg-field text-foreground"
label=""
selectArray={BASIC_TYPES}
onSelectValue={(value: string | number) =>
setBasicType(String(value))
}
value={basicType}
selectContentClass="z-[101]"
/>
</div>
{/* 조회 조건 */}
{basicType === "tb_ms_swage_ppl" && (<SwagePpl />)} {/* 하수관로 */}
{basicType === "tb_il_swage_prcs_fclt" && (<SwagePrcsFclt />)} {/* 하수처리시설 */}
{basicType === "tb_ms_swage_prcs_zone" && (<SwagePrcsZone />)} {/* 하수처리구역 */}
{basicType === "tb_ms_swage_mhl" && (<SwageMhl />)} {/* 하수맨홀 */}
{basicType === "tb_ms_swage_pmstn" && (<SwagePmstn />)} {/* 하수펌프장 */}
{basicType === "tb_ms_stofcbr" && (<Stofcbr />)} {/* 우수토실 */}
</div>
<div className="min-w-0 flex-[1_1_60rem]">
{basicType === "tb_ms_swage_ppl" && <SwagePpl />} {/* 하수관로 */}
{basicType === "tb_il_swage_prcs_fclt" && <SwagePrcsFclt />}{" "}
{/** 하수처리시설 */}
{basicType === "tb_ms_swage_prcs_zone" && <SwagePrcsZone />}{" "}
{/** 하수처리구역 */}
{basicType === "tb_ms_fldng_wtlv_map" && <FldngWtlvMap />}{" "}
{/** 침수흔적도 */}
{basicType === "tb_ms_swage_mhl" && <SwageMhl />} {/* 하수맨홀 */}
{basicType === "tb_ms_swage_pmstn" && <SwagePmstn />} {/* 하수펌프장 */}
{basicType === "tb_ms_stofcbr" && <Stofcbr />} {/* 우수토실 */}
</div>
</PopupSearchPanel>
);
};
@@ -0,0 +1,84 @@
import React from "react";
import { Button, Input, Label } from "@common/components";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
import useSearchModalStore from "../../../store/useSearchModalStore";
import useSearchModal from "./useSearchModal";
// 침수흔적도용 CQL 필터 생성
export const fldngWtlvMapCqlFilters = () => {
const { ctpvCd, sggCd, var1, var2 } = useSearchModalStore.getState();
const cqlFilters: string[] = [];
if (ctpvCd !== "" && ctpvCd !== "0")
cqlFilters.push(`stdg_cd LIKE '${ctpvCd.trim()}%'`);
if (sggCd !== "" && sggCd !== "0")
cqlFilters.push(`stdg_cd LIKE '${sggCd.trim()}%'`);
if (var1) cqlFilters.push(`fldng_yr = '${var1.trim()}'`);
if (var2) cqlFilters.push(`fldng_dstr_nm ILIKE '%${var2.trim()}%'`);
return cqlFilters;
};
const FldngWtlvMap: React.FC = () => {
const { var1, setVar1, var2, setVar2, ctpvCd, setCtpvCd, sggCd, setSggCd } =
useSearchModalStore();
const { handleSearch, handleResetSearch } = useSearchModal();
return (
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
<CtpvSggPrcsFcltSelectBox
sido={ctpvCd}
sigungu={sggCd}
onSidoChange={(sidoId: string) => {
setCtpvCd(sidoId);
setSggCd("0");
}}
onSigunguChange={setSggCd}
/>
<div className="flex shrink-0 items-center gap-2">
<Label className="whitespace-nowrap text-xl font-medium text-label">
침수년도
</Label>
<Input
className="w-[100px] !text-xl placeholder:text-center"
placeholder="YYYY"
value={var1}
onChange={(e) =>
setVar1(e.target.value.replace(/[^0-9]/g, "").slice(0, 4))
}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleSearch();
}
}}
/>
</div>
<div className="flex shrink-0 items-center gap-2">
<Label className="whitespace-nowrap text-xl font-medium text-label">
침수재해명
</Label>
<Input
className="w-[180px] !text-xl placeholder:text-center"
placeholder="입력"
value={var2}
onChange={(e) => setVar2(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleSearch();
}
}}
/>
</div>
<div className="flex min-w-fit flex-1 justify-end gap-2">
<Button onClick={handleSearch}>조회</Button>
<Button color="white" onClick={handleResetSearch}>
초기화
</Button>
</div>
</div>
</div>
);
};
export default React.memo(FldngWtlvMap);
@@ -1,6 +1,6 @@
/**
* ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 유지관리 정보 탭
*
*
* 📄 Operation Tab
* Next.js App Router (app/) 기반 검색 모달 유지관리 정보 탭 컴포넌트
*
@@ -11,37 +11,44 @@
* - Zustand를 통한 상태 관리 및 필터 값 저장
* - Enter 키 입력 시 자동 조회 지원
*/
import React from 'react';
import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox';
import { SelectBox } from '@common/business_components/ui';
import { Button, Input, Label } from '@common/components';
import useStore from '@/store/useStore';
import useSearchModalStore from '@/store/useSearchModalStore';
import useSearchModal from './useSearchModal';
import useOperationTab from './useOperationTab';
import React from "react";
import { YearRangeSelectBox } from "@/components/ui/YearRangeSelectBox";
import { SelectBox } from "@common/business_components/ui/select";
import { Button, Input, Label } from "@common/components";
import useStore from "@/store/useStore";
import useSearchModalStore from "@/store/useSearchModalStore";
import useSearchModal from "./useSearchModal";
import useOperationTab from "./useOperationTab";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
const OperationTab: React.FC = () => {
const { BIZ_TY_CD } = useStore();
const {
workType, setWorkType,
var1, setVar1, var2, setVar2,
yearFilter, setYearFilter,
workType,
setWorkType,
var1,
setVar1,
var2,
setVar2,
yearFilter,
setYearFilter,
} = useSearchModalStore();
const { handleSearch } = useSearchModal();
const { handleSearch, handleResetSearch } = useSearchModal();
const { WORK_TYPES } = useOperationTab();
const BIZ_TY_CD_SELECT_ARRAY = BIZ_TY_CD.map(item => ({
const BIZ_TY_CD_SELECT_ARRAY = BIZ_TY_CD.map((item) => ({
val: item.lwpoCd,
text: item.lwpoCdNm,
}));
return (
<div className="relative flex min-h-[10vh] w-full flex-col rounded-lg border border-border bg-background px-5 pt-5">
<div className="flex items-center mb-3">
<Label className="mr-2 whitespace-nowrap text-xl font-medium text-label">업무구분</Label>
<PopupSearchPanel contentClassName="flex flex-wrap items-center gap-x-5 gap-y-3">
<div className="flex items-center gap-2">
<Label className="whitespace-nowrap text-xl font-medium text-label">
업무구분
</Label>
<SelectBox
className="w-40 mr-8"
className="w-40"
selectClass="rounded-lg bg-field text-xl text-foreground"
label=""
selectArray={WORK_TYPES.map((w) => ({
@@ -53,30 +60,44 @@ const OperationTab: React.FC = () => {
selectContentClass="z-[101]"
/>
</div>
<div className="flex items-center mb-6">
<Label className="mr-2 whitespace-nowrap text-xl font-medium text-label">사업명</Label>
<div className="flex items-center gap-2">
<Label className="whitespace-nowrap text-xl font-medium text-label">
사업명
</Label>
<Input
className="w-[100px] !text-xl placeholder:text-center mr-8"
className="w-[140px] !text-xl placeholder:text-center"
placeholder="입력"
value={var1}
onChange={(e) => { setVar1(e.target.value); }}
onChange={(e) => {
setVar1(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
if (e.key === "Enter") {
e.preventDefault(); // Enter 키 기본 동작 방지
handleSearch();
}
}}
/>
<YearRangeSelectBox
start={yearFilter.start}
end={yearFilter.end}
onChange={range => setYearFilter({ ...yearFilter, start: range.start, end: range.end })}
startLabel="수행기간"
endLabel=""
/>
<Label className="mr-2 whitespace-nowrap text-xl font-medium text-label">사업 유형</Label>
</div>
<YearRangeSelectBox
start={yearFilter.start}
end={yearFilter.end}
onChange={(range) =>
setYearFilter({
...yearFilter,
start: range.start,
end: range.end,
})
}
startLabel="수행기간"
endLabel=""
/>
<div className="flex items-center gap-2">
<Label className="whitespace-nowrap text-xl font-medium text-label">
사업 유형
</Label>
<SelectBox
className="w-60 mr-8"
className="w-60"
selectClass="rounded-lg bg-field text-xl text-foreground"
label=""
selectArray={[{ val: "0", text: "전체" }, ...BIZ_TY_CD_SELECT_ARRAY]}
@@ -86,13 +107,16 @@ const OperationTab: React.FC = () => {
}}
selectContentClass="z-[101]"
/>
{/* 조회 버튼 */}
<div className="flex-1 flex justify-end">
<Button className="text-base py-2" onClick={handleSearch}>조회</Button>
</div>
</div>
</div>
)
{/* 조회 버튼 */}
<div className="flex justify-end gap-2 sm:ml-auto sm:pl-4">
<Button onClick={handleSearch}>조회</Button>
<Button color="white" onClick={handleResetSearch}>
초기화
</Button>
</div>
</PopupSearchPanel>
);
};
export default React.memo(OperationTab);
@@ -1,6 +1,6 @@
/**
* ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 하수도정비기본계획보고서 탭
*
*
* 📄 Report Tab
* Next.js App Router (app/) 기반 검색 모달 보고서 탭 컴포넌트
*
@@ -12,28 +12,30 @@
* - Zustand를 통한 모달 상태 및 데이터 관리
* - 로딩 오버레이 및 선택 초기화 기능
*/
'use client';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Button, Label } from '@common/components';
import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox';
import { SelectBox, UiTable } from '@common/business_components/ui';
import LoadingOverlay from '@/components/ui/LoadingBar';
import useReportTab from './useReportTab';
import useStore from '@/store/useStore';
import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox';
import useSearchModalStore from '@/store/useSearchModalStore';
import { useReportTabStore } from '@/store/useReportTabStore';
"use client";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Button, Label } from "@common/components";
import { YearRangeSelectBox } from "@/components/ui/YearRangeSelectBox";
import { SelectBox } from "@common/business_components/ui/select";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import useReportTab from "./useReportTab";
import useStore from "@/store/useStore";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
import useSearchModalStore from "@/store/useSearchModalStore";
import { useReportTabStore } from "@/store/useReportTabStore";
import type { FlattenedFolder, ReportDataFile } from "./useReportTab";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
// 하수도정비 기본계획 보고서
export const reportTableHeadlist = [
{ id: 'no', title: 'NO' },
{ id: 'sido', title: '시도' },
{ id: 'sigungu', title: '시군구' },
{ id: 'approvalYear', title: '승인년도' },
{ id: 'plantZoneGubun', title: '구분' },
{ id: 'reportGubun', title: '보고서구분' },
{ id: 'fileName', title: '파일명' },
{ id: 'regDate', title: '등록일자' },
{ id: "no", title: "순번" },
{ id: "sido", title: "시도" },
{ id: "sigungu", title: "시군구" },
{ id: "approvalYear", title: "승인년도" },
{ id: "plantZoneGubun", title: "구분" },
{ id: "reportGubun", title: "보고서구분" },
{ id: "fileName", title: "파일명" },
{ id: "regDate", title: "등록일자" },
];
interface ReportTabProps {
open?: boolean;
@@ -43,25 +45,75 @@ interface ReportTabProps {
}
const normalizeYear = (value: string) => {
if (!value || value === '0' || value === '9999') return '';
if (!value || value === "0" || value === "9999") return "";
return value;
};
const getApprovalYearNumber = (value: string | number | undefined) => {
const normalized = String(value ?? '').replace('년', '').trim();
const normalized = String(value ?? "")
.replace("년", "")
.trim();
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : null;
};
const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sggNm }) => {
const [isResetPage, setIsResetPage] = useState<boolean>(false);
const [isResetSelection, setIsResetSelection] = useState<boolean>(false);
const REPORT_PAGE_SIZE = 10;
const REPORT_METADATA_CONCURRENCY = 8;
const REPORT_METADATA_TIMEOUT_MS = 8000;
type ReportSearchFilters = {
ctpvCd: string;
sggCd: string;
plantZoneGubun: string;
reportGubun: string;
yearFilter: { start: string; end: string };
};
const DEFAULT_REPORT_SEARCH_FILTERS: ReportSearchFilters = {
ctpvCd: "0",
sggCd: "0",
plantZoneGubun: "0",
reportGubun: "0",
yearFilter: { start: "", end: "" },
};
type ReportFileMetadata = Pick<
FlattenedFolder,
| "fileName"
| "regDate"
| "fileSn"
| "filePath"
| "fileCount"
| "downloadFileTitle"
>;
const EMPTY_REPORT_METADATA: ReportFileMetadata = {
fileName: "-",
regDate: "",
fileSn: "",
filePath: "",
fileCount: 0,
downloadFileTitle: "",
};
const ReportTab: React.FC<ReportTabProps> = ({
open,
isSearch = true,
ctpvNm,
sggNm,
}) => {
const [showTable, setShowTable] = useState(false);
const [ctpvCd, setCtpvCd] = useState('0');
const [sggCd, setSggCd] = useState('0');
const [plantZoneGubun, setPlantZoneGubun] = useState('0');
const [reportGubun, setReportGubun] = useState('0');
const [ctpvCd, setCtpvCd] = useState("0");
const [sggCd, setSggCd] = useState("0");
const [plantZoneGubun, setPlantZoneGubun] = useState("0");
const [reportGubun, setReportGubun] = useState("0");
const hasAutoFetchedRef = useRef(false);
const fileMetadataCacheRef = useRef<Map<string, ReportFileMetadata>>(
new Map(),
);
const fileMetadataPromiseCacheRef = useRef<
Map<string, Promise<ReportFileMetadata>>
>(new Map());
const {
tableData,
@@ -73,68 +125,71 @@ const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sg
setTableCount,
setIsLoading,
setYearFilter,
setCurPage
setCurPage,
} = useSearchModalStore();
const { ctpv, sgg } = useStore();
const {
dcTree: reportOriginData,
fetchDcTree
} = useReportTabStore();
const { REPORT_TYPES, flattenFolders, addFileName, downloadReportFile } = useReportTab();
const { dcTree: reportOriginData, fetchDcTree } = useReportTabStore();
const { REPORT_TYPES, flattenFolders, getDcFiles, downloadReportFile } =
useReportTab();
const flattenedReportData = useMemo(() => flattenFolders(reportOriginData), [reportOriginData]);
const flattenedReportData = useMemo(
() => flattenFolders(reportOriginData),
[reportOriginData],
);
useEffect(() => {
fileMetadataCacheRef.current.clear();
fileMetadataPromiseCacheRef.current.clear();
}, [reportOriginData]);
const plantZoneOptions = useMemo(() => {
const uniqueOptions = Array.from(
new Set(
flattenedReportData
.map((item) => String(item.plantZoneGubun ?? '').trim())
.filter((item) => item.length > 0)
)
).sort((a, b) => a.localeCompare(b, 'ko'));
.map((item) => String(item.plantZoneGubun ?? "").trim())
.filter((item) => item.length > 0),
),
).sort((a, b) => a.localeCompare(b, "ko"));
return [
{ val: '0', text: '전체' },
{ val: "0", text: "전체" },
...uniqueOptions.map((item) => ({ val: item, text: item })),
];
}, [flattenedReportData]);
useEffect(() => {
void fetchDcTree('기본계획');
void fetchDcTree("기본계획");
}, [fetchDcTree]);
useEffect(() => {
hasAutoFetchedRef.current = false;
setCtpvCd('0');
setSggCd('0');
setPlantZoneGubun('0');
setReportGubun('0');
setYearFilter({ start: '', end: '' });
setCtpvCd("0");
setSggCd("0");
setPlantZoneGubun("0");
setReportGubun("0");
setYearFilter({ start: "", end: "" });
setShowTable(false);
setTableData([]);
setTableCount(0);
setCurPage(1);
setIsResetPage(true);
setIsResetSelection(true);
}, [open, setCurPage, setTableCount, setTableData, setYearFilter]);
useEffect(() => {
const newCtpvCd = ctpv.find((n) => n.text === ctpvNm)?.val || '0';
const newSggCd = sgg.find((n) => n.text === sggNm)?.val || '0';
const newCtpvCd = ctpv.find((n) => n.text === ctpvNm)?.val || "0";
const newSggCd = sgg.find((n) => n.text === sggNm)?.val || "0";
setCtpvCd(newCtpvCd);
setSggCd(newSggCd);
}, [ctpv, ctpvNm, sgg, sggNm]);
useEffect(() => {
if (!open || flattenedReportData.length === 0 || hasAutoFetchedRef.current) return;
if (!open || flattenedReportData.length === 0 || hasAutoFetchedRef.current)
return;
hasAutoFetchedRef.current = true;
void fetchFilteredData(1);
}, [flattenedReportData.length, open]);
useEffect(() => {
setIsResetPage(false);
if (showTable) {
void fetchFilteredData();
}
@@ -142,21 +197,34 @@ const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sg
const handleCtpvCdChange = (sidoId: string) => {
setCtpvCd(sidoId);
setSggCd('0');
setSggCd("0");
};
const handleSearch = () => {
hasAutoFetchedRef.current = true;
setCurPage(1);
void fetchFilteredData(1);
setIsResetPage(true);
setIsResetSelection(false);
setShowTable(true);
};
/** 보고서 조건·페이지·결과를 기본값으로 복원하고 전체 목록을 즉시 재조회한다. */
const handleResetSearch = () => {
setCtpvCd(DEFAULT_REPORT_SEARCH_FILTERS.ctpvCd);
setSggCd(DEFAULT_REPORT_SEARCH_FILTERS.sggCd);
setPlantZoneGubun(DEFAULT_REPORT_SEARCH_FILTERS.plantZoneGubun);
setReportGubun(DEFAULT_REPORT_SEARCH_FILTERS.reportGubun);
setYearFilter({ ...DEFAULT_REPORT_SEARCH_FILTERS.yearFilter });
setCurPage(1);
setTableData([]);
setTableCount(0);
setShowTable(false);
hasAutoFetchedRef.current = true;
void fetchFilteredData(1, DEFAULT_REPORT_SEARCH_FILTERS);
};
const handleReportCellClick = (index: number | undefined, headId: string) => {
if (headId !== 'fileName') return;
if (typeof index !== 'number' || !tableData[index]) return;
if (headId !== "fileName") return;
if (typeof index !== "number" || !tableData[index]) return;
const reportData = tableData[index];
if (!reportData.fileSn) return;
@@ -171,141 +239,272 @@ const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sg
})();
};
const fetchFilteredData = async (page = curPage) => {
const buildReportMetadata = (
fileArr: ReportDataFile[],
): ReportFileMetadata => {
if (fileArr.length === 0) {
return EMPTY_REPORT_METADATA;
}
const primaryFile = fileArr[0];
return {
fileName:
fileArr.length === 1
? (primaryFile?.fileTitle ?? "-")
: `${primaryFile?.fileTitle ?? "대표 파일"} 외 ${fileArr.length - 1}건`,
regDate: primaryFile?.regDt ?? "",
fileSn: primaryFile?.fileSn ?? "",
filePath: primaryFile?.filePath ?? "",
fileCount: fileArr.length,
downloadFileTitle: primaryFile?.fileTitle ?? "",
};
};
const resolveReportMetadata = (item: FlattenedFolder) => {
const key = String(item.clsfSystmSn ?? "");
if (!key) {
return Promise.resolve(EMPTY_REPORT_METADATA);
}
const cached = fileMetadataCacheRef.current.get(key);
if (cached) {
return Promise.resolve(cached);
}
const pending = fileMetadataPromiseCacheRef.current.get(key);
if (pending) {
return pending;
}
const promise = Promise.race<ReportDataFile[] | never>([
getDcFiles(key),
new Promise<never>((_, reject) => {
window.setTimeout(
() => reject(new Error(`Report metadata timeout: ${key}`)),
REPORT_METADATA_TIMEOUT_MS,
);
}),
])
.then((fileArr) => buildReportMetadata(fileArr))
.catch((error) => {
console.warn("보고서 파일 메타데이터 조회 실패:", key, error);
return EMPTY_REPORT_METADATA;
})
.then((metadata) => {
fileMetadataCacheRef.current.set(key, metadata);
fileMetadataPromiseCacheRef.current.delete(key);
return metadata;
});
fileMetadataPromiseCacheRef.current.set(key, promise);
return promise;
};
const enrichReportRows = async (
data: FlattenedFolder[],
concurrency = REPORT_METADATA_CONCURRENCY,
) => {
const missingRows = data.filter((item) => {
const key = String(item.clsfSystmSn ?? "");
return key && !fileMetadataCacheRef.current.has(key);
});
if (missingRows.length > 0) {
let cursor = 0;
const workerCount = Math.min(concurrency, missingRows.length);
await Promise.all(
Array.from({ length: workerCount }, async () => {
while (cursor < missingRows.length) {
const currentIndex = cursor;
cursor += 1;
const currentRow = missingRows[currentIndex];
if (currentRow) {
await resolveReportMetadata(currentRow);
}
}
}),
);
}
return data.map((item) => ({
...item,
...(fileMetadataCacheRef.current.get(String(item.clsfSystmSn ?? "")) ??
EMPTY_REPORT_METADATA),
}));
};
const fetchFilteredData = async (
page = curPage,
filters: ReportSearchFilters = {
ctpvCd,
sggCd,
plantZoneGubun,
reportGubun,
yearFilter,
},
) => {
setIsLoading(true);
setIsResetSelection(true);
try {
let filterReportData = flattenedReportData;
if (ctpvCd !== '0') {
const sidoOption = ctpv.find((n) => String(n.val).trim() === ctpvCd.trim());
filterReportData = filterReportData.filter((item) => item.sido === sidoOption?.text);
if (filters.ctpvCd !== "0") {
const sidoOption = ctpv.find(
(n) => String(n.val).trim() === filters.ctpvCd.trim(),
);
filterReportData = filterReportData.filter(
(item) => item.sido === sidoOption?.text,
);
}
if (sggCd !== '0') {
const sigunguOption = sgg.find((n) => String(n.val) === String(sggCd));
filterReportData = filterReportData.filter((item) => item.sigungu === sigunguOption?.text);
if (filters.sggCd !== "0") {
const sigunguOption = sgg.find(
(n) => String(n.val) === String(filters.sggCd),
);
filterReportData = filterReportData.filter(
(item) => item.sigungu === sigunguOption?.text,
);
}
if (plantZoneGubun !== '0') {
filterReportData = filterReportData.filter((item) => String(item.plantZoneGubun ?? '') === plantZoneGubun);
if (filters.plantZoneGubun !== "0") {
filterReportData = filterReportData.filter(
(item) =>
String(item.plantZoneGubun ?? "") === filters.plantZoneGubun,
);
}
if (reportGubun !== '0') {
const reportGubunOption = REPORT_TYPES.find((n) => n.val === reportGubun);
filterReportData = filterReportData.filter((item) => item.reportGubun === reportGubunOption?.text);
if (filters.reportGubun !== "0") {
const reportGubunOption = REPORT_TYPES.find(
(n) => n.val === filters.reportGubun,
);
filterReportData = filterReportData.filter(
(item) => item.reportGubun === reportGubunOption?.text,
);
}
const normalizedStartYear = normalizeYear(yearFilter.start);
const normalizedEndYear = normalizeYear(yearFilter.end);
const normalizedStartYear = normalizeYear(filters.yearFilter.start);
const normalizedEndYear = normalizeYear(filters.yearFilter.end);
if (normalizedStartYear || normalizedEndYear) {
filterReportData = filterReportData.filter((item) => {
const approvalYear = getApprovalYearNumber(item.approvalYear);
if (approvalYear === null) return false;
if (normalizedStartYear && approvalYear < Number(normalizedStartYear)) return false;
if (normalizedEndYear && approvalYear > Number(normalizedEndYear)) return false;
if (normalizedStartYear && approvalYear < Number(normalizedStartYear))
return false;
if (normalizedEndYear && approvalYear > Number(normalizedEndYear))
return false;
return true;
});
}
setTableCount(filterReportData.length || 0);
const totalCount = filterReportData.length || 0;
setTableCount(totalCount);
const pageOffset = (page - 1) * REPORT_PAGE_SIZE;
const pageStart = (page - 1) * REPORT_PAGE_SIZE;
const pageEnd = page * REPORT_PAGE_SIZE;
let reportRows: FlattenedFolder[];
const pagedReportData = filterReportData
.slice((page - 1) * 10, page * 10)
.map((item, index) => ({ ...item, no: (page - 1) * 10 + index + 1 }));
.slice(pageStart, pageEnd)
.map((item, index) => ({
...item,
no: totalCount - (pageOffset + index),
}));
reportRows = await enrichReportRows(pagedReportData);
const reportRows = await addFileName(pagedReportData);
setTableData(reportRows || []);
setShowTable(true);
setIsResetSelection(false);
} catch (err) {
console.error('API ERROR:', err);
console.error("API ERROR:", err);
setTableData([]);
setIsResetSelection(false);
} finally {
setIsLoading(false);
}
};
return (
<>
<div className="relative mt-4 rounded-lg border px-5 pt-5 pb-3">
<div className="overflow-x-auto">
<div className="flex min-w-max items-center gap-4 whitespace-nowrap">
{isSearch && (
<CtpvSggPrcsFcltSelectBox
className="shrink-0"
sido={ctpvCd}
sigungu={sggCd}
onSidoChange={(sidoId: string) => { handleCtpvCdChange(sidoId); }}
onSigunguChange={setSggCd}
setIsLoading={setIsLoading}
/>
)}
<YearRangeSelectBox
className="shrink-0 !mr-1"
selectClass="w-[78px] flex-shrink-0 rounded-lg bg-field text-foreground"
start={yearFilter.start}
end={yearFilter.end}
onChange={(range) => setYearFilter({
start: normalizeYear(range.start),
end: normalizeYear(range.end),
})}
startLabel="승인년도"
endLabel=""
/>
<Label className="mr-2 whitespace-nowrap text-xl font-medium text-label">구분</Label>
<SelectBox
className="w-36 shrink-0"
selectClass="rounded-lg bg-field text-foreground"
label=""
selectArray={plantZoneOptions}
value={plantZoneGubun}
onSelectValue={(val) => setPlantZoneGubun(String(val))}
selectContentClass="z-[101]"
/>
<Label className="mr-2 whitespace-nowrap text-xl font-medium text-label">보고서 구분</Label>
<SelectBox
className="w-36 shrink-0"
selectClass="rounded-lg bg-field text-foreground"
label=""
selectArray={[...REPORT_TYPES]}
value={reportGubun}
onSelectValue={(val) => setReportGubun(String(val))}
selectContentClass="z-[101]"
/>
<div className="ml-auto shrink-0 pl-4">
<Button className="text-base py-2" onClick={handleSearch}>조회</Button>
</div>
<div className="flex min-w-0 flex-col gap-3">
<PopupSearchPanel contentClassName="flex flex-wrap items-center gap-x-5 gap-y-3">
{isSearch && (
<CtpvSggPrcsFcltSelectBox
className="shrink-0"
sido={ctpvCd}
sigungu={sggCd}
onSidoChange={(sidoId: string) => {
handleCtpvCdChange(sidoId);
}}
onSigunguChange={setSggCd}
setIsLoading={setIsLoading}
/>
)}
<YearRangeSelectBox
className="!mr-1 shrink-0"
selectClass="w-[78px] flex-shrink-0 rounded-lg bg-field text-foreground"
start={yearFilter.start}
end={yearFilter.end}
onChange={(range) =>
setYearFilter({
start: normalizeYear(range.start),
end: normalizeYear(range.end),
})
}
startLabel="승인년도"
endLabel=""
/>
<div className="flex items-center gap-2">
<Label className="whitespace-nowrap text-xl font-medium text-label">
구분
</Label>
<SelectBox
className="w-36 shrink-0"
selectClass="rounded-lg bg-field text-foreground"
label=""
selectArray={plantZoneOptions}
value={plantZoneGubun}
onSelectValue={(val) => setPlantZoneGubun(String(val))}
selectContentClass="z-[101]"
/>
</div>
<div className="flex items-center gap-2">
<Label className="whitespace-nowrap text-xl font-medium text-label">
보고서 구분
</Label>
<SelectBox
className="w-36 shrink-0"
selectClass="rounded-lg bg-field text-foreground"
label=""
selectArray={[...REPORT_TYPES]}
value={reportGubun}
onSelectValue={(val) => setReportGubun(String(val))}
selectContentClass="z-[101]"
/>
</div>
<div className="shrink-0 sm:ml-auto sm:pl-4">
<div className="flex gap-2">
<Button onClick={handleSearch}>조회</Button>
<Button color="white" onClick={handleResetSearch}>
초기화
</Button>
</div>
</div>
</div>
</PopupSearchPanel>
<div className="mt-3">
<LoadingOverlay isLoading={isLoading} />
<UiTable
className="!m-0"
<div>
<MapPopupDataTable
rows={tableData as Array<FlattenedFolder & Record<string, unknown>>}
clickableHeadIds={["fileName"]}
headName={<>조회 결과</>}
tableData={tableData}
title="보고서 목록"
headlist={reportTableHeadlist}
pageSize={10}
total={tableCount}
cellClick={handleReportCellClick}
cellClassName={(row, headId) => {
if (headId !== 'fileName') return '';
if (!row.fileSn) return '!text-gray-400';
return '!text-left !text-blue-600 underline underline-offset-4 hover:!text-blue-800';
}}
onCellClick={(_row, index, headId) =>
handleReportCellClick(index, headId)
}
onPageChange={setCurPage}
resetPageNo={isResetPage}
resetSelection={isResetSelection}
page={curPage}
rowId={(row, index) => String(row.clsfSystmSn ?? index)}
/>
</div>
</>
</div>
);
};
export default React.memo(ReportTab);
@@ -1,6 +1,6 @@
/**
* ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창
*
*
* 📄 Search Modal
* Next.js App Router (app/) 기반 검색 모달 컴포넌트
*
@@ -11,45 +11,68 @@
* - Zustand를 통한 검색 모달 상태 관리
* - 로딩 오버레이 및 선택 초기화 지원
*/
'use client';
import LoadingOverlay from '@/components/ui/LoadingBar';
import { UITab, UiTable } from '@common/business_components/ui';
import { Button } from '@common/components/ui';
import React, { useEffect, useMemo, useRef } from 'react';
import { Toaster } from 'react-hot-toast';
import useSearchModalStore from '@/store/useSearchModalStore';
import { ExclnSewagePumpCol2, MsSwageMhlCol2, MsSwagePplCol2, stofcbrCol2, SwtrmtFcltCol2, SwtrmtPrcsZoneCol2 } from '../components/common/table';
import BasicTab, { BASIC_TYPES } from './basicTab';
import OperationTab from './operationTab';
import ReportTab from './reportTab';
import useSearchModal from './useSearchModal';
import useBasicTab from './useBasicTab';
import useOperationTab from './useOperationTab';
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
"use client";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { UITab } from "@common/business_components/ui/tab";
import { StandardPopupLayout } from "@common/business_components";
import { Button } from "@common/components/ui";
import React, { useEffect, useMemo, useRef } from "react";
import useSearchModalStore from "@/store/useSearchModalStore";
import { useAlertStore } from "@/shared/lib";
import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefinitions";
import {
ExclnSewagePumpCol2,
FldngWtlvMapCol2,
MsSwageMhlCol2,
MsSwagePplCol2,
stofcbrCol2,
SwtrmtFcltCol2,
SwtrmtPrcsZoneCol2,
} from "../components/common/table";
import BasicTab, { BASIC_TYPES } from "./basicTab";
import OperationTab from "./operationTab";
import ReportTab from "./reportTab";
import useSearchModal from "./useSearchModal";
import useBasicTab from "./useBasicTab";
import useOperationTab from "./useOperationTab";
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
import type { UiHeadProps } from "@common/business_components/ui/table";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
type SearchModalProps = {
renderMode?: 'launcher' | 'popup';
renderMode?: "launcher" | "popup";
autoSearchReady?: boolean;
};
const SearchModal: React.FC<SearchModalProps> = ({ renderMode = 'launcher', autoSearchReady = false }) => {
const SearchModal: React.FC<SearchModalProps> = ({
renderMode = "launcher",
autoSearchReady = false,
}) => {
const alert = useAlertStore((state) => state.setMessage);
const {
showTable,
isSearchModalOpen, setIsSearchModalOpen,
activeTab, basicType, workType,
tableData, detailTableData,
tableCount, detailTableCount,
curPage, detailCurPage,
isSearchModalOpen,
setIsSearchModalOpen,
activeTab,
basicType,
workType,
tableData,
detailTableData,
tableCount,
detailTableCount,
curPage,
detailCurPage,
detailBizId,
isLoading, isDetailLoading,
isResetPage, isResetDetailPage,
isResetSelection, isResetDetailSelection,
isLoading,
isDetailLoading,
setActiveTab,
setCurPage, setDetailCurPage,
setIsResetPage, setIsResetDetailPage,
setCurPage,
setDetailCurPage,
setIsResetPage,
setIsResetDetailPage,
setDetailBizId,
setIsResetDetailSelection,
resetFilters
resetFilters,
} = useSearchModalStore();
const { TABS, viewMultipleFeature, fetchFilteredData } = useSearchModal();
const { handleBasicCellClick, basicExcelDownload } = useBasicTab();
@@ -64,56 +87,95 @@ const SearchModal: React.FC<SearchModalProps> = ({ renderMode = 'launcher', auto
handleOperationResultCellClick,
operationExcelDownload,
operationResultExcelDownload,
handleOperationPageNation
handleOperationPageNation,
} = useOperationTab();
const hasAutoSearchedRef = useRef(false);
const hasInitializedTabAutoSearchRef = useRef(false);
const dynamicHeadlist: { id: string, title: string }[] = useMemo(() => {
if (activeTab === 'basic') {
if (basicType === 'tb_ms_swage_ppl') return MsSwagePplCol2;
if (basicType === 'tb_il_swage_prcs_fclt') return SwtrmtFcltCol2;
if (basicType === 'tb_ms_swage_prcs_zone') return SwtrmtPrcsZoneCol2;
if (basicType === 'tb_ms_swage_mhl') return MsSwageMhlCol2;
if (basicType === 'tb_ms_swage_pmstn') return ExclnSewagePumpCol2;
if (basicType === 'tb_ms_stofcbr') return stofcbrCol2;
return [];
const dynamicHeadlist: UiHeadProps[] = useMemo(() => {
if (activeTab === "basic") {
const headlist =
basicType === "tb_ms_swage_ppl"
? MsSwagePplCol2
: basicType === "tb_il_swage_prcs_fclt"
? SwtrmtFcltCol2
: basicType === "tb_ms_swage_prcs_zone"
? SwtrmtPrcsZoneCol2
: basicType === "tb_ms_fldng_wtlv_map"
? FldngWtlvMapCol2
: basicType === "tb_ms_swage_mhl"
? MsSwageMhlCol2
: basicType === "tb_ms_swage_pmstn"
? ExclnSewagePumpCol2
: basicType === "tb_ms_stofcbr"
? stofcbrCol2
: [];
return headlist.map((head) => ({
...head,
title: head.id === "rowNumber" ? "순번" : head.title,
}));
}
if (activeTab === "operation") {
return OPERATION_TABLE_HEADLIST.map((head) => ({
...head,
title: head.id === "rowNumber" ? "순번" : head.title,
}));
}
if (activeTab === 'operation') return OPERATION_TABLE_HEADLIST;
return [];
}, [activeTab, basicType, workType, OPERATION_TABLE_HEADLIST]);
const dynamicHeadlist2: { id: string, title: string }[] = useMemo(() => {
if (activeTab === 'operation') {
if (workType === WORK_TYPES[0]?.val) return MAINTENANACE_TABLE_HEADLIST;
if (workType === WORK_TYPES[1]?.val) return CLEANING_TABLE_HEADLIST;
if (workType === WORK_TYPES[2]?.val) return INSPECTION_TABLE_HEADLIST;
const dynamicHeadlist2: UiHeadProps[] = useMemo(() => {
if (activeTab === "operation") {
const headlist =
workType === WORK_TYPES[0]?.val
? MAINTENANACE_TABLE_HEADLIST
: workType === WORK_TYPES[1]?.val
? CLEANING_TABLE_HEADLIST
: workType === WORK_TYPES[2]?.val
? INSPECTION_TABLE_HEADLIST
: [];
return headlist.map((head) => ({
...head,
title: head.id === "rowNumber" ? "순번" : head.title,
}));
}
return [];
}, [activeTab, workType, WORK_TYPES, MAINTENANACE_TABLE_HEADLIST, CLEANING_TABLE_HEADLIST, INSPECTION_TABLE_HEADLIST]);
}, [
activeTab,
workType,
WORK_TYPES,
MAINTENANACE_TABLE_HEADLIST,
CLEANING_TABLE_HEADLIST,
INSPECTION_TABLE_HEADLIST,
]);
useEffect(() => {
if (renderMode !== 'launcher' || !isSearchModalOpen) return;
if (renderMode !== "launcher" || !isSearchModalOpen) return;
const popupWindow = openWindowPopup('mapSearch', {
const popupWindow = openWindowPopup("mapSearch", {
sourcePath: window.location.pathname,
});
if (!popupWindow) {
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
alert("팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.");
}
setIsSearchModalOpen(false);
}, [isSearchModalOpen, renderMode, setIsSearchModalOpen]);
}, [alert, isSearchModalOpen, renderMode, setIsSearchModalOpen]);
useEffect(() => {
resetFilters();
}, [activeTab, basicType, workType, resetFilters]);
useEffect(() => {
if (renderMode !== 'popup' || !autoSearchReady || hasAutoSearchedRef.current) return;
if (
renderMode !== "popup" ||
!autoSearchReady ||
hasAutoSearchedRef.current
)
return;
hasAutoSearchedRef.current = true;
hasInitializedTabAutoSearchRef.current = true;
@@ -121,9 +183,9 @@ const SearchModal: React.FC<SearchModalProps> = ({ renderMode = 'launcher', auto
}, [autoSearchReady, fetchFilteredData, renderMode]);
useEffect(() => {
if (renderMode !== 'popup' || !autoSearchReady) return;
if (renderMode !== "popup" || !autoSearchReady) return;
if (!hasInitializedTabAutoSearchRef.current) return;
if (activeTab === 'report') return;
if (activeTab === "report") return;
setCurPage(1);
setIsResetPage(true);
@@ -151,88 +213,101 @@ const SearchModal: React.FC<SearchModalProps> = ({ renderMode = 'launcher', auto
<>
<UITab
defaultValue={activeTab}
variant="underline"
className="shrink-0"
tabList={TABS.map((tab: any) => ({
value: tab.val,
label: tab.text
label: tab.text,
}))}
onValueChange={(value: string) => setActiveTab(value)}
/>
{activeTab === 'basic' && (<BasicTab />)}
{activeTab === 'operation' && (<OperationTab />)}
{activeTab === 'report' && (<ReportTab open={activeTab === 'report'} />)}
<LoadingOverlay isLoading={isLoading || isDetailLoading} />
{activeTab === "basic" && <BasicTab />}
{activeTab === "operation" && <OperationTab />}
{activeTab === "report" && <ReportTab open={activeTab === "report"} />}
{activeTab === 'basic' && (
<div className="mt-4 h-[79dvh] w-full overflow-x-hidden overflow-y-auto rounded-lg border">
<LoadingOverlay isLoading={isLoading} />
<UiTable
className='overflow-x-auto [&_table]:min-w-full [&_table]:w-max'
headName={(() => {
const label = BASIC_TYPES.find((type) => type.val === basicType)?.text;
return (<>{label} 조회 결과</>);
{activeTab === "basic" && (
<div className="w-full">
<MapPopupDataTable
rows={tableData as Array<Record<string, unknown>>}
title={(() => {
const label = BASIC_TYPES.find(
(type) => type.val === basicType,
)?.text;
return <>{label} 목록</>;
})()}
tableData={tableData}
headlist={dynamicHeadlist}
pageSize={10}
total={tableCount}
cellClick={handleBasicCellClick}
onCellClick={(_row, index, headId) =>
void handleBasicCellClick(index, headId)
}
onPageChange={setCurPage}
resetPageNo={isResetPage}
resetSelection={isResetSelection}
curPage={curPage}
headButton={
<div className='flex'>
<Button className="px-4 py-2 !mr-[1.2rem] text-white rounded-sm text-lg font-medium primary" size="sm" onClick={viewMultipleFeature}>지도에서 보기</Button>
<Button className="px-4 py-2 !mr-[1.2rem] text-white rounded-sm text-lg font-medium green" size="sm" onClick={basicExcelDownload}>파일 다운로드</Button>
page={curPage}
scrollMode="horizontal"
minTableWidth="160rem"
actions={
<div className="flex gap-2">
<Button onClick={basicExcelDownload}>엑셀다운로드</Button>
<Button onClick={viewMultipleFeature}>지도에서 보기</Button>
</div>
}
/>
</div>
)}
{activeTab === 'operation' && (
{activeTab === "operation" && (
<>
<div className="mt-4 h-[39dvh] w-full overflow-x-hidden overflow-y-auto rounded-lg border">
<LoadingOverlay isLoading={isLoading} />
<UiTable
headName={(() => {
const label = WORK_TYPES.find((type) => type.val === workType)?.text;
return (<>{label} 사업 현황</>);
<div className="w-full">
<MapPopupDataTable
rows={tableData as Array<Record<string, unknown>>}
title={(() => {
const label = WORK_TYPES.find(
(type) => type.val === workType,
)?.text;
return <>{label} 사업 목록</>;
})()}
tableData={tableData}
headlist={dynamicHeadlist}
pageSize={OPERATION_TABLE_SIZE}
total={tableCount}
cellClick={handleOperationCellClick}
onCellClick={(_row, index, headId) =>
void handleOperationCellClick(index, headId)
}
onPageChange={setCurPage}
resetPageNo={isResetPage}
resetSelection={isResetSelection}
curPage={curPage}
headButton={
<Button className="px-4 py-2 !mr-[1.2rem] text-white rounded-sm text-sm font-medium green" size="sm" onClick={operationExcelDownload}>파일 다운로드</Button>
page={curPage}
scrollMode="horizontal"
minTableWidth="140rem"
actions={
<Button onClick={operationExcelDownload}>엑셀다운로드</Button>
}
/>
</div>
<div className="h-[39dvh] w-full overflow-x-hidden overflow-y-auto rounded-lg border">
<LoadingOverlay isLoading={isDetailLoading} />
<UiTable
headName={(() => {
const label = WORK_TYPES.find((type) => type.val === workType)?.text;
return (<>{label} 결과표</>);
<div className="w-full">
<MapPopupDataTable
rows={detailTableData as Array<Record<string, unknown>>}
title={(() => {
const label = WORK_TYPES.find(
(type) => type.val === workType,
)?.text;
return <>{label} 결과 목록</>;
})()}
tableData={detailTableData}
headlist={dynamicHeadlist2}
pageSize={OPERATION_TABLE_SIZE}
total={detailTableCount}
cellClick={handleOperationResultCellClick}
onCellClick={(_row, index, headId) =>
void handleOperationResultCellClick(index, headId)
}
onPageChange={setDetailCurPage}
resetPageNo={isResetDetailPage}
resetSelection={isResetDetailSelection}
curPage={detailCurPage}
headButton={
<div className='flex'>
<Button className="px-4 py-2 !mr-[1.2rem] text-white rounded-sm text-lg font-medium primary" size="sm" onClick={viewMultipleFeature}>지도에서 보기</Button>
<Button className="px-4 py-2 !mr-[1.2rem] text-white rounded-sm text-lg font-medium green" size="sm" onClick={operationResultExcelDownload}>파일 다운로드</Button>
page={detailCurPage}
scrollMode="horizontal"
minTableWidth="160rem"
actions={
<div className="flex gap-2">
<Button onClick={operationResultExcelDownload}>
엑셀다운로드
</Button>
<Button onClick={viewMultipleFeature}>지도에서 보기</Button>
</div>
}
/>
@@ -242,20 +317,18 @@ const SearchModal: React.FC<SearchModalProps> = ({ renderMode = 'launcher', auto
</>
);
if (renderMode === 'launcher') {
if (renderMode === "launcher") {
return null;
}
return (
<div className='h-dvh overflow-x-auto overflow-y-hidden bg-container p-4 text-foreground'>
<h1 className="mb-3 text-2xl font-semibold text-foreground">관망도 검색</h1>
<div className="h-[calc(100dvh-5.6rem)] w-full overflow-y-hidden rounded-lg border border-border bg-background p-4">
{renderBody()}
</div>
<Toaster position="top-center" />
</div>
<StandardPopupLayout
title={windowPopupDefinitions.mapSearch.title}
bodyClassName="overflow-x-hidden"
>
<div className="flex min-w-0 flex-col gap-3">{renderBody()}</div>
</StandardPopupLayout>
);
};
export default React.memo(SearchModal);
@@ -1,6 +1,6 @@
/**
* ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 기본정보 탭 (구분: 우수토실)
*
*
* 📄 Stofcbr Tab
* Next.js App Router (app/) 기반 검색 모달 우수토실 탭 컴포넌트
*
@@ -10,58 +10,51 @@
* - Zustand를 통한 상태 관리 및 필터 값 저장
* - Enter 키 입력 시 자동 조회 지원
*/
import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox';
import { Button } from '@common/components';
import React from 'react';
import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox';
import useSearchModalStore from '../../../store/useSearchModalStore';
import useSearchModal from './useSearchModal';
import { YearRangeSelectBox } from "@/components/ui/YearRangeSelectBox";
import { Button } from "@common/components";
import React from "react";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
import useSearchModalStore from "../../../store/useSearchModalStore";
import useSearchModal from "./useSearchModal";
export const stofcbrCqlFilters = () => {
const {
ctpvCd, sggCd,
ctpvCd,
sggCd,
yearFilter,
todayYear, todayMonth, todayDate,
getDateString
todayYear,
todayMonth,
todayDate,
getDateString,
} = useSearchModalStore.getState();
const cqlFilters: string[] = [];
// 시도, 시군구
if (ctpvCd !== '0' && sggCd !== '0') cqlFilters.push(`pbadms_emd_cd LIKE '${sggCd}%'`);
else if (ctpvCd !== '0') cqlFilters.push(`pbadms_emd_cd LIKE '${ctpvCd?.trim()}%'`);
if (ctpvCd !== "0" && sggCd !== "0")
cqlFilters.push(`pbadms_emd_cd LIKE '${sggCd}%'`);
else if (ctpvCd !== "0")
cqlFilters.push(`pbadms_emd_cd LIKE '${ctpvCd?.trim()}%'`);
// 설치년도 범위
const startDate = getDateString(
yearFilter.start,
'',
'',
"19000101",
);
const startDate = getDateString(yearFilter.start, "", "", "19000101");
const endDate = getDateString(
yearFilter.end,
'',
'',
"",
"",
`${todayYear}${todayMonth}${todayDate}`,
);
cqlFilters.push(
`instl_ymd >= '${startDate}' and instl_ymd <= '${endDate}'`,
);
cqlFilters.push(`instl_ymd >= '${startDate}' and instl_ymd <= '${endDate}'`);
return cqlFilters;
};
const Stofcbr: React.FC = () => {
const {
ctpvCd, setCtpvCd,
sggCd, setSggCd,
yearFilter, setYearFilter,
} = useSearchModalStore();
const { handleSearch } = useSearchModal();
const { ctpvCd, setCtpvCd, sggCd, setSggCd, yearFilter, setYearFilter } =
useSearchModalStore();
const { handleSearch, handleResetSearch } = useSearchModal();
return (
<div className="mb-3">
<div className="flex items-center mb-2">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
{/* 시도, 시군구 */}
<CtpvSggPrcsFcltSelectBox
sido={ctpvCd}
@@ -76,17 +69,22 @@ const Stofcbr: React.FC = () => {
<YearRangeSelectBox
start={yearFilter.start}
end={yearFilter.end}
onChange={range => setYearFilter({ ...yearFilter, start: range.start, end: range.end })}
onChange={(range) =>
setYearFilter({ ...yearFilter, start: range.start, end: range.end })
}
startLabel="설치년도"
endLabel=""
/>
{/* 조회 버튼 */}
<div className="flex-1 flex justify-end">
<Button className="text-base py-2" onClick={handleSearch}>조회</Button>
<div className="flex min-w-fit flex-1 justify-end gap-2">
<Button onClick={handleSearch}>조회</Button>
<Button color="white" onClick={handleResetSearch}>
초기화
</Button>
</div>
</div>
</div>
);
};
export default React.memo(Stofcbr);
export default React.memo(Stofcbr);
@@ -1,6 +1,6 @@
/**
* ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 기본정보 탭 (구분: 하수맨홀)
*
*
* 📄 SwageMhl Tab
* Next.js App Router (app/) 기반 검색 모달 하수맨홀 탭 컴포넌트
*
@@ -11,49 +11,48 @@
* - Zustand를 통한 상태 관리 및 필터 값 저장
* - Enter 키 입력 시 자동 조회 지원
*/
import React from 'react';
import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox';
import { Button, Input, Label } from '@common/components';
import { MS_SWAGE_MHL_USE_CODES } from '../components/common/table';
import { SelectBox } from '@common/business_components/ui';
import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox';
import useSearchModalStore from '../../../store/useSearchModalStore';
import useSearchModal from './useSearchModal';
import React from "react";
import { YearRangeSelectBox } from "@/components/ui/YearRangeSelectBox";
import { Button, Input, Label } from "@common/components";
import { MS_SWAGE_MHL_USE_CODES } from "../components/common/table";
import { SelectBox } from "@common/business_components/ui/select";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
import useSearchModalStore from "../../../store/useSearchModalStore";
import useSearchModal from "./useSearchModal";
export const swageMhlCqlFilters = () => {
const {
ctpvCd, sggCd,
ctpvCd,
sggCd,
yearFilter,
start1, end1,
start1,
end1,
var1,
todayYear, todayMonth, todayDate,
getDateString
todayYear,
todayMonth,
todayDate,
getDateString,
} = useSearchModalStore.getState();
const cqlFilters: string[] = [];
// 시도, 시군구
if (ctpvCd !== '0' && sggCd !== '0') cqlFilters.push(`pbadms_emd_cd LIKE '${sggCd}%'`);
else if (ctpvCd !== '0') cqlFilters.push(`pbadms_emd_cd LIKE '${ctpvCd?.trim()}%'`);
if (ctpvCd !== "0" && sggCd !== "0")
cqlFilters.push(`pbadms_emd_cd LIKE '${sggCd}%'`);
else if (ctpvCd !== "0")
cqlFilters.push(`pbadms_emd_cd LIKE '${ctpvCd?.trim()}%'`);
// // 설치년도 범위
const startDate = getDateString(
yearFilter.start,
'',
'',
"19000101",
);
const startDate = getDateString(yearFilter.start, "", "", "19000101");
const endDate = getDateString(
yearFilter.end,
'',
'',
"",
"",
`${todayYear}${todayMonth}${todayDate}`,
);
cqlFilters.push(
`instl_ymd >= '${startDate}' and instl_ymd <= '${endDate}'`,
);
cqlFilters.push(`instl_ymd >= '${startDate}' and instl_ymd <= '${endDate}'`);
if (var1) cqlFilters.push(`usg_cd='${var1}'`);
// 구경(mm) 범위
if (start1 && end1) cqlFilters.push(`swage_mhL_diam_vl BETWEEN ${start1} AND ${end1}`);
if (start1 && end1)
cqlFilters.push(`swage_mhL_diam_vl BETWEEN ${start1} AND ${end1}`);
else if (start1) cqlFilters.push(`swage_mhL_diam_vl >= ${start1}`);
else if (end1) cqlFilters.push(`swage_mhL_diam_vl <= ${end1}`);
@@ -62,17 +61,24 @@ export const swageMhlCqlFilters = () => {
const SwageMhl: React.FC = () => {
const {
var1, setVar1,
start1, setStart1, end1, setEnd1,
ctpvCd, setCtpvCd,
sggCd, setSggCd,
yearFilter, setYearFilter,
var1,
setVar1,
start1,
setStart1,
end1,
setEnd1,
ctpvCd,
setCtpvCd,
sggCd,
setSggCd,
yearFilter,
setYearFilter,
} = useSearchModalStore();
const { handleSearch } = useSearchModal();
const { handleSearch, handleResetSearch } = useSearchModal();
return (
<div className="mb-3">
<div className="mb-2 flex items-center">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
<CtpvSggPrcsFcltSelectBox
sido={ctpvCd}
sigungu={sggCd}
@@ -97,56 +103,70 @@ const SwageMhl: React.FC = () => {
endLabel=""
/>
{/* 용도 */}
<Label className="mr-2 whitespace-nowrap text-xl font-medium text-label">용도</Label>
<SelectBox
className="w-48 mr-8"
selectClass="rounded-lg bg-field text-xl text-foreground"
label=""
selectArray={[{ val: "0", text: "전체" }, ...MS_SWAGE_MHL_USE_CODES]}
value={var1}
onSelectValue={(val) => {
setVar1(String(val));
}}
selectContentClass="z-[101]"
/>
<div className="flex shrink-0 items-center gap-2">
<Label className="whitespace-nowrap text-xl font-medium text-label">
용도
</Label>
<SelectBox
className="w-48"
selectClass="rounded-lg bg-field text-xl text-foreground"
label=""
selectArray={[
{ val: "0", text: "전체" },
...MS_SWAGE_MHL_USE_CODES,
]}
value={var1}
onSelectValue={(val) => {
setVar1(String(val));
}}
selectContentClass="z-[101]"
/>
</div>
{/* 하수맨홀 구경 */}
<Label className="mr-2 whitespace-nowrap text-xl font-medium text-label">구경(mm)</Label>
<Input
className="w-[100px] !text-xl placeholder:text-center"
placeholder="입력"
value={start1}
onChange={(e) => {
// 숫자만 입력 허용
const val = e.target.value.replace(/[^0-9]/g, "");
setStart1(val);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault(); // Enter 키 기본 동작 방지
handleSearch();
}
}}
/>
<span className="mx-2 text-xl font-semibold">~</span>
<Input
className="mr-8 w-[100px] !text-xl placeholder:text-center"
placeholder="입력"
value={end1}
onChange={(e) => {
// 숫자만 입력 허용
const val = e.target.value.replace(/[^0-9]/g, "");
setEnd1(val);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault(); // Enter 키 기본 동작 방지
handleSearch();
}
}}
/>
<div className="flex shrink-0 items-center gap-2">
<Label className="whitespace-nowrap text-xl font-medium text-label">
구경(mm)
</Label>
<Input
className="w-[100px] !text-xl placeholder:text-center"
placeholder="입력"
value={start1}
onChange={(e) => {
// 숫자만 입력 허용
const val = e.target.value.replace(/[^0-9]/g, "");
setStart1(val);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault(); // Enter 키 기본 동작 방지
handleSearch();
}
}}
/>
<span className="text-xl font-semibold">~</span>
<Input
className="w-[100px] !text-xl placeholder:text-center"
placeholder="입력"
value={end1}
onChange={(e) => {
// 숫자만 입력 허용
const val = e.target.value.replace(/[^0-9]/g, "");
setEnd1(val);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault(); // Enter 키 기본 동작 방지
handleSearch();
}
}}
/>
</div>
{/* 조회 버튼 */}
<div className="flex-1 flex justify-end">
<Button className="text-base py-2" onClick={handleSearch}>조회</Button>
<div className="flex min-w-fit flex-1 justify-end gap-2">
<Button onClick={handleSearch}>조회</Button>
<Button color="white" onClick={handleResetSearch}>
초기화
</Button>
</div>
</div>
</div>
@@ -1,6 +1,6 @@
/**
* ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 기본정보 탭 (구분: 하수펌프장)
*
*
* 📄 SwagePmstn Tab
* Next.js App Router (app/) 기반 검색 모달 하수펌프장 탭 컴포넌트
*
@@ -11,43 +11,43 @@
* - Zustand를 통한 상태 관리 및 필터 값 저장
* - Enter 키 입력 시 자동 조회 지원
*/
import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox';
import { Button, Input, Label } from '@common/components';
import React from 'react';
import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox';
import useSearchModalStore from '../../../store/useSearchModalStore';
import useSearchModal from './useSearchModal';
import { YearRangeSelectBox } from "@/components/ui/YearRangeSelectBox";
import { Button, Input, Label } from "@common/components";
import React from "react";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
import useSearchModalStore from "../../../store/useSearchModalStore";
import useSearchModal from "./useSearchModal";
export const swagePmstnCqlFilters = () => {
const {
ctpvCd, sggCd,
ctpvCd,
sggCd,
yearFilter,
start1, start2, end1, end2,
todayYear, todayMonth, todayDate,
getDateString
start1,
start2,
end1,
end2,
todayYear,
todayMonth,
todayDate,
getDateString,
} = useSearchModalStore.getState();
const cqlFilters: string[] = [];
// 시도, 시군구
if (ctpvCd !== '0' && sggCd !== '0') cqlFilters.push(`pbadms_emd_cd LIKE '${sggCd}%'`);
else if (ctpvCd !== '0') cqlFilters.push(`pbadms_emd_cd LIKE '${ctpvCd?.trim()}%'`);
if (ctpvCd !== "0" && sggCd !== "0")
cqlFilters.push(`pbadms_emd_cd LIKE '${sggCd}%'`);
else if (ctpvCd !== "0")
cqlFilters.push(`pbadms_emd_cd LIKE '${ctpvCd?.trim()}%'`);
// 설치년도 범위
const startDate = getDateString(
yearFilter.start,
'',
'',
"19000101",
);
const startDate = getDateString(yearFilter.start, "", "", "19000101");
const endDate = getDateString(
yearFilter.end,
'',
'',
"",
"",
`${todayYear}${todayMonth}${todayDate}`,
);
cqlFilters.push(
`instl_ymd >= '${startDate}' and instl_ymd <= '${endDate}'`,
);
cqlFilters.push(`instl_ymd >= '${startDate}' and instl_ymd <= '${endDate}'`);
// 일일처리용량 범위
if (start1 && end1)
cqlFilters.push(`daily_prcs_cpct BETWEEN ${start1} AND ${end1}`);
@@ -64,17 +64,22 @@ export const swagePmstnCqlFilters = () => {
const SwagePmstn: React.FC = () => {
const {
start1, setStart1,
end1, setEnd1,
ctpvCd, setCtpvCd,
sggCd, setSggCd,
yearFilter, setYearFilter,
start1,
setStart1,
end1,
setEnd1,
ctpvCd,
setCtpvCd,
sggCd,
setSggCd,
yearFilter,
setYearFilter,
} = useSearchModalStore();
const { handleSearch } = useSearchModal();
const { handleSearch, handleResetSearch } = useSearchModal();
return (
<div className="mb-3">
<div className="flex items-center mb-2">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
{/* 시도, 시군구 */}
<CtpvSggPrcsFcltSelectBox
sido={ctpvCd}
@@ -89,47 +94,53 @@ const SwagePmstn: React.FC = () => {
<YearRangeSelectBox
start={yearFilter.start}
end={yearFilter.end}
onChange={range => setYearFilter({ ...yearFilter, start: range.start, end: range.end })}
onChange={(range) =>
setYearFilter({ ...yearFilter, start: range.start, end: range.end })
}
startLabel="설치년도"
endLabel=""
/>
{/* 일일처리용량 */}
<Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">일일처리용량(㎥)</Label>
<Input
className="w-[100px] !text-xl placeholder:text-center"
placeholder="입력"
value={start1}
onChange={(e) => {
// 숫자만 입력 허용
const val = e.target.value.replace(/[^0-9]/g, "");
setStart1(val);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault(); // Enter 키 기본 동작 방지
handleSearch();
}
}}
/>
<span className="mx-2 text-xl font-semibold">~</span>
<Input
className="mr-8 w-[100px] !text-xl placeholder:text-center"
placeholder="입력"
value={end1}
onChange={(e) => {
// 숫자만 입력 허용
const val = e.target.value.replace(/[^0-9]/g, "");
setEnd1(val);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault(); // Enter 키 기본 동작 방지
handleSearch();
}
}}
/>
<div className="flex shrink-0 items-center gap-2">
<Label className="whitespace-nowrap text-xl font-medium text-label">
일일처리용량(㎥)
</Label>
<Input
className="w-[100px] !text-xl placeholder:text-center"
placeholder="입력"
value={start1}
onChange={(e) => {
// 숫자만 입력 허용
const val = e.target.value.replace(/[^0-9]/g, "");
setStart1(val);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault(); // Enter 키 기본 동작 방지
handleSearch();
}
}}
/>
<span className="text-xl font-semibold">~</span>
<Input
className="w-[100px] !text-xl placeholder:text-center"
placeholder="입력"
value={end1}
onChange={(e) => {
// 숫자만 입력 허용
const val = e.target.value.replace(/[^0-9]/g, "");
setEnd1(val);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault(); // Enter 키 기본 동작 방지
handleSearch();
}
}}
/>
</div>
{/* 양수능력 */}
{/* <Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">양수능력(m²/d)</Label>
{/* <Label className="text-xl text-label font-medium whitespace-nowrap mr-2">양수능력(m²/d)</Label>
<Input
className="w-[100px] !text-xl placeholder:text-center"
placeholder="입력"
@@ -164,12 +175,15 @@ const SwagePmstn: React.FC = () => {
}}
/> */}
{/* 조회 버튼 */}
<div className="flex-1 flex justify-end">
<Button className="text-base py-2" onClick={handleSearch}>조회</Button>
<div className="flex min-w-fit flex-1 justify-end gap-2">
<Button onClick={handleSearch}>조회</Button>
<Button color="white" onClick={handleResetSearch}>
초기화
</Button>
</div>
</div>
</div>
);
};
export default React.memo(SwagePmstn);
export default React.memo(SwagePmstn);
@@ -1,6 +1,6 @@
/**
* ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 기본정보 탭 (구분: 하수관로)
*
*
* 📄 SwagePpl Tab
* Next.js App Router (app/) 기반 검색 모달 하수관로 탭 컴포넌트
*
@@ -11,48 +11,49 @@
* - Zustand를 통한 상태 관리 및 필터 값 저장
* - Enter 키 입력 시 자동 조회 지원
*/
import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox';
import { SelectBox } from '@common/business_components/ui';
import { Button, Input, Label } from '@common/components';
import React from 'react';
import { MS_SWAGE_PPL_USE_CODES } from '../components/common/table';
import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox';
import useSearchModalStore from '../../../store/useSearchModalStore';
import useSearchModal from './useSearchModal';
import { YearRangeSelectBox } from "@/components/ui/YearRangeSelectBox";
import { SelectBox } from "@common/business_components/ui/select";
import { Button, Input, Label } from "@common/components";
import React from "react";
import { MS_SWAGE_PPL_USE_CODES } from "../components/common/table";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
import useSearchModalStore from "../../../store/useSearchModalStore";
import useSearchModal from "./useSearchModal";
// 하수관로용 CQL 필터 생성
export const swagePplCqlFilters = () => {
const {
var1, var2,
ctpvCd, sggCd,
var1,
var2,
ctpvCd,
sggCd,
yearFilter,
start1, start2, end1, end2,
todayYear, todayMonth, todayDate,
getDateString
start1,
start2,
end1,
end2,
todayYear,
todayMonth,
todayDate,
getDateString,
} = useSearchModalStore.getState();
const cqlFilters: string[] = [];
if (var1) cqlFilters.push(`usg_cd='${var1}'`);
if (var2) cqlFilters.push(`mqlt_cd='${var2}'`);
if (ctpvCd !== '0' && sggCd !== '0') cqlFilters.push(`pbadms_emd_cd LIKE '${sggCd}%'`);
else if (ctpvCd !== '0') cqlFilters.push(`pbadms_emd_cd LIKE '${ctpvCd?.trim()}%'`);
if (ctpvCd !== "0" && sggCd !== "0")
cqlFilters.push(`pbadms_emd_cd LIKE '${sggCd}%'`);
else if (ctpvCd !== "0")
cqlFilters.push(`pbadms_emd_cd LIKE '${ctpvCd?.trim()}%'`);
// 설치년도 범위
const startDate = getDateString(
yearFilter.start,
'',
'',
"19000101",
);
const startDate = getDateString(yearFilter.start, "", "", "19000101");
const endDate = getDateString(
yearFilter.end,
'',
'',
"",
"",
`${todayYear}${todayMonth}${todayDate}`,
);
cqlFilters.push(
`instl_ymd >= '${startDate}' and instl_ymd <= '${endDate}'`,
);
cqlFilters.push(`instl_ymd >= '${startDate}' and instl_ymd <= '${endDate}'`);
// 관경(mm) 범위
if (start1 && end1) cqlFilters.push(`pdmt_vl BETWEEN ${start1} AND ${end1}`);
else if (start1) cqlFilters.push(`pdmt_vl >= ${start1}`);
@@ -67,17 +68,24 @@ export const swagePplCqlFilters = () => {
const SwagePpl: React.FC = () => {
const {
var1, setVar1,
start1, setStart1, end1, setEnd1,
ctpvCd, setCtpvCd, sggCd, setSggCd,
yearFilter, setYearFilter,
var1,
setVar1,
start1,
setStart1,
end1,
setEnd1,
ctpvCd,
setCtpvCd,
sggCd,
setSggCd,
yearFilter,
setYearFilter,
} = useSearchModalStore();
const { handleSearch } = useSearchModal();
const { handleSearch, handleResetSearch } = useSearchModal();
return (
<div className="mb-6">
<div className="mb-2 flex items-center">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
<CtpvSggPrcsFcltSelectBox
sido={ctpvCd}
sigungu={sggCd}
@@ -102,23 +110,30 @@ const SwagePpl: React.FC = () => {
endLabel=""
/>
{/* 용도 */}
<Label className="mr-2 whitespace-nowrap text-xl font-medium text-label">용도</Label>
<SelectBox
className="w-32 mr-8"
selectClass="rounded-lg bg-field text-xl text-foreground"
label=""
selectArray={[{ val: "0", text: "전체" }, ...MS_SWAGE_PPL_USE_CODES]}
value={var1}
onSelectValue={(val) => {
setVar1(String(val));
}}
selectContentClass="z-[101]"
/>
<div className="flex shrink-0 items-center gap-2">
<Label className="whitespace-nowrap text-xl font-medium text-label">
용도
</Label>
<SelectBox
className="w-32"
selectClass="rounded-lg bg-field text-xl text-foreground"
label=""
selectArray={[
{ val: "0", text: "전체" },
...MS_SWAGE_PPL_USE_CODES,
]}
value={var1}
onSelectValue={(val) => {
setVar1(String(val));
}}
selectContentClass="z-[101]"
/>
</div>
{/* 재질 */}
{/* <Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">재질</Label>
{/* <Label className="text-xl text-label font-medium whitespace-nowrap mr-2">재질</Label>
<SelectBox
className="w-60 mr-8"
selectClass="rounded-lg bg-white text-xl"
selectClass="rounded-lg bg-background text-xl"
label=""
selectArray={MS_SWAGE_PPL_CODES}
value={var2}
@@ -128,42 +143,46 @@ const SwagePpl: React.FC = () => {
selectContentClass="z-[101]"
/> */}
{/* 관경 */}
<Label className="mr-2 whitespace-nowrap text-xl font-medium text-label">관경(mm)</Label>
<Input
className="w-[60px] !text-xl placeholder:text-center"
placeholder="입력"
value={start1}
onChange={(e) => {
// 숫자만 입력 허용
const val = e.target.value.replace(/[^0-9]/g, "");
setStart1(val);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault(); // Enter 키 기본 동작 방지
handleSearch();
}
}}
/>
<span className="mx-2 text-xl font-semibold">~</span>
<Input
className="mr-8 w-[60px] !text-xl placeholder:text-center"
placeholder="입력"
value={end1}
onChange={(e) => {
// 숫자만 입력 허용
const val = e.target.value.replace(/[^0-9]/g, "");
setEnd1(val);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault(); // Enter 키 기본 동작 방지
handleSearch();
}
}}
/>
<div className="flex shrink-0 items-center gap-2">
<Label className="whitespace-nowrap text-xl font-medium text-label">
관경(mm)
</Label>
<Input
className="w-[60px] !text-xl placeholder:text-center"
placeholder="입력"
value={start1}
onChange={(e) => {
// 숫자만 입력 허용
const val = e.target.value.replace(/[^0-9]/g, "");
setStart1(val);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault(); // Enter 키 기본 동작 방지
handleSearch();
}
}}
/>
<span className="text-xl font-semibold">~</span>
<Input
className="w-[60px] !text-xl placeholder:text-center"
placeholder="입력"
value={end1}
onChange={(e) => {
// 숫자만 입력 허용
const val = e.target.value.replace(/[^0-9]/g, "");
setEnd1(val);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault(); // Enter 키 기본 동작 방지
handleSearch();
}
}}
/>
</div>
{/* 연장 */}
{/* <Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">연장(m)</Label>
{/* <Label className="text-xl text-label font-medium whitespace-nowrap mr-2">연장(m)</Label>
<Input
className="w-[60px] !text-xl placeholder:text-center"
placeholder="입력"
@@ -198,8 +217,11 @@ const SwagePpl: React.FC = () => {
}}
/> */}
{/* 조회 버튼 */}
<div className="flex-1 flex justify-end">
<Button className="text-base py-2" onClick={handleSearch}>조회</Button>
<div className="flex min-w-fit flex-1 justify-end gap-2">
<Button onClick={handleSearch}>조회</Button>
<Button color="white" onClick={handleResetSearch}>
초기화
</Button>
</div>
</div>
</div>
@@ -1,6 +1,6 @@
/**
* ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 기본정보 탭 (구분: 하수처리시설)
*
*
* 📄 SwagePrcsFclt Tab
* Next.js App Router (app/) 기반 검색 모달 하수처리시설 탭 컴포넌트
*
@@ -11,36 +11,34 @@
* - Zustand를 통한 상태 관리 및 필터 값 저장
* - Enter 키 입력 시 자동 조회 지원
*/
import React from 'react';
import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox';
import { Button, Input, Label } from '@common/components';
import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox';
import useSearchModalStore from '../../../store/useSearchModalStore';
import useSearchModal from './useSearchModal';
import React from "react";
import { YearRangeSelectBox } from "@/components/ui/YearRangeSelectBox";
import { Button, Input, Label } from "@common/components";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
import useSearchModalStore from "../../../store/useSearchModalStore";
import useSearchModal from "./useSearchModal";
// 하수처리시설용 CQL 필터 생성
export const swagePrcsFcltCqlFilters = () => {
const {
ctpvName, sggName,
ctpvName,
sggName,
yearFilter,
start1, end1,
todayYear, todayMonth, todayDate,
getDateString
start1,
end1,
todayYear,
todayMonth,
todayDate,
getDateString,
} = useSearchModalStore.getState();
const cqlFilters: string[] = [];
// 가동개시일자 범위
const startDate = getDateString(
yearFilter.start,
'',
'',
"19000101",
);
const startDate = getDateString(yearFilter.start, "", "", "19000101");
const endDate = getDateString(
yearFilter.end,
'',
'',
"",
"",
`${todayYear}${todayMonth}${todayDate}`,
);
cqlFilters.push(
@@ -67,16 +65,24 @@ export const swagePrcsFcltCqlFilters = () => {
const SwagePrcsFclt: React.FC = () => {
const {
start1, setStart1, end1, setEnd1,
ctpvCd, setCtpvCd, setCtpvName,
sggCd, setSggCd, setSggName,
yearFilter, setYearFilter,
start1,
setStart1,
end1,
setEnd1,
ctpvCd,
setCtpvCd,
setCtpvName,
sggCd,
setSggCd,
setSggName,
yearFilter,
setYearFilter,
} = useSearchModalStore();
const { handleSearch } = useSearchModal();
const { handleSearch, handleResetSearch } = useSearchModal();
return (
<div className="mb-3">
<div className="mb-2 flex items-center">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
<CtpvSggPrcsFcltSelectBox
sido={ctpvCd}
sigungu={sggCd}
@@ -89,62 +95,73 @@ const SwagePrcsFclt: React.FC = () => {
setSggName={setSggName}
/>
{/* 가동개시일자 */}
<Label className="mr-2 w-auto text-xl text-gray-600">가동개시일자</Label>
<YearRangeSelectBox
start={yearFilter.start}
end={yearFilter.end}
onChange={(range) =>
setYearFilter({
...yearFilter,
start: range.start,
end: range.end,
})
}
startLabel=""
endLabel=""
/>
<div className="flex shrink-0 items-center gap-2">
<Label className="whitespace-nowrap text-xl font-medium text-label">
가동개시일자
</Label>
<YearRangeSelectBox
start={yearFilter.start}
end={yearFilter.end}
onChange={(range) =>
setYearFilter({
...yearFilter,
start: range.start,
end: range.end,
})
}
startLabel=""
endLabel=""
/>
</div>
{/* 시설용량 */}
<Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">시설용량(㎥/d)</Label>
<Input
className="w-[60px] !text-xl placeholder:text-center"
placeholder="입력"
value={start1}
onChange={(e) => {
// 숫자만 입력 허용
const val = e.target.value.replace(/[^0-9]/g, "");
setStart1(val);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault(); // Enter 키 기본 동작 방지
handleSearch();
}
}}
/>
<span className="mx-2 text-xl font-semibold">~</span>
<Input
className="mr-8 w-[60px] !text-xl placeholder:text-center"
placeholder="입력"
value={end1}
onChange={(e) => {
// 숫자만 입력 허용
const val = e.target.value.replace(/[^0-9]/g, "");
setEnd1(val);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault(); // Enter 키 기본 동작 방지
handleSearch();
}
}}
/>
<div className="flex shrink-0 items-center gap-2">
<Label className="whitespace-nowrap text-xl font-medium text-label">
시설용량(㎥/d)
</Label>
<Input
className="w-[60px] !text-xl placeholder:text-center"
placeholder="입력"
value={start1}
onChange={(e) => {
// 숫자만 입력 허용
const val = e.target.value.replace(/[^0-9]/g, "");
setStart1(val);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault(); // Enter 키 기본 동작 방지
handleSearch();
}
}}
/>
<span className="text-xl font-semibold">~</span>
<Input
className="w-[60px] !text-xl placeholder:text-center"
placeholder="입력"
value={end1}
onChange={(e) => {
// 숫자만 입력 허용
const val = e.target.value.replace(/[^0-9]/g, "");
setEnd1(val);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault(); // Enter 키 기본 동작 방지
handleSearch();
}
}}
/>
</div>
{/* 조회 버튼 */}
<div className="flex-1 flex justify-end">
<Button className="text-base py-2" onClick={handleSearch}>조회</Button>
<div className="flex min-w-fit flex-1 justify-end gap-2">
<Button onClick={handleSearch}>조회</Button>
<Button color="white" onClick={handleResetSearch}>
초기화
</Button>
</div>
</div>
</div>
);
};
export default React.memo(SwagePrcsFclt);
export default React.memo(SwagePrcsFclt);
@@ -1,6 +1,6 @@
/**
* ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 기본정보 탭 (구분: 하수처리구역)
*
*
* 📄 SwagePrcsZone Tab
* Next.js App Router (app/) 기반 검색 모달 하수처리구역 탭 컴포넌트
*
@@ -10,38 +10,32 @@
* - Zustand를 통한 상태 관리 및 필터 값 저장
* - Enter 키 입력 시 자동 조회 지원
*/
import React from 'react';
import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox';
import { Button, Input, Label } from '@common/components';
import useSearchModalStore from '../../../store/useSearchModalStore';
import useSearchModal from './useSearchModal';
import React from "react";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
import { Button, Input, Label } from "@common/components";
import useSearchModalStore from "../../../store/useSearchModalStore";
import useSearchModal from "./useSearchModal";
// 하수처리구역용 CQL 필터 생성
export const swagePrcsZoneCqlFilters = () => {
const {
ctpvCd, sggCd,
var1, var2
} = useSearchModalStore.getState();
const { ctpvCd, sggCd, var1, var2 } = useSearchModalStore.getState();
const cqlFilters: string[] = [];
if (ctpvCd !== '' && ctpvCd !== '0') cqlFilters.push(`ctpv_cd='${ctpvCd?.trim()}'`);
if (sggCd !== '' && sggCd !== '0') cqlFilters.push(`sgg_cd='${sggCd}'`);
if (ctpvCd !== "" && ctpvCd !== "0")
cqlFilters.push(`ctpv_cd='${ctpvCd?.trim()}'`);
if (sggCd !== "" && sggCd !== "0") cqlFilters.push(`sgg_cd='${sggCd}'`);
if (var1) cqlFilters.push(`swtrmt_zone_nm ILIKE '%${var1}%'`);
if (var2) cqlFilters.push(`swtrmt_fclt_nm ILIKE '%${var2}%'`);
return cqlFilters;
};
const swagePrcsZone: React.FC = () => {
const {
var1, setVar1, var2, setVar2,
ctpvCd, setCtpvCd,
sggCd, setSggCd,
} = useSearchModalStore();
const { handleSearch } = useSearchModal();
const { var1, setVar1, var2, setVar2, ctpvCd, setCtpvCd, sggCd, setSggCd } =
useSearchModalStore();
const { handleSearch, handleResetSearch } = useSearchModal();
return (
<div className="mb-3">
<div className="mb-2 flex items-center">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
<CtpvSggPrcsFcltSelectBox
sido={ctpvCd}
sigungu={sggCd}
@@ -51,43 +45,54 @@ const swagePrcsZone: React.FC = () => {
}}
onSigunguChange={setSggCd}
/>
<Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">하수처리시설명</Label>
<Input
className='!text-xl w-[120px] placeholder:text-center mr-8'
placeholder="입력"
value={var2}
onChange={(e) => {
setVar2(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault(); // Enter 키 기본 동작 방지
handleSearch();
}
}}
/>
<Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">하수처리구역명</Label>
<Input
className="mr-8 w-[100px] !text-xl placeholder:text-center"
placeholder="입력"
value={var1}
onChange={(e) => {
setVar1(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault(); // Enter 키 기본 동작 방지
handleSearch();
}
}}
/>
<div className="flex shrink-0 items-center gap-2">
<Label className="whitespace-nowrap text-xl font-medium text-label">
하수처리시설명
</Label>
<Input
className="w-[120px] !text-xl placeholder:text-center"
placeholder="입력"
value={var2}
onChange={(e) => {
setVar2(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault(); // Enter 키 기본 동작 방지
handleSearch();
}
}}
/>
</div>
<div className="flex shrink-0 items-center gap-2">
<Label className="whitespace-nowrap text-xl font-medium text-label">
하수처리구역명
</Label>
<Input
className="w-[100px] !text-xl placeholder:text-center"
placeholder="입력"
value={var1}
onChange={(e) => {
setVar1(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault(); // Enter 키 기본 동작 방지
handleSearch();
}
}}
/>
</div>
{/* 조회 버튼 */}
<div className="flex-1 flex justify-end">
<Button className="text-base py-2" onClick={handleSearch}>조회</Button>
<div className="flex min-w-fit flex-1 justify-end gap-2">
<Button onClick={handleSearch}>조회</Button>
<Button color="white" onClick={handleResetSearch}>
초기화
</Button>
</div>
</div>
</div>
);
};
export default React.memo(swagePrcsZone);
export default React.memo(swagePrcsZone);
@@ -8,44 +8,58 @@
* - CQL 필터 생성을 통한 쿼리 조건 설정
* - Zustand를 통한 상태 관리 및 로딩 처리
* - 데이터 매핑 및 코드 변환 (시도/시군구, 용도 등)
*
*
* 🔗 사용 API
* - 관망도 시설물 목록 조회: GET /api/v1/fclts (sewerage-service)
*/
import { downloadExcelWithKoreanHeaders } from '@common/business_components/ui';
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import {
MS_SWAGE_PPL_USE_CODES, MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_SHAPE, MS_SWAGE_MHL_CODES, MS_SWAGE_MHL_USE_CODES,
MsSwagePplExcelCol, SwtrmtFcltExcelCol, SwtrmtPrcsZoneExcelCol, MsSwageMhlExcelCol, ExclnSewagePumpExcelCol, stofcbrExcelCol
} from '../components/common/table';
MS_SWAGE_PPL_USE_CODES,
MS_SWAGE_PPL_CODES,
MS_SWAGE_PPL_SHAPE,
MS_SWAGE_MHL_CODES,
MS_SWAGE_MHL_USE_CODES,
FldngWtlvMapExcelCol,
MsSwagePplExcelCol,
SwtrmtFcltExcelCol,
SwtrmtPrcsZoneExcelCol,
MsSwageMhlExcelCol,
ExclnSewagePumpExcelCol,
stofcbrExcelCol,
} from "../components/common/table";
import { getData } from "@/components/lib/api";
import { BASIC_TYPES } from './basicTab';
import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue';
import useStore from '@/store/useStore';
import { BASIC_TYPES } from "./basicTab";
import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue";
import useStore from "@/store/useStore";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import { confirmToast } from '@/components/ui/toast'
import { toast } from "react-hot-toast";
import useSearchModalStore from '@/store/useSearchModalStore';
import { swagePplCqlFilters } from './swagePpl';
import { swagePrcsFcltCqlFilters } from './swagePrcsFclt';
import { swagePrcsZoneCqlFilters } from './swagePrcsZone';
import { swageMhlCqlFilters } from './swageMhl';
import { swagePmstnCqlFilters } from './swagePmstn';
import { stofcbrCqlFilters } from './stofcbr';
import { postPopupMessageAndClose } from '@/features/windowPopup/lib/popupMessage';
import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types';
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useAlertStore } from "@/shared/lib";
import useSearchModalStore from "@/store/useSearchModalStore";
import { swagePplCqlFilters } from "./swagePpl";
import { swagePrcsFcltCqlFilters } from "./swagePrcsFclt";
import { swagePrcsZoneCqlFilters } from "./swagePrcsZone";
import { fldngWtlvMapCqlFilters } from "./fldngWtlvMap";
import { swageMhlCqlFilters } from "./swageMhl";
import { swagePmstnCqlFilters } from "./swagePmstn";
import { stofcbrCqlFilters } from "./stofcbr";
import { postPopupMessageAndClose } from "@/features/windowPopup/lib/popupMessage";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
const isPopupWindow = () =>
typeof window !== "undefined" &&
!!window.opener &&
window.location.pathname.startsWith("/popup/");
export default function useBasicTab() {
const alert = useAlertStore((state) => state.setMessage);
const { confirm } = useConfirm();
const {
basicType,
tableData,
curPage,
todayYear, todayMonth, todayDate,
todayYear,
todayMonth,
todayDate,
setIsSearchModalOpen,
setTableData,
setTableCount,
@@ -53,74 +67,114 @@ export default function useBasicTab() {
} = useSearchModalStore();
const { ctpv, sgg } = useStore();
const fetchBasicFilteredData = async () => {
// 초기화 직후에도 이전 렌더의 조건을 사용하지 않도록 최신 스토어 값을 읽는다.
const { basicType, curPage } = useSearchModalStore.getState();
let cqlFilters: string[] = [];
const queryParams = new URLSearchParams({
lyrTypeNm: basicType,
page: `${curPage - 1}`,
size: '10',
sort: 'string',
size: "10",
});
if (basicType === "tb_ms_swage_ppl")
queryParams.set("includeFlowDirectionBasis", "true");
if (basicType === 'tb_ms_swage_ppl') cqlFilters = swagePplCqlFilters(); // 하수관로
else if (basicType === 'tb_il_swage_prcs_fclt') cqlFilters = swagePrcsFcltCqlFilters(); // 하수처리시설
else if (basicType === 'tb_ms_swage_prcs_zone') cqlFilters = swagePrcsZoneCqlFilters(); // 하수처리구역
else if (basicType === 'tb_ms_swage_mhl') cqlFilters = swageMhlCqlFilters(); // 하수맨홀
else if (basicType === 'tb_ms_swage_pmstn') cqlFilters = swagePmstnCqlFilters(); // 하수펌프장
else if (basicType === 'tb_ms_stofcbr') cqlFilters = stofcbrCqlFilters(); // 우수토실
if (cqlFilters.length > 0) queryParams.append('cqlFiter', cqlFilters.join(' and '));
if (basicType === "tb_ms_swage_ppl")
cqlFilters = swagePplCqlFilters(); // 하수관로
else if (basicType === "tb_il_swage_prcs_fclt")
cqlFilters = swagePrcsFcltCqlFilters(); // 하수처리시설
else if (basicType === "tb_ms_swage_prcs_zone")
cqlFilters = swagePrcsZoneCqlFilters(); // 하수처리구역
else if (basicType === "tb_ms_fldng_wtlv_map")
cqlFilters = fldngWtlvMapCqlFilters(); // 침수흔적도
else if (basicType === "tb_ms_swage_mhl")
cqlFilters = swageMhlCqlFilters(); // 하수맨홀
else if (basicType === "tb_ms_swage_pmstn")
cqlFilters = swagePmstnCqlFilters(); // 하수펌프장
else if (basicType === "tb_ms_stofcbr") cqlFilters = stofcbrCqlFilters(); // 우수토실
if (cqlFilters.length > 0)
queryParams.append("cqlFiter", cqlFilters.join(" and "));
const res = await getData(`v1/fclts?${queryParams.toString()}`);
const totalCount = Number(res.count ?? 0);
// 🎯 각 아이템을 개별적으로 포맷팅하면서 일련번호 추가
const dataWithRowNumber = (res.data || []).map((item: any, index: number) => {
// 🎯 먼저 각 아이템을 포맷팅
const formattedItem = formatDisplayData(item);
const dataWithRowNumber = (res.data || []).map(
(item: any, index: number) => {
// 🎯 먼저 각 아이템을 포맷팅
const formattedItem = formatDisplayData(item);
let ctpvCd = item?.ctpvNm ?? formattedItem?.ctpvNm ?? '';
let sggCd = item?.sggNm ?? formattedItem?.sggNm ?? '';
let ctpvCd = item?.ctpvNm ?? formattedItem?.ctpvNm ?? "";
let sggCd = item?.sggNm ?? formattedItem?.sggNm ?? "";
if (!ctpvCd || !sggCd) {
const code = String(item?.pbadmsEmdCd ?? formattedItem?.pbadmsEmdCd ?? '')?.trim();
if (!ctpvCd && code) ctpvCd = code.slice(0, 2);
if (!sggCd && code) sggCd = code.slice(0, 5);
}
if (!ctpvCd || !sggCd) {
const code = String(
item?.pbadmsEmdCd ??
formattedItem?.pbadmsEmdCd ??
item?.stdgCd ??
formattedItem?.stdgCd ??
"",
)?.trim();
if (!ctpvCd && code) ctpvCd = code.slice(0, 2);
if (!sggCd && code) sggCd = code.slice(0, 5);
}
const matchedMqltCode = basicType === 'tb_ms_swage_ppl'
? MS_SWAGE_PPL_CODES.find(code => code.val === item?.mqltCd?.trim()) // 하수관로 재질
: basicType === 'tb_ms_swage_mhl'
? MS_SWAGE_MHL_CODES.find(code => code.val === item?.mqltCd?.trim()) // 맨홀 재질
: undefined;
const matchedMhlUsgCode = MS_SWAGE_MHL_USE_CODES.find(code => code.val === item?.usgCd); // 맨홀 용도
const matchedPplShpCode = MS_SWAGE_PPL_SHAPE.find(code => code.val === item?.fcltyShpCd); // 하수관로 시설물형태코드
const ctpvNm = ctpv.find(code => code.val.trim() === ctpvCd)?.text || item?.ctpvNm;
const sggNm = sgg.find(code => code.val.trim() === sggCd)?.text || item?.sggNm;
const usgCdObj = MS_SWAGE_PPL_USE_CODES.find(code => code.val === item?.usgCd?.trim());
const matchedMqltCode =
basicType === "tb_ms_swage_ppl"
? MS_SWAGE_PPL_CODES.find(
(code) => code.val === item?.mqltCd?.trim(),
) // 하수관로 재질
: basicType === "tb_ms_swage_mhl"
? MS_SWAGE_MHL_CODES.find(
(code) => code.val === item?.mqltCd?.trim(),
) // 맨홀 재질
: undefined;
const matchedMhlUsgCode = MS_SWAGE_MHL_USE_CODES.find(
(code) => code.val === item?.usgCd,
); // 맨홀 용도
const matchedPplShpCode = MS_SWAGE_PPL_SHAPE.find(
(code) => code.val === item?.fcltyShpCd,
); // 하수관로 시설물형태코드
// 추가 처리 및 일련번호 추가
return {
...formattedItem,
rowNumber: (curPage - 1) * 10 + index + 1, // 1부터 시작하는 일련번호 추가
scsYn: item.scsYn === null || item.scsYn === undefined ? 'N' : item?.scsYn, // scsYn이 null이면 'N'으로 변경
mqltCd: matchedMqltCode ? matchedMqltCode.text : item?.mqltCd,
usgCd: matchedMhlUsgCode ? matchedMhlUsgCode.text : (usgCdObj ? usgCdObj.text : item?.usgCd),
ctpvNm: ctpvNm ?? '-',
sggNm: sggNm ?? '-',
swtrmt_zone_nm: item.swtrmtZoneNm || item.swtrmt_zone_nm || '-',
fcltyShpCd: matchedPplShpCode? matchedPplShpCode.text : item?.fcltyShpCd,
};
});
const ctpvNm =
ctpv.find((code) => code.val.trim() === ctpvCd)?.text || item?.ctpvNm;
const sggNm =
sgg.find((code) => code.val.trim() === sggCd)?.text || item?.sggNm;
const usgCdObj = MS_SWAGE_PPL_USE_CODES.find(
(code) => code.val === item?.usgCd?.trim(),
);
// 추가 처리 및 일련번호 추가
return {
...formattedItem,
rowNumber: totalCount - ((curPage - 1) * 10 + index),
scsYn:
item.scsYn === null || item.scsYn === undefined ? "N" : item?.scsYn, // scsYn이 null이면 'N'으로 변경
mqltCd: matchedMqltCode ? matchedMqltCode.text : item?.mqltCd,
usgCd: matchedMhlUsgCode
? matchedMhlUsgCode.text
: usgCdObj
? usgCdObj.text
: item?.usgCd,
ctpvNm: ctpvNm ?? "-",
sggNm: sggNm ?? "-",
swtrmt_zone_nm: item.swtrmtZoneNm || item.swtrmt_zone_nm || "-",
fcltyShpCd: matchedPplShpCode
? matchedPplShpCode.text
: item?.fcltyShpCd,
};
},
);
setTableData(dataWithRowNumber);
// console.log(tableData);
setTableCount(res.count || []);
setTableCount(totalCount);
};
// 기본정보 테이블 cellClick 핸들러
const handleBasicCellClick = async (index?: number | undefined, headId?: String) => {
// 기본정보 테이블 cellClick 핸들러
const handleBasicCellClick = async (
index?: number | undefined,
headId?: String,
) => {
if (typeof index === "number") {
const row = tableData[index];
@@ -153,13 +207,14 @@ export default function useBasicTab() {
return;
}
toast.dismiss("global-confirm-toast");
if (
!(await confirm(
"파일 다운로드에는 2분 내외의 시간이 소요될 수 있습니다.\n파일 다운로드를 진행하시겠습니까?",
))
)
return;
confirmToast(
"파일 다운로드에는 2분 내외의 시간이 소요될 수 있습니다.\n파일 다운로드를 진행하시겠습니까?",
onDownload ?? (() => { }),
"text-xl font-medium text-gray-800"
);
await onDownload();
async function onDownload() {
setIsLoading(true);
@@ -167,50 +222,59 @@ export default function useBasicTab() {
try {
const queryParams = new URLSearchParams({
lyrTypeNm: basicType,
size: '10000',
size: "10000",
});
if (basicType === "tb_ms_swage_ppl")
queryParams.set("includeFlowDirectionBasis", "true");
let cqlFilters: string[] = [];
let headerMapping: { [key: string]: string } = {};
let headerRange: string = "A1:F1";
switch (basicType) {
case 'tb_ms_swage_ppl': {
case "tb_ms_swage_ppl": {
// 하수관로
cqlFilters = swagePplCqlFilters()
cqlFilters = swagePplCqlFilters();
headerMapping = MsSwagePplExcelCol;
headerRange = "A1:Q1";
break;
}
case 'tb_il_swage_prcs_fclt': {
case "tb_il_swage_prcs_fclt": {
// 하수처리시설
cqlFilters = swagePrcsFcltCqlFilters();
headerMapping = SwtrmtFcltExcelCol;
headerRange = "A1:J1";
break;
}
case 'tb_ms_swage_prcs_zone': {
case "tb_ms_swage_prcs_zone": {
// 하수처리구역
cqlFilters = swagePrcsZoneCqlFilters();
headerMapping = SwtrmtPrcsZoneExcelCol;
headerRange = "A1:D1";
break;
}
case 'tb_ms_swage_mhl': {
case "tb_ms_fldng_wtlv_map": {
// 침수흔적도
cqlFilters = fldngWtlvMapCqlFilters();
headerMapping = FldngWtlvMapExcelCol;
headerRange = "A1:O1";
break;
}
case "tb_ms_swage_mhl": {
// 하수맨홀
cqlFilters = swageMhlCqlFilters();
headerMapping = MsSwageMhlExcelCol;
headerRange = "A1:J1";
break;
}
case 'tb_ms_swage_pmstn': {
case "tb_ms_swage_pmstn": {
// 하수펌프장
cqlFilters = swagePmstnCqlFilters();
headerMapping = ExclnSewagePumpExcelCol;
headerRange = "A1:J1";
break;
}
case 'tb_ms_stofcbr': {
case "tb_ms_stofcbr": {
// 우수토실
cqlFilters = stofcbrCqlFilters();
headerMapping = stofcbrExcelCol;
@@ -219,25 +283,44 @@ export default function useBasicTab() {
}
}
if (cqlFilters.length > 0) queryParams.append('cqlFiter', cqlFilters.join(' and '));
if (cqlFilters.length > 0)
queryParams.append("cqlFiter", cqlFilters.join(" and "));
const res = await getData(`v1/fclts?${queryParams.toString()}`);
const mhlUsgCdMap = new Map(MS_SWAGE_MHL_USE_CODES.map(code => [code.val?.trim(), code.text]));
const pplUsgCdMap = new Map(MS_SWAGE_PPL_USE_CODES.map(code => [code.val?.trim(), code.text]));
const pplMqltCdMap = new Map(MS_SWAGE_PPL_CODES.map(code => [code.val?.trim(), code.text]));
const pplShpCdMap = new Map(MS_SWAGE_PPL_SHAPE.map(code => [code.val?.trim(), code.text])); // 관로 시설물형태코드
const ctpvMap = new Map(ctpv.map(code => [code.val?.trim(), code.text]));
const sggMap = new Map(sgg.map(code => [code.val?.trim(), code.text]));
const mhlUsgCdMap = new Map(
MS_SWAGE_MHL_USE_CODES.map((code) => [code.val?.trim(), code.text]),
);
const pplUsgCdMap = new Map(
MS_SWAGE_PPL_USE_CODES.map((code) => [code.val?.trim(), code.text]),
);
const pplMqltCdMap = new Map(
MS_SWAGE_PPL_CODES.map((code) => [code.val?.trim(), code.text]),
);
const pplShpCdMap = new Map(
MS_SWAGE_PPL_SHAPE.map((code) => [code.val?.trim(), code.text]),
); // 관로 시설물형태코드
const ctpvMap = new Map(
ctpv.map((code) => [code.val?.trim(), code.text]),
);
const sggMap = new Map(
sgg.map((code) => [code.val?.trim(), code.text]),
);
const mappedContent = (res.data || []).map((item: any) => {
let mappedContent = (res.data || []).map((item: any) => {
const formattedItem = formatDisplayData(item);
let ctpvCd = item?.ctpvCd ?? formattedItem?.ctpvCd ?? '';
let sggCd = item?.sggCd ?? formattedItem?.sggCd ?? '';
let ctpvCd = item?.ctpvCd ?? formattedItem?.ctpvCd ?? "";
let sggCd = item?.sggCd ?? formattedItem?.sggCd ?? "";
if (!ctpvCd || !sggCd) {
const code = String(item?.pbadmsEmdCd ?? formattedItem?.pbadmsEmdCd ?? '')?.trim();
const code = String(
item?.pbadmsEmdCd ??
formattedItem?.pbadmsEmdCd ??
item?.stdgCd ??
formattedItem?.stdgCd ??
"",
)?.trim();
if (!ctpvCd && code) ctpvCd = code.slice(0, 2);
if (!sggCd && code) sggCd = code.slice(0, 5);
}
@@ -247,93 +330,150 @@ export default function useBasicTab() {
// const trimmedMqltCd = item?.mqltCd?.trim();
switch (basicType) {
case 'tb_ms_swage_ppl': {
case "tb_ms_swage_ppl": {
// 하수관로
return {
fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? '-',
instMngNo: formattedItem.instMngNo ?? item.instMngNo ?? '-',
ctpvNm: ctpvMap.get(ctpvCd) ?? item.ctpvNm ?? '-',
sggNm: sggMap.get(sggCd) ?? item.sggNm ?? '-',
swtrmt_zone_nm: formattedItem.swtrmt_zone_nm ?? item.swtrmt_zone_nm ?? '-',
instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? '-',
usgCd: pplUsgCdMap.get(trimmedUsgCd) || item.usgCd || formattedItem.usgCd || '-',
pdmtVl: formattedItem.pdmtVl ?? item.pdmtVl ?? '-',
prlgVl: formattedItem.prlgVl ?? item.prlgVl ?? '-',
tmptDpth: formattedItem.tmptDpth ?? item.tmptDpth ?? '-',
tmptMhlMngNo: formattedItem.tmptMhlMngNo ?? item.tmptMhlMngNo ?? '-',
scendDpth: formattedItem.scendDpth ?? item.scendDpth ?? '-',
scendMhlMngNo: formattedItem.scendMhlMngNo ?? item.scendMhlMngNo ?? '-',
vrtcLen: formattedItem.vrtcLen ?? item.vrtcLen ?? '-',
wdthLen: formattedItem.wdthLen ?? item.wdthLen ?? '-',
fcltyShpCd: pplShpCdMap.get(item?.fcltyShpCd?.trim()) || item.fcltyShpCd || formattedItem.fcltyShpCd || '-',
mqltCd: pplMqltCdMap.get(item?.mqltCd?.trim()) || item.mqltCd || formattedItem.mqltCd || '-',
fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? "-",
instMngNo: formattedItem.instMngNo ?? item.instMngNo ?? "-",
ctpvNm: ctpvMap.get(ctpvCd) ?? item.ctpvNm ?? "-",
sggNm: sggMap.get(sggCd) ?? item.sggNm ?? "-",
swtrmt_zone_nm:
formattedItem.swtrmt_zone_nm ?? item.swtrmt_zone_nm ?? "-",
instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? "-",
usgCd:
pplUsgCdMap.get(trimmedUsgCd) ||
item.usgCd ||
formattedItem.usgCd ||
"-",
pdmtVl: formattedItem.pdmtVl ?? item.pdmtVl ?? "-",
prlgVl: formattedItem.prlgVl ?? item.prlgVl ?? "-",
tmptDpth: formattedItem.tmptDpth ?? item.tmptDpth ?? "-",
tmptMhlMngNo:
formattedItem.tmptMhlMngNo ?? item.tmptMhlMngNo ?? "-",
scendDpth: formattedItem.scendDpth ?? item.scendDpth ?? "-",
scendMhlMngNo:
formattedItem.scendMhlMngNo ?? item.scendMhlMngNo ?? "-",
vrtcLen: formattedItem.vrtcLen ?? item.vrtcLen ?? "-",
wdthLen: formattedItem.wdthLen ?? item.wdthLen ?? "-",
fcltyShpCd:
pplShpCdMap.get(item?.fcltyShpCd?.trim()) ||
item.fcltyShpCd ||
formattedItem.fcltyShpCd ||
"-",
mqltCd:
pplMqltCdMap.get(item?.mqltCd?.trim()) ||
item.mqltCd ||
formattedItem.mqltCd ||
"-",
};
}
case 'tb_il_swage_prcs_fclt': {
case "tb_il_swage_prcs_fclt": {
// 하수처리시설
return {
swtrmtFcltNm: formattedItem.swtrmtFcltNm ?? item.swtrmtFcltNm ?? '-',
fcltCpct: formattedItem.fcltCpct ?? item.fcltCpct ?? '-',
rgnExpln: formattedItem.rgnExpln ?? item.rgnExpln ?? '-',
oprtngStrtYmd: formattedItem.oprtngStrtYmd ?? item.oprtngStrtYmd ?? '-',
laeNm: formattedItem.laeNm ?? item.laeNm ?? '-',
sareaNm: formattedItem.sareaNm ?? item.sareaNm ?? '-',
rvrNm: formattedItem.rvrNm ?? item.rvrNm ?? '-',
dschgWtstNm: formattedItem.dschgWtstNm ?? item.dschgWtstNm ?? '-',
swtrmtPpltnCnt: formattedItem.swtrmtPpltnCnt ?? item.swtrmtPpltnCnt ?? '-',
rprsMsrcCn: formattedItem.rprsMsrcCn ?? item.rprsMsrcCn ?? '-'
swtrmtFcltNm:
formattedItem.swtrmtFcltNm ?? item.swtrmtFcltNm ?? "-",
fcltCpct: formattedItem.fcltCpct ?? item.fcltCpct ?? "-",
rgnExpln: formattedItem.rgnExpln ?? item.rgnExpln ?? "-",
oprtngStrtYmd:
formattedItem.oprtngStrtYmd ?? item.oprtngStrtYmd ?? "-",
laeNm: formattedItem.laeNm ?? item.laeNm ?? "-",
sareaNm: formattedItem.sareaNm ?? item.sareaNm ?? "-",
rvrNm: formattedItem.rvrNm ?? item.rvrNm ?? "-",
dschgWtstNm:
formattedItem.dschgWtstNm ?? item.dschgWtstNm ?? "-",
swtrmtPpltnCnt:
formattedItem.swtrmtPpltnCnt ?? item.swtrmtPpltnCnt ?? "-",
rprsMsrcCn: formattedItem.rprsMsrcCn ?? item.rprsMsrcCn ?? "-",
};
}
case 'tb_ms_swage_prcs_zone': {
case "tb_ms_swage_prcs_zone": {
// 하수처리구역
return {
swtrmtZoneNm: formattedItem.swtrmtZoneNm ?? item.swtrmtZoneNm ?? '-',
ctpvNm: ctpvMap.get(ctpvCd) ?? formattedItem.ctpvNm ?? '-',
sggNm: sggMap.get(sggCd) ?? formattedItem.sggNm ?? '-',
swtrmtFcltNm: formattedItem.swtrmtFcltNm ?? item.swtrmtFcltNm ?? '-'
swtrmtZoneNm:
formattedItem.swtrmtZoneNm ?? item.swtrmtZoneNm ?? "-",
ctpvNm: ctpvMap.get(ctpvCd) ?? formattedItem.ctpvNm ?? "-",
sggNm: sggMap.get(sggCd) ?? formattedItem.sggNm ?? "-",
swtrmtFcltNm:
formattedItem.swtrmtFcltNm ?? item.swtrmtFcltNm ?? "-",
};
}
case 'tb_ms_swage_mhl': {
case "tb_ms_fldng_wtlv_map": {
// 침수흔적도
return {
fldngYr: formattedItem.fldngYr ?? item.fldngYr ?? "-",
fldngDstrNm:
formattedItem.fldngDstrNm ?? item.fldngDstrNm ?? "-",
ctpvNm: ctpvMap.get(ctpvCd) ?? formattedItem.ctpvNm ?? "-",
sggNm: sggMap.get(sggCd) ?? formattedItem.sggNm ?? "-",
bgngYmd: formattedItem.bgngYmd ?? item.bgngYmd ?? "-",
fldngBgngHm:
formattedItem.fldngBgngHm ?? item.fldngBgngHm ?? "-",
endYmd: formattedItem.endYmd ?? item.endYmd ?? "-",
fldngEndHm: formattedItem.fldngEndHm ?? item.fldngEndHm ?? "-",
fldngHr: formattedItem.fldngHr ?? item.fldngHr ?? "-",
fldngDstrctNm:
formattedItem.fldngDstrctNm ?? item.fldngDstrctNm ?? "-",
fldngCsDtlCd:
formattedItem.fldngCsDtlCd ?? item.fldngCsDtlCd ?? "-",
avgFldngWtlv:
formattedItem.avgFldngWtlv ?? item.avgFldngWtlv ?? "-",
fldngArea: formattedItem.fldngArea ?? item.fldngArea ?? "-",
exmnYr: formattedItem.exmnYr ?? item.exmnYr ?? "-",
damAmt: formattedItem.damAmt ?? item.damAmt ?? "-",
};
}
case "tb_ms_swage_mhl": {
// 하수맨홀
return {
fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? '-',
ctpvNm: ctpvMap.get(ctpvCd) ?? formattedItem.ctpvNm ?? '-',
sggNm: sggMap.get(sggCd) ?? formattedItem.sggNm ?? '-',
instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? '-',
usgCd: mhlUsgCdMap.get(trimmedUsgCd) || item.usgCd || formattedItem.usgCd || '-',
swageMhlDiamVl: formattedItem.swageMhlDiamVl ?? item.swageMhlDiamVl ?? '-',
wdthLen: formattedItem.wdthLen ?? item.wdthLen ?? '-',
vrtcLen: formattedItem.vrtcLen ?? item.vrtcLen ?? '-',
altdVl: formattedItem.altdVl ?? item.altdVl ?? '-',
dpthlVl: formattedItem.dpthlVl ?? item.dpthlVl ?? '-'
fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? "-",
ctpvNm: ctpvMap.get(ctpvCd) ?? formattedItem.ctpvNm ?? "-",
sggNm: sggMap.get(sggCd) ?? formattedItem.sggNm ?? "-",
instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? "-",
usgCd:
mhlUsgCdMap.get(trimmedUsgCd) ||
item.usgCd ||
formattedItem.usgCd ||
"-",
swageMhlDiamVl:
formattedItem.swageMhlDiamVl ?? item.swageMhlDiamVl ?? "-",
wdthLen: formattedItem.wdthLen ?? item.wdthLen ?? "-",
vrtcLen: formattedItem.vrtcLen ?? item.vrtcLen ?? "-",
altdVl: formattedItem.altdVl ?? item.altdVl ?? "-",
dpthlVl: formattedItem.dpthlVl ?? item.dpthlVl ?? "-",
};
}
case 'tb_ms_swage_pmstn': {
case "tb_ms_swage_pmstn": {
// 하수펌프장
return {
fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? '-',
ctpvNm: ctpvMap.get(ctpvCd) ?? formattedItem.ctpvNm ?? '-',
sggNm: sggMap.get(sggCd) ?? formattedItem.sggNm ?? '-',
swagePmstnNm: formattedItem.swagePmstnNm ?? item.swagePmstnNm ?? '-',
instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? '-',
dailyPrcsCpct: formattedItem.dailyPrcsCpct ?? item.dailyPrcsCpct ?? '-',
maxImpndCpct: formattedItem.maxImpndCpct ?? item.maxImpndCpct ?? '-',
altdVl: formattedItem.altdVl ?? item.altdVl ?? '-',
wtlvVl: formattedItem.wtlvVl ?? item.wtlvVl ?? '-',
exclnWpmpAbltVl: formattedItem.exclnWpmpAbltVl ?? item.exclnWpmpAbltVl ?? '-'
fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? "-",
ctpvNm: ctpvMap.get(ctpvCd) ?? formattedItem.ctpvNm ?? "-",
sggNm: sggMap.get(sggCd) ?? formattedItem.sggNm ?? "-",
swagePmstnNm:
formattedItem.swagePmstnNm ?? item.swagePmstnNm ?? "-",
instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? "-",
dailyPrcsCpct:
formattedItem.dailyPrcsCpct ?? item.dailyPrcsCpct ?? "-",
maxImpndCpct:
formattedItem.maxImpndCpct ?? item.maxImpndCpct ?? "-",
altdVl: formattedItem.altdVl ?? item.altdVl ?? "-",
wtlvVl: formattedItem.wtlvVl ?? item.wtlvVl ?? "-",
exclnWpmpAbltVl:
formattedItem.exclnWpmpAbltVl ?? item.exclnWpmpAbltVl ?? "-",
};
}
case 'tb_ms_stofcbr': {
case "tb_ms_stofcbr": {
// 우수토실
return {
fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? '-',
mapshtNo: formattedItem.mapshtNo ?? item.mapshtNo ?? '-',
ctpvNm: ctpvMap.get(ctpvCd) ?? formattedItem.ctpvNm ?? '-',
sggNm: sggMap.get(sggCd) ?? formattedItem.sggNm ?? '-',
instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? '-',
worflwWallHgt: formattedItem.worflwWallHgt ?? item.worflwWallHgt ?? '-',
swageMhlMngNo: formattedItem.swageMhlMngNo ?? item.swageMhlMngNo ?? '-',
drtnAngl: formattedItem.drtnAngl ?? item.drtnAngl ?? '-'
fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? "-",
mapshtNo: formattedItem.mapshtNo ?? item.mapshtNo ?? "-",
ctpvNm: ctpvMap.get(ctpvCd) ?? formattedItem.ctpvNm ?? "-",
sggNm: sggMap.get(sggCd) ?? formattedItem.sggNm ?? "-",
instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? "-",
worflwWallHgt:
formattedItem.worflwWallHgt ?? item.worflwWallHgt ?? "-",
swageMhlMngNo:
formattedItem.swageMhlMngNo ?? item.swageMhlMngNo ?? "-",
drtnAngl: formattedItem.drtnAngl ?? item.drtnAngl ?? "-",
};
}
}
@@ -343,7 +483,7 @@ export default function useBasicTab() {
const excelData = mappedContent; // 배열로 감싸서 전달
// 파일명 생성 (분석번호 기반)
const label = BASIC_TYPES.find((type) => type.val === basicType)?.text
const label = BASIC_TYPES.find((type) => type.val === basicType)?.text;
const fileName = `${label} 조회 결과_${todayYear}${todayMonth}${todayDate}`;
await downloadExcelWithKoreanHeaders({
@@ -351,7 +491,7 @@ export default function useBasicTab() {
fileName: fileName,
sheetNameArray: [fileName],
headerRange: headerRange,
headerMapping: headerMapping
headerMapping: headerMapping,
});
setIsLoading(false);
} catch (error) {
@@ -362,6 +502,5 @@ export default function useBasicTab() {
}
};
return { fetchBasicFilteredData, handleBasicCellClick, basicExcelDownload };
};
}
@@ -9,59 +9,73 @@
* - CQL 필터 생성을 통한 쿼리 조건 설정
* - Zustand를 통한 상태 관리 및 로딩 처리
* - 데이터 매핑 및 코드 변환 (사업 유형, 용도, 재질 등)
*
*
* 🔗 사용 API
* - 유지관리 정보 목록 조회: GET /api/v1/bizInfo (sewerage-service)
* - 유지관리 정보(사업정보) 관련 시설 목록 조회: GET /api/v1/bizInfo/fclts (sewerage-service)
*/
import { downloadExcelWithKoreanHeaders } from '@common/business_components/ui';
import { MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_USE_CODES } from '../components/common/table';
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import {
MS_SWAGE_PPL_CODES,
MS_SWAGE_PPL_USE_CODES,
} from "../components/common/table";
import { getData } from "@/components/lib/api";
import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue';
import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import { confirmToast } from '@/components/ui/toast'
import { toast } from "react-hot-toast";
import useSearchModalStore from '@/store/useSearchModalStore';
import useStore from '@/store/useStore';
import { postPopupMessageAndClose } from '@/features/windowPopup/lib/popupMessage';
import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types';
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useAlertStore } from "@/shared/lib";
import useSearchModalStore from "@/store/useSearchModalStore";
import useStore from "@/store/useStore";
import { postPopupMessageAndClose } from "@/features/windowPopup/lib/popupMessage";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
const isPopupWindow = () =>
typeof window !== "undefined" &&
!!window.opener &&
window.location.pathname.startsWith("/popup/");
export default function useOperationTab() {
const OPERATION_TABLE_SIZE = 5;
const alert = useAlertStore((state) => state.setMessage);
const { confirm } = useConfirm();
const {
workType,
tableData, detailTableData,
curPage, detailCurPage,
tableData,
detailTableData,
curPage,
detailCurPage,
detailBizId,
var1, var2,
var1,
var2,
yearFilter,
todayYear, todayMonth, todayDate,
todayYear,
todayMonth,
todayDate,
setIsSearchModalOpen,
setTableData, setDetailTableData,
setTableCount, setDetailTableCount,
setTableData,
setDetailTableData,
setTableCount,
setDetailTableCount,
setDetailCurPage,
setIsLoading, setIsDetailLoading,
setIsLoading,
setIsDetailLoading,
setIsResetDetailPage,
setIsResetDetailSelection,
setDetailBizId,
getDateString
getDateString,
} = useSearchModalStore();
const { BIZ_TY_CD } = useStore();
const WORK_TYPES = [ // 유지관리 정보 '구분' 선택 목록
const WORK_TYPES = [
// 유지관리 정보 '구분' 선택 목록
{ val: "['200', '201', '204']", text: "보수 및 갱신" },
{ val: "['100']", text: "점검 및 진단" },
{ val: "['203']", text: "청소 및 준설" },
];
const OPERATION_TABLE_HEADLIST = [ // 유지관리 정보 - 사업 현황
{ id: 'rowNumber', title: '일련번호' },
const OPERATION_TABLE_HEADLIST = [
// 유지관리 정보 - 사업 현황
{ id: "rowNumber", title: "순번" },
{ id: "bizInfoNm", title: "사업명" },
{ id: "flfmtBgngYmd", title: "수행시작일자" },
{ id: "flfmtEndYmd", title: "수행종료일자" },
@@ -69,8 +83,9 @@ export default function useOperationTab() {
{ id: "bizTypeCd", title: "사업유형" },
{ id: "bizAmt", title: "사업금액" },
];
const MAINTENANACE_TABLE_HEADLIST = [ // 유지관리 정보 - 보수 및 갱신 결과표
{ id: 'rowNumber', title: '일련번호' },
const MAINTENANACE_TABLE_HEADLIST = [
// 유지관리 정보 - 보수 및 갱신 결과표
{ id: "rowNumber", title: "순번" },
{ id: "fcltMngNo", title: "관리번호" },
{ id: "swtrmt_zone_nm", title: "처리구역" },
{ id: "usgCd", title: "용도" },
@@ -82,8 +97,9 @@ export default function useOperationTab() {
{ id: "instlYmd", title: "설치일자" },
// { id: "instlYmd", title: "사업구분" },
];
const CLEANING_TABLE_HEADLIST = [ // 유지관리 정보 - 청소 및 준설 결과표
{ id: 'rowNumber', title: '일련번호' },
const CLEANING_TABLE_HEADLIST = [
// 유지관리 정보 - 청소 및 준설 결과표
{ id: "rowNumber", title: "순번" },
{ id: "fcltMngNo", title: "관리번호" },
{ id: "swtrmt_zone_nm", title: "처리구역" },
{ id: "usgCd", title: "용도" },
@@ -94,8 +110,9 @@ export default function useOperationTab() {
{ id: "prlgVl", title: "연장(m)" },
{ id: "instlYmd", title: "설치일자" },
];
const INSPECTION_TABLE_HEADLIST = [ // 유지관리 정보 - 점검 및 진단 결과표
{ id: 'rowNumber', title: '일련번호' },
const INSPECTION_TABLE_HEADLIST = [
// 유지관리 정보 - 점검 및 진단 결과표
{ id: "rowNumber", title: "순번" },
{ id: "fcltMngNo", title: "관리번호" },
{ id: "swtrmt_zone_nm", title: "처리구역" },
{ id: "usgCd", title: "용도" },
@@ -117,18 +134,13 @@ export default function useOperationTab() {
size: `${OPERATION_TABLE_SIZE}`,
};
if (var1) RequestDTO.bizInfoNm = var1;
if (var2 != '0' && var2) RequestDTO.bizTypeCd = var2;
if (var2 != "0" && var2) RequestDTO.bizTypeCd = var2;
// 수행기간 범위
const startDate = getDateString(
yearFilter.start,
'',
'',
"19000101",
);
const startDate = getDateString(yearFilter.start, "", "", "19000101");
const endDate = getDateString(
yearFilter.end,
'',
'',
"",
"",
`${todayYear}${todayMonth}${todayDate}`,
);
RequestDTO.fromflfmtEndYmd = startDate;
@@ -140,25 +152,34 @@ export default function useOperationTab() {
const params = new URLSearchParams(RequestDTO).toString();
const res = await getData(`v1/bizInfo?${params}`);
const totalElements = Number(res.totalElements ?? 0);
const mappedContent = (res.content || []).map((item: any, index: number) => {
const formattedItem = formatDisplayData(item);
const matched = BIZ_TY_CD.find(code => code.lwpoCd === item.bizTypeCd);
return {
...formattedItem,
rowNumber: (curPage - 1) * OPERATION_TABLE_SIZE + index + 1, // 1부터 시작하는 일련번호 추가
bizTypeCd: matched ? matched.lwpoCdNm : item.bizTypeCd,
};
});
const mappedContent = (res.content || []).map(
(item: any, index: number) => {
const formattedItem = formatDisplayData(item);
const matched = BIZ_TY_CD.find(
(code) => code.lwpoCd === item.bizTypeCd,
);
return {
...formattedItem,
rowNumber:
totalElements - ((curPage - 1) * OPERATION_TABLE_SIZE + index),
bizTypeCd: matched ? matched.lwpoCdNm : item.bizTypeCd,
};
},
);
setTableData(mappedContent || []);
setTableCount(res.totalElements || 0);
setTableCount(totalElements);
setDetailTableData([]);
setDetailTableCount(0);
};
// 유지관리 정보 테이블 cellClick 핸들러
const handleOperationCellClick = async (index?: number | undefined, headId?: String) => {
// 유지관리 정보 테이블 cellClick 핸들러
const handleOperationCellClick = async (
index?: number | undefined,
headId?: String,
) => {
setIsResetDetailPage(true); // 페이지를 1로 초기화
setIsResetDetailSelection(true); // 선택 초기화
setIsResetDetailPage(true); // 디테일 페이지를 초기화
@@ -169,33 +190,45 @@ export default function useOperationTab() {
setDetailCurPage(1); // 상세 페이지 초기화
try {
const RequestDTO: Record<string, string> = {
page: `${detailCurPage - 1}`,
page: "0",
size: `${OPERATION_TABLE_SIZE}`,
bizId: tableData[index % OPERATION_TABLE_SIZE].bizId,
};
RequestDTO.workTypes = JSON.parse(workType.replace(/'/g, '"'));
const res = await getData(`v1/bizInfo/fclts?${new URLSearchParams(RequestDTO).toString()}`);
const res = await getData(
`v1/bizInfo/fclts?${new URLSearchParams(RequestDTO).toString()}`,
);
const totalDetailElements = Number(res.count ?? 0);
const mappedData = (res.data || []).map((item: any, detailIndex: number) => {
const formattedItem = formatDisplayData(item);
const mappedData = (res.data || []).map(
(item: any, detailIndex: number) => {
const formattedItem = formatDisplayData(item);
const usgCdObj = MS_SWAGE_PPL_USE_CODES.find(code => code.val === item?.usgCd?.trim());
const mqltCdObj = MS_SWAGE_PPL_CODES.find(code => code.val === item?.mqltCd?.trim());
return {
...formattedItem,
rowNumber: (detailCurPage - 1) * OPERATION_TABLE_SIZE + detailIndex + 1,
usgCd: usgCdObj ? usgCdObj.text : item.usgCd,
mqltCd: mqltCdObj ? mqltCdObj.text : item.mqltCd,
swtrmt_zone_nm: item.swtrmt_zone_nm ? item.swtrmt_zone_nm : '-',
};
});
const usgCdObj = MS_SWAGE_PPL_USE_CODES.find(
(code) => code.val === item?.usgCd?.trim(),
);
const mqltCdObj = MS_SWAGE_PPL_CODES.find(
(code) => code.val === item?.mqltCd?.trim(),
);
return {
...formattedItem,
rowNumber: totalDetailElements - detailIndex,
usgCd: usgCdObj ? usgCdObj.text : item.usgCd,
mqltCd: mqltCdObj ? mqltCdObj.text : item.mqltCd,
swtrmt_zone_nm: item.swtrmt_zone_nm ? item.swtrmt_zone_nm : "-",
};
},
);
setDetailTableData(mappedData);
setDetailTableCount(res.count || 0);
setDetailTableCount(totalDetailElements);
} catch (error) {
console.error('유지관리 정보 cellClick 에러:', error);
console.error("유지관리 정보 cellClick 에러:", error);
setDetailTableData([]);
setDetailTableCount(0);
alert("선택한 사업의 결과 목록을 불러오지 못했습니다.");
} finally {
setIsDetailLoading(false);
setIsResetDetailPage(false);
@@ -204,8 +237,11 @@ export default function useOperationTab() {
}
};
// 유지관리 정보 디테일 테이블 cellClick 핸들러
const handleOperationResultCellClick = async (index?: number | undefined, headId?: String) => {
// 유지관리 정보 디테일 테이블 cellClick 핸들러
const handleOperationResultCellClick = async (
index?: number | undefined,
headId?: String,
) => {
if (typeof index === "number") {
const row = detailTableData[index];
@@ -231,7 +267,7 @@ export default function useOperationTab() {
}
};
// 유지관리 정보 디테일 테이블 페이지네이션 핸들러
// 유지관리 정보 디테일 테이블 페이지네이션 핸들러
const handleOperationPageNation = async (index?: number | undefined) => {
if (typeof index === "number") {
setIsDetailLoading(true);
@@ -241,26 +277,38 @@ export default function useOperationTab() {
size: `${OPERATION_TABLE_SIZE}`,
bizId: String(detailBizId),
};
const res = await getData(`v1/bizInfo/fclts?${new URLSearchParams(RequestDTO).toString()}`);
const res = await getData(
`v1/bizInfo/fclts?${new URLSearchParams(RequestDTO).toString()}`,
);
const totalElements = Number(res.count ?? 0);
const mappedData = (res.data || []).map((item: any, detailIndex: number) => {
const formattedItem = formatDisplayData(item);
const mappedData = (res.data || []).map(
(item: any, detailIndex: number) => {
const formattedItem = formatDisplayData(item);
const usgCdObj = MS_SWAGE_PPL_USE_CODES.find(code => code.val === item?.usgCd?.trim());
const mqltCdObj = MS_SWAGE_PPL_CODES.find(code => code.val === item?.mqltCd?.trim());
return {
...formattedItem,
rowNumber: (detailCurPage - 1) * OPERATION_TABLE_SIZE + detailIndex + 1,
usgCd: usgCdObj ? usgCdObj.text : item.usgCd,
mqltCd: mqltCdObj ? mqltCdObj.text : item.mqltCd,
swtrmt_zone_nm: item.swtrmt_zone_nm ? item.swtrmt_zone_nm : '-',
};
});
const usgCdObj = MS_SWAGE_PPL_USE_CODES.find(
(code) => code.val === item?.usgCd?.trim(),
);
const mqltCdObj = MS_SWAGE_PPL_CODES.find(
(code) => code.val === item?.mqltCd?.trim(),
);
return {
...formattedItem,
rowNumber:
totalElements -
((detailCurPage - 1) * OPERATION_TABLE_SIZE + detailIndex),
usgCd: usgCdObj ? usgCdObj.text : item.usgCd,
mqltCd: mqltCdObj ? mqltCdObj.text : item.mqltCd,
swtrmt_zone_nm: item.swtrmt_zone_nm ? item.swtrmt_zone_nm : "-",
};
},
);
setDetailTableData(mappedData);
setDetailTableCount(res.count || 0);
setDetailTableCount(totalElements);
} catch (error) {
console.error('유지관리 정보 페이지네이션 에러:', error);
console.error("유지관리 정보 페이지네이션 에러:", error);
alert("선택한 사업의 결과 목록을 불러오지 못했습니다.");
} finally {
setIsDetailLoading(false);
}
@@ -274,34 +322,30 @@ export default function useOperationTab() {
return;
}
toast.dismiss("global-confirm-toast");
if (
!(await confirm(
"파일 다운로드에는 2분 내외의 시간이 소요될 수 있습니다.\n파일 다운로드를 진행하시겠습니까?",
))
)
return;
confirmToast(
"파일 다운로드에는 2분 내외의 시간이 소요될 수 있습니다.\n파일 다운로드를 진행하시겠습니까?",
onAnalysis ?? (() => { }),
"text-xl font-medium text-gray-800"
);
await onAnalysis();
async function onAnalysis() {
setIsLoading(true);
try {
const RequestDTO: Record<string, string> = {
size: '10000',
size: "10000",
};
if (var1) RequestDTO.bizInfoNm = var1;
if (var2 != '0' && var2) RequestDTO.bizTypeCd = var2;
if (var2 != "0" && var2) RequestDTO.bizTypeCd = var2;
// 수행기간 범위
const startDate = getDateString(
yearFilter.start,
'',
'',
"19000101",
);
const startDate = getDateString(yearFilter.start, "", "", "19000101");
const endDate = getDateString(
yearFilter.end,
'',
'',
"",
"",
`${todayYear}${todayMonth}${todayDate}`,
);
RequestDTO.fromflfmtEndYmd = startDate;
@@ -315,26 +359,34 @@ export default function useOperationTab() {
// 헤더 매핑 정의
const headerMapping: { [key: string]: string } = {
'bizInfoNm': '사업명',
'flfmtBgngYmd': '수행시작일자',
'flfmtEndYmd': '수행종료일자',
'flfmtYr': '수행년도',
'bizTypeCd': '사업유형',
'bizAmt': '사업금액(원)',
bizInfoNm: "사업명",
flfmtBgngYmd: "수행시작일자",
flfmtEndYmd: "수행종료일자",
flfmtYr: "수행년도",
bizTypeCd: "사업유형",
bizAmt: "사업금액(원)",
};
const bizTyCdMap = new Map(BIZ_TY_CD.map(code => [code.lwpoCd, code.lwpoCdNm]));
const bizTyCdMap = new Map(
BIZ_TY_CD.map((code) => [code.lwpoCd, code.lwpoCdNm]),
);
const mappedContent = (res.content || []).map((item: any) => {
const formattedItem = formatDisplayData(item);
return {
bizInfoNm: formattedItem.bizInfoNm ?? item.bizInfoNm ?? '',
flfmtBgngYmd: formattedItem.flfmtBgngYmd ?? item.flfmtBgngYmd ?? '',
flfmtEndYmd: formattedItem.flfmtEndYmd ?? item.flfmtEndYmd ?? '',
flfmtYr: formattedItem.flfmtYr ?? item.flfmtYr ?? '',
bizTypeCd: bizTyCdMap.get(item.bizTypeCd) || item.bizTypeCd || formattedItem.bizTypeCd || '',
bizAmt: ((v) => (v === 0 || v === '0') ? '0' : (v ?? ''))(formattedItem.bizAmt ?? item.bizAmt),
bizInfoNm: formattedItem.bizInfoNm ?? item.bizInfoNm ?? "",
flfmtBgngYmd: formattedItem.flfmtBgngYmd ?? item.flfmtBgngYmd ?? "",
flfmtEndYmd: formattedItem.flfmtEndYmd ?? item.flfmtEndYmd ?? "",
flfmtYr: formattedItem.flfmtYr ?? item.flfmtYr ?? "",
bizTypeCd:
bizTyCdMap.get(item.bizTypeCd) ||
item.bizTypeCd ||
formattedItem.bizTypeCd ||
"",
bizAmt: ((v) => (v === 0 || v === "0" ? "0" : (v ?? "")))(
formattedItem.bizAmt ?? item.bizAmt,
),
};
});
@@ -342,7 +394,7 @@ export default function useOperationTab() {
const excelData = mappedContent; // 배열로 감싸서 전달
// 파일명 생성 (분석번호 기반)
const label = WORK_TYPES.find((type) => type.val === workType)?.text
const label = WORK_TYPES.find((type) => type.val === workType)?.text;
const fileName = `${label} 사업 현황_${todayYear}${todayMonth}${todayDate}`;
await downloadExcelWithKoreanHeaders({
@@ -350,7 +402,7 @@ export default function useOperationTab() {
fileName: fileName,
sheetNameArray: [fileName],
headerRange: "A1:F1",
headerMapping: headerMapping
headerMapping: headerMapping,
});
setIsLoading(false);
} catch (error) {
@@ -368,41 +420,47 @@ export default function useOperationTab() {
return;
}
toast.dismiss("global-confirm-toast");
if (
!(await confirm(
"파일 다운로드에는 2분 내외의 시간이 소요될 수 있습니다.\n파일 다운로드를 진행하시겠습니까?",
))
)
return;
confirmToast(
"파일 다운로드에는 2분 내외의 시간이 소요될 수 있습니다.\n파일 다운로드를 진행하시겠습니까?",
onAnalysis ?? (() => { }),
"text-xl font-medium text-gray-800"
);
await onAnalysis();
async function onAnalysis() {
setIsLoading(true);
try {
const RequestDTO: Record<string, string> = {
size: '100',
size: "100",
bizId: String(detailBizId),
};
const res = await getData(`v1/bizInfo/fclts?${new URLSearchParams(RequestDTO).toString()}`);
const res = await getData(
`v1/bizInfo/fclts?${new URLSearchParams(RequestDTO).toString()}`,
);
// 헤더 매핑 정의
const headerMapping: { [key: string]: string } = {
'fcltMngNo': '관리번호',
'swtrmt_zone_nm': '처리구역',
'usgCd': '용도',
'mqltCd': '재질',
'pdmtVl': '관경(mm)',
'prlgVl': '연장(m)',
'instlYmd': '설치일자',
'strc_stts_grd': '구조적 상태등급',
'oper_stts_grd': '운영적 상태등급',
fcltMngNo: "관리번호",
swtrmt_zone_nm: "처리구역",
usgCd: "용도",
mqltCd: "재질",
pdmtVl: "관경(mm)",
prlgVl: "연장(m)",
instlYmd: "설치일자",
strc_stts_grd: "구조적 상태등급",
oper_stts_grd: "운영적 상태등급",
};
const usgCdMap = new Map(MS_SWAGE_PPL_USE_CODES.map(code => [code.val, code.text]));
const mqltCdMap = new Map(MS_SWAGE_PPL_CODES.map(code => [code.val, code.text]));
const usgCdMap = new Map(
MS_SWAGE_PPL_USE_CODES.map((code) => [code.val, code.text]),
);
const mqltCdMap = new Map(
MS_SWAGE_PPL_CODES.map((code) => [code.val, code.text]),
);
// 🎯 단일 반복문으로 최적화 - 각 아이템마다 필요한 필드만 직접 매핑
const mappedData = (res.data || []).map((item: any) => {
@@ -413,19 +471,35 @@ export default function useOperationTab() {
const trimmedMqltCd = item?.mqltCd?.trim();
// zone 값 한 번만 계산
const zone = item.swtrmtZoneNm ?? formattedItem.swtrmt_zone_nm ?? item.swtrmt_zone_nm ?? '';
const zone =
item.swtrmtZoneNm ??
formattedItem.swtrmt_zone_nm ??
item.swtrmt_zone_nm ??
"";
// 직접 매핑 - 반복문 없이 한 번에 객체 생성
return {
fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? '',
swtrmt_zone_nm: (zone === null || zone === '') ? '-' : zone,
usgCd: usgCdMap.get(trimmedUsgCd) || item.usgCd || formattedItem.usgCd || '',
mqltCd: mqltCdMap.get(trimmedMqltCd) || item.mqltCd || formattedItem.mqltCd || '',
pdmtVl: formattedItem.pdmtVl ?? item.pdmtVl ?? '',
prlgVl: formattedItem.prlgVl ?? item.prlgVl ?? '',
instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? '',
strc_stts_grd: ((v) => (v === 0 || v === '0') ? '0' : (v ?? ''))(formattedItem.strc_stts_grd ?? item.strc_stts_grd),
oper_stts_grd: ((v) => (v === 0 || v === '0') ? '0' : (v ?? ''))(formattedItem.oper_stts_grd ?? item.oper_stts_grd),
fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? "",
swtrmt_zone_nm: zone === null || zone === "" ? "-" : zone,
usgCd:
usgCdMap.get(trimmedUsgCd) ||
item.usgCd ||
formattedItem.usgCd ||
"",
mqltCd:
mqltCdMap.get(trimmedMqltCd) ||
item.mqltCd ||
formattedItem.mqltCd ||
"",
pdmtVl: formattedItem.pdmtVl ?? item.pdmtVl ?? "",
prlgVl: formattedItem.prlgVl ?? item.prlgVl ?? "",
instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? "",
strc_stts_grd: ((v) => (v === 0 || v === "0" ? "0" : (v ?? "")))(
formattedItem.strc_stts_grd ?? item.strc_stts_grd,
),
oper_stts_grd: ((v) => (v === 0 || v === "0" ? "0" : (v ?? "")))(
formattedItem.oper_stts_grd ?? item.oper_stts_grd,
),
};
});
@@ -433,7 +507,7 @@ export default function useOperationTab() {
const excelData = mappedData; // 배열로 감싸서 전달
// 파일명 생성 (분석번호 기반)
const label = WORK_TYPES.find((type) => type.val === workType)?.text
const label = WORK_TYPES.find((type) => type.val === workType)?.text;
const fileName = `${label} 결과표_${todayYear}${todayMonth}${todayDate}`;
await downloadExcelWithKoreanHeaders({
@@ -441,7 +515,7 @@ export default function useOperationTab() {
fileName: fileName,
sheetNameArray: [fileName],
headerRange: "A1:I1",
headerMapping: headerMapping
headerMapping: headerMapping,
});
setIsLoading(false);
@@ -467,7 +541,6 @@ export default function useOperationTab() {
return true;
};
return {
WORK_TYPES,
OPERATION_TABLE_HEADLIST,
@@ -481,6 +554,6 @@ export default function useOperationTab() {
handleOperationPageNation,
emitMultipleFeaturesToMap,
operationExcelDownload,
operationResultExcelDownload
operationResultExcelDownload,
};
};
}
@@ -17,6 +17,7 @@ import { getDcData } from '@/components/lib/api';
import { fileDownload } from '@/utils';
import { useReportTabStore } from '@/store/useReportTabStore';
import useSearchModalStore from '@/store/useSearchModalStore';
import { useAlertStore } from '@/shared/lib';
export type Folder = {
clsfSystmSn: string;
@@ -50,6 +51,7 @@ const buildDisplayFileName = (files: ReportDataFile[]) => {
};
export default function useReportTab() {
const alert = useAlertStore((state) => state.setMessage);
const REPORT_TYPES = [
{ val: '0', text: '전체' },
{ val: '1', text: '보고서' },
@@ -8,24 +8,27 @@
* - 여러 시설 보기 기능
* - Zustand를 통한 상태 관리 및 로딩 처리
* - 데이터 매핑 및 코드 변환 (사업 유형 등)
*
*
* 🔗 사용 API
* - 유지관리 정보 목록 조회: GET /api/v1/bizInfo (sewerage-service)
*/
import { getData } from '@/components/lib/api';
import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue';
import useStore from '../../../store/useStore';
import GisApp from '@common/business_components/ui/map/lib/gis.core';
import useSearchModalStore from '../../../store/useSearchModalStore';
import useBasicTab from './useBasicTab';
import useOperationTab from './useOperationTab';
import { postPopupMessageAndClose } from '@/features/windowPopup/lib/popupMessage';
import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types';
import { getData } from "@/components/lib/api";
import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue";
import useStore from "../../../store/useStore";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import useSearchModalStore, {
SEARCH_MODAL_DEFAULT_BASIC_TYPE,
SEARCH_MODAL_DEFAULT_WORK_TYPE,
} from "../../../store/useSearchModalStore";
import useBasicTab from "./useBasicTab";
import useOperationTab from "./useOperationTab";
import { postPopupMessageAndClose } from "@/features/windowPopup/lib/popupMessage";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
const isPopupWindow = () =>
typeof window !== 'undefined' &&
typeof window !== "undefined" &&
!!window.opener &&
window.location.pathname.startsWith('/popup/');
window.location.pathname.startsWith("/popup/");
export default function useSearchModal() {
const {
@@ -48,9 +51,9 @@ export default function useSearchModal() {
const { BIZ_TY_CD } = useStore();
const TABS = [
{ val: 'basic', text: '기본 정보' },
{ val: 'operation', text: '유지관리 정보' },
{ val: 'report', text: '하수도 정비 기본계획 보고서' },
{ val: "basic", text: "기본 정보" },
{ val: "operation", text: "유지관리 정보" },
{ val: "report", text: "하수도 정비 기본계획 보고서" },
];
// 검색 API 호출 함수
@@ -80,47 +83,47 @@ export default function useSearchModal() {
setIsResetDetailSelection(true);
try {
if (currentTab === 'basic') {
if (currentTab === "basic") {
await fetchBasicFilteredData();
} else if (currentTab === 'operation') {
} else if (currentTab === "operation") {
const requestDto: Record<string, string> = {
page: `${curPage - 1}`,
size: `${OPERATION_TABLE_SIZE}`,
};
if (var1) requestDto.bizInfoNm = var1;
if (var2 !== '0' && var2) requestDto.bizTypeCd = var2;
if (var2 !== "0" && var2) requestDto.bizTypeCd = var2;
const startDate = getDateString(
yearFilter.start,
'',
'',
'19000101',
);
const startDate = getDateString(yearFilter.start, "", "", "19000101");
const endDate = getDateString(
yearFilter.end,
'',
'',
"",
"",
`${todayYear}${todayMonth}${todayDate}`,
);
requestDto.fromflfmtEndYmd = startDate;
requestDto.toflfmtEndYmd = endDate;
requestDto.workTypes = JSON.parse(workType.replace(/'/g, '"'));
const params = new URLSearchParams(requestDto).toString();
const res = await getData(`v1/bizInfo?${params}`);
const totalElements = Number(res.totalElements ?? 0);
const mappedContent = (res.content || []).map((item: any, index: number) => {
const formattedItem = formatDisplayData(item);
const matched = BIZ_TY_CD.find((code) => code.lwpoCd === item.bizTypeCd);
return {
...formattedItem,
rowNumber: (curPage - 1) * OPERATION_TABLE_SIZE + index + 1,
bizTypeCd: matched ? matched.lwpoCdNm : item.bizTypeCd,
};
});
const mappedContent = (res.content || []).map(
(item: any, index: number) => {
const formattedItem = formatDisplayData(item);
const matched = BIZ_TY_CD.find(
(code) => code.lwpoCd === item.bizTypeCd,
);
return {
...formattedItem,
rowNumber:
totalElements - ((curPage - 1) * OPERATION_TABLE_SIZE + index),
bizTypeCd: matched ? matched.lwpoCdNm : item.bizTypeCd,
};
},
);
setTableData(mappedContent || []);
setTableCount(res.totalElements || 0);
setTableCount(totalElements);
setDetailTableData([]);
setDetailTableCount(0);
}
@@ -128,7 +131,7 @@ export default function useSearchModal() {
setShowTable(true);
setIsResetSelection(false);
} catch (err) {
console.error('API ERROR:', err);
console.error("API ERROR:", err);
setTableData([]);
setIsResetSelection(false);
} finally {
@@ -148,9 +151,37 @@ export default function useSearchModal() {
setShowTable(true);
};
/** 현재 탭의 조건·페이지·선택을 기본값으로 복원하고 전체 기본 목록을 즉시 재조회한다. */
const handleResetSearch = () => {
const state = useSearchModalStore.getState();
const resetsBasicType =
state.activeTab === "basic" &&
state.basicType !== SEARCH_MODAL_DEFAULT_BASIC_TYPE;
const resetsWorkType =
state.activeTab === "operation" &&
state.workType !== SEARCH_MODAL_DEFAULT_WORK_TYPE;
state.resetFilters();
if (state.activeTab === "basic") {
state.setBasicType(SEARCH_MODAL_DEFAULT_BASIC_TYPE);
}
if (state.activeTab === "operation") {
state.setWorkType(SEARCH_MODAL_DEFAULT_WORK_TYPE);
}
state.setCurPage(1);
state.setDetailCurPage(1);
state.setIsResetPage(true);
state.setIsResetDetailPage(true);
state.setIsResetSelection(true);
state.setIsResetDetailSelection(true);
// 구분 변경 시 SearchModal의 자동조회 effect가 실행되므로 중복 요청을 만들지 않는다.
if (!resetsBasicType && !resetsWorkType) {
void fetchFilteredData();
}
};
// 여러 시설 보기
const viewMultipleFeature = async () => {
if (activeTab === 'basic') {
if (activeTab === "basic") {
if (!(tableData.length === 0)) {
if (isPopupWindow()) {
const row = tableData[0];
@@ -174,7 +205,7 @@ export default function useSearchModal() {
setIsLoading(false);
setIsSearchModalOpen(false);
}
} else if (activeTab === 'operation') {
} else if (activeTab === "operation") {
if (!(detailTableData.length === 0)) {
if (isPopupWindow()) {
const row = detailTableData[0];
@@ -201,5 +232,12 @@ export default function useSearchModal() {
}
};
return { TABS, getDateString, handleSearch, viewMultipleFeature, fetchFilteredData };
return {
TABS,
getDateString,
handleSearch,
handleResetSearch,
viewMultipleFeature,
fetchFilteredData,
};
}
@@ -1,6 +1,6 @@
/**
* ➡️ 경로 : 하수관망도 메인페이지 내 침수심 범례
*
*
* 📄 WaterDepth Component
* 침수심 범례 컴포넌트
*
@@ -16,8 +16,8 @@ import { Label } from "@common/components/ui/label";
import { useEffect } from "react";
export const WATER_DEPTH_LAYER_TYPES: string[] = [
'tb_ms_flood_dngr_map', // 홍수위험지도
'tb_ms_sbmrg_dngr_map' // 침수위험지도
"tb_ms_flood_dngr_map", // 홍수위험지도
"tb_ms_sbmrg_dngr_map", // 침수위험지도
];
export type WaterDepthLegendItem = {
@@ -29,7 +29,9 @@ export type WaterDepthLegendItem = {
};
const getLayerTypeName = (layerInfo: any) =>
String(layerInfo?.lyrTyNm ?? layerInfo?.lyrTypeNm ?? layerInfo?.lyr_type_nm ?? "").trim();
String(
layerInfo?.lyrTyNm ?? layerInfo?.lyrTypeNm ?? layerInfo?.lyr_type_nm ?? "",
).trim();
const getFiniteSortOrder = (value: unknown) => {
const parsed = Number(value);
@@ -65,20 +67,36 @@ const getLegendNumericOrder = (label: string) => {
};
const getLegendColor = (layerInfo: any) =>
String(layerInfo?.polgnColor ?? layerInfo?.spacColor ?? layerInfo?.lnColor ?? layerInfo?.lineColor ?? "").trim();
String(
layerInfo?.polgnColor ??
layerInfo?.spacColor ??
layerInfo?.lnColor ??
layerInfo?.lineColor ??
"",
).trim();
const getLegendBorderColor = (layerInfo: any) =>
String(layerInfo?.lnColor ?? layerInfo?.lineColor ?? layerInfo?.polgnColor ?? layerInfo?.spacColor ?? "").trim();
String(
layerInfo?.lnColor ??
layerInfo?.lineColor ??
layerInfo?.polgnColor ??
layerInfo?.spacColor ??
"",
).trim();
const hasLegendStyle = (layerInfo: any) => getLegendColor(layerInfo) !== "" || getLegendBorderColor(layerInfo) !== "";
const hasLegendStyle = (layerInfo: any) =>
getLegendColor(layerInfo) !== "" || getLegendBorderColor(layerInfo) !== "";
const normalizeLegendLabel = (label: string) => {
const normalized = String(label).trim();
return normalized.endsWith("미만") ? normalized : `${normalized} 미만`;
};
export const extractWaterDepthLegendItems = (checkedNodesData: any[]): WaterDepthLegendItem[] => {
if (!Array.isArray(checkedNodesData) || checkedNodesData.length === 0) return [];
export const extractWaterDepthLegendItems = (
checkedNodesData: any[],
): WaterDepthLegendItem[] => {
if (!Array.isArray(checkedNodesData) || checkedNodesData.length === 0)
return [];
const items = new Map<string, WaterDepthLegendItem>();
@@ -108,8 +126,10 @@ export const extractWaterDepthLegendItems = (checkedNodesData: any[]): WaterDept
const leftOrder = getLegendNumericOrder(left.key);
const rightOrder = getLegendNumericOrder(right.key);
if (leftOrder.start !== rightOrder.start) return leftOrder.start - rightOrder.start;
if (leftOrder.priority !== rightOrder.priority) return leftOrder.priority - rightOrder.priority;
if (leftOrder.start !== rightOrder.start)
return leftOrder.start - rightOrder.start;
if (leftOrder.priority !== rightOrder.priority)
return leftOrder.priority - rightOrder.priority;
if (leftOrder.end !== rightOrder.end) return leftOrder.end - rightOrder.end;
if (left.sortSeq !== right.sortSeq) return left.sortSeq - right.sortSeq;
return left.label.localeCompare(right.label, "ko");
@@ -133,7 +153,6 @@ export function WaterDepth({
currentStep = 0,
legendItems = [],
}: WaterDepthProps) {
useEffect(() => {
if (setIsPlaying) setIsPlaying(isPlaying);
}, [isPlaying, setIsPlaying]);
@@ -143,33 +162,50 @@ export function WaterDepth({
};
// 진행률 계산 (0-100%)
const progressPercentage = totalSteps > 0 ? (currentStep / totalSteps) * 100 : 100;
const progressPercentage =
totalSteps > 0 ? (currentStep / totalSteps) * 100 : 100;
return (
<>
<div className="bg-white rounded-lg shadow-lg border border-gray-200 w-[160px] px-4 py-3">
<div className="flex items-center justify-between mx-2 my-4">
<Label className="text-3xl text-gray-900 font-bold">침수심 범례</Label>
<button type="button" className="mt-1 ml-2" onClick={handlePlayToggle}>
<div className="w-[160px] rounded-lg border border-border bg-background px-4 py-3 shadow-lg">
<div className="mx-2 my-4 flex items-center justify-between">
<Label className="text-3xl font-bold text-foreground">
침수심 범례
</Label>
<button
type="button"
className="ml-2 mt-1"
onClick={handlePlayToggle}
>
{isPlaying ? (
<img src="/images/map/toolbar/pause_button.png" alt="pause" className="w-6 h-6 object-contain" />
<img
src="/images/map/toolbar/pause_button.png"
alt="pause"
className="h-6 w-6 object-contain"
/>
) : (
<img src="/images/map/toolbar/play_button_arrowhead.png" alt="play" className="w-6 h-6 object-contain" />
<img
src="/images/map/toolbar/play_button_arrowhead.png"
alt="play"
className="h-6 w-6 object-contain"
/>
)}
</button>
</div>
<div className="mb-3">
<div className="w-full bg-gray-200 rounded-full h-1">
<div className="h-1 w-full rounded-full bg-muted">
<div
className="bg-blue-900 h-1 rounded-full transition-all duration-300 ease-in-out"
className="h-1 rounded-full bg-blue-900 transition-all duration-300 ease-in-out"
style={{ width: `${progressPercentage}%` }}
/>
</div>
</div>
<div className="w-[140px] mx-auto flex flex-col items-center">
<div className="bg-blue-900 text-white text-2xl text-center py-1 mb-2 rounded w-full">침수심 범례</div>
<div className="space-y-1 w-full">
<div className="mx-auto flex w-[140px] flex-col items-center">
{/* 침수심 범례 제목은 지도 수심정보를 구분하는 의미색이므로 고정 청색을 유지한다. */}
<div className="mb-2 w-full rounded bg-blue-900 py-1 text-center text-2xl text-white">
침수심 범례
</div>
<div className="w-full space-y-1">
{legendItems.map((item) => (
<LegendItem
key={item.key}
@@ -179,21 +215,29 @@ export function WaterDepth({
/>
))}
</div>
<div className="border-t-2 border-blue-900 mt-1 mx-auto w-full" />
<div className="mx-auto mt-1 w-full border-t-2 border-blue-900" />
</div>
</div>
</>
);
}
function LegendItem({ color, borderColor, label }: { color: string; borderColor: string; label: string }) {
function LegendItem({
color,
borderColor,
label,
}: {
color: string;
borderColor: string;
label: string;
}) {
return (
<div className="flex items-center gap-2 w-full">
<div className="flex w-full items-center gap-2">
<span
className="inline-block w-16 h-6"
className="inline-block h-6 w-16"
style={{ backgroundColor: color, border: `1px solid ${borderColor}` }}
/>
<span className="text-xl text-gray-900 font-bold">{label}</span>
<span className="text-xl font-bold text-foreground">{label}</span>
</div>
);
}
+1 -3
View File
@@ -4,8 +4,6 @@ export default function PopupLayout({
children: React.ReactNode;
}) {
return (
<main className="min-h-dvh bg-white text-foreground">
{children}
</main>
<main className="min-h-dvh bg-background text-foreground">{children}</main>
);
}
@@ -1,5 +1,5 @@
import { isWindowPopupKey } from "@/features/windowPopup/model/popupDefinitions";
import { windowPopupRegistry } from "@/features/windowPopup/registry";
import WindowPopupRenderer from "@/features/windowPopup/ui/WindowPopupRenderer";
type PopupPageProps = {
params: Promise<{
@@ -19,12 +19,15 @@ export default async function PopupPage({
return (
<section className="flex min-h-dvh flex-col items-center justify-center gap-4 p-8 text-center">
<h1 className="text-2xl font-semibold">등록되지 않은 팝업입니다.</h1>
<p className="text-base text-gray-600">popupKey: {popupKey}</p>
<p className="text-base text-label">popupKey: {popupKey}</p>
</section>
);
}
const PopupComponent = windowPopupRegistry[popupKey].component;
return <PopupComponent searchParams={resolvedSearchParams} />;
return (
<WindowPopupRenderer
popupKey={popupKey}
searchParams={resolvedSearchParams}
/>
);
}
+159 -118
View File
@@ -1,6 +1,6 @@
/**
* ➡️ 경로 : I/I 분석 모든 페이지
*
*
* 📄 Base Layout
* 분석 페이지 기본 레이아웃 컴포넌트
*
@@ -11,58 +11,57 @@
* - 양식 파일 다운로드 링크 제공
* - 폼 제출 및 이벤트 핸들링
*/
'use client'
import { DateMonthPicker, Form, SelectBox } from '@common/business_components/ui'
import ListDiv from '@common/business_components/ui/list_div'
import { Button, Label } from '@common/components/ui'
import { useEffect, type ReactNode } from 'react'
import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox'
import { confirmToast } from '@/components/ui/toast'
import { toast, Toaster } from "react-hot-toast";
"use client";
import { DateMonthPicker, Form } from "@common/business_components/ui";
import { Button, Label } from "@common/components/ui";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import type { ReactNode } from "react";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
type TitleName = {
mainTitle: string
firstCardTitle: string
secondCardTitle?: string
}
mainTitle: string;
firstCardTitle: string;
secondCardTitle?: string;
};
type SelectBox = {
labelNm?: Label
searchChk?: boolean
labelNm?: Label;
searchChk?: boolean;
title?: string;
onSearchClick?: () => Promise<void>;
}
onResetClick?: () => void | Promise<void>;
};
type Label = {
first: string
second: string
third: string
fourth: string
}
first: string;
second: string;
third: string;
fourth: string;
};
// API로부터 받아올 데이터 타입을 정의 (SelectBox에 사용될 형태)
interface ApiSelectOption {
text: string;
val: string;
}
type BaseLayout = {
children?: ReactNode[]
titleName: TitleName
form: any
onSubmitEvent: Function
graphBtn?: boolean
analysisBtn?: string
resultViewBtn?: string
firstDivBtn?: boolean
selectBox?: SelectBox
selectionComponent?: ReactNode
children?: ReactNode[];
titleName: TitleName;
form: any;
onSubmitEvent: Function;
graphBtn?: boolean;
analysisBtn?: string;
resultViewBtn?: string;
firstDivBtn?: boolean;
selectBox?: SelectBox;
selectionComponent?: ReactNode;
showDatePicker?: boolean;
ctpvCd: string;
sggCd: string;
sggName?: string;
sigungu?: ApiSelectOption
sigungu?: ApiSelectOption;
selectedSwagePrcsFclt?: string;
onSwagePrcsFcltChange?: (value: string) => void;
aplcnYmFrom?: string
aplcnYmFrom?: string;
setAplcnYmFrom?: (aplcnYmFrom: string) => void;
aplcnYmTo?: string
aplcnYmTo?: string;
setAplcnYmTo?: (aplcnYmTo: string) => void;
setCtpvCd: (ctpv: string) => void;
setSggCd: (sgg: string) => void;
@@ -72,12 +71,12 @@ type BaseLayout = {
setIsLoading?: (isLoading: boolean) => void;
onExcelDownload?: () => void;
onDeleteFile?: () => void;
onAnalysis?: () => void;
onAnalysis?: () => void | Promise<void>;
onResultView?: () => void;
selectedRowData?: any;
isIr?: boolean;
isRdii?: boolean;
}
};
const BaseLayout = ({
children,
@@ -113,14 +112,8 @@ const BaseLayout = ({
isIr = false,
isRdii = false,
}: BaseLayout) => {
// 컴포넌트 언마운트 시 confirmToast 정리
useEffect(() => {
return () => {
// 컴포넌트가 언마운트될 때 global-confirm-toast ID를 가진 Toast 제거
toast.dismiss("global-confirm-toast");
};
}, []);
// 분석 실행 전 CS와 동일한 전역 확인 대화상자를 호출한다.
const { confirm } = useConfirm();
// 파일 다운로드
const isChckFile = () => {
@@ -129,8 +122,8 @@ const BaseLayout = ({
onExcelDownload();
return;
}
return true
}
return true;
};
// 파일 삭제
const isDeleteFile = async () => {
// Excel 다운로드 함수가 있으면 Excel 다운로드 실행
@@ -138,64 +131,75 @@ const BaseLayout = ({
onDeleteFile();
return;
}
}
};
const handleConfirm = () => {
// 기존 confirmToast가 있다면 먼저 제거
toast.dismiss("global-confirm-toast");
confirmToast(
const handleConfirm = async () => {
const shouldRunAnalysis = await confirm(
"분석에는 2분 내외의 시간이 소요될 수 있습니다.\n분석을 진행하시겠습니까?",
onAnalysis ?? (() => { }),
"text-xl font-medium text-gray-800"
);
if (!shouldRunAnalysis) return;
await onAnalysis?.();
};
return (
<>
<Form {...form} className="min-h-screen flex flex-col">
<ListDiv>
<Form {...form} className="flex min-h-screen flex-col">
<section className="card card-border">
{/* searchBox.title이 있으면 h3 태그로 제목을 표시합니다. */}
{selectBox?.title && (
<div className="card-header">
<h3 className="text-2xl font-medium">{selectBox.title}</h3>
<h3 className="text-2xl font-medium text-label">
{selectBox.title}
</h3>
</div>
)}
<div className="ml-2 mt-2 flex items-center w-full">
<div className="flex w-full flex-wrap items-center gap-3 p-[1.2rem]">
{showDatePicker && ( // showDatePicker 값에 따라 DatePicker 렌더링
<>
<Label className="mr-4 w-20 text-xl font-medium text-gray-600 whitespace-nowrap">상수사용기간</Label>
<Label className="whitespace-nowrap text-label">
상수사용기간
</Label>
<DateMonthPicker
field={{
value: aplcnYmFrom || (() => { // aplcnYm이 없으면 현재 연월을 기본값으로 설정
const now = new Date();
const year = now.getFullYear();
// const month = String(now.getMonth() + 1).padStart(2, '0');
// return `${year}${month}`;
return `${year}01`;
})(), // yyyyMM 형식
value:
aplcnYmFrom ||
(() => {
// aplcnYm이 없으면 현재 연월을 기본값으로 설정
const now = new Date();
const year = now.getFullYear();
// const month = String(now.getMonth() + 1).padStart(2, '0');
// return `${year}${month}`;
return `${year}01`;
})(), // yyyyMM 형식
onChange: (value: string) => {
setAplcnYmFrom?.(value); // string 값을 직접 전달
}
},
}}
selectClass="ml-8 w-[100px]"
startYear={1900} // 시작 연도 설정
selectClass="w-[10rem]"
startYear={1900} // 시작 연도 설정
/>
<span className="font-semibold text-xl mx-4">~</span>
<span className="text-muted-foreground">~</span>
<DateMonthPicker
field={{
value: aplcnYmTo || (() => { // aplcnYm이 없으면 현재 연월을 기본값으로 설정
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
return `${year}${month}`;
})(), // yyyyMM 형식
value:
aplcnYmTo ||
(() => {
// aplcnYm이 없으면 현재 연월을 기본값으로 설정
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(
2,
"0",
);
return `${year}${month}`;
})(), // yyyyMM 형식
onChange: (value: string) => {
setAplcnYmTo?.(value); // string 값을 직접 전달
}
},
}}
selectClass="w-[100px] mr-8"
startYear={1900} // 시작 연도 설정
selectClass="w-[10rem]"
startYear={1900} // 시작 연도 설정
/>
</>
)}
@@ -207,7 +211,7 @@ const BaseLayout = ({
sggName={sggName}
onSidoChange={(sidoId: string) => {
setCtpvCd(sidoId);
setSggCd('0');
setSggCd("0");
}}
onSigunguChange={setSggCd}
setSggName={setSggName}
@@ -219,86 +223,123 @@ const BaseLayout = ({
/>
)}
{selectBox?.searchChk && (
<div className='flex flex-1 justify-end mr-6'>
<Button size="sm" onClick={() => selectBox.onSearchClick?.()}>조회</Button>
<div className="ml-auto flex items-center gap-2">
<Button
type="button"
onClick={() => selectBox.onSearchClick?.()}
>
조회
</Button>
{selectBox.onResetClick ? (
<Button
type="button"
color="white"
onClick={() => selectBox.onResetClick?.()}
>
초기화
</Button>
) : null}
</div>
)}
</div>
</ListDiv>
<ListDiv>
</section>
<section className="card card-border">
<div className="card-header">
<h3 className="text-2xl font-medium">{titleName.firstCardTitle}</h3>
<div>
<h3 className="text-2xl font-medium text-label">
{titleName.firstCardTitle}
</h3>
<div className="flex flex-wrap items-center gap-2">
{graphBtn && (
<Button className="px-4 py-2 !mr-[1.2rem] bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700" size="sm" onClick={isChckFile}>파일 다운로드</Button>
)}
{/* 그래프 있을 시 */}
{graphBtn && (
<Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700 !mr-[1.2rem]" size="sm" onClick={isDeleteFile}>삭제</Button>
<Button type="button" onClick={isChckFile}>
엑셀다운로드
</Button>
)}
{/* 침입수 분석 */}
{isIr && (
<Button
size="sm"
className="mx-4 py-2 text-white rounded-sm text-lg font-medium green"
>
<a href="/samples/ir_setting_sample.zip" download="침입수 분석 업로드 파일 양식.zip">양식 받기</a>
<Button type="button">
<a
href="/samples/ir_setting_sample.zip"
download="침입수 분석 업로드 파일 양식.zip"
>
양식다운로드
</a>
</Button>
)}
{/* RDII 분석 */}
{isRdii && (
<Button
size="sm"
className="mx-4 py-2 text-white rounded-sm text-lg font-medium green"
>
<a href="/samples/rdii_sample.zip" download="RDII 분석 업로드 파일 양식.zip">양식 받기</a>
<Button type="button">
<a
href="/samples/rdii_sample.zip"
download="RDII 분석 업로드 파일 양식.zip"
>
양식다운로드
</a>
</Button>
)}
{/* 분석 버튼 */}
{analysisBtn && (
<Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700 !mr-[1.2rem]" size="sm" onClick={handleConfirm}>{analysisBtn}</Button>
<Button type="button" onClick={handleConfirm}>
{analysisBtn}
</Button>
)}
{resultViewBtn && (
<Button
className="px-4 py-2 bg-slate-600 text-white rounded-sm text-sm font-medium hover:bg-slate-700 !mr-[1.2rem]"
size="sm"
type="button"
color="white"
onClick={onResultView}
>
{resultViewBtn}
</Button>
)}
{graphBtn && (
<Button
type="button"
color="red"
onClick={isDeleteFile}
>
삭제
</Button>
)}
</div>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmitEvent)} className="flex-grow flex flex-col">
<div className="card !m-[1.2rem] flex-grow flex flex-col">
<form
onSubmit={form.handleSubmit(onSubmitEvent)}
className="flex flex-grow flex-col"
>
<div className="min-w-0 flex-grow p-[1.2rem]">
{/* 폼 정보 입력 칸 */}
{children?.[0]}
</div>
</form>
</Form>
</ListDiv>
</section>
{children?.[1] != null && (
<ListDiv>
<section className="card card-border">
<div className="card-header flex items-center">
<h3 className="text-2xl font-medium">{titleName?.secondCardTitle}</h3>
<div className='flex flex-1 justify-end mr-8'>
<h3 className="text-2xl font-medium text-label">
{titleName?.secondCardTitle}
</h3>
<div className="ml-auto flex items-center gap-2">
{graphBtn && (
<Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700 !mr-[1.2rem]" size="sm">파일 다운로드</Button>
<Button type="button">
엑셀다운로드
</Button>
)}
{/* 그래프 있을 시 */}
{graphBtn && (
<Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700 !mr-[1.2rem]" size="sm">삭제</Button>
<Button type="button" color="red">
삭제
</Button>
)}
</div>
</div>
<div className="card !m-[1.2rem]">{children?.[1]}</div>
</ListDiv>
<div className="p-[1.2rem]">{children?.[1]}</div>
</section>
)}
</Form>
<Toaster position="top-center"></Toaster>
</>
)
}
);
};
export default BaseLayout
export default BaseLayout;

Some files were not shown because too many files have changed in this diff Show More