Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be14fe2603 | ||
|
|
d7f101ee30 | ||
|
|
35b9f813c4 | ||
|
|
aafb326662 | ||
|
|
aa1b309b26 | ||
|
|
bf0aef59cd | ||
|
|
b2ddcd0cf3 | ||
|
|
49ace44c9b | ||
|
|
91576f401b | ||
|
|
febcd18eda | ||
|
|
8cc9f0d291 | ||
|
|
a8f2c8f41a | ||
|
|
4d685d5a67 | ||
|
|
96196229f4 | ||
|
|
94b2e84c10 | ||
|
|
80d2230c9a | ||
|
|
d5f0c3bc95 | ||
|
|
b7134cb3e4 | ||
|
|
ec78658e46 | ||
|
|
44361af392 | ||
|
|
76130caa6d | ||
|
|
352a78a0c5 | ||
|
|
65bee9586d | ||
|
|
31cda8579a | ||
|
|
1dc40b9efc | ||
|
|
6cb3d8ad65 | ||
|
|
225e2732d1 | ||
|
|
d82ae99b6a | ||
|
|
23341b93c7 | ||
|
|
b99f4904b0 | ||
|
|
f742feca4b | ||
|
|
569e003827 | ||
|
|
2db5dd59af | ||
|
|
8a8504dca9 | ||
|
|
7e82c6a802 | ||
|
|
bdc6251474 | ||
|
|
479cf5c4a0 | ||
|
|
2b3502161c | ||
|
|
0544a72011 | ||
|
|
34ba09ce1b | ||
|
|
aa5a71ae68 | ||
|
|
9067e645b3 | ||
|
|
10d3fc096a | ||
|
|
7fd95ba6f9 | ||
|
|
e707637c88 | ||
|
|
5da89e8ee7 | ||
|
|
f9ff279895 | ||
|
|
4cde9b67c1 | ||
|
|
80d93c1631 | ||
|
|
872b3d08c0 | ||
|
|
cb191817a8 | ||
|
|
8198fda45b | ||
|
|
37374cb754 | ||
|
|
9e36edec7f | ||
|
|
3204bba71b | ||
|
|
3cdcf6a6f7 | ||
|
|
922861c872 | ||
|
|
4a3945f1e4 | ||
|
|
a1caa1b860 | ||
|
|
166dea2b86 | ||
|
|
3f7262e213 | ||
|
|
231ee10ff8 | ||
|
|
5aa9ab4f39 | ||
|
|
8c46f7ca06 | ||
|
|
23e15daa37 | ||
|
|
dd56ed60ad | ||
|
|
18129905d8 | ||
|
|
d03263a3c6 | ||
|
|
be46bcbd17 | ||
|
|
201dbf577f | ||
|
|
884dd71ca7 | ||
|
|
70efdc9db5 | ||
|
|
36549e7281 | ||
|
|
c1ffc33834 | ||
|
|
2c3961644c | ||
|
|
2fe3631a0d | ||
|
|
e0cdba00be | ||
|
|
5e206dd210 | ||
|
|
2008a5a9ee | ||
|
|
53aeeccc2a | ||
|
|
c7738a6d6c | ||
|
|
baf29df950 | ||
|
|
0a13f34092 | ||
|
|
0b45361135 | ||
|
|
83420c76b8 | ||
|
|
42f83bcd24 | ||
|
|
ccfbe6ae32 | ||
|
|
8126fb844a | ||
|
|
23c9ddf4ce | ||
|
|
63388ccc90 | ||
|
|
bda3228d0f | ||
|
|
95ca40913f | ||
|
|
8bfe1c9ab2 | ||
|
|
0dc981819f | ||
|
|
4b7ada9362 | ||
|
|
71c5dfb8dd | ||
|
|
7e3911dbd6 | ||
|
|
839d31b9ca | ||
|
|
386fdade59 | ||
|
|
0cbbc3b934 | ||
|
|
f5051225ea | ||
|
|
f7218fc98a | ||
|
|
c9e3ff0d91 | ||
|
|
474ad1ab26 | ||
|
|
099e000f88 | ||
|
|
7fa4538667 | ||
|
|
91e11a7857 | ||
|
|
d582293e5f | ||
|
|
d8c5344451 | ||
|
|
d23a16905d | ||
|
|
2649ce286b | ||
|
|
2f9e97b65b | ||
|
|
e1940043e9 | ||
|
|
7f1c804a0c | ||
|
|
d07bb6074d | ||
|
|
4109691eea | ||
|
|
4eaac5dbf1 | ||
|
|
b99fe8daf1 | ||
|
|
3f4e3581dd | ||
|
|
f00627270f | ||
|
|
1858739bb9 | ||
|
|
3d320d0e19 | ||
|
|
9d2c0afb98 | ||
|
|
29f2a9169e | ||
|
|
493acc9ebd | ||
|
|
d3bdc58994 | ||
|
|
20efccb4d9 | ||
|
|
f58daf3bac | ||
|
|
b42810d29a | ||
|
|
2e6a75141b | ||
|
|
ce60e5f5fb | ||
|
|
c9d85a8ce4 | ||
|
|
0f8b6cd6d0 | ||
|
|
b11658e1d6 | ||
|
|
bcf5d3caec | ||
|
|
474676af17 | ||
|
|
3cc0e9de14 | ||
|
|
ff7be74437 | ||
|
|
f52f22e80f | ||
|
|
86e00d48dc | ||
|
|
bf61f6f76a | ||
|
|
62c41297c4 | ||
|
|
09b5cc3798 | ||
|
|
155a727a77 | ||
|
|
a52dacf500 | ||
|
|
e8e38310ee | ||
|
|
2652e2cfe0 | ||
|
|
5a76ae8a88 | ||
|
|
7d9b97aac9 | ||
|
|
69c3183e35 | ||
|
|
5e6477cddd | ||
|
|
c13cab394a | ||
|
|
73f1940cb0 | ||
|
|
8a35d232ef | ||
|
|
728981cfe6 | ||
|
|
11cb2b9044 | ||
|
|
c1d6cd7b8e | ||
|
|
477426c384 | ||
|
|
ec60a05b01 | ||
|
|
90f0e4691b | ||
|
|
5e0693e057 | ||
|
|
1708555133 | ||
|
|
c38d95fd60 | ||
|
|
667f7b2094 | ||
|
|
d17909df41 |
@@ -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
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -1,121 +1,90 @@
|
||||
"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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -185,7 +185,9 @@ export default function DemandStatusPage() {
|
||||
const userData = JSON.parse(user) as SessionUser;
|
||||
const dmdInfoId = userData.dmdInfoId?.trim();
|
||||
if (dmdInfoId) {
|
||||
getSdmInfoStatusList(dmdInfoId);
|
||||
void getSdmInfoStatusList(dmdInfoId).then((active) => {
|
||||
if (!active) window.location.href = "/";
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing user data:", error);
|
||||
@@ -341,7 +343,9 @@ export default function DemandStatusPage() {
|
||||
if (res) {
|
||||
const user = JSON.parse(sessionStorage.getItem("user") || "{}");
|
||||
if (user?.dmdInfoId) {
|
||||
getSdmInfoStatusList(user.dmdInfoId);
|
||||
void getSdmInfoStatusList(user.dmdInfoId).then((active) => {
|
||||
if (!active) window.location.href = "/";
|
||||
});
|
||||
}
|
||||
setIsEditMode(false);
|
||||
}
|
||||
|
||||
@@ -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 }} />;
|
||||
}
|
||||
|
||||
@@ -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,107 +49,71 @@ 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,64 +146,54 @@ 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='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>
|
||||
))}
|
||||
@@ -276,32 +206,39 @@ export default function RainStatusSection() {
|
||||
</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>
|
||||
))}
|
||||
|
||||
@@ -1,70 +1,64 @@
|
||||
/**
|
||||
* 하수처리수
|
||||
*/
|
||||
"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 />
|
||||
지역 경제와 환경 보호를 동시에 실현하고 있습니다.
|
||||
</>
|
||||
),
|
||||
@@ -72,45 +66,32 @@ function SewageIntroSummary() {
|
||||
];
|
||||
|
||||
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,21 +108,14 @@ 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);
|
||||
@@ -168,11 +135,7 @@ 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(() => {
|
||||
@@ -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,54 +180,36 @@ 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
|
||||
|
||||
@@ -282,9 +224,9 @@ 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);
|
||||
@@ -292,92 +234,67 @@ export default function SewageStatusSection() {
|
||||
<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 />
|
||||
|
||||
<div id="status-treated-year" className="scroll-mt-[120px]">
|
||||
<div id='status-treated-year' className='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 />
|
||||
@@ -387,10 +304,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)}
|
||||
@@ -398,9 +312,7 @@ export default function SewageStatusSection() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{pbnstByLawYears.map((row, idx, arr) => (
|
||||
<tr key={idx}>
|
||||
{renderCells(idx, row, arr, pbnstByLawYearsHeader, 1000)}
|
||||
</tr>
|
||||
<tr key={idx}>{renderCells(idx, row, arr, pbnstByLawYearsHeader, 1000)}</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -411,20 +323,18 @@ 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}>
|
||||
<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,15 +344,13 @@ 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>
|
||||
))}
|
||||
@@ -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,52 +427,51 @@ 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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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,28 +47,24 @@ 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">
|
||||
<section className='mt-10'>
|
||||
<SectionTitle>연도별 하수처리수 재이용 현황</SectionTitle>
|
||||
<div className="mt-10">
|
||||
<div className='mt-10'>
|
||||
<div className={tableStyles.container}>
|
||||
<table className={tableStyles.table}>
|
||||
<colgroup>
|
||||
@@ -87,7 +79,9 @@ export default function TreatedStatusPage() {
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th colSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`}>구분</th>
|
||||
<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>
|
||||
@@ -99,41 +93,38 @@ export default function TreatedStatusPage() {
|
||||
<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>
|
||||
)}
|
||||
@@ -143,7 +134,7 @@ export default function TreatedStatusPage() {
|
||||
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 }}
|
||||
/>
|
||||
);
|
||||
@@ -159,12 +150,12 @@ export default function TreatedStatusPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Hr className="my-11" />
|
||||
<Hr className='my-11' />
|
||||
|
||||
{/* 2) 용도별 하수처리수 재이용 현황 */}
|
||||
<section>
|
||||
<SectionTitle>용도별 하수처리수 재이용 현황</SectionTitle>
|
||||
<div className="mt-8">
|
||||
<div className='mt-8'>
|
||||
<div className={tableStyles.container}>
|
||||
<table className={tableStyles.table}>
|
||||
<colgroup>
|
||||
@@ -189,43 +180,45 @@ export default function TreatedStatusPage() {
|
||||
</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-[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>
|
||||
<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>
|
||||
<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>
|
||||
))}
|
||||
@@ -239,12 +232,12 @@ export default function TreatedStatusPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Hr className="my-11" />
|
||||
<Hr className='my-11' />
|
||||
|
||||
{/* 3) 하수처리시설 설치 및 이용 현황 */}
|
||||
<section>
|
||||
<SectionTitle>하수처리시설 설치 및 이용 현황</SectionTitle>
|
||||
<div className="mt-8">
|
||||
<div className='mt-8'>
|
||||
<div className={tableStyles.container}>
|
||||
<table className={tableStyles.table}>
|
||||
<colgroup>
|
||||
@@ -259,7 +252,9 @@ export default function TreatedStatusPage() {
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th colSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`}>구분</th>
|
||||
<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>
|
||||
@@ -271,38 +266,35 @@ export default function TreatedStatusPage() {
|
||||
<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>
|
||||
)}
|
||||
@@ -312,7 +304,7 @@ export default function TreatedStatusPage() {
|
||||
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 }}
|
||||
/>
|
||||
);
|
||||
@@ -328,14 +320,14 @@ export default function TreatedStatusPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Hr className="my-11" />
|
||||
<Hr className='my-11' />
|
||||
|
||||
{/* 4) 시도별 하수처리수 재이용 현황 */}
|
||||
<section className="pb-[200px]">
|
||||
<section className='pb-[200px]'>
|
||||
<SectionTitle>시도별 하수처리수 재이용 현황</SectionTitle>
|
||||
<div className="mt-8">
|
||||
<div className='mt-8'>
|
||||
<div className={tableStyles.container}>
|
||||
<div className="text-right text-[13px] mb-2 text-neutral-600">(단위: 백만m³/일)</div>
|
||||
<div className='text-right text-[13px] mb-2 text-neutral-600'>(단위: 백만m³/년)</div>
|
||||
<table className={tableStyles.table}>
|
||||
<colgroup>
|
||||
<col style={{ width: '160px' }} />
|
||||
@@ -352,16 +344,38 @@ export default function TreatedStatusPage() {
|
||||
</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>
|
||||
<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>
|
||||
<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로 적용 */}
|
||||
@@ -374,32 +388,29 @@ export default function TreatedStatusPage() {
|
||||
</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'>
|
||||
{/* 💡 연간하수처리량(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]" : ""}`}
|
||||
>
|
||||
<td key={i} className={`${tableStyles.td} ${i === 0 ? '!font-bold !text-neutral-900' : ''} ${[1, 3, 9].includes(i) ? 'bg-[#EAF5F5]' : ''}`}>
|
||||
{val}
|
||||
</td>
|
||||
))}
|
||||
@@ -411,7 +422,6 @@ export default function TreatedStatusPage() {
|
||||
<SourceText />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -211,18 +211,32 @@ const selectMiddleRegionData: SelectDataType[] = [
|
||||
* STORE - API
|
||||
*/
|
||||
|
||||
/** API 선택 목록의 빈 값과 중복 값을 제거하고 전체 항목을 한 번만 추가한다. */
|
||||
function normalizeSelectData<T extends SelectDataType>(items: T[], allItem: T): T[] {
|
||||
const uniqueItems = new Map<string, T>();
|
||||
|
||||
items.forEach((item) => {
|
||||
const val = String(item.val ?? '').trim();
|
||||
|
||||
if (!val || val === '0' || uniqueItems.has(val)) return;
|
||||
uniqueItems.set(val, { ...item, val });
|
||||
});
|
||||
|
||||
return [allItem, ...uniqueItems.values()];
|
||||
}
|
||||
|
||||
// 시도 API 호출 함수 추가
|
||||
const fetchSidoData = async (): Promise<SelectDataType[]> => {
|
||||
try {
|
||||
const res = await getFetch(fetchType.FO, '/addr/city');
|
||||
const data = res.data;
|
||||
selectSidoData = [
|
||||
{ text: "전체", val: '0'},
|
||||
...data.map((item: sidoDataType) => ({
|
||||
selectSidoData = normalizeSelectData(
|
||||
data.map((item: sidoDataType) => ({
|
||||
text: item.sido || '',
|
||||
val: String(item.adstDstrCd) || ''
|
||||
}))
|
||||
];
|
||||
})),
|
||||
{ text: "전체", val: '0' }
|
||||
);
|
||||
return selectSidoData;
|
||||
} catch (error) {
|
||||
console.error('시도 데이터를 가져오는데 실패했습니다:', error);
|
||||
@@ -235,14 +249,13 @@ const fetchSigunData = async (): Promise<SelectDataType[]> => {
|
||||
try {
|
||||
const res = await getFetch(fetchType.FO, '/addr/sgg');
|
||||
const data = res.data;
|
||||
selectSigunData = [
|
||||
{ text: "전체", val: '0' },
|
||||
...data
|
||||
.map((item: sigunDataType) => ({
|
||||
selectSigunData = normalizeSelectData(
|
||||
data.map((item: sigunDataType) => ({
|
||||
text: item.gugun || '',
|
||||
val: String(item.adstDstrCd) || ''
|
||||
}))
|
||||
];
|
||||
})),
|
||||
{ text: "전체", val: '0' }
|
||||
);
|
||||
return selectSigunData;
|
||||
} catch (error) {
|
||||
console.error('시군구 데이터를 가져오는데 실패했습니다:', error);
|
||||
@@ -255,13 +268,13 @@ const fetchRegionData = async (): Promise<SelectDataType[]> => {
|
||||
try {
|
||||
const res = await getFetch(fetchType.FO, '/addr/sarea');
|
||||
const data = res.data;
|
||||
return [
|
||||
{ text: "전체", val: '0'},
|
||||
...data.map((item: regionDataType) => ({
|
||||
return normalizeSelectData(
|
||||
data.map((item: regionDataType) => ({
|
||||
text: item.rvbsn || '',
|
||||
val: String(item.rvbsnCd) || ''
|
||||
}))
|
||||
];
|
||||
})),
|
||||
{ text: "전체", val: '0' }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('권역 데이터를 가져오는데 실패했습니다:', error);
|
||||
return selectRegionData;
|
||||
@@ -273,14 +286,13 @@ const fetchMiddleRegionData = async (): Promise<SelectDataType[]> => {
|
||||
try {
|
||||
const res = await getFetch(fetchType.FO, '/addr/midArea');
|
||||
const data = res.data;
|
||||
return [
|
||||
{ text: "전체", val: '0' },
|
||||
...data
|
||||
.map((item: middleRegionDataType) => ({
|
||||
return normalizeSelectData(
|
||||
data.map((item: middleRegionDataType) => ({
|
||||
text: item.mdmbsn || '',
|
||||
val: String(item.rvbsnCd) || ''
|
||||
}))
|
||||
];
|
||||
})),
|
||||
{ text: "전체", val: '0' }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('중권역 데이터를 가져오는데 실패했습니다:', error);
|
||||
return selectMiddleRegionData;
|
||||
@@ -292,14 +304,13 @@ const fetchUnitWaterShedData = async (): Promise<SelectDataType[]> => {
|
||||
try {
|
||||
const res = await getFetch(fetchType.FO, '/addr/largeArea');
|
||||
const data = res.data;
|
||||
return [
|
||||
{ text: "전체", val: '0' },
|
||||
...data
|
||||
.map((item: unitWaterShedDataType) => ({
|
||||
return normalizeSelectData(
|
||||
data.map((item: unitWaterShedDataType) => ({
|
||||
text: item.lgbsn || '',
|
||||
val: String(item.rvbsnCd) || ''
|
||||
}))
|
||||
];
|
||||
})),
|
||||
{ text: "전체", val: '0' }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('단위유역 데이터를 가져오는데 실패했습니다:', error);
|
||||
return selectUnitWaterShedData;
|
||||
@@ -311,15 +322,14 @@ const fetchEnvPartData = async (): Promise<envPartSelectDataType[]> => {
|
||||
try {
|
||||
const res = await getFetch(fetchType.FO, '/addr/govArea');
|
||||
const data = res.data;
|
||||
selectEnvPartData = [
|
||||
{ text: "전체", val: '0', adstDliStrCd: '' },
|
||||
...data
|
||||
.map((item: envPartDataType) => ({
|
||||
selectEnvPartData = normalizeSelectData(
|
||||
data.map((item: envPartDataType) => ({
|
||||
text: item.lclgvrnmntNm || '',
|
||||
val: String(item.lclgvrnmntCd) || '',
|
||||
adstDliStrCd: item.adstDliStrCd || ''
|
||||
}))
|
||||
];
|
||||
})),
|
||||
{ text: "전체", val: '0', adstDliStrCd: '' }
|
||||
);
|
||||
return selectEnvPartData;
|
||||
} catch (error) {
|
||||
console.error('환경청 데이터를 가져오는데 실패했습니다:', error);
|
||||
|
||||
@@ -134,22 +134,23 @@ export type SupplyCandidate = {
|
||||
|
||||
|
||||
export type DemandMatchings = {
|
||||
splyFcltFk: string | '';
|
||||
splyFcltFlg: string | '';
|
||||
pbnstRuseFcltBizFk: number | '';
|
||||
splyBgngDay: string | '';
|
||||
splyEndDay: string | '';
|
||||
splyCpct: number | '';
|
||||
splyFmYn: string | '';
|
||||
useUsgCd: string | '';
|
||||
dmdInfoSn: number | '';
|
||||
splyCndtList: string | '';
|
||||
mtchnCmptnYmd: string | '';
|
||||
rvwCmptnYmd: string | '';
|
||||
regDt: string | '';
|
||||
rgtr: string | '';
|
||||
mdfcnDt: string | '';
|
||||
mdfr: string | '';
|
||||
splyFcltFk: string | ''; // 공급시설 외래키
|
||||
splyFcltFlg: string | ''; // 공급시설 진행상태
|
||||
pbnstRuseFcltBizFk: number | ''; // 공공하수 재이용시설 사업 외래키
|
||||
splyBgngDay: string | ''; // 공급시작일
|
||||
splyEndDay: string | ''; // 공급종료일
|
||||
splyCpct: number | ''; // 계약 공급량
|
||||
splyFmYn: string | ''; // 공급 유량계 유무
|
||||
useUsgCd: string | ''; // 사용용도 코드
|
||||
dmdInfoSn: number | ''; // 수요정보 일련번호
|
||||
ruseUtztnFcltNm?: string | ''; // 재이용시설명
|
||||
splyCndtList: string | ''; // 공급 후보지 목록
|
||||
mtchnCmptnYmd: string | ''; // 매칭 완료일자
|
||||
rvwCmptnYmd: string | ''; // 검토 완료일자
|
||||
regDt: string | ''; // 등록일시
|
||||
rgtr: string | ''; // 등록자
|
||||
mdfcnDt: string | ''; // 수정일시
|
||||
mdfr: string | ''; // 수정자
|
||||
}
|
||||
|
||||
// 유사사례
|
||||
@@ -503,6 +504,14 @@ const postSdmData = async (data: DemandCompType) => {
|
||||
// 수요정보 등록
|
||||
const postSdmInfoData = async (data: SdmInfoFormData): Promise<boolean> => {
|
||||
try {
|
||||
const activeDemand = data.dmdInfoId
|
||||
? await getSdmInfoStatusList(data.dmdInfoId)
|
||||
: null;
|
||||
if (!activeDemand?.dmdInfoId) {
|
||||
sessionStorage.removeItem("user");
|
||||
alert('사용 중지되었거나 등록되지 않은 수요처입니다. 다시 로그인해 주세요.');
|
||||
return false;
|
||||
}
|
||||
await postFetch("/reuseFc/request/demandInfo", data);
|
||||
alert('수요 정보 등록에 성공하였습니다.');
|
||||
return true;
|
||||
@@ -516,6 +525,14 @@ const postSdmInfoData = async (data: SdmInfoFormData): Promise<boolean> => {
|
||||
// 수요정보 수정
|
||||
const fetchUpdateSdmInfoData = async (data: SdmInfoFormData): Promise<boolean> => {
|
||||
try {
|
||||
const activeDemand = data.dmdInfoId
|
||||
? await getSdmInfoStatusList(data.dmdInfoId)
|
||||
: null;
|
||||
if (!activeDemand?.dmdInfoId) {
|
||||
sessionStorage.removeItem("user");
|
||||
alert('사용 중지되었거나 등록되지 않은 수요처입니다. 다시 로그인해 주세요.');
|
||||
return false;
|
||||
}
|
||||
await postFetch("/reuseFc/request/updateDemandInfo", data);
|
||||
alert('수요 정보 수정에 성공하였습니다.');
|
||||
return true;
|
||||
@@ -584,14 +601,19 @@ const getDemandCompList = async (searchText: string, pageCnt: number) => {
|
||||
}
|
||||
}
|
||||
|
||||
const getSdmInfoStatusList = async (dmdInfoId: string) => {
|
||||
type DemandStatusResponse = DemandCompType & {
|
||||
demandInfo?: DemandCompBizType[];
|
||||
demandMatchings?: DemandMatchings[];
|
||||
};
|
||||
|
||||
const getSdmInfoStatusList = async (dmdInfoId: string): Promise<DemandStatusResponse | null> => {
|
||||
try {
|
||||
const url = '/reuseFc/request/' + dmdInfoId + '?infoListYn=Y';
|
||||
const res = await getFetch(fetchType.WR, url);
|
||||
return res?.data || [];
|
||||
return res?.data ?? null;
|
||||
} catch (error) {
|
||||
console.error(error + ' 수요정보 신청현황을 불러오지 못했습니다.');
|
||||
return [];
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -620,7 +642,7 @@ export type SdmActions = {
|
||||
updateSdmInfoData: (data: SdmInfoFormData) => Promise<boolean>;
|
||||
getDemandCompList: (searchText: string, pageCnt: number) => Promise<void>;
|
||||
actionLogin: (data: DemandCompType) => Promise<boolean>;
|
||||
getSdmInfoStatusList: (dmdInfoId: string) => Promise<void>;
|
||||
getSdmInfoStatusList: (dmdInfoId: string) => Promise<boolean>;
|
||||
getSimilarCaseList: (part: string, value: number | string) => Promise<void>;
|
||||
actionLogout: () => Promise<void>;
|
||||
|
||||
@@ -736,19 +758,25 @@ export const SdmReducer = (set: any): SdmActions => {
|
||||
getSdmInfoStatusList: async (dmdInfoId: string) => {
|
||||
const res = await getSdmInfoStatusList(dmdInfoId);
|
||||
|
||||
if (res.length === 0) {
|
||||
if (!res?.dmdInfoId) {
|
||||
sessionStorage.removeItem("user");
|
||||
set((state: SdmType) => ({
|
||||
Sdm: {
|
||||
...state.Sdm,
|
||||
sdmInfoStatusList: [],
|
||||
successLogin: false,
|
||||
loginUser: null,
|
||||
}
|
||||
}))
|
||||
return;
|
||||
toast.error("사용 중지되었거나 등록되지 않은 수요처입니다.");
|
||||
return false;
|
||||
}
|
||||
//const sdmInfoStatusList : DemandCompBizType[] = [];
|
||||
|
||||
const sdmInfoStatusList: DemandCompBizType[] = res.demandInfo.map((el: DemandCompBizType, index: number) => {
|
||||
const matchingInfo = res.demandMatchings.find((matching: DemandMatchings) => matching.dmdInfoSn === el.dmdInfoSn);
|
||||
const demandInfo = Array.isArray(res.demandInfo) ? res.demandInfo : [];
|
||||
const demandMatchings = Array.isArray(res.demandMatchings) ? res.demandMatchings : [];
|
||||
const sdmInfoStatusList: DemandCompBizType[] = demandInfo.map((el: DemandCompBizType, index: number) => {
|
||||
const matchingInfo = demandMatchings.find((matching: DemandMatchings) => matching.dmdInfoSn === el.dmdInfoSn);
|
||||
const status = matchingInfo?.splyFcltFlg?.trim() === 'Sub' ? '검토' : matchingInfo?.splyFcltFlg?.trim() === 'matched' ? '매칭완료' : '신청';
|
||||
const ruseUtztnFcltNm = matchingInfo?.ruseUtztnFcltNm ? matchingInfo.ruseUtztnFcltNm : '';
|
||||
|
||||
@@ -781,6 +809,7 @@ export const SdmReducer = (set: any): SdmActions => {
|
||||
sdmInfoStatusList: sdmInfoStatusList,
|
||||
}
|
||||
}))
|
||||
return true;
|
||||
},
|
||||
|
||||
actionLogin: async (data: DemandCompType): Promise<boolean> => {
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 317 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 229 KiB After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 284 KiB After Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 24 KiB |
@@ -8,8 +8,10 @@ NEXT_PUBLIC_PROXY_HOST = http://localhost:3000
|
||||
NEXT_PUBLIC_SUB_DOMAIN = http://localhost:13000
|
||||
#NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://localhost:3001/auth/login
|
||||
SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1
|
||||
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/api/v1
|
||||
FO_SERVER_API_URL = http://localhost:3000/operation-service/api/v1
|
||||
# WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
|
||||
WR_SERVER_API_URL = http://localhost:3000/reuse-service/api/v1
|
||||
CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
|
||||
NEXT_PUBLIC_GEOSERVER_URL = https://hasudo.geo.rsonesoft.com/geoserver
|
||||
NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
|
||||
|
||||
@@ -237,6 +237,26 @@ export const COD_YEAR = 2021;
|
||||
* STORE - API
|
||||
*/
|
||||
|
||||
/** API 선택 목록을 값 기준으로 정규화하고 프런트 표준 전체 항목을 한 번만 추가한다. */
|
||||
const normalizeSelectData = (items: SelectDataType[]): SelectDataType[] => {
|
||||
const uniqueItems = new Map<string, SelectDataType>();
|
||||
|
||||
items.forEach((item) => {
|
||||
const val = String(item.val ?? '').trim();
|
||||
if (!val || val === '0' || uniqueItems.has(val)) return;
|
||||
|
||||
uniqueItems.set(val, {
|
||||
...item,
|
||||
val,
|
||||
});
|
||||
});
|
||||
|
||||
return [
|
||||
{ text: '전체', val: '0' },
|
||||
...uniqueItems.values(),
|
||||
];
|
||||
};
|
||||
|
||||
// 처리시설 계획정보 호출 (알에스원)
|
||||
export const planInfoUrl = '/dc-service/api/v1/nowDmgMtncGnsvy';
|
||||
|
||||
@@ -263,13 +283,12 @@ const fetchSidoData = async (): Promise<SelectDataType[]> => {
|
||||
try {
|
||||
const res = await getFetch('fo', '/addr/city');
|
||||
const data = res.data;
|
||||
selectSidoData = [
|
||||
{ text: "전체", val: '0' },
|
||||
...data.map((item: sidoDataType) => ({
|
||||
selectSidoData = normalizeSelectData(
|
||||
data.map((item: sidoDataType) => ({
|
||||
text: item.sido || '',
|
||||
val: String(item.adstDstrCd) || ''
|
||||
}))
|
||||
];
|
||||
})),
|
||||
);
|
||||
return selectSidoData;
|
||||
} catch (error) {
|
||||
console.error('시도 데이터를 가져오는데 실패했습니다:', error);
|
||||
@@ -282,14 +301,12 @@ const fetchSigunData = async (): Promise<SelectDataType[]> => {
|
||||
try {
|
||||
const res = await getFetch('fo', '/addr/sgg');
|
||||
const data = res.data;
|
||||
selectSigunData = [
|
||||
{ text: "전체", val: '0' },
|
||||
...data
|
||||
.map((item: sigunDataType) => ({
|
||||
selectSigunData = normalizeSelectData(
|
||||
data.map((item: sigunDataType) => ({
|
||||
text: item.gugun || '',
|
||||
val: String(item.adstDstrCd) || ''
|
||||
}))
|
||||
];
|
||||
})),
|
||||
);
|
||||
return selectSigunData;
|
||||
} catch (error) {
|
||||
console.error('시군구 데이터를 가져오는데 실패했습니다:', error);
|
||||
@@ -302,13 +319,12 @@ const fetchRegionData = async (): Promise<SelectDataType[]> => {
|
||||
try {
|
||||
const res = await getFetch('fo', '/addr/sarea');
|
||||
const data = res.data;
|
||||
return [
|
||||
{ text: "전체", val: '0' },
|
||||
...data.map((item: regionDataType) => ({
|
||||
return normalizeSelectData(
|
||||
data.map((item: regionDataType) => ({
|
||||
text: item.rvbsn || '',
|
||||
val: String(item.rvbsnCd) || ''
|
||||
}))
|
||||
];
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('권역 데이터를 가져오는데 실패했습니다:', error);
|
||||
return selectRegionData;
|
||||
|
||||
@@ -19,3 +19,4 @@ DC_URL = https://apigateway.apps.rsonesoft.com
|
||||
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_NODE_ENV = "development"
|
||||
@@ -15,3 +15,4 @@ 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_NODE_ENV = "development"
|
||||
@@ -6,6 +6,39 @@
|
||||
현재 목표는 리팩토링 전용 작업이 아니라, 하수관로 운영관리 시스템 2차사업 고도화를 수행하는 것이다.
|
||||
즉, 기존 `sn` 화면과 흐름을 안정적으로 유지하면서 2차 요구사항에 해당하는 화면 개발, 기능 확장, 백엔드 연동 보완을 수행해야 한다.
|
||||
|
||||
## 사용자 작업 범위: UI/UX 및 퍼블리싱
|
||||
|
||||
현재 사용자와 진행하는 작업의 기본 범위는 UI/UX 및 프론트엔드 퍼블리싱으로 제한한다.
|
||||
사용자가 별도로 명시하지 않는 한 아래 기준을 모든 후속 작업에 적용한다.
|
||||
|
||||
### 허용 범위
|
||||
|
||||
- 화면 레이아웃과 시각적 위계 조정
|
||||
- JSX 마크업, Tailwind CSS, CSS 스타일 수정
|
||||
- 색상, 여백, 간격, 크기, 타이포그래피, 아이콘, 테두리, 배경 조정
|
||||
- 반응형 화면, 접근성, 포커스와 시각적 상태 개선
|
||||
- 표시 전용 컴포넌트 구성과 퍼블리싱 품질 개선
|
||||
- 기존 화면 내용과 동작을 유지하는 범위의 UI 구조 정리
|
||||
|
||||
### 기본 금지 범위
|
||||
|
||||
- 백엔드 코드, API 계약, 요청·응답 데이터 구조 변경
|
||||
- `.env*`, middleware, 프록시, 인증과 권한 설정 변경
|
||||
- 업무 로직, 상태 모델, 데이터 가공 및 저장 흐름 변경
|
||||
- 데이터베이스, 서버 프로세스와 배포 설정 변경
|
||||
- 패키지 설치·업데이트와 의존성 구조 변경
|
||||
- 사용자 요청 없이 화면의 문구, 데이터 항목, 기능 동작 변경
|
||||
|
||||
금지 범위의 변경이 필요해 보이면 직접 수정하지 않고 원인과 필요한 작업만 사용자에게 설명한다.
|
||||
|
||||
### UI 검증 안전 기준
|
||||
|
||||
- 사용자가 실행한 개발 서버가 있는지 실제 앱 포트까지 먼저 확인한다.
|
||||
- 개발 서버 실행 중에는 같은 앱의 `next build`를 실행하지 않는다.
|
||||
- UI 작업의 기본 검증은 실행 중인 개발 서버의 HMR, 브라우저 화면 확인, `git diff --check`로 제한한다.
|
||||
- 개발 서버 종료, `.next` 정리, 전체 빌드와 프로세스 재시작은 사용자가 명시적으로 승인한 경우에만 수행한다.
|
||||
- UI 변경 전후에 기존 문구, 데이터, 이벤트와 업무 동작이 유지되는지 확인한다.
|
||||
|
||||
---
|
||||
|
||||
## 공통 시작 기준
|
||||
@@ -101,7 +134,7 @@ Codex 작업 루트는 가능하면 `../../../../../`로 잡고, 공통 시작
|
||||
|
||||
## 작업 원칙
|
||||
|
||||
1. 모든 변경은 2차 요구사항, 화면설계, 기능 명세 또는 승인된 작업 문서를 근거로 한다.
|
||||
1. 모든 변경은 원본 요구사항정의서와 화면설계서, 2차 요구사항, 기능 명세 또는 승인된 작업 문서를 근거로 한다.
|
||||
2. `apps/sn`과 `packages/common/business_components` 범위를 넘는 수정은 하지 않는다.
|
||||
3. 공통 패키지 수정은 `sn` 요구사항을 충족하기 위한 최소 범위로 제한한다.
|
||||
4. 다른 앱 전용 로직을 `business_components`에 넣지 않는다.
|
||||
@@ -117,6 +150,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 +226,17 @@ Codex 작업 루트는 가능하면 `../../../../../`로 잡고, 공통 시작
|
||||
VSCode나 별도 터미널에서 `sn` Next 개발 서버가 실행 중이면 `next build`, `pnpm --filter sn run build`, `pnpm turbo run build --filter=sn` 같은 빌드 명령을 실행하지 않는다.
|
||||
개발 서버와 빌드가 동시에 `.next` 산출물을 갱신하면 개발 서버가 종료되거나 꼬일 수 있으므로, 빌드 검증은 사용자에게 확인한 뒤 개발 서버를 끄고 수행한다.
|
||||
|
||||
### 프론트엔드 실행 프로세스 관리
|
||||
|
||||
- 모든 세션·역할·문서/검증 섹션에 공통 D:/workspace/AGENTS.md의 테스트 임시 파일·결과 파일 정리 기준을 적용한다. 일회성 검증 스크립트·캡처·Network/서버 로그·원시 결과 파일은 최종 보고서에 요약 후 삭제한다. 정식 회귀 테스트·최종 산출물·사용자 원본/복구 백업은 유지한다. 사용자 dev server가 쓰는 .next·공유 캐시·node_modules는 임시 검증물이라는 이유로 포괄 삭제하지 않는다.
|
||||
|
||||
- Codex 에이전트가 작업 또는 검증을 위해 직접 기동한 `next dev`, `dev:sn`, Turborepo dev와 기타 프론트엔드 장시간 실행 프로세스는 작업 종료 전에 반드시 종료한다.
|
||||
- 셸, 백그라운드, 숨김 창 등 VSCode 밖에서 에이전트가 기동한 프로세스도 종료 대상이다.
|
||||
- 사용자가 VSCode 또는 별도 터미널에서 직접 실행한 프론트엔드 개발 서버는 임의로 종료·재시작·교체하지 않는다. 종료가 필요하면 먼저 사용자에게 확인한다.
|
||||
- 기동 시 PID와 포트를 기록하고, 종료 시에는 에이전트가 기동한 정확한 PID와 하위 프로세스 트리만 종료한다.
|
||||
- 사용자가 계속 실행해 두라고 명시한 경우에는 종료하지 않고 최종 보고에 PID, 포트와 종료 방법을 남긴다.
|
||||
- 최종 보고 전에 에이전트가 사용한 프론트엔드 포트 해제와 임시 로그 파일 핸들 종료를 확인한다.
|
||||
|
||||
이 저장소는 `main` 브랜치 push 전에 `.husky/pre-push`에서 아래 빌드를 수행한다.
|
||||
|
||||
```sh
|
||||
|
||||
@@ -9,9 +9,5 @@ export default function AuthLayout({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="ko">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
return children;
|
||||
}
|
||||
|
||||
@@ -1,49 +1,79 @@
|
||||
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>
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
/**
|
||||
* 화면명: 노후관로 레이어 설정
|
||||
* 사업차수와 구조적·운영적 상태등급별 지도 표시 조건을 설정한다.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { validateElapsedYears, type AgedPipeFilters } from "./agedPipeUtils";
|
||||
import LayerSettingCard from "../layerSetting/LayerSettingCard";
|
||||
|
||||
type MaybePromise<T = void> = T | Promise<T>;
|
||||
|
||||
@@ -10,6 +16,9 @@ type Props = {
|
||||
onFiltersChange: (filters: AgedPipeFilters) => void;
|
||||
onApply: (filters?: AgedPipeFilters) => MaybePromise;
|
||||
compact?: boolean;
|
||||
enabled?: boolean;
|
||||
onEnabledChange?: (enabled: boolean) => void;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export default function AgedPipeLayerSettingPanel({
|
||||
@@ -17,16 +26,23 @@ export default function AgedPipeLayerSettingPanel({
|
||||
onFiltersChange,
|
||||
onApply,
|
||||
compact = false,
|
||||
enabled = true,
|
||||
onEnabledChange = () => {},
|
||||
onClose = () => {},
|
||||
}: 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,18 +57,19 @@ export default function AgedPipeLayerSettingPanel({
|
||||
alert(`노후관로 경과년도 ${elapsedYears}년으로 반영되었습니다.`);
|
||||
};
|
||||
|
||||
const inputClass = "h-[22px] border border-[#ccd6df] px-[2px] text-center text-[1rem] leading-none text-[#222]";
|
||||
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 inputClass =
|
||||
"h-[26px] !rounded border border-input bg-background px-2 py-0 text-center text-[12px] leading-none text-foreground";
|
||||
const numberInputClass = inputClass + " min-w-0 flex-1";
|
||||
const applyButtonClass =
|
||||
"h-[26px] shrink-0 rounded border border-[#A9C5E5] bg-[#EDF4FC] px-2 py-0 text-[12px] font-medium leading-none text-[#2463A6]";
|
||||
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-w-0 items-center gap-1">
|
||||
{indicator}
|
||||
<label htmlFor="aged-pipe-elapsed-years" className="shrink-0">노후관로 경과년도(년)</label>
|
||||
<div className="flex min-h-[26px] flex-col items-stretch gap-1.5 whitespace-nowrap text-[1.15rem] font-medium text-label">
|
||||
<div className="flex min-w-0 items-center">
|
||||
<label htmlFor="aged-pipe-elapsed-years" className="shrink-0">
|
||||
노후관로 경과년도(년)
|
||||
</label>
|
||||
</div>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
<div className="flex w-full items-center gap-1">
|
||||
<input
|
||||
id="aged-pipe-elapsed-years"
|
||||
className={numberInputClass}
|
||||
@@ -77,7 +94,7 @@ export default function AgedPipeLayerSettingPanel({
|
||||
title="적용"
|
||||
aria-label="경과년도 적용"
|
||||
>
|
||||
✓
|
||||
확인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -85,31 +102,34 @@ 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)]">
|
||||
<LayerSettingCard
|
||||
title="노후관로"
|
||||
enabled={enabled}
|
||||
onEnabledChange={onEnabledChange}
|
||||
onClose={onClose}
|
||||
>
|
||||
{settingRow}
|
||||
</div>
|
||||
</LayerSettingCard>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
/**
|
||||
* 화면명: 노후관로 목록
|
||||
* 노후관로를 검색하고 목록·엑셀 다운로드·지도 위치 확인 기능을 제공한다.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
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 {
|
||||
@@ -19,34 +24,47 @@ import {
|
||||
type AgedPipeFilters,
|
||||
type AgedPipeRow,
|
||||
} from "./agedPipeUtils";
|
||||
import { downloadAgedPipeCsv } from "./agedPipeExport";
|
||||
import { downloadAgedPipeExcel } 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>(() =>
|
||||
const initialFilters = useMemo(
|
||||
() =>
|
||||
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,
|
||||
}),
|
||||
[searchParams],
|
||||
);
|
||||
const [filters, setFilters] = useState<AgedPipeFilters>(initialFilters);
|
||||
// 화면 목록을 마지막으로 조회한 조건이며 페이지 이동·Excel이 동일하게 사용한다.
|
||||
const [appliedFilters, setAppliedFilters] =
|
||||
useState<AgedPipeFilters>(initialFilters);
|
||||
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("");
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
@@ -62,7 +80,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 +89,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,10 +104,15 @@ 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);
|
||||
setAppliedFilters(normalizeAgedPipeFilters(nextFilters));
|
||||
} catch (error) {
|
||||
console.error("노후관로 목록 조회 중 오류:", error);
|
||||
setRows([]);
|
||||
@@ -113,9 +136,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 +158,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,
|
||||
@@ -154,22 +173,36 @@ export default function AgedPipePopupPage() {
|
||||
alert("다운로드할 데이터가 없습니다.");
|
||||
return;
|
||||
}
|
||||
if (!validateElapsedYears(Number(filters.elapsedYears))) {
|
||||
alert("경과년도는 1년~100년 범위 내에서 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedDraft = normalizeAgedPipeFilters(filters);
|
||||
if (JSON.stringify(normalizedDraft) !== JSON.stringify(appliedFilters)) {
|
||||
alert("조회조건이 변경되었습니다. 조회 후 Excel을 다운로드해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setDownloadProgressText("CSV export 작업을 준비하고 있습니다.");
|
||||
await downloadAgedPipeCsv(filters, (job) => {
|
||||
setDownloadProgressText("Excel 파일을 준비하고 있습니다.");
|
||||
await downloadAgedPipeExcel(
|
||||
appliedFilters,
|
||||
(job) => {
|
||||
if (job.status === "QUEUED") {
|
||||
setDownloadProgressText("CSV export 작업이 대기 중입니다.");
|
||||
setDownloadProgressText("Excel 다운로드 작업이 대기 중입니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.status === "RUNNING") {
|
||||
setDownloadProgressText(
|
||||
`CSV export 생성 중입니다. (${job.progressPercent ?? 0}%)`,
|
||||
`Excel 파일 생성 중입니다. (${job.progressPercent ?? 0}%)`,
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
total,
|
||||
);
|
||||
setDownloadProgressText("");
|
||||
} catch (error) {
|
||||
console.error("노후관로 목록 다운로드 중 오류:", error);
|
||||
@@ -177,7 +210,7 @@ export default function AgedPipePopupPage() {
|
||||
alert(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "노후관로 CSV 다운로드 중 오류가 발생했습니다.",
|
||||
: "노후관로 Excel 다운로드 중 오류가 발생했습니다.",
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -196,44 +229,30 @@ 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">
|
||||
<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}
|
||||
>
|
||||
<StandardPopupLayout
|
||||
title="노후관로 목록"
|
||||
actions={
|
||||
<Button type="button" color="white" onClick={handleMinimize}>
|
||||
최소화
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
}
|
||||
>
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
|
||||
{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>
|
||||
<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="sn-popup-input !text-[1.1rem]"
|
||||
className="w-[12rem]"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
@@ -243,7 +262,8 @@ export default function AgedPipePopupPage() {
|
||||
const digits = event.target.value.replace(/[^0-9]/g, "");
|
||||
setFilters({
|
||||
...filters,
|
||||
elapsedYears: digits === "" ? ("" as unknown as number) : Number(digits),
|
||||
elapsedYears:
|
||||
digits === "" ? ("" as unknown as number) : Number(digits),
|
||||
});
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
@@ -252,10 +272,12 @@ export default function AgedPipePopupPage() {
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="sn-popup-field">
|
||||
<div className="sn-popup-field-label">시도</div>
|
||||
<label className="flex items-center gap-2">
|
||||
<span className="whitespace-nowrap text-[1.3rem] text-label">
|
||||
시도
|
||||
</span>
|
||||
<select
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
className="h-[3.2rem] w-[18rem] rounded-md border border-input bg-field px-3 text-[1.3rem]"
|
||||
value={filters.ctpvCd}
|
||||
onChange={(event) =>
|
||||
setFilters({
|
||||
@@ -274,12 +296,16 @@ export default function AgedPipePopupPage() {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="sn-popup-field">
|
||||
<div className="sn-popup-field-label">시군구</div>
|
||||
<label className="flex items-center gap-2">
|
||||
<span className="whitespace-nowrap text-[1.3rem] text-label">
|
||||
시군구
|
||||
</span>
|
||||
<select
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
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 })}
|
||||
onChange={(event) =>
|
||||
setFilters({ ...filters, sggCd: event.target.value })
|
||||
}
|
||||
>
|
||||
<option value="0">전체</option>
|
||||
{sggOptions.map((item) => (
|
||||
@@ -290,87 +316,46 @@ export default function AgedPipePopupPage() {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="sn-popup-actions">
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<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}
|
||||
color="white"
|
||||
onClick={() => {
|
||||
const resetFilters = normalizeAgedPipeFilters(
|
||||
AGED_PIPE_DEFAULT_FILTERS,
|
||||
);
|
||||
setFilters(resetFilters);
|
||||
void loadRows(1, resetFilters);
|
||||
}}
|
||||
>
|
||||
다운로드
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopupSearchPanel>
|
||||
|
||||
<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}
|
||||
<MapPopupDataTable
|
||||
rows={rows as Array<AgedPipeRow & Record<string, unknown>>}
|
||||
headlist={AGED_PIPE_LIST_HEADLIST}
|
||||
pageSize={10}
|
||||
title="노후관로 목록"
|
||||
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"
|
||||
page={currentPage}
|
||||
pageSize={AGED_PIPE_PAGE_SIZE}
|
||||
onPageChange={(page) => void loadRows(page, appliedFilters)}
|
||||
onRowClick={handleRowClick}
|
||||
rowId={(row, index) => String(row.fid ?? row.fcltMngNo ?? index)}
|
||||
actions={
|
||||
<Button type="button" onClick={handleDownload}>
|
||||
엑셀다운로드
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<style jsx global>{`
|
||||
.aged-pipe-popup-table .card-header {
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.aged-pipe-popup-table .card-header h3 {
|
||||
font-size: 1.7rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/** 노후관로 목록의 엑셀 다운로드 데이터와 서식을 생성한다. */
|
||||
"use client";
|
||||
|
||||
import { postBlobData } from "@/components/lib/api";
|
||||
@@ -14,26 +15,35 @@ type AgedPipeExportProgress = {
|
||||
status: "QUEUED" | "RUNNING" | "COMPLETED";
|
||||
};
|
||||
|
||||
/** 노후관로 CSV 다운로드를 요청한다. */
|
||||
export const downloadAgedPipeCsv = async (
|
||||
/** 노후관로 XLSX 다운로드를 요청한다. */
|
||||
export const downloadAgedPipeExcel = async (
|
||||
filters: AgedPipeFilters,
|
||||
onProgress?: (progress: AgedPipeExportProgress) => void,
|
||||
expectedTotal?: number,
|
||||
) => {
|
||||
const normalizedFilters = normalizeAgedPipeFilters(filters);
|
||||
|
||||
onProgress?.({ status: "RUNNING", progressPercent: 0 });
|
||||
|
||||
const { blob, fileName } = await postBlobData("v1/fclts/agedPipes/exportJobs/download", {
|
||||
const { blob, fileName } = await postBlobData(
|
||||
"v1/fclts/agedPipes/exportJobs/download",
|
||||
{
|
||||
elapsedYears: normalizedFilters.elapsedYears,
|
||||
ctpvCd: normalizedFilters.ctpvCd,
|
||||
sggCd: normalizedFilters.sggCd,
|
||||
format: "csv",
|
||||
format: "xlsx",
|
||||
columns: [...AGED_PIPE_EXCEL_FIELDS],
|
||||
});
|
||||
expectedTotal,
|
||||
},
|
||||
);
|
||||
|
||||
const resolvedFileName = fileName || "노후관로_현황.csv";
|
||||
const resolvedFileName = fileName || "노후관로_현황.xlsx";
|
||||
fileDownload(blob, resolvedFileName);
|
||||
onProgress?.({ status: "COMPLETED", fileName: resolvedFileName, progressPercent: 100 });
|
||||
onProgress?.({
|
||||
status: "COMPLETED",
|
||||
fileName: resolvedFileName,
|
||||
progressPercent: 100,
|
||||
});
|
||||
|
||||
return {
|
||||
status: "COMPLETED" as const,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/** 노후관로 검색 조건, 표시값 변환 및 지도 연계 유틸을 제공한다. */
|
||||
"use client";
|
||||
|
||||
import type { SelectDataType } from "@common/business_components";
|
||||
@@ -54,7 +55,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,315 @@
|
||||
/**
|
||||
* 화면명: 시도별 노후관로현황
|
||||
* 기준연도별 시도 단위 노후관로 현황을 조회하고 엑셀로 내려받는다.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button, Input } from "@common/components";
|
||||
import {
|
||||
getStandardRowNumber,
|
||||
StandardDataTable,
|
||||
StandardPopupLayout,
|
||||
} from "@common/business_components";
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
type PaginationState,
|
||||
} from "@tanstack/react-table";
|
||||
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,
|
||||
buildAgedPipeStatusYearOptions,
|
||||
getAgedPipeStatusByCtpvValidationMessage,
|
||||
normalizeAgedPipeStatusByCtpvResponse,
|
||||
normalizeAgedPipeStatusByCtpvFilters,
|
||||
toAgedPipeStatusByCtpvRows,
|
||||
type AgedPipeStatusByCtpvFilters,
|
||||
type AgedPipeStatusByCtpvResponse,
|
||||
type AgedPipeStatusByCtpvRow,
|
||||
} from "./agedPipeStatusByCtpvUtils";
|
||||
import { downloadAgedPipeStatusByCtpvExcel } from "./agedPipeStatusByCtpvExport";
|
||||
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,
|
||||
}),
|
||||
);
|
||||
// 화면 목록을 마지막으로 조회한 조건이며 페이지 이동·Excel이 동일하게 사용한다.
|
||||
const [appliedFilters, setAppliedFilters] =
|
||||
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 columns = useMemo<ColumnDef<AgedPipeStatusByCtpvRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "rowNumber",
|
||||
header: "순번",
|
||||
cell: ({ row }) =>
|
||||
getStandardRowNumber({
|
||||
total: rows.length,
|
||||
rowIndex: row.index,
|
||||
}),
|
||||
},
|
||||
{ accessorKey: "ctpvNm", header: "시도" },
|
||||
...years.map((year) => ({
|
||||
id: `year_${year}`,
|
||||
header: `${year}년`,
|
||||
columns: [
|
||||
{
|
||||
accessorKey: `year${year}TotalLengthKm`,
|
||||
header: "전체관로연장(km)",
|
||||
},
|
||||
{
|
||||
accessorKey: `year${year}AgedLengthKm`,
|
||||
header: "노후관로연장(km)",
|
||||
},
|
||||
{
|
||||
accessorKey: `year${year}AgedRate`,
|
||||
header: "노후화율(%)",
|
||||
},
|
||||
],
|
||||
})),
|
||||
],
|
||||
[rows.length, years],
|
||||
);
|
||||
|
||||
const pagination: PaginationState = {
|
||||
pageIndex: currentPage - 1,
|
||||
pageSize: AGED_PIPE_STATUS_PAGE_SIZE,
|
||||
};
|
||||
const table = useReactTable({
|
||||
data: rows,
|
||||
columns,
|
||||
state: { pagination },
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onPaginationChange: (updater) => {
|
||||
const next =
|
||||
typeof updater === "function" ? updater(pagination) : updater;
|
||||
setCurrentPage(next.pageIndex + 1);
|
||||
},
|
||||
});
|
||||
|
||||
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/agedStatusByCtpv?${queryParams.toString()}`,
|
||||
);
|
||||
const response = normalizeAgedPipeStatusByCtpvResponse(
|
||||
rawResponse,
|
||||
) as AgedPipeStatusByCtpvResponse;
|
||||
setYears(Array.isArray(response?.years) ? response.years : []);
|
||||
setRows(toAgedPipeStatusByCtpvRows(response));
|
||||
setCurrentPage(1);
|
||||
setAppliedFilters(normalized);
|
||||
} catch (error) {
|
||||
console.error("시도별 노후관로현황 조회 중 오류:", error);
|
||||
setYears([]);
|
||||
setRows([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const initialFilters = normalizeAgedPipeStatusByCtpvFilters({
|
||||
elapsedYears: AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS,
|
||||
});
|
||||
void loadRows(initialFilters);
|
||||
}, [loadRows]);
|
||||
|
||||
const handleSearch = async () => {
|
||||
const validationMessage = getAgedPipeStatusByCtpvValidationMessage(filters);
|
||||
if (validationMessage) {
|
||||
alert(validationMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeAgedPipeStatusByCtpvFilters(filters);
|
||||
setFilters(normalized);
|
||||
await loadRows(normalized);
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
const validationMessage = getAgedPipeStatusByCtpvValidationMessage(filters);
|
||||
if (validationMessage) {
|
||||
alert(validationMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedDraft = normalizeAgedPipeStatusByCtpvFilters(filters);
|
||||
if (JSON.stringify(normalizedDraft) !== JSON.stringify(appliedFilters)) {
|
||||
alert("조회조건이 변경되었습니다. 조회 후 Excel을 다운로드해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setDownloadProgressText(
|
||||
"시도별 노후관로현황 Excel 파일을 준비하고 있습니다.",
|
||||
);
|
||||
await downloadAgedPipeStatusByCtpvExcel(appliedFilters);
|
||||
setDownloadProgressText("");
|
||||
} catch (error) {
|
||||
console.error("시도별 노후관로현황 다운로드 중 오류:", error);
|
||||
setDownloadProgressText("");
|
||||
alert(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "시도별 노후관로현황 Excel 다운로드 중 오류가 발생했습니다.",
|
||||
);
|
||||
} 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) => ({
|
||||
...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>
|
||||
|
||||
<StandardDataTable
|
||||
table={table}
|
||||
className="!w-full !max-w-full"
|
||||
title="시도별 노후관로 목록"
|
||||
scrollMode="horizontal"
|
||||
minTableWidth={years.length > 0 ? "120rem" : "100%"}
|
||||
actions={
|
||||
<Button type="button" onClick={handleDownload}>
|
||||
엑셀다운로드
|
||||
</Button>
|
||||
}
|
||||
flushOuterMargin
|
||||
/>
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/** 시도별 노후관로 현황의 연도 그룹형 엑셀 데이터를 생성한다. */
|
||||
"use client";
|
||||
|
||||
import * as XLSX from "xlsx";
|
||||
import { getRawJson } from "@/components/lib/api";
|
||||
import {
|
||||
getAgedPipeStatusByCtpvValidationMessage,
|
||||
normalizeAgedPipeStatusByCtpvFilters,
|
||||
type AgedPipeStatusByCtpvFilters,
|
||||
type AgedPipeStatusByCtpvResponse,
|
||||
} from "./agedPipeStatusByCtpvUtils";
|
||||
|
||||
/** 적용조건의 시도별 노후관로현황 전체를 조회해 연도 그룹 Excel로 생성한다. */
|
||||
export const downloadAgedPipeStatusByCtpvExcel = async (
|
||||
filters: AgedPipeStatusByCtpvFilters,
|
||||
) => {
|
||||
const validationMessage = getAgedPipeStatusByCtpvValidationMessage(filters);
|
||||
if (validationMessage) throw new Error(validationMessage);
|
||||
const normalized = normalizeAgedPipeStatusByCtpvFilters(filters);
|
||||
const params = new URLSearchParams({
|
||||
startYear: String(normalized.startYear),
|
||||
endYear: String(normalized.endYear),
|
||||
elapsedYears: String(normalized.elapsedYears),
|
||||
});
|
||||
const response: any = await getRawJson(
|
||||
`v1/swagePpl/agedStatusByCtpv?${params.toString()}`,
|
||||
);
|
||||
const payload = (response?.data ?? response) as AgedPipeStatusByCtpvResponse;
|
||||
const years = Array.isArray(payload?.years)
|
||||
? payload.years.map(Number).filter(Number.isFinite)
|
||||
: [];
|
||||
const rows = Array.isArray(payload?.rows) ? payload.rows : [];
|
||||
if (rows.length === 0) throw new Error("다운로드할 데이터가 없습니다.");
|
||||
|
||||
const headers = ["순번", "시도"];
|
||||
years.forEach((year) => headers.push(`${year}년`, "", ""));
|
||||
const subHeaders = ["", ""];
|
||||
years.forEach(() =>
|
||||
subHeaders.push("전체관로연장(km)", "노후관로연장(km)", "노후화율(%)"),
|
||||
);
|
||||
const data = rows.map((row, rowIndex) => {
|
||||
const statsByYear = new Map(
|
||||
(row.yearStats ?? []).map((stat) => [Number(stat.year), stat]),
|
||||
);
|
||||
const values: Array<string | number> = [rowIndex + 1, row.ctpvNm || "-"];
|
||||
years.forEach((year) => {
|
||||
const stat = statsByYear.get(year);
|
||||
values.push(
|
||||
stat ? Number(stat.totalLengthKm ?? 0) : "-",
|
||||
stat ? Number(stat.agedLengthKm ?? 0) : "-",
|
||||
stat ? Number(stat.agedRate ?? 0) : "-",
|
||||
);
|
||||
});
|
||||
return values;
|
||||
});
|
||||
|
||||
const worksheet = XLSX.utils.aoa_to_sheet([headers, subHeaders, ...data]);
|
||||
worksheet["!merges"] = [
|
||||
{ s: { r: 0, c: 0 }, e: { r: 1, c: 0 } },
|
||||
{ s: { r: 0, c: 1 }, e: { r: 1, c: 1 } },
|
||||
...years.map((_, index) => ({
|
||||
s: { r: 0, c: 2 + index * 3 },
|
||||
e: { r: 0, c: 4 + index * 3 },
|
||||
})),
|
||||
];
|
||||
worksheet["!cols"] = [
|
||||
{ wch: 8 },
|
||||
{ wch: 18 },
|
||||
...years.flatMap(() => [{ wch: 18 }, { wch: 18 }, { wch: 14 }]),
|
||||
];
|
||||
worksheet["!autofilter"] = {
|
||||
ref: `A2:${XLSX.utils.encode_col(headers.length - 1)}${data.length + 2}`,
|
||||
};
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, "시도별 노후관로현황");
|
||||
XLSX.writeFile(workbook, "시도별_노후관로현황.xlsx", {
|
||||
bookType: "xlsx",
|
||||
compression: true,
|
||||
cellStyles: true,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,185 @@
|
||||
/** 시도별 노후관로 현황의 조회 연도와 그룹 컬럼을 구성한다. */
|
||||
"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 - 2));
|
||||
let endYear = Math.trunc(Number(filters.endYear ?? currentYear));
|
||||
let elapsedYears = Math.trunc(
|
||||
Number(
|
||||
filters.elapsedYears ?? AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS,
|
||||
),
|
||||
);
|
||||
|
||||
if (!Number.isFinite(startYear)) startYear = currentYear - 2;
|
||||
if (!Number.isFinite(endYear)) endYear = currentYear;
|
||||
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;
|
||||
}
|
||||
|
||||
return {
|
||||
startYear,
|
||||
endYear,
|
||||
elapsedYears,
|
||||
};
|
||||
};
|
||||
|
||||
/** 입력한 기준년도·경과년도가 조회 가능한 범위인지 사용자 안내 문구로 반환한다. */
|
||||
export const getAgedPipeStatusByCtpvValidationMessage = (
|
||||
filters: AgedPipeStatusByCtpvFilters,
|
||||
) => {
|
||||
if (filters.startYear > filters.endYear) {
|
||||
return "기준년도 시작은 종료보다 늦을 수 없습니다.";
|
||||
}
|
||||
if (
|
||||
filters.endYear - filters.startYear + 1 >
|
||||
AGED_PIPE_STATUS_BY_CTPV_MAX_YEAR_SPAN
|
||||
) {
|
||||
return `기준년도 조회 범위는 최대 ${AGED_PIPE_STATUS_BY_CTPV_MAX_YEAR_SPAN}년까지 가능합니다.`;
|
||||
}
|
||||
if (filters.elapsedYears < 1 || filters.elapsedYears > 100) {
|
||||
return "노후관로 기준은 1년부터 100년까지 입력할 수 있습니다.";
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const validateAgedPipeStatusByCtpvFilters = (
|
||||
filters: AgedPipeStatusByCtpvFilters,
|
||||
) => getAgedPipeStatusByCtpvValidationMessage(filters) === null;
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -1,10 +1,14 @@
|
||||
/**
|
||||
* 화면명: 분석도구 메뉴
|
||||
* 지도에서 실행할 분석·현황 팝업 메뉴를 펼치고 선택한다.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type ComponentType, type ReactNode } from "react";
|
||||
import { type ComponentType, type ReactNode,useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
ChartColumnBig,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
PanelsTopLeft,
|
||||
PencilRuler,
|
||||
Search,
|
||||
@@ -12,7 +16,14 @@ import {
|
||||
TriangleAlert,
|
||||
Waves,
|
||||
} from "lucide-react";
|
||||
import type { WindowPopupKey } from "@/features/windowPopup/model/popupDefinitions";
|
||||
|
||||
import { createPredictionAnalysisTimeRange } from "@/features/fldngPredcPrst/model/analysisTimeRange";
|
||||
import {
|
||||
windowPopupDefinitions,
|
||||
type WindowPopupKey,
|
||||
} from "@/features/windowPopup/model/popupDefinitions";
|
||||
|
||||
import { getRawJson } from "@/components/lib/api";
|
||||
|
||||
type Props = {
|
||||
onOpenPopup: (popupKey: WindowPopupKey) => Window | null;
|
||||
@@ -27,10 +38,10 @@ type MenuIconProps = {
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
const InftwtMenuIcon = ({ isActive }: MenuIconProps) => (
|
||||
const InftwtMenuIcon = () => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={`h-[15px] w-[15px] shrink-0 ${isActive ? "text-primary" : "text-label"}`}
|
||||
className="h-[15px] w-[15px] shrink-0 text-current"
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
>
|
||||
@@ -58,14 +69,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 +125,35 @@ 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);
|
||||
const [seriousPredictionCount, setSeriousPredictionCount] = useState(0);
|
||||
|
||||
// 침수예측현황 팝업의 기본 분석시각 범위와 동일한 범위로 메뉴 건수를 조회한다.
|
||||
useEffect(() => {
|
||||
const range = createPredictionAnalysisTimeRange();
|
||||
const params = new URLSearchParams({
|
||||
analysisFrom: range.analysisFrom,
|
||||
analysisTo: range.analysisTo,
|
||||
});
|
||||
let cancelled = false;
|
||||
void getRawJson(
|
||||
`/sewerage-service/api/v1/fldng/predc/prst/count?${params.toString()}`,
|
||||
)
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
const payload = response?.data ?? response;
|
||||
const count = Number(payload);
|
||||
setSeriousPredictionCount(Number.isFinite(count) ? count : 0);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setSeriousPredictionCount(0);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGisEditActive && activeKey === "gisEditPopup") {
|
||||
@@ -97,31 +173,63 @@ export default function AnalysisPopupMenu({
|
||||
}, [initialActiveKey]);
|
||||
|
||||
return (
|
||||
<div className="absolute right-[0.55rem] top-[20px] z-[45]">
|
||||
<div className="relative">
|
||||
<div className="pointer-events-none absolute right-[68px] top-[16px] z-[45] flex flex-col items-end gap-[8px]">
|
||||
<button
|
||||
type="button"
|
||||
className={`pointer-events-auto flex h-[40px] w-[40px] shrink-0 items-center justify-center overflow-hidden rounded-[3px] p-0 shadow-[0_1px_2px_rgba(60,64,67,0.3),0_1px_3px_1px_rgba(60,64,67,0.15)] ${
|
||||
isOpen
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-background text-label hover:bg-primary hover:text-primary-foreground"
|
||||
}`}
|
||||
onClick={() => setIsOpen((value) => !value)}
|
||||
title={isOpen ? "분석 메뉴 숨기기" : "분석도구"}
|
||||
aria-label={isOpen ? "분석 메뉴 숨기기" : "분석 메뉴 보이기"}
|
||||
aria-expanded={isOpen}
|
||||
aria-controls="analysis-popup-menu"
|
||||
>
|
||||
<PanelsTopLeft className="h-[20px] w-[20px]" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
{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
|
||||
id="analysis-popup-menu"
|
||||
className="pointer-events-auto flex w-max min-w-[154px] flex-col overflow-hidden rounded-[6px] bg-background shadow-[0_1px_2px_rgba(60,64,67,0.3),0_1px_3px_1px_rgba(60,64,67,0.15)]"
|
||||
>
|
||||
<div className="flex h-[36px] shrink-0 items-center justify-between gap-2 border-b border-border bg-background 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>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-[28px] w-[28px] shrink-0 items-center justify-center rounded-[4px] text-label hover:bg-accent hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
|
||||
onClick={() => setIsOpen(false)}
|
||||
title="분석 메뉴 숨기기"
|
||||
aria-label="분석 메뉴 숨기기"
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 p-[6px]">
|
||||
<div className="flex flex-col gap-[3px] p-[6px]">
|
||||
{ANALYSIS_POPUP_ITEMS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = item.key === "gisEditPopup"
|
||||
const isActive =
|
||||
item.key === "gisEditPopup"
|
||||
? isGisEditActive
|
||||
: activeKey === item.key;
|
||||
const label =
|
||||
item.key === "fldngPredcPrst"
|
||||
? `침수예측현황(${seriousPredictionCount}건)`
|
||||
: item.label;
|
||||
|
||||
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 ${
|
||||
className={`relative flex h-[33px] items-center gap-[7px] rounded-[5px] border px-[8px] pl-[10px] text-left text-[12px] font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary ${
|
||||
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-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={() => {
|
||||
@@ -155,30 +263,17 @@ export default function AnalysisPopupMenu({
|
||||
>
|
||||
{item.renderIcon ? item.renderIcon({ isActive }) : null}
|
||||
{Icon ? (
|
||||
<Icon
|
||||
className={`h-[15px] w-[15px] shrink-0 ${
|
||||
isActive ? "text-primary" : "text-label"
|
||||
}`}
|
||||
/>
|
||||
<Icon className="h-[15px] w-[15px] shrink-0 text-current" />
|
||||
) : null}
|
||||
<span className="whitespace-nowrap">{item.label}</span>
|
||||
<span className="whitespace-nowrap" title={label}>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/** 지도 시설 상세정보의 코드값을 업무 표시명으로 변환한다. */
|
||||
import { MS_SWAGE_PPL_USE_CODES, MS_SWAGE_PPL_CODES, MS_SWAGE_MHL_CODES, MS_SWAGE_MHL_USE_CODES, DRTW_SEWAGE_PUMP_USE_CODES } from './table';
|
||||
|
||||
interface CodeMapperProps {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/** 선택한 지도 레이어 종류에 맞는 시설 상세 컴포넌트를 반환한다. */
|
||||
import MsSwagePpl from "../popupComponent/MsSwagePpl"; // 하수관로
|
||||
import sewagePump from "../popupComponent/sewagePump"; // 하수펌프
|
||||
import smh from "../popupComponent/smh";
|
||||
@@ -17,10 +18,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 +47,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,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/** 지도 시설 상세 및 목록에서 사용하는 컬럼·코드 정의를 제공한다. */
|
||||
// 하수관로
|
||||
export const MsSwagePplCol = [
|
||||
{ id: 'fclt_mng_no', title: '시설관리번호' },
|
||||
@@ -32,7 +33,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 +399,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 +462,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 +479,74 @@ 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 FldngDpthMapCol = [
|
||||
{ id: 'fldng_yr', title: '침수년도' },
|
||||
{ id: 'ctpv_nm', title: '시도명' },
|
||||
{ id: 'sgg_nm', title: '시군구명' },
|
||||
{ id: 'fldng_dpth', title: '침수깊이(m)' },
|
||||
{ id: 'fldng_area', title: '침수면적(㎡)' },
|
||||
// { id: 'shape_area', title: '형상면적(㎡)' },
|
||||
// { id: 'shape_len', title: '형상길이' },
|
||||
];
|
||||
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 +571,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 +693,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 +749,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 +798,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 +888,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 +918,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,101 @@
|
||||
/**
|
||||
* 화면명: 관로조사 레이어 설정
|
||||
* 사업차수와 조사 종류·상태등급별 지도 표시 조건을 설정한다.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import type { ExmnMapFilters, ExmnMapJobType } from "./exmnMapTypes";
|
||||
import LayerSettingCard from "../layerSetting/LayerSettingCard";
|
||||
|
||||
type Props = {
|
||||
jobType: ExmnMapJobType;
|
||||
filters: ExmnMapFilters;
|
||||
onFiltersChange: (filters: ExmnMapFilters) => void;
|
||||
enabled?: boolean;
|
||||
onEnabledChange?: (enabled: boolean) => void;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
const GRADE_OPTIONS = ["", "1", "2", "3", "4", "5"];
|
||||
|
||||
/** 조사결과 레이어의 세 가지 자동 적용 필터다. */
|
||||
export default function ExmnLayerSettingPanel({
|
||||
jobType,
|
||||
filters,
|
||||
onFiltersChange,
|
||||
enabled = true,
|
||||
onEnabledChange = () => {},
|
||||
onClose = () => {},
|
||||
}: Props) {
|
||||
const updateFilter = (key: keyof ExmnMapFilters, value: string) => {
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
[key]: value.replace(/[^0-9]/g, ""),
|
||||
});
|
||||
};
|
||||
const selectClass =
|
||||
"h-[26px] w-full rounded border border-input bg-background px-2 py-0 text-center text-[12px] leading-none text-foreground outline-none focus:border-primary";
|
||||
const labelClass = "shrink-0 text-[1.15rem] font-medium text-label";
|
||||
const title = jobType === "PPL" ? "관로조사" : "맨홀조사";
|
||||
|
||||
return (
|
||||
<LayerSettingCard
|
||||
title={title}
|
||||
enabled={enabled}
|
||||
onEnabledChange={onEnabledChange}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="space-y-[6px]">
|
||||
<label className="flex min-h-[24px] flex-col items-stretch gap-1">
|
||||
<span className={`flex items-center gap-1 ${labelClass}`}>
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${jobType === "PPL" ? "bg-[#facc15]" : "bg-[#8b5cf6]"}`}
|
||||
/>
|
||||
사업차수
|
||||
</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-[24px] flex-col items-stretch gap-1">
|
||||
<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-[24px] flex-col items-stretch gap-1">
|
||||
<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>
|
||||
</LayerSettingCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 화면명: 관로조사 결과
|
||||
* 선택 시설의 조사 결과와 상세 판정 정보를 표시한다.
|
||||
*/
|
||||
"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,51 @@
|
||||
/** 관로조사 지도 데이터 조회 API와 응답 변환 함수를 제공한다. */
|
||||
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,299 @@
|
||||
/** 관로조사 지도 화면에서 사용하는 타입·상수와 표시 조건을 정의한다. */
|
||||
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,157 @@
|
||||
/**
|
||||
* 화면명: 침수흔적도 상세정보
|
||||
* 지도에서 선택한 침수흔적의 기본정보·강우정보·피해정보를 표시한다.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import type { FldngHstryItem } from "./fldngHstryTypes";
|
||||
import { formatFldngHstryArea, formatFldngHstryDepth } from "./fldngHstryUtils";
|
||||
|
||||
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(
|
||||
() =>
|
||||
[
|
||||
["침수심", `${formatFldngHstryDepth(detail.floodDepthM)} m`],
|
||||
["침수면적", formatFldngHstryArea(detail.floodAreaHa)],
|
||||
["침수년도", `${detail.floodYear}년`],
|
||||
["등급", detail.gradeLabel],
|
||||
["침수구역", detail.districtName],
|
||||
] as Array<[string, string]>,
|
||||
[detail],
|
||||
);
|
||||
|
||||
const disasterRows = useMemo(
|
||||
() =>
|
||||
[
|
||||
["침수재해명", detail.disasterName],
|
||||
["침수면적", formatFldngHstryArea(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,397 @@
|
||||
/**
|
||||
* 화면명: 침수흔적위선 연계정보
|
||||
* 우측 정보 화면의 심선·위선 목록, 선택 상태와 화면 생명주기 캐시를 관리한다.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import GisApp from "@common/business_components/ui/map/lib/gis.core";
|
||||
import type Feature from "ol/Feature.js";
|
||||
import VectorSource from "ol/source/Vector.js";
|
||||
|
||||
import { loadFldngHstryDetail } from "./fldngHstryClient";
|
||||
import {
|
||||
FLDNG_HSTRY_DEPTH_LAYER_TYPE,
|
||||
FLDNG_HSTRY_LAYER_TYPE,
|
||||
type FldngHstryDetailCacheValue,
|
||||
} from "./fldngHstryTypes";
|
||||
|
||||
type FldngHstryGisLayer = {
|
||||
searchLayer?: { getSource?: () => VectorSource | null } | null;
|
||||
clearSearchLayer: (option: unknown) => void;
|
||||
ensureSearchLayer: (option: unknown) => {
|
||||
set: (key: string, value: unknown) => void;
|
||||
setSource: (source: VectorSource) => void;
|
||||
};
|
||||
showSearchResultsFromGeoJSON: (
|
||||
layerTypeName: string,
|
||||
featureId: string,
|
||||
) => void | Promise<void>;
|
||||
};
|
||||
|
||||
type FldngHstryScrollButtonProps = {
|
||||
targetRef: RefObject<HTMLDivElement | null>;
|
||||
direction: -1 | 1;
|
||||
label: string;
|
||||
children: string;
|
||||
};
|
||||
|
||||
const FLDNG_HSTRY_SCROLL_CLICK_DISTANCE = 120;
|
||||
const FLDNG_HSTRY_SCROLL_HOLD_DELAY = 300;
|
||||
const FLDNG_HSTRY_SCROLL_HOLD_SPEED = 4;
|
||||
|
||||
/** 짧게 누르면 한 칸, 길게 누르면 목록을 해당 방향으로 계속 이동한다. */
|
||||
const FldngHstryScrollButton = ({
|
||||
targetRef,
|
||||
direction,
|
||||
label,
|
||||
children,
|
||||
}: FldngHstryScrollButtonProps) => {
|
||||
const holdTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
const pointerClickHandledRef = useRef(false);
|
||||
|
||||
const stopContinuousScroll = useCallback(() => {
|
||||
if (holdTimerRef.current !== null) {
|
||||
clearTimeout(holdTimerRef.current);
|
||||
holdTimerRef.current = null;
|
||||
}
|
||||
if (animationFrameRef.current !== null) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scrollOnce = useCallback(() => {
|
||||
targetRef.current?.scrollBy({
|
||||
left: direction * FLDNG_HSTRY_SCROLL_CLICK_DISTANCE,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, [direction, targetRef]);
|
||||
|
||||
const startContinuousScroll = useCallback(() => {
|
||||
const scroll = () => {
|
||||
const target = targetRef.current;
|
||||
if (!target) {
|
||||
stopContinuousScroll();
|
||||
return;
|
||||
}
|
||||
target.scrollLeft += direction * FLDNG_HSTRY_SCROLL_HOLD_SPEED;
|
||||
animationFrameRef.current = requestAnimationFrame(scroll);
|
||||
};
|
||||
animationFrameRef.current = requestAnimationFrame(scroll);
|
||||
}, [direction, stopContinuousScroll, targetRef]);
|
||||
|
||||
useEffect(() => stopContinuousScroll, [stopContinuousScroll]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
className="shrink-0 select-none px-1 text-lg leading-none text-foreground"
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0) return;
|
||||
stopContinuousScroll();
|
||||
pointerClickHandledRef.current = true;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
scrollOnce();
|
||||
holdTimerRef.current = setTimeout(
|
||||
startContinuousScroll,
|
||||
FLDNG_HSTRY_SCROLL_HOLD_DELAY,
|
||||
);
|
||||
}}
|
||||
onPointerUp={stopContinuousScroll}
|
||||
onPointerCancel={() => {
|
||||
stopContinuousScroll();
|
||||
pointerClickHandledRef.current = false;
|
||||
}}
|
||||
onLostPointerCapture={stopContinuousScroll}
|
||||
onClick={() => {
|
||||
if (pointerClickHandledRef.current) {
|
||||
pointerClickHandledRef.current = false;
|
||||
return;
|
||||
}
|
||||
scrollOnce();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const cloneFldngHstryFeature = (feature: Feature) => {
|
||||
const clonedFeature = feature.clone();
|
||||
const featureId = feature.getId();
|
||||
if (featureId !== undefined) clonedFeature.setId(featureId);
|
||||
return clonedFeature;
|
||||
};
|
||||
|
||||
const displayCachedFldngHstryFeatures = (
|
||||
gisLayer: FldngHstryGisLayer,
|
||||
layerTypeName: string,
|
||||
features: Feature[],
|
||||
) => {
|
||||
const matched = Object.values(GisApp.layerCode ?? {}).find(
|
||||
(item: any) => item?.lyrTyNm === layerTypeName,
|
||||
) as any;
|
||||
if (!matched || features.length === 0) return;
|
||||
|
||||
gisLayer.clearSearchLayer(matched);
|
||||
const layer = gisLayer.ensureSearchLayer(matched);
|
||||
layer.set("gobjTy", matched.gobjTy);
|
||||
const clonedFeatures = features.map(cloneFldngHstryFeature);
|
||||
layer.setSource(new VectorSource({ features: clonedFeatures }));
|
||||
(GisApp.Module.select as any)?.selectFeature(clonedFeatures[0], false);
|
||||
};
|
||||
|
||||
const showFldngHstryFeature = async (
|
||||
cache: Map<string, Promise<Feature[]>>,
|
||||
gisLayer: FldngHstryGisLayer,
|
||||
layerTypeName: string,
|
||||
featureId: string,
|
||||
) => {
|
||||
const cacheKey = `${layerTypeName}:${featureId}`;
|
||||
const cachedRequest = cache.get(cacheKey);
|
||||
if (cachedRequest) {
|
||||
displayCachedFldngHstryFeatures(
|
||||
gisLayer,
|
||||
layerTypeName,
|
||||
await cachedRequest,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await gisLayer.showSearchResultsFromGeoJSON(layerTypeName, featureId);
|
||||
const source = gisLayer.searchLayer?.getSource?.();
|
||||
if (!source) return;
|
||||
|
||||
const featureRequest = new Promise<Feature[]>((resolve, reject) => {
|
||||
const resolveFeatures = () => {
|
||||
const features = source.getFeatures();
|
||||
if (features.length > 0) {
|
||||
resolve(features.map(cloneFldngHstryFeature));
|
||||
} else {
|
||||
reject(new Error("침수흔적 위치 정보가 없습니다."));
|
||||
}
|
||||
};
|
||||
const rejectFeatures = () =>
|
||||
reject(new Error("침수흔적 위치 조회에 실패했습니다."));
|
||||
|
||||
if (source.getFeatures().length > 0) {
|
||||
resolveFeatures();
|
||||
return;
|
||||
}
|
||||
source.once("featuresloadend", resolveFeatures);
|
||||
source.once("featuresloaderror", rejectFeatures);
|
||||
});
|
||||
|
||||
cache.set(cacheKey, featureRequest);
|
||||
try {
|
||||
await featureRequest;
|
||||
} catch (error) {
|
||||
cache.delete(cacheKey);
|
||||
console.warn(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const useFldngHstryFeatureClick = () => {
|
||||
const fldngHstryDetailCacheRef = useRef(
|
||||
new Map<string, Promise<FldngHstryDetailCacheValue>>(),
|
||||
);
|
||||
const fldngHstryFeatureCacheRef = useRef(
|
||||
new Map<string, Promise<Feature[]>>(),
|
||||
);
|
||||
const [fldngHstryDepthRows, setFldngHstryDepthRows] = useState<
|
||||
Record<string, unknown>[]
|
||||
>([]);
|
||||
const [fldngHstryWtlvRows, setFldngHstryWtlvRows] = useState<
|
||||
Record<string, unknown>[]
|
||||
>([]);
|
||||
const [selectedFldngHstryDepthFid, setSelectedFldngHstryDepthFid] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [selectedFldngHstryWtlvFid, setSelectedFldngHstryWtlvFid] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const fldngHstryDepthYearListRef = useRef<HTMLDivElement>(null);
|
||||
const fldngHstryWtlvListRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const detailCache = fldngHstryDetailCacheRef.current;
|
||||
const featureCache = fldngHstryFeatureCacheRef.current;
|
||||
return () => {
|
||||
detailCache.clear();
|
||||
featureCache.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const resetFldngHstryFeatureClick = useCallback(() => {
|
||||
setFldngHstryDepthRows([]);
|
||||
setFldngHstryWtlvRows([]);
|
||||
setSelectedFldngHstryDepthFid(null);
|
||||
setSelectedFldngHstryWtlvFid(null);
|
||||
}, []);
|
||||
|
||||
const loadFldngHstryFeatureClickDetail = useCallback(
|
||||
async (fldngHstryFid: string) => {
|
||||
const cacheKey = `${FLDNG_HSTRY_LAYER_TYPE}:${fldngHstryFid}`;
|
||||
let request = fldngHstryDetailCacheRef.current.get(cacheKey);
|
||||
if (!request) {
|
||||
request = loadFldngHstryDetail(fldngHstryFid);
|
||||
fldngHstryDetailCacheRef.current.set(cacheKey, request);
|
||||
}
|
||||
|
||||
const detail = await request;
|
||||
if (!detail.complete) fldngHstryDetailCacheRef.current.delete(cacheKey);
|
||||
setFldngHstryDepthRows(detail.fldngHstryDepthRows);
|
||||
setFldngHstryWtlvRows(detail.fldngHstryWtlvRows);
|
||||
setSelectedFldngHstryDepthFid(null);
|
||||
setSelectedFldngHstryWtlvFid(null);
|
||||
return detail;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const headName = useMemo(
|
||||
() => (
|
||||
<div className="flex w-full min-w-0 flex-col items-start gap-2">
|
||||
<span>기본정보</span>
|
||||
{fldngHstryDepthRows.length > 0 && (
|
||||
<div className="flex w-full items-center gap-1">
|
||||
<span className="w-9 shrink-0 text-[1.1rem] font-semibold">
|
||||
심선
|
||||
</span>
|
||||
<FldngHstryScrollButton
|
||||
targetRef={fldngHstryDepthYearListRef}
|
||||
direction={-1}
|
||||
label="이전 연도"
|
||||
>
|
||||
‹
|
||||
</FldngHstryScrollButton>
|
||||
<div
|
||||
ref={fldngHstryDepthYearListRef}
|
||||
className="flex min-w-0 flex-1 gap-2 overflow-x-auto whitespace-nowrap [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
{[...fldngHstryDepthRows]
|
||||
.map((row, index) => ({
|
||||
row,
|
||||
index,
|
||||
year: String(
|
||||
row.fldng_yr ?? row.fldngYr ?? row.yr ?? "",
|
||||
).trim(),
|
||||
}))
|
||||
.filter(({ year }) => Boolean(year))
|
||||
.sort((left, right) => {
|
||||
const yearOrder = left.year.localeCompare(
|
||||
right.year,
|
||||
undefined,
|
||||
{ numeric: true },
|
||||
);
|
||||
return yearOrder !== 0 ? yearOrder : left.index - right.index;
|
||||
})
|
||||
.map(({ row, index, year }) => {
|
||||
const depthFid = String(
|
||||
row.fldng_dpth_map_sn ?? row.fid ?? "",
|
||||
).trim();
|
||||
return (
|
||||
<button
|
||||
key={`${depthFid || "row"}-${index}`}
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (!depthFid) return;
|
||||
setSelectedFldngHstryDepthFid(depthFid);
|
||||
setSelectedFldngHstryWtlvFid(null);
|
||||
await showFldngHstryFeature(
|
||||
fldngHstryFeatureCacheRef.current,
|
||||
GisApp.Module.layer as any,
|
||||
FLDNG_HSTRY_DEPTH_LAYER_TYPE,
|
||||
`${FLDNG_HSTRY_DEPTH_LAYER_TYPE}.${depthFid.replace(new RegExp(`^${FLDNG_HSTRY_DEPTH_LAYER_TYPE}\\.`), "")}`,
|
||||
);
|
||||
}}
|
||||
className={`shrink-0 rounded border px-3 py-1 text-[1.2rem] font-medium leading-5 ${selectedFldngHstryDepthFid === depthFid ? "border-blue-600 bg-white text-blue-700" : "border-border bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
{year}년
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<FldngHstryScrollButton
|
||||
targetRef={fldngHstryDepthYearListRef}
|
||||
direction={1}
|
||||
label="다음 연도"
|
||||
>
|
||||
›
|
||||
</FldngHstryScrollButton>
|
||||
</div>
|
||||
)}
|
||||
{fldngHstryWtlvRows.length > 0 && (
|
||||
<div className="flex w-full items-center gap-1">
|
||||
<span className="w-9 shrink-0 text-[1.1rem] font-semibold">
|
||||
위선
|
||||
</span>
|
||||
<FldngHstryScrollButton
|
||||
targetRef={fldngHstryWtlvListRef}
|
||||
direction={-1}
|
||||
label="이전 위선"
|
||||
>
|
||||
‹
|
||||
</FldngHstryScrollButton>
|
||||
<div
|
||||
ref={fldngHstryWtlvListRef}
|
||||
className="flex min-w-0 flex-1 gap-2 overflow-x-auto whitespace-nowrap [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
{fldngHstryWtlvRows.map((row, index) => {
|
||||
const waterLevelFid = String(row.fid ?? "").trim();
|
||||
const disasterName = String(
|
||||
row.fldng_dstr_nm ?? row.fldngDstrNm ?? "",
|
||||
).trim();
|
||||
return (
|
||||
<button
|
||||
key={`${waterLevelFid || "wtlv"}-${index}`}
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (!waterLevelFid) return;
|
||||
setSelectedFldngHstryWtlvFid(waterLevelFid);
|
||||
setSelectedFldngHstryDepthFid(null);
|
||||
await showFldngHstryFeature(
|
||||
fldngHstryFeatureCacheRef.current,
|
||||
GisApp.Module.layer as any,
|
||||
FLDNG_HSTRY_LAYER_TYPE,
|
||||
`${FLDNG_HSTRY_LAYER_TYPE}.${waterLevelFid.replace(new RegExp(`^${FLDNG_HSTRY_LAYER_TYPE}\\.`), "")}`,
|
||||
);
|
||||
}}
|
||||
className={`shrink-0 rounded border px-3 py-1 text-[1.2rem] font-medium leading-5 ${selectedFldngHstryWtlvFid === waterLevelFid ? "border-blue-600 bg-white text-blue-700" : "border-border bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
{disasterName || "재해명 없음"}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<FldngHstryScrollButton
|
||||
targetRef={fldngHstryWtlvListRef}
|
||||
direction={1}
|
||||
label="다음 위선"
|
||||
>
|
||||
›
|
||||
</FldngHstryScrollButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
[
|
||||
fldngHstryDepthRows,
|
||||
fldngHstryWtlvRows,
|
||||
selectedFldngHstryDepthFid,
|
||||
selectedFldngHstryWtlvFid,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
hasFldngHstryLinkedFeatures:
|
||||
fldngHstryDepthRows.length > 0 || fldngHstryWtlvRows.length > 0,
|
||||
fldngHstryFeatureClickHeadName: headName,
|
||||
loadFldngHstryFeatureClickDetail,
|
||||
resetFldngHstryFeatureClick,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 화면명: 침수흔적도 레이어 설정
|
||||
* 침수흔적위선·심선의 표시 여부와 조회 연도 범위를 설정한다.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { SelectBox } from "@common/business_components/ui";
|
||||
import {
|
||||
FLDNG_HSTRY_MIN_YEAR,
|
||||
type FldngHstryYearRange,
|
||||
} from "./fldngHstryTypes";
|
||||
import { normalizeFldngHstryYearRange } from "./fldngHstryUtils";
|
||||
import LayerSettingCard from "../layerSetting/LayerSettingCard";
|
||||
|
||||
type Props = {
|
||||
yearRange: FldngHstryYearRange;
|
||||
enabled: boolean;
|
||||
onYearRangeChange: (yearRange: FldngHstryYearRange) => void;
|
||||
onEnabledChange: (enabled: boolean) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
/** 침수흔적위선·심선에 공통 적용되는 즉시 반영 침수년도 설정 카드다. */
|
||||
export default function FldngHstryLayerSettingPanel({
|
||||
yearRange,
|
||||
enabled,
|
||||
onYearRangeChange,
|
||||
onEnabledChange,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const normalizedRange = normalizeFldngHstryYearRange(yearRange);
|
||||
const currentYear = new Date().getFullYear();
|
||||
const yearOptions = Array.from(
|
||||
{ length: currentYear - FLDNG_HSTRY_MIN_YEAR + 1 },
|
||||
(_, index) => String(currentYear - index),
|
||||
);
|
||||
const startYearOptions = yearOptions
|
||||
.filter((year) => Number(year) <= Number(normalizedRange.endYear))
|
||||
.map((year) => ({ val: year, text: year }));
|
||||
const endYearOptions = yearOptions
|
||||
.filter((year) => Number(year) >= Number(normalizedRange.startYear))
|
||||
.map((year) => ({ val: year, text: year }));
|
||||
const yearSelectClass =
|
||||
"!h-[26px] w-full !rounded border border-input bg-background px-2 py-0 text-center text-[12px] leading-none text-foreground";
|
||||
|
||||
const changeYear = (key: keyof FldngHstryYearRange, value: string) => {
|
||||
onYearRangeChange(
|
||||
normalizeFldngHstryYearRange({
|
||||
...normalizedRange,
|
||||
[key]: value,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<LayerSettingCard
|
||||
title="침수흔적도"
|
||||
enabled={enabled}
|
||||
onEnabledChange={onEnabledChange}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5 font-medium text-label">
|
||||
<span className="shrink-0">침수년도</span>
|
||||
<div className="flex w-full items-center gap-1.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<SelectBox
|
||||
className="w-full"
|
||||
selectClass={yearSelectClass}
|
||||
label=""
|
||||
selectArray={startYearOptions}
|
||||
value={normalizedRange.startYear}
|
||||
onSelectValue={(value) => changeYear("startYear", String(value))}
|
||||
selectContentClass="z-[101]"
|
||||
labelClass="hidden"
|
||||
/>
|
||||
</div>
|
||||
<span className="px-[1px] text-[0.95rem] font-normal text-muted-foreground">
|
||||
~
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<SelectBox
|
||||
className="w-full"
|
||||
selectClass={yearSelectClass}
|
||||
label=""
|
||||
selectArray={endYearOptions}
|
||||
value={normalizedRange.endYear}
|
||||
onSelectValue={(value) => changeYear("endYear", String(value))}
|
||||
selectContentClass="z-[101]"
|
||||
labelClass="hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LayerSettingCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
/**
|
||||
* 화면명: 침수발생이력 목록
|
||||
* 침수발생이력을 검색하고 엑셀 다운로드 및 지도 위치 확인 기능을 제공한다.
|
||||
*/
|
||||
"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 { fetchAllPages } from "@/shared/lib/fetch-all-pages";
|
||||
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 {
|
||||
FLDNG_HSTRY_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,
|
||||
type FldngHstryApiItem,
|
||||
type FldngHstryFilters,
|
||||
type FldngHstryTableRow,
|
||||
} from "./fldngHstryTypes";
|
||||
import {
|
||||
attachFldngHstryGeometry,
|
||||
buildFldngHstryListQueryString,
|
||||
buildFldngHstryYearOptions,
|
||||
normalizeFldngHstryFilters,
|
||||
toFldngHstryTableRows,
|
||||
} from "./fldngHstryUtils";
|
||||
|
||||
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(`${FLDNG_HSTRY_LAYER_TYPE}.`)) return fid;
|
||||
if (fid) return `${FLDNG_HSTRY_LAYER_TYPE}.${fid}`;
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
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 ||
|
||||
String((row as any).ctpv_nm ?? row.ctpvCd ?? "").trim(),
|
||||
sggNm:
|
||||
resolveOptionText(row.sggCd ?? "", sggOptions, 5) ||
|
||||
row.sggNm ||
|
||||
String((row as any).sgg_nm ?? row.sggCd ?? "").trim(),
|
||||
});
|
||||
|
||||
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.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) ||
|
||||
String(
|
||||
(hydrated as any).ctpvNm ?? (hydrated as any).ctpv_nm ?? "",
|
||||
).trim() ||
|
||||
String(hydrated.ctpvCd ?? "").trim(),
|
||||
sggNm:
|
||||
resolveOptionText(hydrated.sggCd ?? "", sgg as any, 5) ||
|
||||
String(
|
||||
(hydrated as any).sggNm ?? (hydrated as any).sgg_nm ?? "",
|
||||
).trim() ||
|
||||
String(hydrated.sggCd ?? "").trim(),
|
||||
};
|
||||
});
|
||||
|
||||
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: FLDNG_HSTRY_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 fetchAllPages({
|
||||
batchSize: FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE,
|
||||
fetchPage: (page, size) => fetchRows(page + 1, appliedFilters, size),
|
||||
getItems: (page) => page.rows,
|
||||
getTotal: (page) => page.total,
|
||||
});
|
||||
await downloadExcelWithKoreanHeaders({
|
||||
data: buildDownloadRows(response.items) 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-[12rem] 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-[8rem] 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-[11rem]"
|
||||
type="date"
|
||||
value={filters.rainStartDate}
|
||||
min={filters.floodYear ? `${filters.floodYear}-01-01` : undefined}
|
||||
max={filters.floodYear ? `${filters.floodYear}-12-31` : undefined}
|
||||
disabled={!filters.floodYear}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
rainStartDate: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<span className="whitespace-nowrap text-[1.3rem] text-label">
|
||||
침수지구명
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
value={filters.districtName}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
districtName: event.target.value,
|
||||
}))
|
||||
}
|
||||
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>
|
||||
<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="140rem"
|
||||
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,61 @@
|
||||
/** 침수흔적 강우분석과 연결된 심선·위선 목록 조회를 담당한다. */
|
||||
|
||||
import { getData } from "@/components/lib/api";
|
||||
|
||||
import type { FldngHstryDetailCacheValue } from "./fldngHstryTypes";
|
||||
|
||||
const normalizeFldngHstryRainAnalysisPayload = (data: any) => {
|
||||
if (!data || typeof data !== "object") return {};
|
||||
return {
|
||||
rain_bgng_tm: data.rainBgngTm ?? data.rain_bgng_tm ?? null,
|
||||
rainBgngTm: data.rainBgngTm ?? data.rain_bgng_tm ?? null,
|
||||
rain_end_tm: data.rainEndTm ?? data.rain_end_tm ?? null,
|
||||
rainEndTm: data.rainEndTm ?? data.rain_end_tm ?? null,
|
||||
rain_tot: data.rainTot ?? data.rain_tot ?? null,
|
||||
rainTot: data.rainTot ?? data.rain_tot ?? null,
|
||||
rain_str: data.rainStr ?? data.rain_str ?? null,
|
||||
rainStr: data.rainStr ?? data.rain_str ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
export const loadFldngHstryDetail = async (
|
||||
fldngHstryFid: string,
|
||||
): Promise<FldngHstryDetailCacheValue> => {
|
||||
const [rainResult, depthResult] = await Promise.allSettled([
|
||||
getData(`v1/fldng/hstry/rainAnls/${encodeURIComponent(fldngHstryFid)}`),
|
||||
getData(`v1/fldng/hstry/depthMap/${encodeURIComponent(fldngHstryFid)}`),
|
||||
]);
|
||||
|
||||
if (rainResult.status === "rejected") {
|
||||
console.warn("침수강우분석 조회 실패:", rainResult.reason);
|
||||
}
|
||||
if (depthResult.status === "rejected") {
|
||||
console.warn("침수깊이지도 조회 실패:", depthResult.reason);
|
||||
}
|
||||
|
||||
const depthPayload =
|
||||
depthResult.status === "fulfilled"
|
||||
? (depthResult.value?.data ?? depthResult.value)
|
||||
: null;
|
||||
const depthItems = Array.isArray(depthPayload)
|
||||
? depthPayload
|
||||
: depthPayload?.depthMaps;
|
||||
const waterLevelItems = Array.isArray(depthPayload?.waterLevelMaps)
|
||||
? depthPayload.waterLevelMaps
|
||||
: [];
|
||||
|
||||
return {
|
||||
rainAnalysis:
|
||||
rainResult.status === "fulfilled"
|
||||
? normalizeFldngHstryRainAnalysisPayload(rainResult.value)
|
||||
: {},
|
||||
fldngHstryDepthRows: Array.isArray(depthItems)
|
||||
? depthItems.filter((row) => row && typeof row === "object")
|
||||
: [],
|
||||
fldngHstryWtlvRows: waterLevelItems.filter(
|
||||
(row: unknown) => row && typeof row === "object",
|
||||
),
|
||||
complete:
|
||||
rainResult.status === "fulfilled" && depthResult.status === "fulfilled",
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,164 @@
|
||||
/** 침수흔적 화면에서 사용하는 타입·상수와 화면 설정값을 정의한다. */
|
||||
import type { UiHeadProps } from "@common/business_components/ui";
|
||||
|
||||
export const FLDNG_HSTRY_POPUP_KEY = "fldngHstryPopup";
|
||||
export const FLDNG_HSTRY_ROW_SELECT_MESSAGE = "SN_FLOOD_HISTORY_ROW_SELECT";
|
||||
export const FLDNG_HSTRY_LAYER_TYPE = "tb_ms_fldng_wtlv_map";
|
||||
export const FLDNG_HSTRY_DEPTH_LAYER_TYPE = "tb_ms_fldng_dpth_map";
|
||||
export const FLDNG_HSTRY_PREVIEW_LAYER_TYPE = "tb_sn_flood_trace_mock";
|
||||
export const FLDNG_HSTRY_LAYER_NAME = "침수흔적위선";
|
||||
export const FLDNG_HSTRY_DEPTH_LAYER_NAME = "침수흔적심선";
|
||||
export const FLDNG_HSTRY_ROOT_NAME = "침수흔적도";
|
||||
export const FLDNG_HSTRY_AREA_LAYER_ID = "SN_FLOOD_HISTORY_AREA";
|
||||
export const FLDNG_HSTRY_DEPTH_LAYER_ID = "SN_FLOOD_HISTORY_DEPTH";
|
||||
export const FLDNG_HSTRY_PAGE_SIZE = 10;
|
||||
export const FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE = 2000;
|
||||
export const FLDNG_HSTRY_MIN_YEAR = 2002;
|
||||
|
||||
export type FldngHstryDetailCacheValue = {
|
||||
rainAnalysis: Record<string, unknown>;
|
||||
fldngHstryDepthRows: Record<string, unknown>[];
|
||||
fldngHstryWtlvRows: Record<string, unknown>[];
|
||||
complete: boolean;
|
||||
};
|
||||
export type FldngHstryYearRange = { startYear: string; endYear: string };
|
||||
export type FldngHstryFilters = {
|
||||
ctpvCd: string;
|
||||
sggCd: string;
|
||||
floodYear: string;
|
||||
disasterName: string;
|
||||
rainStartDate: string;
|
||||
districtName: string;
|
||||
};
|
||||
export type FldngHstryDepthZone = {
|
||||
zoneId: string;
|
||||
depthLabel: string;
|
||||
color: string;
|
||||
ring: [number, number][];
|
||||
};
|
||||
export type FldngHstryApiItem = {
|
||||
fid: string;
|
||||
fldngMapFid?: string;
|
||||
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: "",
|
||||
rainStartDate: "",
|
||||
districtName: "",
|
||||
};
|
||||
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: "순번" },
|
||||
{ id: "floodYearDisplay", title: "침수\n년도" },
|
||||
{ id: "ctpvNm", title: "시도" },
|
||||
{ id: "sggNm", title: "시군구" },
|
||||
{ id: "disasterName", title: "침수재해명" },
|
||||
{ id: "rainBgngTmDisplay", title: "강우\n시작일시" },
|
||||
{ id: "rainEndTmDisplay", title: "강우\n종료일시" },
|
||||
{ id: "rainTotDisplay", title: "총강우량(mm)" },
|
||||
{ id: "rainStrDisplay", title: "강우강도(mm/hr)" },
|
||||
{ id: "floodHourDisplay", title: "침수시간" },
|
||||
{ id: "districtName", title: "침수지구명" },
|
||||
{ id: "avgFloodDepthDisplay", title: "평균\n침수위" },
|
||||
{ id: "floodAreaDisplay", title: "침수\n면적(ha)" },
|
||||
{ id: "exmnYearDisplay", title: "조사년도" },
|
||||
{ id: "damageAmountDisplay", title: "피해금액" },
|
||||
];
|
||||
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: "피해금액",
|
||||
};
|
||||
@@ -0,0 +1,440 @@
|
||||
/** 침수흔적 검색조건·표시값·geometry·레이어 판별 변환을 제공한다. */
|
||||
|
||||
import { appendApiSearchParams } from "@/shared/util/util";
|
||||
|
||||
import {
|
||||
FLDNG_HSTRY_PAGE_SIZE,
|
||||
FLDNG_HSTRY_DEPTH_LAYER_NAME,
|
||||
FLDNG_HSTRY_DEPTH_LAYER_TYPE,
|
||||
FLDNG_HSTRY_LAYER_NAME,
|
||||
FLDNG_HSTRY_LAYER_TYPE,
|
||||
FLDNG_HSTRY_MIN_YEAR,
|
||||
FLDNG_HSTRY_PREVIEW_LAYER_TYPE,
|
||||
FLDNG_HSTRY_ROOT_NAME,
|
||||
type FldngHstryApiItem,
|
||||
type FldngHstryDepthZone,
|
||||
type FldngHstryFilters,
|
||||
type FldngHstryItem,
|
||||
type FldngHstryTableRow,
|
||||
type FldngHstryYearRange,
|
||||
} from "./fldngHstryTypes";
|
||||
|
||||
export const normalizeFldngHstryWtlvDisplayId = (value: unknown) => {
|
||||
const normalized = String(value ?? "").trim();
|
||||
const prefix = `${FLDNG_HSTRY_LAYER_TYPE}.`;
|
||||
return normalized.startsWith(prefix)
|
||||
? normalized.slice(prefix.length)
|
||||
: normalized;
|
||||
};
|
||||
|
||||
export const normalizeFldngHstryWtlvApiFid = (value: unknown) => {
|
||||
const normalized = String(value ?? "").trim();
|
||||
return normalized ? normalizeFldngHstryWtlvDisplayId(normalized) : "";
|
||||
};
|
||||
|
||||
export const formatFldngHstryDateTime = (
|
||||
dateValue: unknown,
|
||||
timeValue: unknown,
|
||||
) => {
|
||||
const rawDate = String(dateValue ?? "").trim();
|
||||
const rawTime = String(timeValue ?? "").trim();
|
||||
const normalizedDate = /^\d{8}$/.test(rawDate)
|
||||
? `${rawDate.slice(0, 4)}-${rawDate.slice(4, 6)}-${rawDate.slice(6, 8)}`
|
||||
: rawDate;
|
||||
const timeDigits = rawTime.replace(/[^0-9]/g, "");
|
||||
const normalizedTime = !timeDigits
|
||||
? ""
|
||||
: timeDigits.length <= 2
|
||||
? `${timeDigits.padStart(2, "0")}:00`
|
||||
: `${timeDigits.padStart(4, "0").slice(0, 2)}:${timeDigits.padStart(4, "0").slice(2, 4)}`;
|
||||
return [normalizedDate, normalizedTime].filter(Boolean).join(" ");
|
||||
};
|
||||
|
||||
export const buildFldngHstryDefaultYearRange = (
|
||||
currentYear = new Date().getFullYear(),
|
||||
): FldngHstryYearRange => ({
|
||||
startYear: String(FLDNG_HSTRY_MIN_YEAR),
|
||||
endYear: String(currentYear),
|
||||
});
|
||||
|
||||
export const normalizeFldngHstryYearRange = (
|
||||
range?: Partial<FldngHstryYearRange> | null,
|
||||
fallback: FldngHstryYearRange = buildFldngHstryDefaultYearRange(),
|
||||
): FldngHstryYearRange => {
|
||||
const normalizeYear = (value: unknown) =>
|
||||
String(value ?? "")
|
||||
.replace(/[^0-9]/g, "")
|
||||
.slice(0, 4);
|
||||
const startYear = normalizeYear(range?.startYear);
|
||||
const endYear = normalizeYear(range?.endYear);
|
||||
return {
|
||||
startYear: /^\d{4}$/.test(startYear) ? startYear : fallback.startYear,
|
||||
endYear: /^\d{4}$/.test(endYear) ? endYear : fallback.endYear,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildFldngHstryYearCql = (
|
||||
range: FldngHstryYearRange,
|
||||
enabled: boolean,
|
||||
) => {
|
||||
if (!enabled) return "";
|
||||
const normalized = normalizeFldngHstryYearRange(range);
|
||||
return `(fldng_yr >= '${normalized.startYear}' AND fldng_yr <= '${normalized.endYear}')`;
|
||||
};
|
||||
|
||||
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;
|
||||
return [
|
||||
centerX + radiusX * scale * Math.cos(angle),
|
||||
centerY + radiusY * scale * Math.sin(angle),
|
||||
] 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[] => [
|
||||
{
|
||||
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.8, 1.06, 0.84, 0.94, 0.76, 0.98],
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const normalizeDate = (value?: string | null) => {
|
||||
const source = String(value ?? "").trim();
|
||||
return /^\d{8}$/.test(source)
|
||||
? `${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);
|
||||
return Number.isFinite(numeric)
|
||||
? `${numeric.toFixed(digits)}${suffix}`
|
||||
: `0${suffix}`;
|
||||
};
|
||||
|
||||
const formatInteger = (value?: number | null, suffix = "") => {
|
||||
const numeric = Number(value ?? 0);
|
||||
return Number.isFinite(numeric)
|
||||
? `${numeric.toLocaleString("ko-KR")}${suffix}`
|
||||
: `0${suffix}`;
|
||||
};
|
||||
|
||||
const formatOptionalNumber = (
|
||||
value?: number | null,
|
||||
digits = 1,
|
||||
suffix = "",
|
||||
) => {
|
||||
if (value === null || value === undefined) return "-";
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? `${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(),
|
||||
rainStartDate: String(filters.rainStartDate ?? "").trim(),
|
||||
districtName: String(filters.districtName ?? "").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),
|
||||
});
|
||||
appendApiSearchParams(params, {
|
||||
ctpvCd: normalized.ctpvCd === "0" ? "0" : normalized.ctpvCd.slice(0, 2),
|
||||
sggCd: normalized.sggCd === "0" ? "0" : normalized.sggCd.slice(0, 5),
|
||||
fldngYr: normalized.floodYear,
|
||||
fldngDstrNm: normalized.disasterName,
|
||||
rainBgngTm: normalized.rainStartDate,
|
||||
fldngDstrctNm: normalized.districtName,
|
||||
});
|
||||
return params.toString();
|
||||
};
|
||||
|
||||
export const formatFldngHstryArea = (value: number) =>
|
||||
Number(value ?? 0).toLocaleString("ko-KR", {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
export const formatFldngHstryDepth = (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: formatFldngHstryDepth(item.avgFloodDepthM),
|
||||
floodAreaDisplay: formatFldngHstryArea(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 === FLDNG_HSTRY_LAYER_NAME ||
|
||||
name === FLDNG_HSTRY_DEPTH_LAYER_NAME ||
|
||||
layerType === FLDNG_HSTRY_LAYER_TYPE ||
|
||||
layerType === FLDNG_HSTRY_DEPTH_LAYER_TYPE ||
|
||||
layerType === FLDNG_HSTRY_PREVIEW_LAYER_TYPE
|
||||
);
|
||||
};
|
||||
|
||||
export const isFldngHstryWtlvNode = (node: any) => {
|
||||
const name = resolveFldngHstryNodeName(node);
|
||||
const layerType = resolveFldngHstryLayerType(node);
|
||||
return (
|
||||
name === FLDNG_HSTRY_LAYER_NAME || layerType === FLDNG_HSTRY_LAYER_TYPE
|
||||
);
|
||||
};
|
||||
|
||||
export const isFldngHstryDepthNode = (node: any) => {
|
||||
const name = resolveFldngHstryNodeName(node);
|
||||
const layerType = resolveFldngHstryLayerType(node);
|
||||
return (
|
||||
name === FLDNG_HSTRY_DEPTH_LAYER_NAME ||
|
||||
layerType === FLDNG_HSTRY_DEPTH_LAYER_TYPE
|
||||
);
|
||||
};
|
||||
|
||||
export const isFldngHstryRootNode = (node: any) =>
|
||||
resolveFldngHstryNodeName(node) === FLDNG_HSTRY_ROOT_NAME;
|
||||
@@ -0,0 +1,400 @@
|
||||
/**
|
||||
* 화면명: 도시침수지도 목록
|
||||
* 빈도별 도시침수지도를 검색하고 목록·엑셀·지도 보기 기능을 제공한다.
|
||||
*/
|
||||
"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.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/floodDngrMap?${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/floodDngrMap/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/floodDngrMap/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}`}
|
||||
/>
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/** 도시침수지도 화면의 검색 조건, 범례, 컬럼과 응답 변환을 정의한다. */
|
||||
"use client";
|
||||
|
||||
import type { UiHeadProps } from "@common/business_components/ui";
|
||||
import { appendApiSearchParams } from "@/shared/util/util";
|
||||
|
||||
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 = 10;
|
||||
|
||||
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),
|
||||
});
|
||||
appendApiSearchParams(params, {
|
||||
ctpvCd: filters.ctpvCd === "0" ? "0" : filters.ctpvCd.slice(0, 2),
|
||||
sggCd: filters.sggCd === "0" ? "0" : filters.sggCd.slice(0, 5),
|
||||
sbmrgDpthlCd: filters.sbmrgDpthlCd,
|
||||
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;
|
||||
};
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 화면명: 하수관로 편집정보
|
||||
* 편집 중인 하수관로의 기본정보와 입력정보를 표시하고 수정한다.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
@@ -25,9 +29,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 +62,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 +75,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 +171,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 +182,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 +209,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 +246,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 +270,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(() => {
|
||||
@@ -181,7 +287,7 @@ export default function GisEditPipeSessionPopup({
|
||||
|
||||
const fetchMngInstOptions = async () => {
|
||||
try {
|
||||
const result = await getData("v1/fcltppl/mngInsts");
|
||||
const result = await getData("v1/fcltPpl/mngInsts");
|
||||
if (!isMounted) return;
|
||||
setMngInstOptions(Array.isArray(result) ? result : []);
|
||||
} catch {
|
||||
@@ -195,8 +301,7 @@ export default function GisEditPipeSessionPopup({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const mngInstSelectOptions = useMemo(
|
||||
() => {
|
||||
const mngInstSelectOptions = useMemo(() => {
|
||||
const sortedOptions = [...mngInstOptions].sort((a, b) =>
|
||||
String(a.lwrInstNm ?? a.mngInstCd ?? "").localeCompare(
|
||||
String(b.lwrInstNm ?? b.mngInstCd ?? ""),
|
||||
@@ -210,9 +315,7 @@ export default function GisEditPipeSessionPopup({
|
||||
label: option.lwrInstNm || String(option.mngInstCd ?? "").trim(),
|
||||
})),
|
||||
];
|
||||
},
|
||||
[mngInstOptions],
|
||||
);
|
||||
}, [mngInstOptions]);
|
||||
|
||||
const basicInfoFields = useMemo(
|
||||
() =>
|
||||
@@ -239,14 +342,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 +369,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 +407,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,6 +1,13 @@
|
||||
/**
|
||||
* 화면명: 하수관로 편집 세션
|
||||
* 현재 편집 대상과 변경 상태를 표시하고 저장·취소 동작을 제공한다.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { Button } from "@common/components";
|
||||
|
||||
import type { GisEditSessionSnapshot, GisEditTool } from "./gisEditTypes";
|
||||
import { useGisEditOperationLabels } from "./useGisEditCommonCodes";
|
||||
|
||||
type Props = GisEditSessionSnapshot & {
|
||||
onRemoveFocusedChange: () => void;
|
||||
@@ -12,64 +19,76 @@ type Props = GisEditSessionSnapshot & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const TOOL_LABELS: Record<GisEditTool, string> = {
|
||||
ADD: "추가",
|
||||
MOVE: "이동",
|
||||
VERTEX_EDIT: "꼭지점편집",
|
||||
DELETE: "삭제",
|
||||
};
|
||||
|
||||
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 getChangeLabel = (
|
||||
change: GisEditSessionSnapshot["changes"][number],
|
||||
operationLabels: Record<GisEditTool, string>,
|
||||
) =>
|
||||
change.attributeOnly
|
||||
? "상세정보수정"
|
||||
: operationLabels[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 "삭제할 객체가 맞는지 확인하세요.";
|
||||
};
|
||||
|
||||
type ChangeListProps = {
|
||||
changes: GisEditSessionSnapshot["changes"];
|
||||
focusedChangeId: string | null;
|
||||
operationLabels: Record<GisEditTool, string>;
|
||||
onFocusChange: (changeId: string) => void;
|
||||
};
|
||||
|
||||
const ChangeList = ({ changes, focusedChangeId, onFocusChange }: ChangeListProps) => (
|
||||
const ChangeList = ({
|
||||
changes,
|
||||
focusedChangeId,
|
||||
operationLabels,
|
||||
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]">
|
||||
{index + 1}. {getChangeLabel(change)} / {change.objectLabel}
|
||||
<div className="shrink-0 whitespace-nowrap text-[1.1rem] font-semibold leading-4 text-label">
|
||||
{index + 1}. {getChangeLabel(change, operationLabels)} /{" "}
|
||||
{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>
|
||||
))}
|
||||
</>
|
||||
@@ -90,67 +109,87 @@ export default function GisEditSessionPanel({
|
||||
variant = "inline",
|
||||
className = "",
|
||||
}: Props) {
|
||||
// 인라인·윈도우 팝업 세션 모두 동일한 CMS 공통코드 라벨을 공유한다.
|
||||
const operationLabels = useGisEditOperationLabels();
|
||||
const isCompactInline = variant === "inline" && changes.length === 0;
|
||||
|
||||
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-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={`flex shrink-0 justify-between gap-3 ${isCompactInline ? "mb-0 items-center" : "mb-3 items-start"}`}>
|
||||
<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
|
||||
? operationLabels[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>
|
||||
) : (
|
||||
@@ -159,6 +198,7 @@ export default function GisEditSessionPanel({
|
||||
<ChangeList
|
||||
changes={changes}
|
||||
focusedChangeId={focusedChangeId}
|
||||
operationLabels={operationLabels}
|
||||
onFocusChange={onFocusChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
/**
|
||||
* 화면명: 하수관로 편집 도구
|
||||
* 관로 추가·이동·꼭지점 편집·삭제 도구를 선택하고 제어한다.
|
||||
*/
|
||||
"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";
|
||||
import { useGisEditOperationLabels } from "./useGisEditCommonCodes";
|
||||
|
||||
type Props = {
|
||||
isEditMode: boolean;
|
||||
@@ -29,13 +27,12 @@ type Props = {
|
||||
|
||||
const TOOL_BUTTONS: Array<{
|
||||
tool: GisEditTool;
|
||||
label: string;
|
||||
icon: typeof Plus;
|
||||
}> = [
|
||||
{ tool: "ADD", label: "추가", icon: Plus },
|
||||
{ tool: "MOVE", label: "이동", icon: Move },
|
||||
{ tool: "VERTEX_EDIT", label: "꼭지점편집", icon: PenTool },
|
||||
{ tool: "DELETE", label: "삭제", icon: Trash2 },
|
||||
{ tool: "ADD", icon: Plus },
|
||||
{ tool: "MOVE", icon: Move },
|
||||
{ tool: "VERTEX_EDIT", icon: PenTool },
|
||||
{ tool: "DELETE", icon: Trash2 },
|
||||
];
|
||||
|
||||
export default function GisEditToolbar({
|
||||
@@ -45,36 +42,55 @@ export default function GisEditToolbar({
|
||||
canUseTools,
|
||||
canSave,
|
||||
isSaving = false,
|
||||
statusMessage: _statusMessage,
|
||||
statusMessage,
|
||||
allowedTools = [],
|
||||
onClose,
|
||||
onToggleSnap,
|
||||
onSelectTool,
|
||||
onSave,
|
||||
}: Props) {
|
||||
// 버튼의 업무 코드값은 그대로 유지하고 CMS 공통코드명만 화면 라벨로 사용한다.
|
||||
const operationLabels = useGisEditOperationLabels();
|
||||
|
||||
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 !m-0 flex !border-0 !bg-background !p-[0.8rem] shadow-[0_1px_2px_rgba(60,64,67,0.3),0_1px_3px_1px_rgba(60,64,67,0.15)]">
|
||||
<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">
|
||||
{TOOL_BUTTONS.map(({ tool, label, icon: Icon }) => {
|
||||
<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, icon: Icon }) => {
|
||||
const isActive = activeTool === tool;
|
||||
const isToolEnabled =
|
||||
canUseTools && allowedTools.includes(tool);
|
||||
const label = operationLabels[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 +99,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 +107,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 +119,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 +149,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>
|
||||
|
||||
@@ -1,111 +1,2 @@
|
||||
export type GisEditTool = "ADD" | "MOVE" | "VERTEX_EDIT" | "DELETE";
|
||||
|
||||
export type GisEditGeometryType = "Point" | "LineString" | "Polygon";
|
||||
|
||||
export type GisEditPreviewColors = {
|
||||
pointColor: string;
|
||||
lineColor: string;
|
||||
fillColor: string;
|
||||
};
|
||||
|
||||
export type GisEditFeatureRef = {
|
||||
fcltMngNo?: string;
|
||||
mngInstCd?: string;
|
||||
fid?: string;
|
||||
};
|
||||
|
||||
export type GisEditPipeBasicInfo = {
|
||||
[key: string]: string | null | undefined;
|
||||
fcltMngNo?: string | null;
|
||||
mngInstCd?: string | null;
|
||||
instMngNo?: string | null;
|
||||
instlYmd?: string | null;
|
||||
mapshtNo?: string | null;
|
||||
pdmtVl?: string | null;
|
||||
prlgVl?: string | null;
|
||||
usgCd?: string | null;
|
||||
mqltCd?: string | null;
|
||||
avgGrdn?: string | null;
|
||||
drtwMltvArea?: string | null;
|
||||
exclnMltvArea?: string | null;
|
||||
tmptDpth?: string | null;
|
||||
tmptMhlMngNo?: string | null;
|
||||
scendDpth?: string | null;
|
||||
scendMhlMngNo?: string | null;
|
||||
trflnCnt?: string | null;
|
||||
vrtcLen?: string | null;
|
||||
wdthLen?: string | null;
|
||||
instlMbdyCd?: string | null;
|
||||
swtrmtZoneNm?: string | null;
|
||||
mltvZoneNm?: string | null;
|
||||
mltvDtdvNm?: string | null;
|
||||
lineCd?: string | null;
|
||||
floCd?: string | null;
|
||||
layCd?: string | null;
|
||||
};
|
||||
|
||||
export type GisEditLayerInfo = {
|
||||
layerId: string;
|
||||
layerName: string;
|
||||
geometryType: GisEditGeometryType;
|
||||
editableYn: "Y" | "N";
|
||||
allowedTools: GisEditTool[];
|
||||
previewColors: GisEditPreviewColors;
|
||||
disabledReason?: string;
|
||||
};
|
||||
|
||||
export type GisEditChange = {
|
||||
changeId: string;
|
||||
editType: GisEditTool;
|
||||
layerId: string;
|
||||
layerName: string;
|
||||
objectId?: string;
|
||||
fid?: string;
|
||||
baseEditSn?: string;
|
||||
featureRef?: GisEditFeatureRef;
|
||||
objectLabel: string;
|
||||
beforeGeometry?: Record<string, unknown> | null;
|
||||
afterGeometry?: Record<string, unknown> | null;
|
||||
featureProperties?: Record<string, unknown> | null;
|
||||
basicInfo?: GisEditPipeBasicInfo | null;
|
||||
basicInfoSavedAt?: string | null;
|
||||
attributeOnly?: boolean;
|
||||
summary: string;
|
||||
createdAt: string;
|
||||
source: "MAP_SELECTION" | "MAP_CENTER" | "MAP_DRAW";
|
||||
};
|
||||
|
||||
export type GisEditSavePayload = {
|
||||
screenId: string;
|
||||
layerId: string;
|
||||
layerName: string;
|
||||
saveTargetType: "VERSION" | "FINAL";
|
||||
view: {
|
||||
srs: string;
|
||||
bbox: string | null;
|
||||
};
|
||||
changes: Array<{
|
||||
changeId: string;
|
||||
editType: GisEditTool;
|
||||
objectId?: string;
|
||||
clientTempId?: string;
|
||||
fid?: string;
|
||||
baseEditSn?: string;
|
||||
featureRef?: GisEditFeatureRef;
|
||||
attributes?: GisEditPipeBasicInfo | null;
|
||||
beforeGeometry?: Record<string, unknown> | null;
|
||||
afterGeometry?: Record<string, unknown> | null;
|
||||
geometry?: Record<string, unknown> | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type GisEditSessionSnapshot = {
|
||||
isEditMode: boolean;
|
||||
selectedLayerName: string;
|
||||
activeTool: GisEditTool | null;
|
||||
isSnapEnabled: boolean;
|
||||
changes: GisEditChange[];
|
||||
lastSavedPayload: GisEditSavePayload | null;
|
||||
canUndo: boolean;
|
||||
focusedChangeId: string | null;
|
||||
};
|
||||
/** GIS 편집 모델은 기능이 소유하고 기존 화면 import 경로는 유지한다. */
|
||||
export * from "@/features/gisEdit/model/types";
|
||||
|
||||
@@ -1,484 +1,37 @@
|
||||
/** 하수관로 GIS 편집 도구의 선택 상태와 변경 내역을 관리하는 훅이다. */
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import GeoJSON from "ol/format/GeoJSON.js";
|
||||
import {
|
||||
type GisEditInteractionRequest,
|
||||
isActionableChange,
|
||||
isGeometryChange,
|
||||
buildPreviewFocusMessage,
|
||||
FLOW_DIRECTION_SRS,
|
||||
SCREEN_ID,
|
||||
buildPipeSaveAttributes,
|
||||
EMPTY_GIS_EDIT_PIPE_BASIC_INFO,
|
||||
normalizeGeometryType,
|
||||
collectEditableLayers,
|
||||
toGeometryObject,
|
||||
buildBbox,
|
||||
getFeatureLayerId,
|
||||
getFeatureObjectId,
|
||||
getFeatureObjectLabel,
|
||||
getFeatureRef,
|
||||
getFeatureProperties,
|
||||
normalizePipeGeometry,
|
||||
createChangeId,
|
||||
} from "@/features/gisEdit";
|
||||
export { EMPTY_GIS_EDIT_PIPE_BASIC_INFO } from "@/features/gisEdit";
|
||||
|
||||
import type {
|
||||
GisEditChange,
|
||||
GisEditFeatureRef,
|
||||
GisEditGeometryType,
|
||||
GisEditLayerInfo,
|
||||
GisEditPipeBasicInfo,
|
||||
GisEditPreviewColors,
|
||||
GisEditSavePayload,
|
||||
GisEditTool,
|
||||
} from "./gisEditTypes";
|
||||
|
||||
type GisEditInteractionRequest = {
|
||||
changeId: string;
|
||||
editType: "MOVE" | "VERTEX_EDIT";
|
||||
};
|
||||
|
||||
const isActionableChange = (change: GisEditChange) =>
|
||||
!change.attributeOnly || Boolean(change.basicInfoSavedAt);
|
||||
|
||||
const isGeometryChange = (change: GisEditChange) =>
|
||||
!change.attributeOnly && change.editType !== "ADD";
|
||||
|
||||
const buildPreviewFocusMessage = (
|
||||
change: Pick<GisEditChange, "editType" | "layerName" | "objectLabel">,
|
||||
) => {
|
||||
if (change.editType === "MOVE") {
|
||||
return `${change.layerName} ${change.objectLabel} 미리보기 객체를 드래그해 위치를 조정하세요.`;
|
||||
}
|
||||
if (change.editType === "VERTEX_EDIT") {
|
||||
return `${change.layerName} ${change.objectLabel}의 주황색 실꼭지점을 드래그하거나, 선분 위 흰색 midpoint를 끌어 새 꼭지점을 추가하세요.`;
|
||||
}
|
||||
if (change.editType === "DELETE") {
|
||||
return `${change.layerName} ${change.objectLabel} 삭제 미리보기를 유지 중입니다.`;
|
||||
}
|
||||
return `${change.layerName} ${change.objectLabel} 신규 객체 미리보기를 확인하세요.`;
|
||||
};
|
||||
|
||||
const EDITABLE_LAYER_RULES: Record<
|
||||
string,
|
||||
Omit<GisEditLayerInfo, "layerId" | "editableYn">
|
||||
> = {
|
||||
tb_ms_swage_ppl: {
|
||||
layerName: "하수관로",
|
||||
geometryType: "LineString",
|
||||
allowedTools: ["ADD", "MOVE", "VERTEX_EDIT", "DELETE"],
|
||||
previewColors: {
|
||||
pointColor: "rgba(22, 163, 74, 0.92)",
|
||||
lineColor: "rgba(22, 163, 74, 0.95)",
|
||||
fillColor: "rgba(22, 163, 74, 0.18)",
|
||||
},
|
||||
},
|
||||
tb_ms_swage_mhl: {
|
||||
layerName: "하수맨홀",
|
||||
geometryType: "Point",
|
||||
allowedTools: ["ADD", "MOVE", "DELETE"],
|
||||
previewColors: {
|
||||
pointColor: "rgba(5, 150, 105, 0.92)",
|
||||
lineColor: "rgba(5, 150, 105, 0.95)",
|
||||
fillColor: "rgba(5, 150, 105, 0.18)",
|
||||
},
|
||||
},
|
||||
tb_ms_swage_pmstn: {
|
||||
layerName: "하수펌프장",
|
||||
geometryType: "Point",
|
||||
allowedTools: ["ADD", "MOVE", "DELETE"],
|
||||
previewColors: {
|
||||
pointColor: "rgba(59, 130, 246, 0.92)",
|
||||
lineColor: "rgba(59, 130, 246, 0.95)",
|
||||
fillColor: "rgba(59, 130, 246, 0.18)",
|
||||
},
|
||||
},
|
||||
tb_il_swage_prcs_fclt: {
|
||||
layerName: "하수처리시설",
|
||||
geometryType: "Point",
|
||||
allowedTools: ["ADD", "MOVE", "DELETE"],
|
||||
previewColors: {
|
||||
pointColor: "rgba(168, 85, 247, 0.92)",
|
||||
lineColor: "rgba(168, 85, 247, 0.95)",
|
||||
fillColor: "rgba(168, 85, 247, 0.18)",
|
||||
},
|
||||
},
|
||||
tb_ms_swage_prcs_zone: {
|
||||
layerName: "하수처리구역",
|
||||
geometryType: "Polygon",
|
||||
allowedTools: ["ADD", "MOVE", "VERTEX_EDIT", "DELETE"],
|
||||
previewColors: {
|
||||
pointColor: "rgba(249, 115, 22, 0.92)",
|
||||
lineColor: "rgba(249, 115, 22, 0.95)",
|
||||
fillColor: "rgba(249, 115, 22, 0.22)",
|
||||
},
|
||||
},
|
||||
tb_ms_stofcbr: {
|
||||
layerName: "우수토실",
|
||||
geometryType: "Point",
|
||||
allowedTools: ["ADD", "MOVE", "DELETE"],
|
||||
previewColors: {
|
||||
pointColor: "rgba(234, 88, 12, 0.92)",
|
||||
lineColor: "rgba(234, 88, 12, 0.95)",
|
||||
fillColor: "rgba(234, 88, 12, 0.18)",
|
||||
},
|
||||
},
|
||||
om_dt_untrt_swg_rain: {
|
||||
layerName: "중점우수토실",
|
||||
geometryType: "Point",
|
||||
allowedTools: ["ADD", "MOVE", "DELETE"],
|
||||
previewColors: {
|
||||
pointColor: "rgba(220, 38, 38, 0.92)",
|
||||
lineColor: "rgba(220, 38, 38, 0.95)",
|
||||
fillColor: "rgba(220, 38, 38, 0.18)",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const FLOW_DIRECTION_SRS = "EPSG:5179";
|
||||
const SCREEN_ID = "SN_MN010301";
|
||||
const GIS_EDIT_ALLOWED_LAYER_IDS = new Set(["tb_ms_swage_ppl"]);
|
||||
const GIS_EDIT_PIPE_ATTRIBUTE_FIELD_IDS = [
|
||||
"mngInstCd",
|
||||
"fcltMngNo",
|
||||
"instMngNo",
|
||||
"instlYmd",
|
||||
"mapshtNo",
|
||||
"pdmtVl",
|
||||
"prlgVl",
|
||||
"usgCd",
|
||||
"mqltCd",
|
||||
"avgGrdn",
|
||||
"drtwMltvArea",
|
||||
"exclnMltvArea",
|
||||
"tmptDpth",
|
||||
"tmptMhlMngNo",
|
||||
"scendDpth",
|
||||
"scendMhlMngNo",
|
||||
"trflnCnt",
|
||||
"vrtcLen",
|
||||
"wdthLen",
|
||||
"instlMbdyCd",
|
||||
"swtrmtZoneNm",
|
||||
"mltvZoneNm",
|
||||
"mltvDtdvNm",
|
||||
"lineCd",
|
||||
"floCd",
|
||||
"layCd",
|
||||
] as const;
|
||||
|
||||
const buildPipeSaveAttributes = (basicInfo: GisEditPipeBasicInfo | null | undefined) => {
|
||||
if (!basicInfo) return undefined;
|
||||
|
||||
return GIS_EDIT_PIPE_ATTRIBUTE_FIELD_IDS.reduce<GisEditPipeBasicInfo>((acc, fieldId) => {
|
||||
if (!Object.prototype.hasOwnProperty.call(basicInfo, fieldId)) return acc;
|
||||
|
||||
const value = basicInfo[fieldId];
|
||||
const normalizedValue = fieldId === "instlYmd" ? String(value ?? "").replaceAll("-", "") : value;
|
||||
acc[fieldId] =
|
||||
typeof normalizedValue === "string" && normalizedValue.trim() === ""
|
||||
? null
|
||||
: normalizedValue;
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
export const EMPTY_GIS_EDIT_PIPE_BASIC_INFO: GisEditPipeBasicInfo = {
|
||||
mngInstCd: "",
|
||||
fcltMngNo: "",
|
||||
instMngNo: "",
|
||||
instlYmd: "",
|
||||
usgCd: "",
|
||||
instlMbdyCd: "",
|
||||
swtrmtZoneNm: "",
|
||||
mltvZoneNm: "",
|
||||
mltvDtdvNm: "",
|
||||
lineCd: "",
|
||||
floCd: "",
|
||||
layCd: "",
|
||||
};
|
||||
|
||||
const normalizeLayerIdCandidate = (value: unknown) => {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) return "";
|
||||
|
||||
const withoutNamespace = raw.includes(":") ? raw.split(":").pop() ?? raw : raw;
|
||||
return withoutNamespace.split(".")[0] ?? withoutNamespace;
|
||||
};
|
||||
|
||||
// 현재 레이어 트리 구조 안에서 실제 편집 대상으로 풀 수 있는 레이어만 추출한다.
|
||||
const getLayerTypeName = (layerInfo: any) =>
|
||||
String(layerInfo?.lyrTyNm ?? layerInfo?.lyrTypeNm ?? layerInfo?.lyr_type_nm ?? "").trim();
|
||||
|
||||
const getLayerName = (layerInfo: any) =>
|
||||
String(layerInfo?.lyrNm ?? layerInfo?.name ?? "").trim();
|
||||
|
||||
const normalizeGeometryType = (value: unknown): GisEditGeometryType => {
|
||||
const normalized = String(value ?? "").toLowerCase();
|
||||
if (normalized.includes("polygon")) return "Polygon";
|
||||
if (normalized.includes("line")) return "LineString";
|
||||
return "Point";
|
||||
};
|
||||
|
||||
const getColorString = (value: unknown) => {
|
||||
const resolved = String(value ?? "").trim();
|
||||
return resolved || null;
|
||||
};
|
||||
|
||||
const withAlpha = (color: string | null | undefined, alpha: number) => {
|
||||
const resolvedColor = String(color ?? "").trim();
|
||||
if (!resolvedColor) return null;
|
||||
|
||||
const rgbaMatch = resolvedColor.match(/^rgba?\(([^)]+)\)$/i);
|
||||
if (rgbaMatch) {
|
||||
const [red = "40", green = "90", blue = "180"] = (rgbaMatch[1] ?? "")
|
||||
.split(",")
|
||||
.map((part) => part.trim());
|
||||
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
||||
}
|
||||
|
||||
const hexMatch = resolvedColor.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
|
||||
if (hexMatch) {
|
||||
const hex = hexMatch[1] ?? "";
|
||||
const normalized = hex.length === 3
|
||||
? hex.split("").map((char) => char + char).join("")
|
||||
: hex;
|
||||
const red = parseInt(normalized.slice(0, 2), 16);
|
||||
const green = parseInt(normalized.slice(2, 4), 16);
|
||||
const blue = parseInt(normalized.slice(4, 6), 16);
|
||||
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
||||
}
|
||||
|
||||
return resolvedColor;
|
||||
};
|
||||
|
||||
const resolvePreviewColors = (
|
||||
layerInfo: any,
|
||||
fallback: GisEditPreviewColors,
|
||||
): GisEditPreviewColors => {
|
||||
const lineColor =
|
||||
withAlpha(
|
||||
getColorString(
|
||||
layerInfo?.lineColr ??
|
||||
layerInfo?.line_colr ??
|
||||
layerInfo?.lineColor ??
|
||||
layerInfo?.fontInsdColr ??
|
||||
layerInfo?.font_insd_colr,
|
||||
),
|
||||
0.95,
|
||||
) ?? fallback.lineColor;
|
||||
const pointColor =
|
||||
withAlpha(
|
||||
getColorString(
|
||||
layerInfo?.lineColr ??
|
||||
layerInfo?.line_colr ??
|
||||
layerInfo?.fontInsdColr ??
|
||||
layerInfo?.font_insd_colr ??
|
||||
layerInfo?.fontExtnlColr ??
|
||||
layerInfo?.font_extnl_colr,
|
||||
),
|
||||
0.92,
|
||||
) ?? fallback.pointColor;
|
||||
const fillColor =
|
||||
withAlpha(
|
||||
getColorString(
|
||||
layerInfo?.spacColr ??
|
||||
layerInfo?.spac_colr ??
|
||||
layerInfo?.spaceColor ??
|
||||
layerInfo?.lineColr ??
|
||||
layerInfo?.line_colr,
|
||||
),
|
||||
0.22,
|
||||
) ?? fallback.fillColor;
|
||||
|
||||
return {
|
||||
pointColor,
|
||||
lineColor,
|
||||
fillColor,
|
||||
};
|
||||
};
|
||||
|
||||
// 현행 운영 기준상 GIS 편집은 하수관로만 다루므로 treeData에서도 해당 레이어만 추린다.
|
||||
const collectEditableLayers = (treeData: any[]): GisEditLayerInfo[] => {
|
||||
const results = new Map<string, GisEditLayerInfo>();
|
||||
|
||||
const visit = (items: any[]) => {
|
||||
items.forEach((item) => {
|
||||
const data = item?.data ?? item;
|
||||
const layerTypeName = getLayerTypeName(data);
|
||||
const layerRule = EDITABLE_LAYER_RULES[layerTypeName];
|
||||
|
||||
if (
|
||||
layerRule &&
|
||||
GIS_EDIT_ALLOWED_LAYER_IDS.has(layerTypeName) &&
|
||||
!results.has(layerTypeName)
|
||||
) {
|
||||
results.set(layerTypeName, {
|
||||
layerId: layerTypeName,
|
||||
layerName: layerRule.layerName,
|
||||
geometryType: layerRule.geometryType,
|
||||
editableYn: "Y",
|
||||
allowedTools: layerRule.allowedTools,
|
||||
previewColors: resolvePreviewColors(data, layerRule.previewColors),
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(item?.children)) {
|
||||
visit(item.children);
|
||||
}
|
||||
if (Array.isArray(data?.children)) {
|
||||
visit(data.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
visit(Array.isArray(treeData) ? treeData : []);
|
||||
|
||||
if (results.size === 0) {
|
||||
Object.entries(EDITABLE_LAYER_RULES).forEach(([layerId, layerRule]) => {
|
||||
if (!GIS_EDIT_ALLOWED_LAYER_IDS.has(layerId)) return;
|
||||
results.set(layerId, {
|
||||
layerId,
|
||||
layerName: layerRule.layerName,
|
||||
geometryType: layerRule.geometryType,
|
||||
editableYn: "Y",
|
||||
allowedTools: layerRule.allowedTools,
|
||||
previewColors: layerRule.previewColors,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(results.values());
|
||||
};
|
||||
|
||||
// 지도에서 선택한 OpenLayers Feature의 현재 geometry를 저장 payload 형태로 스냅샷한다.
|
||||
const toGeometryObject = (featureData: any) => {
|
||||
if (!featureData || typeof featureData.getGeometry !== "function") return null;
|
||||
|
||||
try {
|
||||
return new GeoJSON().writeGeometryObject(featureData.getGeometry(), {
|
||||
featureProjection: FLOW_DIRECTION_SRS,
|
||||
dataProjection: FLOW_DIRECTION_SRS,
|
||||
}) as Record<string, unknown>;
|
||||
} catch (error) {
|
||||
console.error("GIS 편집 geometry 변환 실패:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 저장 시점의 지도 범위를 함께 남겨 BE가 편집 세션 컨텍스트를 해석할 수 있게 한다.
|
||||
const buildBbox = (map: any) => {
|
||||
try {
|
||||
const size = map?.getSize?.();
|
||||
const view = map?.getView?.();
|
||||
if (!size || !view?.calculateExtent) return null;
|
||||
return view.calculateExtent(size).map((value: number) => value.toFixed(3)).join(",");
|
||||
} catch (error) {
|
||||
console.error("GIS 편집 BBOX 계산 실패:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getFeatureLayerId = (featureData: any) => {
|
||||
if (typeof featureData?.get === "function") {
|
||||
const previewLayerId = normalizeLayerIdCandidate(featureData.get("layerId"));
|
||||
if (previewLayerId) return previewLayerId;
|
||||
}
|
||||
if (typeof featureData?.getId === "function") {
|
||||
return normalizeLayerIdCandidate(featureData.getId());
|
||||
}
|
||||
if (typeof featureData?.id_ === "string") {
|
||||
return normalizeLayerIdCandidate(featureData.id_);
|
||||
}
|
||||
return normalizeLayerIdCandidate(featureData?.lyrTypeNm ?? featureData?.lyrTyNm ?? "");
|
||||
};
|
||||
|
||||
const stripLayerPrefix = (value: unknown) => {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) return "";
|
||||
|
||||
const withoutNamespace = raw.includes(":") ? raw.split(":").pop() ?? raw : raw;
|
||||
if (withoutNamespace.startsWith("tb_ms_swage_ppl.")) {
|
||||
return withoutNamespace.slice("tb_ms_swage_ppl.".length);
|
||||
}
|
||||
return withoutNamespace;
|
||||
};
|
||||
|
||||
const getFeatureFid = (featureData: any) =>
|
||||
stripLayerPrefix(
|
||||
featureData?.get?.("fid") ??
|
||||
featureData?.values_?.fid ??
|
||||
featureData?.fid ??
|
||||
featureData?.getId?.() ??
|
||||
featureData?.id_ ??
|
||||
"",
|
||||
);
|
||||
|
||||
const getFeatureObjectId = (featureData: any) =>
|
||||
String(
|
||||
getFeatureFid(featureData) ||
|
||||
String(
|
||||
featureData?.get?.("objectId") ??
|
||||
featureData?.values_?.fclt_mng_no ??
|
||||
featureData?.values_?.swtrmt_fclt_cd ??
|
||||
featureData?.fcltMngNo ??
|
||||
featureData?.fid ??
|
||||
"",
|
||||
),
|
||||
).trim();
|
||||
|
||||
const getFeatureObjectLabel = (featureData: any) => {
|
||||
const values = featureData?.values_ ?? {};
|
||||
return String(
|
||||
featureData?.get?.("fclt_mng_no") ??
|
||||
values?.fclt_mng_no ??
|
||||
featureData?.fcltMngNo ??
|
||||
featureData?.get?.("objectId") ??
|
||||
values?.swtrmt_fclt_cd ??
|
||||
getFeatureFid(featureData) ??
|
||||
"",
|
||||
).trim();
|
||||
};
|
||||
|
||||
const getFeatureRef = (featureData: any): GisEditFeatureRef | undefined => {
|
||||
const mapValues = featureData?.values_ ?? {};
|
||||
const featureRef: GisEditFeatureRef = {
|
||||
fcltMngNo: String(
|
||||
featureData?.get?.("fclt_mng_no") ??
|
||||
mapValues?.fclt_mng_no ??
|
||||
featureData?.fcltMngNo ??
|
||||
"",
|
||||
).trim() || undefined,
|
||||
mngInstCd: String(
|
||||
featureData?.get?.("mng_inst_cd") ??
|
||||
mapValues?.mng_inst_cd ??
|
||||
featureData?.mngInstCd ??
|
||||
"",
|
||||
).trim() || undefined,
|
||||
fid: getFeatureFid(featureData) || undefined,
|
||||
};
|
||||
|
||||
if (!featureRef.fcltMngNo && !featureRef.mngInstCd && !featureRef.fid) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return featureRef;
|
||||
};
|
||||
|
||||
// 선택한 원본 관로의 속성 snapshot을 보관해 preview 재선택 시 상세정보가 비지 않게 한다.
|
||||
const getFeatureProperties = (featureData: any): Record<string, unknown> | null => {
|
||||
const values = featureData?.values_ ?? featureData?.getProperties?.() ?? featureData;
|
||||
if (!values || typeof values !== "object") return null;
|
||||
|
||||
const { geometry, ...properties } = values;
|
||||
return properties;
|
||||
};
|
||||
|
||||
const normalizePipeGeometry = (
|
||||
layerId: string,
|
||||
geometry: Record<string, unknown> | null,
|
||||
): Record<string, unknown> | null => {
|
||||
if (layerId !== "tb_ms_swage_ppl" || !geometry) return geometry;
|
||||
if (geometry.type !== "LineString") return geometry;
|
||||
|
||||
const coordinates = geometry.coordinates;
|
||||
if (!Array.isArray(coordinates)) return geometry;
|
||||
|
||||
return {
|
||||
...geometry,
|
||||
type: "MultiLineString",
|
||||
coordinates: [coordinates],
|
||||
};
|
||||
};
|
||||
|
||||
const createChangeId = (prefix: string, sequence: number) => {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return `${prefix}-${crypto.randomUUID()}`;
|
||||
}
|
||||
return `${prefix}-${Date.now()}-${sequence}`;
|
||||
};
|
||||
|
||||
export const useGisEdit = (treeData: any[]) => {
|
||||
const editableLayers = useMemo(() => collectEditableLayers(treeData), [treeData]);
|
||||
// GIS 편집 모드 on/off는 우측 분석 메뉴의 GIS 편집 버튼과 직접 연결된다.
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/** 하수관로 GIS 편집에 필요한 공통코드를 조회하는 훅이다. */
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { getCommonCodes } from "@/components/lib/common/apiHelpers";
|
||||
|
||||
import type { GisEditTool } from "./gisEditTypes";
|
||||
|
||||
const GIS_EDIT_OPERATION_UP_CD = "GISEDITOPRT";
|
||||
|
||||
/** CMS 조회 실패 시 기존 GIS 편집 화면 동작과 문구를 유지하기 위한 최소 fallback이다. */
|
||||
export const DEFAULT_GIS_EDIT_OPERATION_LABELS: Record<GisEditTool, string> = {
|
||||
ADD: "추가",
|
||||
MOVE: "이동",
|
||||
VERTEX_EDIT: "꼭지점편집",
|
||||
DELETE: "삭제",
|
||||
};
|
||||
|
||||
let cachedOperationLabels: Record<GisEditTool, string> | null = null;
|
||||
let operationLabelsPromise: Promise<Record<GisEditTool, string>> | null = null;
|
||||
|
||||
const isGisEditTool = (value: string): value is GisEditTool =>
|
||||
value === "ADD" ||
|
||||
value === "MOVE" ||
|
||||
value === "VERTEX_EDIT" ||
|
||||
value === "DELETE";
|
||||
|
||||
/** 같은 브라우저 세션에서는 GIS 편집작업코드를 한 번만 조회해 툴바와 세션이 공유한다. */
|
||||
const loadGisEditOperationLabels = async () => {
|
||||
if (cachedOperationLabels) return cachedOperationLabels;
|
||||
|
||||
if (!operationLabelsPromise) {
|
||||
operationLabelsPromise = getCommonCodes(GIS_EDIT_OPERATION_UP_CD)
|
||||
.then((codes) => {
|
||||
const labels = { ...DEFAULT_GIS_EDIT_OPERATION_LABELS };
|
||||
codes.forEach((code) => {
|
||||
if (isGisEditTool(code.lwpoCd) && code.lwpoCdNm) {
|
||||
labels[code.lwpoCd] = code.lwpoCdNm;
|
||||
}
|
||||
});
|
||||
cachedOperationLabels = labels;
|
||||
return labels;
|
||||
})
|
||||
.finally(() => {
|
||||
operationLabelsPromise = null;
|
||||
});
|
||||
}
|
||||
|
||||
return operationLabelsPromise;
|
||||
};
|
||||
|
||||
/** GIS 편집 도구 코드값은 유지하고 사용자 표시명만 CMS 공통코드로 교체한다. */
|
||||
export const useGisEditOperationLabels = () => {
|
||||
const [operationLabels, setOperationLabels] = useState<
|
||||
Record<GisEditTool, string>
|
||||
>(cachedOperationLabels ?? DEFAULT_GIS_EDIT_OPERATION_LABELS);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
loadGisEditOperationLabels()
|
||||
.then((labels) => {
|
||||
if (!cancelled) setOperationLabels(labels);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("GIS 편집작업 공통코드 조회에 실패했습니다.", error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return operationLabels;
|
||||
};
|
||||
@@ -0,0 +1,577 @@
|
||||
/**
|
||||
* 화면명: GIS 업로드 미리보기 지도
|
||||
* 업로드한 공간정보를 지도에 미리 표시하고 검증 결과를 확인한다.
|
||||
*/
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/** GIS 관리 화면에 필요한 공통코드를 조회하고 선택 항목으로 제공한다. */
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { getCommonCodes } from "@/components/lib/common/apiHelpers";
|
||||
|
||||
type CodeLabelMap = Record<string, string>;
|
||||
|
||||
const COMMON_CODE_GROUPS = {
|
||||
uploadStatus: "GISULDSTTS",
|
||||
downloadStatus: "GISDWNLDSTTS",
|
||||
facilityType: "SNFCLTSE",
|
||||
} as const;
|
||||
|
||||
const DEFAULT_UPLOAD_STATUS_LABELS: CodeLabelMap = {
|
||||
COMPLETED: "업로드완료",
|
||||
VALIDATION_FAILED: "검증실패",
|
||||
FAILED: "업로드실패",
|
||||
};
|
||||
|
||||
const DEFAULT_DOWNLOAD_STATUS_LABELS: CodeLabelMap = {
|
||||
QUEUED: "작업대기",
|
||||
RUNNING: "파일생성중",
|
||||
READY: "다운로드준비완료",
|
||||
FAILED: "파일생성실패",
|
||||
};
|
||||
|
||||
const DEFAULT_FACILITY_TYPE_LABELS: CodeLabelMap = {
|
||||
PPL: "하수관로",
|
||||
MNHL: "맨홀",
|
||||
};
|
||||
|
||||
const cachedLabelMaps = new Map<string, CodeLabelMap>();
|
||||
const pendingLabelMaps = new Map<string, Promise<CodeLabelMap>>();
|
||||
|
||||
/** 같은 브라우저 세션에서 각 GIS 관리 코드그룹을 한 번만 조회한다. */
|
||||
const loadCodeLabelMap = async (
|
||||
upCd: string,
|
||||
fallback: CodeLabelMap,
|
||||
): Promise<CodeLabelMap> => {
|
||||
const cached = cachedLabelMaps.get(upCd);
|
||||
if (cached) return cached;
|
||||
|
||||
const pending = pendingLabelMaps.get(upCd);
|
||||
if (pending) return pending;
|
||||
|
||||
const request = getCommonCodes(upCd)
|
||||
.then((codes) => {
|
||||
const labels = { ...fallback };
|
||||
codes.forEach((code) => {
|
||||
if (code.lwpoCd && code.lwpoCdNm) {
|
||||
labels[code.lwpoCd] = code.lwpoCdNm;
|
||||
}
|
||||
});
|
||||
cachedLabelMaps.set(upCd, labels);
|
||||
return labels;
|
||||
})
|
||||
.finally(() => {
|
||||
pendingLabelMaps.delete(upCd);
|
||||
});
|
||||
|
||||
pendingLabelMaps.set(upCd, request);
|
||||
return request;
|
||||
};
|
||||
|
||||
/** GIS 관리 화면의 코드값은 유지하고 상태·시설구분 표시명만 CMS 기준으로 제공한다. */
|
||||
export const useGisManagementCommonCodes = () => {
|
||||
const [uploadStatusLabels, setUploadStatusLabels] =
|
||||
useState<CodeLabelMap>(
|
||||
cachedLabelMaps.get(COMMON_CODE_GROUPS.uploadStatus) ??
|
||||
DEFAULT_UPLOAD_STATUS_LABELS,
|
||||
);
|
||||
const [downloadStatusLabels, setDownloadStatusLabels] =
|
||||
useState<CodeLabelMap>(
|
||||
cachedLabelMaps.get(COMMON_CODE_GROUPS.downloadStatus) ??
|
||||
DEFAULT_DOWNLOAD_STATUS_LABELS,
|
||||
);
|
||||
const [facilityTypeLabels, setFacilityTypeLabels] =
|
||||
useState<CodeLabelMap>(
|
||||
cachedLabelMaps.get(COMMON_CODE_GROUPS.facilityType) ??
|
||||
DEFAULT_FACILITY_TYPE_LABELS,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const applyLabels = (
|
||||
upCd: string,
|
||||
fallback: CodeLabelMap,
|
||||
setter: (labels: CodeLabelMap) => void,
|
||||
) => {
|
||||
loadCodeLabelMap(upCd, fallback)
|
||||
.then((labels) => {
|
||||
if (!cancelled) setter(labels);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`${upCd} 공통코드 조회에 실패했습니다.`, error);
|
||||
});
|
||||
};
|
||||
|
||||
applyLabels(
|
||||
COMMON_CODE_GROUPS.uploadStatus,
|
||||
DEFAULT_UPLOAD_STATUS_LABELS,
|
||||
setUploadStatusLabels,
|
||||
);
|
||||
applyLabels(
|
||||
COMMON_CODE_GROUPS.downloadStatus,
|
||||
DEFAULT_DOWNLOAD_STATUS_LABELS,
|
||||
setDownloadStatusLabels,
|
||||
);
|
||||
applyLabels(
|
||||
COMMON_CODE_GROUPS.facilityType,
|
||||
DEFAULT_FACILITY_TYPE_LABELS,
|
||||
setFacilityTypeLabels,
|
||||
);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
uploadStatusLabels,
|
||||
downloadStatusLabels,
|
||||
facilityTypeLabels,
|
||||
};
|
||||
};
|
||||
@@ -1,14 +1,30 @@
|
||||
/**
|
||||
* 화면명: 침입수 분석결과 목록
|
||||
* 침입수 분석결과를 검색하고 목록·엑셀·지도 보기 기능을 제공한다.
|
||||
*/
|
||||
"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,
|
||||
type UiTableSort,
|
||||
} from "@common/business_components";
|
||||
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
|
||||
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
import { getData } from "@/components/lib/api";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import { appendApiSearchParams } from "@/shared/util/util";
|
||||
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,18 +34,23 @@ import {
|
||||
|
||||
const INFTWT_RESULT_PAGE_SIZE = 10;
|
||||
|
||||
const INFTWT_RESULT_HEADLIST = [
|
||||
{ id: "rowNumber", title: "일련번호" },
|
||||
const INFTWT_RESULT_HEADLIST: UiHeadProps[] = [
|
||||
{
|
||||
id: "rowNumber",
|
||||
title: "순번",
|
||||
sortable: true,
|
||||
sortKey: "inftwtAnlsSn",
|
||||
},
|
||||
{ id: "ctpvNm", title: "시도" },
|
||||
{ id: "sggNm", title: "시군구" },
|
||||
{ id: "swtrmtFcltNm", title: "하수처리시설" },
|
||||
{ id: "aplcnYm", title: "적용연월" },
|
||||
{ id: "drtwSwtcRt", title: "오수 전환율" },
|
||||
{ id: "anlsInfo", title: "침입수 분석정보" },
|
||||
{ id: "regDt", title: "분석일자" },
|
||||
{ id: "swtrmtFcltNm", title: "하수처리시설", sortable: true },
|
||||
{ id: "aplcnYm", title: "적용연월", sortable: true },
|
||||
{ id: "drtwSwtcRt", title: "오수 전환율", sortable: true },
|
||||
{ id: "anlsInfo", title: "침입수 분석정보", sortable: true },
|
||||
{ id: "regDt", title: "분석일자", sortable: true },
|
||||
{ id: "rgtrNm", title: "분석자" },
|
||||
{ id: "scsYn", title: "분석성공여부" },
|
||||
] as const;
|
||||
{ id: "scsYn", title: "분석성공여부", sortable: true },
|
||||
];
|
||||
|
||||
type InftwtResultFilters = {
|
||||
ctpvCd: string;
|
||||
@@ -82,6 +103,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 +111,7 @@ 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 [sort, setSort] = useState<UiTableSort>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -98,31 +120,49 @@ export default function InftwtResultPopupPage() {
|
||||
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
|
||||
|
||||
const fetchRows = useCallback(
|
||||
async (page: number, nextFilters: InftwtResultFilters) => {
|
||||
async (
|
||||
page: number,
|
||||
nextFilters: InftwtResultFilters,
|
||||
nextSort: UiTableSort | undefined = sort,
|
||||
) => {
|
||||
const queryParams = new URLSearchParams({
|
||||
page: String(page - 1),
|
||||
size: String(INFTWT_RESULT_PAGE_SIZE),
|
||||
sort: "inftwtAnlsSn,desc",
|
||||
scsYn: "Y",
|
||||
});
|
||||
queryParams.append(
|
||||
"sort",
|
||||
nextSort
|
||||
? `${nextSort.key},${nextSort.direction}`
|
||||
: "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());
|
||||
appendApiSearchParams(queryParams, {
|
||||
aplcnYmFrom: nextFilters.aplcnYmFrom,
|
||||
aplcnYmTo: nextFilters.aplcnYmTo,
|
||||
ctpvCd: nextFilters.ctpvCd,
|
||||
sggCd: nextFilters.sggCd,
|
||||
swtrmtFcltNm: nextFilters.swtrmtFcltNm,
|
||||
});
|
||||
|
||||
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,17 +171,21 @@ export default function InftwtResultPopupPage() {
|
||||
.filter((item: InftwtResultRow) => item.scsYn === "Y");
|
||||
|
||||
setRows(nextRows);
|
||||
setTotal(Number(response?.totalElements ?? nextRows.length));
|
||||
setTotal(totalElements);
|
||||
setCurrentPage(page);
|
||||
},
|
||||
[ctpv, sgg],
|
||||
[ctpv, sgg, sort],
|
||||
);
|
||||
|
||||
const loadRows = useCallback(
|
||||
async (page = 1, nextFilters = filters) => {
|
||||
async (
|
||||
page = 1,
|
||||
nextFilters = filters,
|
||||
nextSort: UiTableSort | undefined = sort,
|
||||
) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await fetchRows(page, nextFilters);
|
||||
await fetchRows(page, nextFilters, nextSort);
|
||||
} catch (error) {
|
||||
console.error("침입수 분석결과 목록 조회 중 오류:", error);
|
||||
alert("침입수 분석결과 목록을 불러오지 못했습니다.");
|
||||
@@ -151,26 +195,40 @@ export default function InftwtResultPopupPage() {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[fetchRows, filters],
|
||||
[alert, fetchRows, filters, sort],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRows(1, DEFAULT_FILTERS);
|
||||
void loadRows(1, DEFAULT_FILTERS, undefined);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleSearch = async (page = 1) => {
|
||||
setSelectedIndex(-1);
|
||||
setSelectedRow(null);
|
||||
await loadRows(page, filters);
|
||||
};
|
||||
|
||||
/** CS 검색 카드와 동일하게 조건과 선택 결과를 기본값으로 되돌린다. */
|
||||
const handleReset = async () => {
|
||||
setFilters(DEFAULT_FILTERS);
|
||||
setSelectedSwagePrcsFclt("0");
|
||||
setSelectedRow(null);
|
||||
setSort(undefined);
|
||||
await loadRows(1, DEFAULT_FILTERS, undefined);
|
||||
};
|
||||
|
||||
/** 정렬 변경 시 선택 결과를 해제하고 서버 첫 페이지를 조회한다. */
|
||||
const handleSortChange = (nextSort?: UiTableSort) => {
|
||||
setSort(nextSort);
|
||||
setSelectedRow(null);
|
||||
void loadRows(1, filters, nextSort);
|
||||
};
|
||||
|
||||
const handleRowClick = (index?: number) => {
|
||||
if (typeof index !== "number") return;
|
||||
const row = rows[index];
|
||||
if (!row) return;
|
||||
|
||||
setSelectedIndex(index);
|
||||
setSelectedRow(row);
|
||||
postRowSelectMessage(row);
|
||||
};
|
||||
@@ -183,16 +241,24 @@ 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 fetchAllPages<any, Record<string, unknown>>({
|
||||
fetchPage: (page, size) =>
|
||||
getData(
|
||||
`v1/inftwtAnlsRslt/ppl/${selectedRow.inftwtAnlsSn}?page=${page}&size=${size}`,
|
||||
),
|
||||
getItems: (page) =>
|
||||
Array.isArray(page?.content) ? page.content : [],
|
||||
getTotal: (page) => Number(page?.totalElements ?? 0),
|
||||
});
|
||||
const excelRows = response.items;
|
||||
|
||||
await downloadExcelWithKoreanHeaders({
|
||||
data: excelRows as JSON[],
|
||||
data: excelRows as unknown as JSON[],
|
||||
fileName: `침입수분석결과_${selectedRow.swtrmtFcltNm}_${selectedRow.aplcnYm}_${selectedRow.inftwtAnlsSn}`,
|
||||
sheetNameArray: ["침입수 분석결과"],
|
||||
headerRange: "A1:D1",
|
||||
headerMapping: {
|
||||
inftwtAnlsSn: "침입수분석\n일련번호",
|
||||
inftwtAnlsSn: "침입수분석\n분석번호",
|
||||
pplMngNo: "관로\n관리번호",
|
||||
anlsTm: "분석시각",
|
||||
predcOilq: "예측유량\n(m³/h)",
|
||||
@@ -207,17 +273,16 @@ 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>
|
||||
<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,
|
||||
@@ -228,10 +293,12 @@ export default function InftwtResultPopupPage() {
|
||||
}));
|
||||
},
|
||||
}}
|
||||
selectClass="w-[145px]"
|
||||
selectClass="w-[145px] !text-[1.3rem]"
|
||||
startYear={1900}
|
||||
/>
|
||||
<span className="text-[1.1rem] font-semibold text-muted-foreground">~</span>
|
||||
<span className="text-[1.1rem] font-semibold text-muted-foreground">
|
||||
~
|
||||
</span>
|
||||
<DateMonthPicker
|
||||
field={{
|
||||
value: filters.aplcnYmTo,
|
||||
@@ -242,13 +309,16 @@ export default function InftwtResultPopupPage() {
|
||||
}));
|
||||
},
|
||||
}}
|
||||
selectClass="w-[145px]"
|
||||
selectClass="w-[145px] !text-[1.3rem]"
|
||||
startYear={1900}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 overflow-x-auto">
|
||||
<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}
|
||||
@@ -290,56 +360,53 @@ export default function InftwtResultPopupPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<div className="ml-auto flex shrink-0 items-center justify-end 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()}
|
||||
color="white"
|
||||
onClick={() => void handleReset()}
|
||||
>
|
||||
다운로드
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopupSearchPanel>
|
||||
|
||||
<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}
|
||||
<MapPopupDataTable
|
||||
rows={rows as Array<InftwtResultRow & Record<string, unknown>>}
|
||||
headlist={INFTWT_RESULT_HEADLIST}
|
||||
title="침입수 분석결과 목록"
|
||||
total={total}
|
||||
cellClick={(index) => handleRowClick(index)}
|
||||
cellClassName={(_, headId) =>
|
||||
headId === "anlsInfo"
|
||||
? "whitespace-normal break-all leading-[1.35]"
|
||||
: ""
|
||||
}
|
||||
page={currentPage}
|
||||
pageSize={INFTWT_RESULT_PAGE_SIZE}
|
||||
onPageChange={(page) => void handleSearch(page)}
|
||||
curPage={currentPage}
|
||||
index={selectedIndex}
|
||||
widthFixed={true}
|
||||
onRowClick={(_, index) => handleRowClick(index)}
|
||||
rowId={(row) => String(row.inftwtAnlsSn)}
|
||||
scrollMode="horizontal"
|
||||
minTableWidth="110rem"
|
||||
sort={sort}
|
||||
onSortChange={handleSortChange}
|
||||
actions={
|
||||
<Button type="button" onClick={() => void handleDownload()}>
|
||||
엑셀다운로드
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedRow ? (
|
||||
<div className="mt-3 rounded-md border border-border bg-secondary px-4 py-3 text-[1rem] text-label">
|
||||
<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>
|
||||
<span className="font-semibold text-foreground">
|
||||
{selectedRow.anlsInfo}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</main>
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/** 침입수 분석결과 화면의 타입·검색 조건·컬럼과 응답 변환을 정의한다. */
|
||||
"use client";
|
||||
|
||||
export const INFTWT_RESULT_ROW_SELECT_MESSAGE = "SN_INFTWT_RESULT_ROW_SELECT";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
/** 침입수 분석결과 선택 시 부모 지도에 결과 레이어를 표시하는 훅이다. */
|
||||
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react";
|
||||
import GeoJSON from "ol/format/GeoJSON.js";
|
||||
import VectorLayer from "ol/layer/Vector.js";
|
||||
@@ -11,6 +12,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 +196,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);
|
||||
@@ -387,13 +390,6 @@ export const useInftwtResultMap = ({
|
||||
pipeLayerRef.current?.setVisible?.(true);
|
||||
pipeLayerRef.current?.changed?.();
|
||||
gisMaps.current?.renderSync?.();
|
||||
|
||||
console.info("移⑥엯??FE overlay ?뚮뜑 寃곌낵", {
|
||||
focusTargetType: mapView.focusTargetType,
|
||||
zoneFeatureCount: zoneFeatures.length,
|
||||
pipeFeatureCount: pipeFeatures.length,
|
||||
});
|
||||
|
||||
await moveToStableFocus(mapView);
|
||||
},
|
||||
[clearLayers, ensurePipeLayer, ensureZoneLayer, gisMaps, moveToStableFocus],
|
||||
@@ -433,10 +429,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 +490,11 @@ export const useInftwtResultMap = ({
|
||||
});
|
||||
|
||||
if (!popupWindow) {
|
||||
alert("?앹뾽??李⑤떒?섏뿀?듬땲?? 釉뚮씪?곗? ?앹뾽 ?덉슜 ?ㅼ젙???뺤씤?댁<?몄슂.");
|
||||
alert("팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [activeMapView]);
|
||||
}, [activeMapView, alert]);
|
||||
|
||||
return {
|
||||
activeMapView,
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 화면명: 선택 레이어 설정 카드
|
||||
* 레이어별 설정 제목과 활성화·닫기 영역을 공통 레이아웃으로 제공한다.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
enabled: boolean;
|
||||
onEnabledChange: (enabled: boolean) => void;
|
||||
onClose: () => void;
|
||||
children?: ReactNode;
|
||||
showToggle?: boolean;
|
||||
};
|
||||
|
||||
/** 침수흔적도 설정과 같은 제목·토글·닫기·본문 여백을 제공한다. */
|
||||
export default function LayerSettingCard({
|
||||
title,
|
||||
enabled,
|
||||
onEnabledChange,
|
||||
onClose,
|
||||
children,
|
||||
showToggle = true,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="w-full overflow-visible rounded-[6px] border border-border bg-background shadow-blur">
|
||||
<div className="flex min-h-[28px] items-center justify-between border-b border-border pl-[0.8rem] pr-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-[7px] w-[7px] shrink-0 rotate-45 bg-[#7CCE5C]"
|
||||
/>
|
||||
<span className="truncate text-[12px] font-semibold text-label">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{showToggle ? (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
aria-label={`${title} 적용 여부`}
|
||||
title={enabled ? `${title} 적용 중` : `${title} 미적용`}
|
||||
className={`relative h-[16px] w-[28px] rounded-full border transition-colors ${enabled ? "border-primary bg-primary" : "border-[#94a3b8] bg-[#cbd5e1]"}`}
|
||||
onClick={() => onEnabledChange(!enabled)}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1/2 h-[12px] w-[12px] -translate-y-1/2 rounded-full bg-white shadow-sm transition-[left] ${enabled ? "left-[14px]" : "left-[2px]"}`}
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${title} 설정 닫기`}
|
||||
title="닫기"
|
||||
className="grid h-[20px] w-[20px] place-items-center bg-transparent text-[1.3rem] leading-none text-muted-foreground hover:text-foreground"
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{children ? (
|
||||
<div className="flex flex-col gap-[6px] p-[0.8rem] text-[1.15rem] text-label">
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,24 @@
|
||||
/**
|
||||
* 화면명: 관로현황 목록
|
||||
* 관로분석 집계 항목에 해당하는 개별 관로 목록을 표시한다.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
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 { appendApiSearchParams } from "@/shared/util/util";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import useStore from "@/store/useStore";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
|
||||
import { useUserStore } from "@/shared/lib/use-user-store";
|
||||
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
|
||||
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
|
||||
import { MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_USE_CODES } from "../common/table";
|
||||
import {
|
||||
PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS,
|
||||
PIPE_ANALYSIS_DETAIL_EXCEL_HEADERS,
|
||||
@@ -28,6 +39,8 @@ import {
|
||||
} from "./pipeAnalysisUtils";
|
||||
|
||||
type DetailViewRow = ReturnType<typeof toPipeAnalysisDetailViewRows>[number];
|
||||
const PIPE_ANALYSIS_DETAIL_PAGE_SIZE = 10;
|
||||
const PIPE_ANALYSIS_MAP_WINDOW_NAME = "sn-sewerage-map-root";
|
||||
const PIPE_ANALYSIS_DETAIL_EXPORT_KEYS = [
|
||||
"rowNo",
|
||||
"ctpvNm",
|
||||
@@ -41,11 +54,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,9 +75,34 @@ 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,
|
||||
@@ -69,7 +111,6 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
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,31 +121,44 @@ 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),
|
||||
size: String(size),
|
||||
});
|
||||
|
||||
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.useCd !== "0") params.set("useCd", nextFilters.useCd);
|
||||
if (nextFilters.diameterFrom) params.set("diameterFrom", nextFilters.diameterFrom);
|
||||
if (nextFilters.diameterTo) params.set("diameterTo", nextFilters.diameterTo);
|
||||
appendApiSearchParams(params, {
|
||||
ctpvCd: nextFilters.ctpvCd,
|
||||
sggCd: nextFilters.sggCd,
|
||||
swtrmtZoneNm: nextFilters.swtrmtZoneNm,
|
||||
elapsedYearBucket: baseElapsedYearBucket,
|
||||
materialCode: nextFilters.materialCode,
|
||||
useCd: nextFilters.useCd,
|
||||
diameterFrom: nextFilters.diameterFrom,
|
||||
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/pplAnalysis/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,42 +169,61 @@ 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(
|
||||
if (!targetWindow || targetWindow.closed) return;
|
||||
|
||||
// Chrome가 다른 탭/창의 focus()만 무시하는 경우를 대비해 사용자 클릭 안에서
|
||||
// 기존 최상위 지도창을 고정 이름으로 다시 선택한 뒤 포커스를 요청한다.
|
||||
targetWindow.name = targetWindow.name || PIPE_ANALYSIS_MAP_WINDOW_NAME;
|
||||
const namedTargetWindow =
|
||||
window.open("", targetWindow.name) ?? targetWindow;
|
||||
window.opener?.blur();
|
||||
window.blur();
|
||||
namedTargetWindow.focus();
|
||||
targetWindow.postMessage(
|
||||
{
|
||||
type: PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE,
|
||||
row,
|
||||
@@ -167,8 +240,12 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetchRows(1, filters, Math.max(total, 1000));
|
||||
const exportRows = response.rows.map((row) => ({
|
||||
const response = await fetchAllPages({
|
||||
fetchPage: (page, size) => fetchRows(page + 1, appliedFilters, size),
|
||||
getItems: (page) => page.rows,
|
||||
getTotal: (page) => page.total,
|
||||
});
|
||||
const exportRows = response.items.map((row) => ({
|
||||
rowNo: row.rowNo,
|
||||
ctpvNm: row.ctpvNm,
|
||||
sggNm: row.sggNm,
|
||||
@@ -179,7 +256,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 +269,9 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("관로현황 다운로드 중 오류:", error);
|
||||
alert(resolveErrorMessage(error, "관로현황 다운로드 중 오류가 발생했습니다."));
|
||||
alert(
|
||||
resolveErrorMessage(error, "관로현황 다운로드 중 오류가 발생했습니다."),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -200,15 +280,9 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
const materialOptions = useMemo(() => {
|
||||
const options = [
|
||||
{ value: "0", label: "전체" },
|
||||
...Array.from(
|
||||
new Set(
|
||||
rows
|
||||
.map((row) => String(row.materialCode ?? "").trim())
|
||||
.filter((value) => value && value !== "0"),
|
||||
),
|
||||
).map((value) => ({
|
||||
value,
|
||||
label: resolveMaterialName(value),
|
||||
...MS_SWAGE_PPL_CODES.map((item) => ({
|
||||
value: item.val.trim(),
|
||||
label: item.text,
|
||||
})),
|
||||
];
|
||||
|
||||
@@ -224,41 +298,64 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
}
|
||||
|
||||
return options;
|
||||
}, [baseMaterialCode, rows]);
|
||||
}, [baseMaterialCode]);
|
||||
|
||||
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>
|
||||
<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 !text-[1.1rem]"
|
||||
className="sn-popup-input !h-[3.2rem] w-[14rem] !text-xl"
|
||||
value={filters.useCd}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
@@ -268,17 +365,20 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
}
|
||||
>
|
||||
<option value="0">전체</option>
|
||||
<option value="SBA001">합류관</option>
|
||||
<option value="SBA002">차집관</option>
|
||||
<option value="SBA003">우수관</option>
|
||||
<option value="SBA004">오수관</option>
|
||||
{MS_SWAGE_PPL_USE_CODES.map((item) => (
|
||||
<option key={item.val} value={item.val}>
|
||||
{item.text}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">관재질</div>
|
||||
<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 !text-[1.1rem]"
|
||||
className="sn-popup-input !h-[3.2rem] w-[14rem] !text-xl"
|
||||
value={filters.materialCode}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
@@ -295,10 +395,14 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">관경 시작</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
|
||||
관경(mm)
|
||||
</div>
|
||||
<Input
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
className="sn-popup-input !h-[3.2rem] w-[12rem] !text-xl"
|
||||
aria-label="관경 최소값"
|
||||
placeholder="입력"
|
||||
value={filters.diameterFrom}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
@@ -307,15 +411,14 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
}))
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") handleSearch(1);
|
||||
if (event.key === "Enter") handleSearch();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">관경 종료</div>
|
||||
<span className="shrink-0 text-xl font-semibold text-foreground">~</span>
|
||||
<Input
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
className="sn-popup-input !h-[3.2rem] w-[12rem] !text-xl"
|
||||
aria-label="관경 최대값"
|
||||
placeholder="입력"
|
||||
value={filters.diameterTo}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
@@ -324,46 +427,62 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
}))
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") handleSearch(1);
|
||||
if (event.key === "Enter") handleSearch();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<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)}
|
||||
>
|
||||
<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"
|
||||
size="sm"
|
||||
color="green"
|
||||
className="h-10 px-5 !text-[1.1rem]"
|
||||
onClick={() => void handleDownload()}
|
||||
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>
|
||||
</div>
|
||||
</PopupSearchPanel>
|
||||
|
||||
<UiTable<DetailViewRow>
|
||||
headName={<>관로현황 조회 결과 총 {total.toLocaleString()}건</>}
|
||||
tableData={rows}
|
||||
headlist={PIPE_ANALYSIS_DETAIL_HEADLIST}
|
||||
pageSize={20}
|
||||
<MapPopupDataTable
|
||||
rows={rows as Array<DetailViewRow & Record<string, unknown>>}
|
||||
headlist={PIPE_ANALYSIS_DETAIL_STANDARD_HEADLIST}
|
||||
title="관로현황 목록"
|
||||
total={total}
|
||||
cellClick={(index) => handleRowClick(index)}
|
||||
onPageChange={(page) => handleSearch(page)}
|
||||
curPage={currentPage}
|
||||
index={selectedIndex}
|
||||
widthFixed={true}
|
||||
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>
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
/**
|
||||
* 화면명: 관로분석
|
||||
* 행정구역·처리구역과 분석구분별 관로 집계 결과를 조회한다.
|
||||
*/
|
||||
"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 UiPageNations from "@common/business_components/ui/table/uiPageNations";
|
||||
import { Button } from "@common/components";
|
||||
import { StandardPopupLayout } from "@common/business_components";
|
||||
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
|
||||
import { getData } from "@/components/lib/api";
|
||||
import { appendApiSearchParams } from "@/shared/util/util";
|
||||
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 PipeAnalysisSummaryTable from "./PipeAnalysisSummaryTable";
|
||||
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
|
||||
import {
|
||||
PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS,
|
||||
PIPE_ANALYSIS_TYPE_OPTIONS,
|
||||
@@ -48,33 +57,31 @@ const toExcelColumnName = (index: number) => {
|
||||
return result || "A";
|
||||
};
|
||||
|
||||
const formatGridNumber = (value: unknown) => {
|
||||
const num = Number(value ?? 0);
|
||||
if (!Number.isFinite(num)) {
|
||||
return String(value ?? "");
|
||||
}
|
||||
return num.toLocaleString("ko-KR", {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 3,
|
||||
});
|
||||
};
|
||||
|
||||
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>(() =>
|
||||
const [draftFilters, setDraftFilters] = useState<PipeAnalysisSummaryFilters>(
|
||||
() =>
|
||||
applyScopeToSummaryFilters(PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS, scope),
|
||||
);
|
||||
const [appliedFilters, setAppliedFilters] = useState<PipeAnalysisSummaryFilters>(() =>
|
||||
const [appliedFilters, setAppliedFilters] =
|
||||
useState<PipeAnalysisSummaryFilters>(() =>
|
||||
applyScopeToSummaryFilters(PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS, scope),
|
||||
);
|
||||
const [materialColumns, setMaterialColumns] = useState<PipeAnalysisMaterialColumn[]>([]);
|
||||
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);
|
||||
@@ -97,6 +104,9 @@ export default function PipeAnalysisPopupPage() {
|
||||
const zones = await fetchPipeAnalysisZoneOptions({
|
||||
ctpvCd: draftFilters.ctpvCd,
|
||||
sggCd: draftFilters.sggCd,
|
||||
sggNm: sgg.find(
|
||||
(option) => String(option.val).trim() === draftFilters.sggCd,
|
||||
)?.text,
|
||||
scopeRegionCode: scope.regionCode,
|
||||
});
|
||||
if (!mounted) return;
|
||||
@@ -111,7 +121,7 @@ export default function PipeAnalysisPopupPage() {
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [draftFilters.ctpvCd, draftFilters.sggCd, scope.regionCode]);
|
||||
}, [draftFilters.ctpvCd, draftFilters.sggCd, scope.regionCode, sgg]);
|
||||
|
||||
const fetchSummary = useCallback(
|
||||
async (nextFilters: PipeAnalysisSummaryFilters) => {
|
||||
@@ -119,13 +129,16 @@ export default function PipeAnalysisPopupPage() {
|
||||
analysisType: nextFilters.analysisType,
|
||||
});
|
||||
|
||||
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);
|
||||
appendApiSearchParams(params, {
|
||||
ctpvCd: nextFilters.ctpvCd,
|
||||
sggCd: nextFilters.sggCd,
|
||||
swtrmtZoneNm: nextFilters.swtrmtZoneNm,
|
||||
});
|
||||
if (nextFilters.scopeRegionCode)
|
||||
params.set("scopeRegionCode", nextFilters.scopeRegionCode);
|
||||
|
||||
const response = (await getData(
|
||||
`v1/fclts/ppl-analysis/summaries?${params.toString()}`,
|
||||
`v1/fclts/pplAnalysis/summaries?${params.toString()}`,
|
||||
)) as PipeAnalysisSummaryResponse;
|
||||
|
||||
const nextMaterialColumns = Array.isArray(response?.materialColumns)
|
||||
@@ -148,14 +161,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,16 +196,19 @@ export default function PipeAnalysisPopupPage() {
|
||||
materialColumns,
|
||||
);
|
||||
const headerRange = `A1:${toExcelColumnName(exportHeadList.length)}1`;
|
||||
const mergeRanges = appliedFilters.analysisType === "YEAR_MATERIAL"
|
||||
const mergeRanges =
|
||||
appliedFilters.analysisType === "YEAR_MATERIAL"
|
||||
? (() => {
|
||||
const ranges: string[] = [];
|
||||
let currentRow = 2;
|
||||
|
||||
yearMaterialGroups.forEach((group) => {
|
||||
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}`);
|
||||
ranges.push(
|
||||
`${column}${currentRow}:${column}${currentRow + rowSpan - 1}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
currentRow += rowSpan;
|
||||
@@ -196,8 +217,9 @@ export default function PipeAnalysisPopupPage() {
|
||||
return ranges;
|
||||
})()
|
||||
: [];
|
||||
const exportRows = appliedFilters.analysisType === "YEAR_MATERIAL"
|
||||
? yearMaterialGroups.flatMap((group) =>
|
||||
const exportRows =
|
||||
appliedFilters.analysisType === "YEAR_MATERIAL"
|
||||
? orderedYearMaterialGroups.flatMap((group) =>
|
||||
group.rows.map((yearRow, rowIndex) => {
|
||||
const exportRow: Record<string, unknown> = {
|
||||
rowNo: rowIndex === 0 ? group.rowNo : "",
|
||||
@@ -227,32 +249,45 @@ export default function PipeAnalysisPopupPage() {
|
||||
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,71 +298,90 @@ 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"
|
||||
() =>
|
||||
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 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>
|
||||
|
||||
<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>
|
||||
<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 !text-[1.1rem]"
|
||||
className="sn-popup-input !h-[3.2rem] w-[14rem] !text-xl"
|
||||
value={draftFilters.ctpvCd}
|
||||
disabled={scope.ctpvLocked}
|
||||
onChange={(event) =>
|
||||
@@ -348,10 +402,12 @@ export default function PipeAnalysisPopupPage() {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">시군구</div>
|
||||
<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 !text-[1.1rem]"
|
||||
className="sn-popup-input !h-[3.2rem] w-[14rem] !text-xl"
|
||||
value={draftFilters.sggCd}
|
||||
disabled={scope.sggLocked}
|
||||
onChange={(event) =>
|
||||
@@ -371,10 +427,12 @@ export default function PipeAnalysisPopupPage() {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">처리구역</div>
|
||||
<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 !text-[1.1rem]"
|
||||
className="sn-popup-input !h-[3.2rem] w-[14rem] !text-xl"
|
||||
value={draftFilters.swtrmtZoneNm}
|
||||
onChange={(event) =>
|
||||
setDraftFilters((current) => ({
|
||||
@@ -391,15 +449,18 @@ export default function PipeAnalysisPopupPage() {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">분석구분</div>
|
||||
<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 !text-[1.1rem]"
|
||||
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"],
|
||||
analysisType: event.target
|
||||
.value as PipeAnalysisSummaryFilters["analysisType"],
|
||||
}))
|
||||
}
|
||||
>
|
||||
@@ -411,154 +472,53 @@ export default function PipeAnalysisPopupPage() {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2 md:pr-3">
|
||||
<div className="flex min-w-fit flex-1 items-center justify-end gap-2">
|
||||
<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()}
|
||||
>
|
||||
다운로드
|
||||
</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"
|
||||
}`}
|
||||
>
|
||||
{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}`}>
|
||||
{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" : ""
|
||||
}`}
|
||||
color="white"
|
||||
onClick={() => {
|
||||
if (isClickable) {
|
||||
handleYearMaterialCellClick(group, yearRow, column.key);
|
||||
}
|
||||
const resetFilters = applyScopeToSummaryFilters(
|
||||
PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS,
|
||||
scope,
|
||||
);
|
||||
setDraftFilters(resetFilters);
|
||||
void handleSearch(resetFilters);
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
초기화
|
||||
</Button>
|
||||
</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}
|
||||
</PopupSearchPanel>
|
||||
|
||||
<UiTable<PipeAnalysisSummaryViewRow>
|
||||
className={appliedFilters.analysisType === "YEAR_MATERIAL"
|
||||
? "hidden"
|
||||
: useHorizontalScrollTable
|
||||
? "overflow-x-auto overflow-y-visible [&_table]:min-w-full [&_table]:w-max"
|
||||
: ""}
|
||||
headName={
|
||||
<PipeAnalysisSummaryTable
|
||||
rows={pagedRows}
|
||||
groups={visibleYearMaterialGroups}
|
||||
grouped={appliedFilters.analysisType === "YEAR_MATERIAL"}
|
||||
heads={headList}
|
||||
title={`${resolvePipeAnalysisTypeLabel(appliedFilters.analysisType)} 목록`}
|
||||
total={
|
||||
appliedFilters.analysisType === "YEAR_MATERIAL"
|
||||
? `경과년도&재질 관로현황 ${summaryTotalCount.toLocaleString("ko-KR")}건`
|
||||
: `관로분석현황 ${summaryTotalCount.toLocaleString("ko-KR")}건`
|
||||
? orderedYearMaterialGroups.length
|
||||
: rows.length
|
||||
}
|
||||
tableData={pagedRows}
|
||||
headlist={headList}
|
||||
page={summaryCurrentPage}
|
||||
pageSize={PIPE_ANALYSIS_SUMMARY_PAGE_SIZE}
|
||||
total={rows.length}
|
||||
cellClick={(index, columnId) =>
|
||||
handleCellClick(
|
||||
typeof index === "number" ? index + summaryPageOffset : index,
|
||||
columnId,
|
||||
)}
|
||||
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"
|
||||
: ""
|
||||
onCellClick={handleCellClick}
|
||||
onGroupedCellClick={handleYearMaterialCellClick}
|
||||
actions={
|
||||
<Button type="button" onClick={() => void handleDownload()}>
|
||||
엑셀다운로드
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import {
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
StandardDataTable,
|
||||
type StandardDataTableLayout,
|
||||
type UiHeadProps,
|
||||
} from "@common/business_components";
|
||||
import type {
|
||||
PipeAnalysisSummaryViewRow,
|
||||
PipeAnalysisYearMaterialGroup,
|
||||
PipeAnalysisYearMaterialGroupRow,
|
||||
} from "./pipeAnalysisUtils";
|
||||
|
||||
type SummaryRow =
|
||||
| {
|
||||
kind: "flat";
|
||||
value: PipeAnalysisSummaryViewRow;
|
||||
sourceIndex: number;
|
||||
}
|
||||
| {
|
||||
kind: "grouped";
|
||||
group: PipeAnalysisYearMaterialGroup;
|
||||
value: PipeAnalysisYearMaterialGroupRow;
|
||||
groupIndex: number;
|
||||
groupRowIndex: number;
|
||||
};
|
||||
type Props = {
|
||||
rows: PipeAnalysisSummaryViewRow[];
|
||||
groups: PipeAnalysisYearMaterialGroup[];
|
||||
grouped: boolean;
|
||||
heads: UiHeadProps[];
|
||||
title: string;
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
actions: ReactNode;
|
||||
onPageChange: (page: number) => void;
|
||||
onCellClick: (row: PipeAnalysisSummaryViewRow, columnId: string) => void;
|
||||
onGroupedCellClick: (
|
||||
group: PipeAnalysisYearMaterialGroup,
|
||||
row: PipeAnalysisYearMaterialGroupRow,
|
||||
columnId: string,
|
||||
) => void;
|
||||
};
|
||||
|
||||
/** 지역 식별 열은 가로 스크롤해도 남겨 두고, 모든 집계 방식에 같은 너비를 사용한다. */
|
||||
const FIXED_WIDTHS: Record<string, number> = {
|
||||
rowNo: 56,
|
||||
ctpvNm: 120,
|
||||
sggNm: 120,
|
||||
swtrmtZoneNm: 160,
|
||||
totalLengthKm: 100,
|
||||
};
|
||||
/** 공통 그리드가 같은 순서로 왼쪽 고정 위치를 계산할 관로 식별 열이다. */
|
||||
const PINNED_COLUMN_IDS = Object.keys(FIXED_WIDTHS);
|
||||
/** 관로분석에서 선택하는 공통 표 레이아웃이며 화면 전용 CSS를 대체한다. */
|
||||
const PIPE_SUMMARY_LAYOUT: StandardDataTableLayout = {
|
||||
tableLayout: "fixed",
|
||||
stickyHeader: true,
|
||||
headerHeight: "64px",
|
||||
headerWrap: true,
|
||||
cellOverflow: "ellipsis",
|
||||
};
|
||||
/** 기존 집계와 같은 천 단위 구분 및 소수점 세 자리 표시를 유지한다. */
|
||||
const formatValue = (value: unknown) => {
|
||||
if (value === null || value === undefined || value === "") return "-";
|
||||
return typeof value === "number"
|
||||
? value.toLocaleString("ko-KR", { maximumFractionDigits: 3 })
|
||||
: String(value);
|
||||
};
|
||||
|
||||
/** 결과가 있는 셀만 링크형 버튼으로 표시하고 나머지는 전체값 title을 유지한다. */
|
||||
const renderValue = (value: unknown, onClick?: () => void) => {
|
||||
const formatted = formatValue(value);
|
||||
return onClick ? (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-inherit text-primary underline underline-offset-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
|
||||
onClick={onClick}
|
||||
>
|
||||
{formatted}
|
||||
</button>
|
||||
) : (
|
||||
<span className="block overflow-hidden text-ellipsis" title={formatted}>
|
||||
{formatted}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
/** 표준 카드·페이징 안에서 관로 집계의 병합 셀과 고정 열을 렌더링한다. */
|
||||
export default function PipeAnalysisSummaryTable({
|
||||
rows,
|
||||
groups,
|
||||
grouped,
|
||||
heads,
|
||||
title,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
actions,
|
||||
onPageChange,
|
||||
onCellClick,
|
||||
onGroupedCellClick,
|
||||
}: Props) {
|
||||
// 병합 표도 공통 렌더러가 행을 순회할 수 있도록 처리구역의 4개 경과년도 행만 펼친다.
|
||||
const data = useMemo<SummaryRow[]>(
|
||||
() =>
|
||||
grouped
|
||||
? groups.flatMap((group, groupIndex) =>
|
||||
group.rows.map((value, groupRowIndex) => ({
|
||||
kind: "grouped" as const,
|
||||
group,
|
||||
value,
|
||||
groupIndex,
|
||||
groupRowIndex,
|
||||
})),
|
||||
)
|
||||
: rows.map((value, sourceIndex) => ({
|
||||
kind: "flat" as const,
|
||||
value,
|
||||
sourceIndex,
|
||||
})),
|
||||
[grouped, groups, rows],
|
||||
);
|
||||
// 공통 렌더러가 colgroup과 고정 위치에 사용할 열별 픽셀 너비를 한곳에서 계산한다.
|
||||
const layout = useMemo(() => {
|
||||
return heads.map((head) => {
|
||||
const width =
|
||||
FIXED_WIDTHS[head.id] ??
|
||||
(head.id === "elapsedYearLabel"
|
||||
? 112
|
||||
: String(head.title).length > 10
|
||||
? 160
|
||||
: 120);
|
||||
const fixed = head.id in FIXED_WIDTHS;
|
||||
return { ...head, width, fixed };
|
||||
});
|
||||
}, [heads]);
|
||||
// 모든 재질 열의 너비 합계를 최소 폭으로 사용해 좁은 팝업에서 열 압축을 막는다.
|
||||
const tableWidth = layout.reduce((sum, head) => sum + head.width, 0);
|
||||
// 열은 값·업무 클릭만 정의하고 실제 th/td, 고정 열과 overflow는 공통 렌더러가 소유한다.
|
||||
const columns = useMemo<ColumnDef<SummaryRow>[]>(
|
||||
() =>
|
||||
layout.map((head) => ({
|
||||
id: head.id,
|
||||
size: head.width,
|
||||
minSize: head.width,
|
||||
maxSize: head.width,
|
||||
header: () => <span title={String(head.title)}>{head.title}</span>,
|
||||
cell: ({ row }) => {
|
||||
const summaryRow = row.original;
|
||||
if (summaryRow.kind === "flat") {
|
||||
const value =
|
||||
head.id === "rowNo"
|
||||
? total - ((page - 1) * pageSize + summaryRow.sourceIndex)
|
||||
: summaryRow.value[head.id];
|
||||
const onClick =
|
||||
summaryRow.value.__cellFilters[head.id] && Number(value) > 0
|
||||
? () => onCellClick(summaryRow.value, head.id)
|
||||
: undefined;
|
||||
return renderValue(value, onClick);
|
||||
}
|
||||
|
||||
if (head.fixed) {
|
||||
return renderValue(
|
||||
summaryRow.group[head.id as keyof PipeAnalysisYearMaterialGroup],
|
||||
);
|
||||
}
|
||||
if (head.id === "elapsedYearLabel") {
|
||||
return renderValue(summaryRow.value.elapsedYearLabel);
|
||||
}
|
||||
|
||||
const value = Number(summaryRow.value.metrics[head.id] ?? 0);
|
||||
const onClick =
|
||||
value > 0 && summaryRow.value.cellFilters[head.id]
|
||||
? () =>
|
||||
onGroupedCellClick(
|
||||
summaryRow.group,
|
||||
summaryRow.value,
|
||||
head.id,
|
||||
)
|
||||
: undefined;
|
||||
return renderValue(value, onClick);
|
||||
},
|
||||
meta: {
|
||||
align: head.align === "right" ? "right" : "center",
|
||||
},
|
||||
})),
|
||||
[layout, onCellClick, onGroupedCellClick, page, pageSize, total],
|
||||
);
|
||||
// 페이지 단위는 기존과 동일하게 일반 표는 행, 병합 표는 처리구역 그룹이다.
|
||||
const pagination = { pageIndex: Math.max(0, page - 1), pageSize };
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
state: { pagination },
|
||||
rowCount: total,
|
||||
manualPagination: true,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId: (row) =>
|
||||
row.kind === "flat"
|
||||
? `flat-${row.sourceIndex}`
|
||||
: `group-${row.groupIndex}-${row.value.elapsedYearBucket}`,
|
||||
onPaginationChange: (updater) => {
|
||||
const next =
|
||||
typeof updater === "function" ? updater(pagination) : updater;
|
||||
if (next.pageIndex !== pagination.pageIndex)
|
||||
onPageChange(next.pageIndex + 1);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<StandardDataTable
|
||||
key={`${title}-${page}`}
|
||||
table={table}
|
||||
title={title}
|
||||
flushOuterMargin
|
||||
scrollMode="both"
|
||||
maxHeight="max(240px, calc(100dvh - 34rem))"
|
||||
minTableWidth={`${tableWidth}px`}
|
||||
layout={PIPE_SUMMARY_LAYOUT}
|
||||
pinnedColumns={PINNED_COLUMN_IDS}
|
||||
getCellProps={({ row, columnId }) => {
|
||||
const summaryRow = row.original;
|
||||
const isPinnedColumn = PINNED_COLUMN_IDS.includes(columnId);
|
||||
const isGrouped = summaryRow.kind === "grouped";
|
||||
|
||||
return {
|
||||
hidden: isGrouped && isPinnedColumn && summaryRow.groupRowIndex > 0,
|
||||
rowSpan:
|
||||
isGrouped && isPinnedColumn && summaryRow.groupRowIndex === 0
|
||||
? summaryRow.group.rows.length
|
||||
: undefined,
|
||||
className:
|
||||
isGrouped && summaryRow.groupRowIndex === 0
|
||||
? "border-t border-border"
|
||||
: undefined,
|
||||
};
|
||||
}}
|
||||
actions={actions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
/** 관로분석 검색 조건, 집계 컬럼과 표시값 변환 유틸을 제공한다. */
|
||||
"use client";
|
||||
|
||||
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 +125,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 = {
|
||||
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 +179,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 +192,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 +272,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 +323,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 +376,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 +437,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 +500,7 @@ export const buildPipeAnalysisSummaryDownloadHeaders = (
|
||||
materialColumns: PipeAnalysisMaterialColumn[],
|
||||
) => {
|
||||
const headers: Record<string, string> = {
|
||||
rowNo: "NO",
|
||||
rowNo: "순번",
|
||||
ctpvNm: "시도",
|
||||
sggNm: "시군구",
|
||||
swtrmtZoneNm: "처리구역",
|
||||
@@ -499,7 +525,7 @@ export const buildPipeAnalysisSummaryDownloadHeaders = (
|
||||
};
|
||||
|
||||
export const PIPE_ANALYSIS_DETAIL_EXCEL_HEADERS = {
|
||||
rowNo: "NO",
|
||||
rowNo: "순번",
|
||||
ctpvNm: "시도",
|
||||
sggNm: "시군구",
|
||||
fcltMngNo: "시설관리번호",
|
||||
@@ -548,7 +574,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 +589,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 +622,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,25 +641,37 @@ 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,
|
||||
sggCd,
|
||||
sggNm,
|
||||
scopeRegionCode,
|
||||
}: {
|
||||
ctpvCd: string;
|
||||
sggCd: string;
|
||||
sggNm?: string;
|
||||
scopeRegionCode?: string;
|
||||
}): Promise<PipeAnalysisZoneOption[]> => {
|
||||
const filters: string[] = [];
|
||||
const selectedSggCd = String(sggCd ?? "").trim();
|
||||
const selectedSggNm = String(sggNm ?? "").trim();
|
||||
if (sggCd && sggCd !== "0") {
|
||||
filters.push(`sgg_cd='${sggCd}'`);
|
||||
// 처리구역 원천의 시군구코드가 잘못된 경우도 있어 시도 범위로 받은 뒤
|
||||
// 기준코드 또는 시군구명 중 하나가 일치하는 처리구역만 남긴다.
|
||||
const selectedCtpvCd =
|
||||
ctpvCd && ctpvCd !== "0" ? ctpvCd : selectedSggCd.slice(0, 2);
|
||||
filters.push(`ctpv_cd='${selectedCtpvCd}'`);
|
||||
} else if (ctpvCd && ctpvCd !== "0") {
|
||||
filters.push(`ctpv_cd='${ctpvCd}'`);
|
||||
} else if (scopeRegionCode) {
|
||||
@@ -646,7 +688,7 @@ export const fetchPipeAnalysisZoneOptions = async ({
|
||||
request: "GetFeature",
|
||||
typeName: "hasudo:tb_ms_swage_prcs_zone",
|
||||
outputFormat: "application/json",
|
||||
propertyName: "ctpv_cd,sgg_cd,swtrmt_zone_nm",
|
||||
propertyName: "ctpv_cd,sgg_cd,sgg_nm,swtrmt_zone_nm",
|
||||
});
|
||||
|
||||
if (filters.length > 0) {
|
||||
@@ -665,12 +707,22 @@ export const fetchPipeAnalysisZoneOptions = async ({
|
||||
features.forEach((feature: any) => {
|
||||
const properties = feature?.properties ?? {};
|
||||
const zoneName = String(properties.swtrmt_zone_nm ?? "").trim();
|
||||
const featureSggCd = String(properties.sgg_cd ?? "").trim();
|
||||
const featureSggNm = String(properties.sgg_nm ?? "").trim();
|
||||
if (!zoneName) return;
|
||||
if (
|
||||
selectedSggCd &&
|
||||
selectedSggCd !== "0" &&
|
||||
featureSggCd !== selectedSggCd &&
|
||||
(!selectedSggNm || featureSggNm !== selectedSggNm)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
dedupMap.set(zoneName, {
|
||||
value: zoneName,
|
||||
label: zoneName,
|
||||
ctpvCd: String(properties.ctpv_cd ?? "").trim(),
|
||||
sggCd: String(properties.sgg_cd ?? "").trim(),
|
||||
sggCd: selectedSggCd !== "0" ? selectedSggCd : featureSggCd,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/**
|
||||
* 화면명: 하수관로 정보
|
||||
* ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (하수관로)
|
||||
*
|
||||
* 📄 MsSwagePpl
|
||||
@@ -34,7 +35,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 +57,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 +69,7 @@ const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClic
|
||||
}, [featureData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!requestMngInstCd || !requestFcltMngNo) {
|
||||
if (!showOperationSections || !requestMngInstCd || !requestFcltMngNo) {
|
||||
setInspectionData([]);
|
||||
setCleaningData([]);
|
||||
setRepairData([]);
|
||||
@@ -121,7 +132,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,6 +165,9 @@ const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClic
|
||||
onSaveClick={handleSaveClick}
|
||||
onFormDataChange={handleFormDataChange} // 폼 데이터 변경 콜백 추가
|
||||
/>
|
||||
{surveyResultContent}
|
||||
{showOperationSections && (
|
||||
<>
|
||||
<InputSection
|
||||
title="보수 및 갱신"
|
||||
data={repairData}
|
||||
@@ -173,6 +187,8 @@ const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClic
|
||||
onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value, dataMap)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/**
|
||||
* 화면명: 우수토실 정보
|
||||
* ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (우수토실)
|
||||
*
|
||||
* 📄 Ssc
|
||||
@@ -13,48 +14,73 @@
|
||||
* - 위경도로 강우시 미처리 하수 목록 조회: 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 +89,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 +116,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 +193,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 +214,7 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick, rend
|
||||
});
|
||||
|
||||
if (!popupWindow) {
|
||||
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
|
||||
alert("팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -175,14 +229,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 +256,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 +329,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 +337,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 +354,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;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/**
|
||||
* 화면명: 하수처리시설 정보
|
||||
* ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (하수처리시설)
|
||||
*
|
||||
* 📄 SwtrmtFclt
|
||||
@@ -13,6 +14,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 +22,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,76 @@
|
||||
/**
|
||||
* 화면명: 처리구역내 관로분석정보
|
||||
* 선택한 하수처리구역의 관로 분석 현황과 보수이력 연계 기능을 제공한다.
|
||||
*/
|
||||
"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 +79,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 +95,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 +122,7 @@ const MetricVerticalGrid = ({
|
||||
</div>
|
||||
);
|
||||
|
||||
/** 처리구역 분석 패널과 보수이력 윈도우 팝업의 부모 지도 연계를 담당한다. */
|
||||
const SwtrmtPrcsZoneAnalysis = ({
|
||||
featureData,
|
||||
rawFeatureData,
|
||||
@@ -76,9 +130,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 +148,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;
|
||||
|
||||
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,3 +1,4 @@
|
||||
/** 침입수·RDII 분석의 적용연월 선택 상태와 월 목록을 제공한다. */
|
||||
import { useState, useEffect } from "react";
|
||||
import moment from "moment";
|
||||
import { getData } from "@/components/lib/api";
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
'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 +34,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 +89,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,11 +118,13 @@ 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);
|
||||
fn
|
||||
.selectPplInftwtQty(targetInftwtAnlsSn, normalizedPplMngNo)
|
||||
.catch((err) => {
|
||||
console.error("selectPplInftwtQty 에러:", err);
|
||||
return 0;
|
||||
}),
|
||||
]);
|
||||
@@ -120,26 +134,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)
|
||||
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.', {
|
||||
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 +184,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 +198,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 +209,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 +247,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} wideLabels>
|
||||
<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="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>
|
||||
</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="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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[10px] bg-white px-2 pb-2 pt-3">
|
||||
<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={'침입수량 그래프'}
|
||||
chartTitle=""
|
||||
yFieldText="유량(m³/h)"
|
||||
yFieldText2=""
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-[464px] items-center justify-center text-[18px] text-[#2c2f33]">
|
||||
데이터가 없습니다.
|
||||
<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="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>
|
||||
<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="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white text-xl"
|
||||
selectClass="w-full"
|
||||
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 text-[14px] font-semibold text-[#5c6470] whitespace-nowrap">
|
||||
분석정보:
|
||||
</Label>
|
||||
</StandardFormField>
|
||||
<StandardFormField label="분석정보" columns={2}>
|
||||
<SelectBox
|
||||
className="w-full"
|
||||
selectClass="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white text-xl"
|
||||
selectClass="w-full"
|
||||
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>
|
||||
<Button size="lg" color="gray" onClick={handleReset} >
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</StandardFormField>
|
||||
{renderInfoField("오수전환율", inftwtAnls?.drtwSwtcRt ?? "")}
|
||||
{renderInfoField(
|
||||
"시간패턴 파일명",
|
||||
inftwtAnls?.atflHrPttnNm ?? "",
|
||||
)}
|
||||
{renderInfoField("수용가-맨홀 매칭", inftwtAnls?.atflMpngNm ?? "")}
|
||||
{renderInfoField("관망도 파일명", inftwtAnls?.atflInpNm ?? "")}
|
||||
{renderInfoField("유량 파일명", inftwtAnls?.atflOilqNm ?? "", 2)}
|
||||
</StandardFormSection>
|
||||
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 화면명: RDII 분석
|
||||
* RDII 분석 조건 입력, 실행 상태와 분석결과를 표시한다.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
@@ -7,9 +11,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 +52,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 +112,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 +129,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 +192,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,135 +209,139 @@ 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>
|
||||
</div>
|
||||
<div className="text-[20px] font-extrabold text-[#222]">
|
||||
{selectedAplcnYm ? moment(selectedAplcnYm, "YYYYMM").format("YYYY-MM") : "-"}
|
||||
<section className="card card-border !m-0 min-w-0 overflow-hidden !p-[1.2rem]">
|
||||
<StandardFormSection title="RDII 분석결과" columns={2} wideLabels>
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</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">
|
||||
<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={"RDII 그래프"}
|
||||
chartTitle=""
|
||||
yFieldText2="강수량(mm)"
|
||||
yFieldText="유량(m³/h)"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-[464px] items-center justify-center text-[18px] text-[#2c2f33]">데이터가 없습니다.</div>
|
||||
<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,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="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>
|
||||
<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="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white text-xl"
|
||||
selectClass="w-full"
|
||||
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>
|
||||
</StandardFormField>
|
||||
<StandardFormField label="분석정보" columns={2}>
|
||||
<SelectBox
|
||||
className="w-full"
|
||||
selectClass="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white text-xl"
|
||||
selectClass="w-full"
|
||||
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>
|
||||
|
||||
<Button size="lg" color="gray" onClick={resetState}>초기화</Button>
|
||||
</div>
|
||||
</section>
|
||||
</StandardFormField>
|
||||
{renderInfoField("오수전환율", rdiiAnls?.drtwSwtcRt ?? "")}
|
||||
{renderInfoField("수용가-맨홀 매칭", rdiiAnls?.atflMpngNm ?? "")}
|
||||
{renderInfoField("관망도 파일명", rdiiAnls?.atflInpNm ?? "")}
|
||||
{renderInfoField("강우 파일명", rdiiAnls?.atflRainNm ?? "")}
|
||||
{renderInfoField("RTK계수 출처", rdiiAnls?.srcNm ?? "", 2)}
|
||||
</StandardFormSection>
|
||||
|
||||
{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>
|
||||
);
|
||||
|
||||
if (isResultOnlyMode) {
|
||||
return <div className="min-h-dvh bg-white p-3">{renderResultPanel()}</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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/**
|
||||
* 화면명: 하수펌프장 정보
|
||||
* ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (하수펌프장)
|
||||
*
|
||||
* 📄 sewagePump
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/**
|
||||
* 화면명: 하수맨홀 정보
|
||||
* ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (하수맨홀)
|
||||
*
|
||||
* 📄 Smh
|
||||
@@ -10,26 +11,30 @@
|
||||
* - 저장 버튼을 통한 데이터 저장 기능
|
||||
*
|
||||
* 🔗 사용 API
|
||||
* - 상류관로 조회: GET /api/v1/fcltmhl/upperPpls/{fcltMngNo} (sewerage-service)
|
||||
* - 상류관로 조회: 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}`);
|
||||
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 +64,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 +85,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 +117,7 @@ const Smh = ({ featureData, onSaveClick }: { featureData: any, onSaveClick?: ()
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<VerticalTable
|
||||
headName='입력정보'
|
||||
tableData={featureData ? [formData] : []}
|
||||
@@ -120,6 +134,8 @@ const Smh = ({ featureData, onSaveClick }: { featureData: any, onSaveClick?: ()
|
||||
onSaveClick={handleSaveClick}
|
||||
onFormDataChange={handleFormDataChange} // 폼 데이터 변경 콜백 추가
|
||||
/>
|
||||
{surveyResultContent}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/**
|
||||
* 화면명: 빗물펌프 정보
|
||||
* ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (빗물펌프)
|
||||
*
|
||||
* 📄 StormWaterPump
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getData } from "@/components/lib/api";
|
||||
/** 처리구역 관로분석정보와 보수이력을 조회·다운로드하는 API를 제공한다. */
|
||||
import { getBlobDataWithHeaders, getData } from "@/components/lib/api";
|
||||
|
||||
export type SwtrmtPrcsZoneAnalysisMetric = {
|
||||
avgInstlYear?: number | string | null;
|
||||
@@ -32,7 +33,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 +134,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,30 @@
|
||||
/**
|
||||
* 화면명: RDII 분석결과 목록
|
||||
* RDII 분석결과를 검색하고 목록·엑셀·지도 보기 기능을 제공한다.
|
||||
*/
|
||||
"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,
|
||||
type UiTableSort,
|
||||
} from "@common/business_components";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
import { getData } from "@/components/lib/api";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import { appendApiSearchParams } from "@/shared/util/util";
|
||||
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
|
||||
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
|
||||
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,20 +34,25 @@ import {
|
||||
|
||||
const RDII_RESULT_PAGE_SIZE = 10;
|
||||
|
||||
const RDII_RESULT_HEADLIST = [
|
||||
{ id: "rowNumber", title: "일련번호" },
|
||||
const RDII_RESULT_HEADLIST: UiHeadProps[] = [
|
||||
{
|
||||
id: "rowNumber",
|
||||
title: "순번",
|
||||
sortable: true,
|
||||
sortKey: "rainInfwtAnlsSn",
|
||||
},
|
||||
{ id: "ctpvNm", title: "시도" },
|
||||
{ id: "sggNm", title: "시군구" },
|
||||
{ id: "swtrmtFcltNm", title: "하수처리시설" },
|
||||
{ id: "aplcnYm", title: "상수사용연월" },
|
||||
{ id: "drtwSwtcRt", title: "오수 전환율" },
|
||||
{ id: "srcNm", title: "RTK 계수 출처" },
|
||||
{ id: "cntbArea", title: "RDII 기여면적" },
|
||||
{ id: "anlsInfo", title: "RDII\n분석정보" },
|
||||
{ id: "regDt", title: "분석일자" },
|
||||
{ id: "swtrmtFcltNm", title: "하수처리시설", sortable: true },
|
||||
{ id: "aplcnYm", title: "상수사용연월", sortable: true },
|
||||
{ id: "drtwSwtcRt", title: "오수 전환율", sortable: true },
|
||||
{ id: "srcNm", title: "RTK 계수 출처", sortable: true },
|
||||
{ id: "cntbArea", title: "RDII 기여면적", sortable: true },
|
||||
{ id: "anlsInfo", title: "RDII\n분석정보", sortable: true },
|
||||
{ id: "regDt", title: "분석일자", sortable: true },
|
||||
{ id: "rgtrNm", title: "분석자" },
|
||||
{ id: "scsYn", title: "분석성공여부" },
|
||||
] as const;
|
||||
{ id: "scsYn", title: "분석성공여부", sortable: true },
|
||||
];
|
||||
|
||||
type RdiiResultFilters = {
|
||||
ctpvCd: string;
|
||||
@@ -84,6 +105,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 +113,7 @@ 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 [sort, setSort] = useState<UiTableSort>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -100,31 +122,49 @@ export default function RdiiResultPopupPage() {
|
||||
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
|
||||
|
||||
const fetchRows = useCallback(
|
||||
async (page: number, nextFilters: RdiiResultFilters) => {
|
||||
async (
|
||||
page: number,
|
||||
nextFilters: RdiiResultFilters,
|
||||
nextSort: UiTableSort | undefined = sort,
|
||||
) => {
|
||||
const queryParams = new URLSearchParams({
|
||||
page: String(page - 1),
|
||||
size: String(RDII_RESULT_PAGE_SIZE),
|
||||
sort: "rainInfwtAnlsSn,desc",
|
||||
scsYn: "Y",
|
||||
});
|
||||
queryParams.append(
|
||||
"sort",
|
||||
nextSort
|
||||
? `${nextSort.key},${nextSort.direction}`
|
||||
: "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());
|
||||
appendApiSearchParams(queryParams, {
|
||||
aplcnYmFrom: nextFilters.aplcnYmFrom,
|
||||
aplcnYmTo: nextFilters.aplcnYmTo,
|
||||
ctpvCd: nextFilters.ctpvCd,
|
||||
sggCd: nextFilters.sggCd,
|
||||
swtrmtFcltNm: nextFilters.swtrmtFcltNm,
|
||||
});
|
||||
|
||||
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,17 +173,21 @@ export default function RdiiResultPopupPage() {
|
||||
.filter((item: RdiiResultRow) => item.scsYn === "Y");
|
||||
|
||||
setRows(nextRows);
|
||||
setTotal(Number(response?.totalElements ?? nextRows.length));
|
||||
setTotal(totalElements);
|
||||
setCurrentPage(page);
|
||||
},
|
||||
[ctpv, sgg],
|
||||
[ctpv, sgg, sort],
|
||||
);
|
||||
|
||||
const loadRows = useCallback(
|
||||
async (page = 1, nextFilters = filters) => {
|
||||
async (
|
||||
page = 1,
|
||||
nextFilters = filters,
|
||||
nextSort: UiTableSort | undefined = sort,
|
||||
) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await fetchRows(page, nextFilters);
|
||||
await fetchRows(page, nextFilters, nextSort);
|
||||
} catch (error) {
|
||||
console.error("RDII 분석결과 목록 조회 중 오류:", error);
|
||||
alert("RDII 분석결과 목록을 불러오지 못했습니다.");
|
||||
@@ -153,26 +197,40 @@ export default function RdiiResultPopupPage() {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[fetchRows, filters],
|
||||
[alert, fetchRows, filters, sort],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRows(1, DEFAULT_FILTERS);
|
||||
void loadRows(1, DEFAULT_FILTERS, undefined);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleSearch = async (page = 1) => {
|
||||
setSelectedIndex(-1);
|
||||
setSelectedRow(null);
|
||||
await loadRows(page, filters);
|
||||
};
|
||||
|
||||
/** CS 검색 카드와 동일하게 조건과 선택 결과를 기본값으로 되돌린다. */
|
||||
const handleReset = async () => {
|
||||
setFilters(DEFAULT_FILTERS);
|
||||
setSelectedSwagePrcsFclt("0");
|
||||
setSelectedRow(null);
|
||||
setSort(undefined);
|
||||
await loadRows(1, DEFAULT_FILTERS, undefined);
|
||||
};
|
||||
|
||||
/** 정렬 변경 시 선택 결과를 해제하고 서버 첫 페이지를 조회한다. */
|
||||
const handleSortChange = (nextSort?: UiTableSort) => {
|
||||
setSort(nextSort);
|
||||
setSelectedRow(null);
|
||||
void loadRows(1, filters, nextSort);
|
||||
};
|
||||
|
||||
const handleRowClick = (index?: number) => {
|
||||
if (typeof index !== "number") return;
|
||||
const row = rows[index];
|
||||
if (!row) return;
|
||||
|
||||
setSelectedIndex(index);
|
||||
setSelectedRow(row);
|
||||
postRowSelectMessage(row);
|
||||
};
|
||||
@@ -185,16 +243,24 @@ 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 fetchAllPages<any, Record<string, unknown>>({
|
||||
fetchPage: (page, size) =>
|
||||
getData(
|
||||
`v1/rdiiAnlsRslt/ppl/${selectedRow.rainInfwtAnlsSn}?page=${page}&size=${size}`,
|
||||
),
|
||||
getItems: (page) =>
|
||||
Array.isArray(page?.content) ? page.content : [],
|
||||
getTotal: (page) => Number(page?.totalElements ?? 0),
|
||||
});
|
||||
const excelRows = response.items;
|
||||
|
||||
await downloadExcelWithKoreanHeaders({
|
||||
data: excelRows as JSON[],
|
||||
data: excelRows as unknown as JSON[],
|
||||
fileName: `RDII분석결과_${selectedRow.swtrmtFcltNm}_${selectedRow.aplcnYm}_${selectedRow.rainInfwtAnlsSn}`,
|
||||
sheetNameArray: ["RDII 분석결과"],
|
||||
headerRange: "A1:D1",
|
||||
headerMapping: {
|
||||
rainInfwtAnlsSn: "RDII분석\n일련번호",
|
||||
rainInfwtAnlsSn: "RDII분석\n분석번호",
|
||||
pplMngNo: "관로\n관리번호",
|
||||
anlsTm: "분석시각",
|
||||
predcOilq: "예측유량\n(m³/h)",
|
||||
@@ -209,17 +275,16 @@ 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>
|
||||
<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,
|
||||
@@ -230,10 +295,12 @@ export default function RdiiResultPopupPage() {
|
||||
}));
|
||||
},
|
||||
}}
|
||||
selectClass="w-[145px]"
|
||||
selectClass="w-[145px] !text-[1.3rem]"
|
||||
startYear={1900}
|
||||
/>
|
||||
<span className="text-[1.1rem] font-semibold text-muted-foreground">~</span>
|
||||
<span className="text-[1.1rem] font-semibold text-muted-foreground">
|
||||
~
|
||||
</span>
|
||||
<DateMonthPicker
|
||||
field={{
|
||||
value: filters.aplcnYmTo,
|
||||
@@ -244,13 +311,16 @@ export default function RdiiResultPopupPage() {
|
||||
}));
|
||||
},
|
||||
}}
|
||||
selectClass="w-[145px]"
|
||||
selectClass="w-[145px] !text-[1.3rem]"
|
||||
startYear={1900}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 overflow-x-auto">
|
||||
<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}
|
||||
@@ -292,56 +362,53 @@ export default function RdiiResultPopupPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<div className="ml-auto flex shrink-0 items-center justify-end 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()}
|
||||
color="white"
|
||||
onClick={() => void handleReset()}
|
||||
>
|
||||
다운로드
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopupSearchPanel>
|
||||
|
||||
<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}
|
||||
<MapPopupDataTable
|
||||
rows={rows as Array<RdiiResultRow & Record<string, unknown>>}
|
||||
headlist={RDII_RESULT_HEADLIST}
|
||||
title="RDII 분석결과 목록"
|
||||
total={total}
|
||||
cellClick={(index) => handleRowClick(index)}
|
||||
cellClassName={(_, headId) =>
|
||||
headId === "anlsInfo"
|
||||
? "whitespace-normal break-all leading-[1.35]"
|
||||
: ""
|
||||
}
|
||||
page={currentPage}
|
||||
pageSize={RDII_RESULT_PAGE_SIZE}
|
||||
onPageChange={(page) => void handleSearch(page)}
|
||||
curPage={currentPage}
|
||||
index={selectedIndex}
|
||||
widthFixed={true}
|
||||
onRowClick={(_, index) => handleRowClick(index)}
|
||||
rowId={(row) => String(row.rainInfwtAnlsSn)}
|
||||
scrollMode="horizontal"
|
||||
minTableWidth="125rem"
|
||||
sort={sort}
|
||||
onSortChange={handleSortChange}
|
||||
actions={
|
||||
<Button type="button" onClick={() => void handleDownload()}>
|
||||
엑셀다운로드
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedRow ? (
|
||||
<div className="mt-3 rounded-md border border-border bg-secondary px-4 py-3 text-[1rem] text-label">
|
||||
<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>
|
||||
<span className="font-semibold text-foreground">
|
||||
{selectedRow.anlsInfo}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</main>
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/** RDII 분석결과 화면의 타입·검색 조건·컬럼과 응답 변환을 정의한다. */
|
||||
"use client";
|
||||
|
||||
export const RDII_RESULT_ROW_SELECT_MESSAGE = "SN_RDII_RESULT_ROW_SELECT";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/** RDII 분석결과 선택 시 부모 지도에 결과 레이어를 표시하는 훅이다. */
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react";
|
||||
@@ -9,6 +10,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 +140,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 +297,7 @@ export const useRdiiResultMap = ({
|
||||
alert("RDII 분석결과를 지도에 연계하지 못했습니다.");
|
||||
}
|
||||
},
|
||||
[renderMapView, setFeatureData, setIsOpen, setIsSearchModalOpen],
|
||||
[alert, renderMapView, setFeatureData, setIsOpen, setIsSearchModalOpen],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -356,7 +359,7 @@ export const useRdiiResultMap = ({
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [activeMapView]);
|
||||
}, [activeMapView, alert]);
|
||||
|
||||
return {
|
||||
activeMapView,
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
/**
|
||||
* 화면명: 보수이력 목록
|
||||
* 선택 시설의 보수이력을 조회하고 목록으로 표시한다.
|
||||
*/
|
||||
"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,163 @@
|
||||
/** 우측 상세 탭의 표시·분류 모델을 지도 조회 수명에서 분리한다. */
|
||||
import type { ExmnMapJobType } from "../exmn/exmnMapTypes";
|
||||
import type { SinkholeDetail, SinkholeNearbyPipe } from "../sinkhole/sinkholeTypes";
|
||||
|
||||
export type RightPanelTabKind = "feature" | "sinkhole" | "gisEdit";
|
||||
export type RightPanelTabCategory =
|
||||
| "sinkhole"
|
||||
| "pipe"
|
||||
| "exmnSurvey"
|
||||
| "treatmentFacility"
|
||||
| "treatmentZone"
|
||||
| "defaultFeature"
|
||||
| "gisEdit";
|
||||
|
||||
export type RightPanelTab = {
|
||||
id: string;
|
||||
kind: RightPanelTabKind;
|
||||
category: RightPanelTabCategory;
|
||||
displayLabel: string;
|
||||
surveyJobType?: ExmnMapJobType;
|
||||
parentTabId?: string | null;
|
||||
featureData: any;
|
||||
sinkholeDetail?: SinkholeDetail | null;
|
||||
nearbyPipes?: SinkholeNearbyPipe[];
|
||||
gisEditChangeId?: string | null;
|
||||
lastActiveAt: number;
|
||||
};
|
||||
|
||||
export type RightPanelTabStyle = {
|
||||
active: string;
|
||||
inactive: string;
|
||||
};
|
||||
|
||||
export type RightPanelBookmarkTabsProps = {
|
||||
tabs: RightPanelTab[];
|
||||
activeTabId: string | null;
|
||||
onActivate: (tabId: string) => void;
|
||||
onBack: (tabId: string) => void;
|
||||
};
|
||||
|
||||
// 지도 객체 유형과 조사구분을 식별하는 범례 의미색이므로 테마와 무관한 정적 class를 유지한다.
|
||||
export const RIGHT_PANEL_TAB_STYLE_BY_CATEGORY: Record<
|
||||
RightPanelTabCategory,
|
||||
RightPanelTabStyle
|
||||
> = {
|
||||
sinkhole: {
|
||||
active: "border-[#d97706] bg-[#d97706] text-white",
|
||||
inactive: "border-[#fdba74] bg-[#fff7ed] text-[#9a3412] hover:bg-[#ffedd5]",
|
||||
},
|
||||
pipe: {
|
||||
active: "border-[#2563eb] bg-[#2563eb] text-white",
|
||||
inactive: "border-[#93c5fd] bg-[#eff6ff] text-[#1e40af] hover:bg-[#dbeafe]",
|
||||
},
|
||||
exmnSurvey: {
|
||||
active: "border-[#0f766e] bg-[#0f766e] text-white",
|
||||
inactive: "border-[#5eead4] bg-[#f0fdfa] text-[#115e59] hover:bg-[#ccfbf1]",
|
||||
},
|
||||
treatmentFacility: {
|
||||
active: "border-[#15803d] bg-[#15803d] text-white",
|
||||
inactive: "border-[#86efac] bg-[#f0fdf4] text-[#166534] hover:bg-[#dcfce7]",
|
||||
},
|
||||
treatmentZone: {
|
||||
active: "border-[#7e22ce] bg-[#7e22ce] text-white",
|
||||
inactive: "border-[#d8b4fe] bg-[#faf5ff] text-[#6b21a8] hover:bg-[#f3e8ff]",
|
||||
},
|
||||
gisEdit: {
|
||||
active: "border-[#0f766e] bg-[#0f766e] text-white",
|
||||
inactive: "border-[#5eead4] bg-[#f0fdfa] text-[#115e59] hover:bg-[#ccfbf1]",
|
||||
},
|
||||
defaultFeature: {
|
||||
active: "border-[#475569] bg-[#475569] text-white",
|
||||
inactive: "border-[#cbd5e1] bg-[#f8fafc] text-[#334155] hover:bg-[#f1f5f9]",
|
||||
},
|
||||
};
|
||||
|
||||
export const RIGHT_PANEL_EXMN_TAB_STYLE_BY_JOB_TYPE: Record<
|
||||
ExmnMapJobType,
|
||||
RightPanelTabStyle
|
||||
> = {
|
||||
PPL: {
|
||||
active: "border-[#eab308] bg-[#facc15] text-[#713f12]",
|
||||
inactive: "border-[#fde68a] bg-[#fefce8] text-[#854d0e] hover:bg-[#fef9c3]",
|
||||
},
|
||||
MNHL: {
|
||||
active: "border-[#8b5cf6] bg-[#8b5cf6] text-white",
|
||||
inactive: "border-[#c4b5fd] bg-[#f5f3ff] text-[#5b21b6] hover:bg-[#ede9fe]",
|
||||
},
|
||||
};
|
||||
|
||||
// 공용 우측 탭은 유형과 활성 상태에 관계없이 먼저 열린 탭부터 제거한다.
|
||||
export const findRightPanelTabOverflowCandidate = (
|
||||
tabs: RightPanelTab[],
|
||||
nextTabId: string,
|
||||
) => {
|
||||
return tabs.find((tab) => tab.id !== nextTabId) ?? null;
|
||||
};
|
||||
|
||||
// 부모 지반침하가 제거되면 그 부모에서 열린 연계 관로도 같은 탭 그룹으로 제거한다.
|
||||
export const removeRightPanelTabGroup = (
|
||||
tabs: RightPanelTab[],
|
||||
removedTabId: string,
|
||||
) =>
|
||||
tabs.filter(
|
||||
(tab) => tab.id !== removedTabId && tab.parentTabId !== removedTabId,
|
||||
);
|
||||
|
||||
// 오른쪽 상세 패널에 상세가 2개 이상 쌓였을 때만 책갈피형 전환 버튼을 보여준다.
|
||||
export const RightPanelBookmarkTabs = ({
|
||||
tabs,
|
||||
activeTabId,
|
||||
onActivate,
|
||||
onBack,
|
||||
}: RightPanelBookmarkTabsProps) => {
|
||||
if (tabs.length < 2) return null;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto absolute right-[350px] top-[92px] z-[70] flex w-[112px] flex-col items-end gap-2">
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
const style =
|
||||
tab.category === "exmnSurvey" && tab.surveyJobType
|
||||
? RIGHT_PANEL_EXMN_TAB_STYLE_BY_JOB_TYPE[tab.surveyJobType]
|
||||
: RIGHT_PANEL_TAB_STYLE_BY_CATEGORY[tab.category];
|
||||
const parentTab = tab.parentTabId
|
||||
? tabs.find((candidate) => candidate.id === tab.parentTabId)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={[
|
||||
"flex min-h-[42px] w-[112px] items-center border border-r-0 px-1 py-1 shadow-sm transition-colors",
|
||||
isActive ? style.active : style.inactive,
|
||||
].join(" ")}
|
||||
>
|
||||
{tab.category === "pipe" && parentTab ? (
|
||||
<button
|
||||
type="button"
|
||||
className="mr-1 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded text-sm font-bold transition-colors hover:bg-accent focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1"
|
||||
title={parentTab.displayLabel + "로 돌아가기"}
|
||||
aria-label={parentTab.displayLabel + "로 돌아가기"}
|
||||
onClick={() => onBack(parentTab.id)}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex min-w-0 flex-1 items-center justify-center self-stretch px-1 text-center text-[0.82rem] font-semibold leading-tight"
|
||||
title={tab.displayLabel}
|
||||
aria-pressed={isActive}
|
||||
onClick={() => onActivate(tab.id)}
|
||||
>
|
||||
<span className="block max-h-[2.5rem] overflow-hidden break-all">
|
||||
{tab.displayLabel}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* 화면명: 침수위험지도 목록
|
||||
* 침수위험지도를 검색하고 목록·엑셀·지도 보기 기능을 제공한다.
|
||||
*/
|
||||
"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.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/sbmrgDngrMap?${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/sbmrgDngrMap?${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) =>
|
||||
[
|
||||
row.emphsMngRgnMngNo ?? "unknown",
|
||||
row.floodWtlvFrqVl ?? "unknown",
|
||||
row.ctpvCd ?? "unknown",
|
||||
row.sggCd ?? "unknown",
|
||||
index,
|
||||
].join(":")
|
||||
}
|
||||
/>
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/** 침수위험지도 화면의 검색 조건, 컬럼과 응답 변환을 정의한다. */
|
||||
"use client";
|
||||
|
||||
import type { UiHeadProps } from "@common/business_components/ui";
|
||||
import { appendApiSearchParams } from "@/shared/util/util";
|
||||
|
||||
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 = 10;
|
||||
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),
|
||||
});
|
||||
appendApiSearchParams(params, {
|
||||
ctpvCd: filters.ctpvCd === "0" ? "0" : filters.ctpvCd.slice(0, 2),
|
||||
sggCd: filters.sggCd === "0" ? "0" : filters.sggCd.slice(0, 5),
|
||||
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, "");
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 화면명: 지반침하 상세정보
|
||||
* 선택한 지반침하 정보와 주변 관로 조회 결과를 표시한다.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
@@ -5,6 +9,7 @@ import { Button } from "@common/components";
|
||||
import { VerticalTable } from "@/components/ui/verticalTable";
|
||||
import { getCmsData } from "@/components/lib/api";
|
||||
import type { SinkholeDetail, SinkholeNearbyPipe } from "./sinkholeTypes";
|
||||
import { useSinkholeCommonCodes } from "./useSinkholeCommonCodes";
|
||||
|
||||
type Props = {
|
||||
detail: SinkholeDetail;
|
||||
@@ -74,7 +79,11 @@ export default function SinkholeDetailPanel({
|
||||
onClose,
|
||||
onSelectPipe,
|
||||
}: Props) {
|
||||
const [pipeUseCodeMap, setPipeUseCodeMap] = useState<Map<string, string>>(new Map());
|
||||
// 상세의 원천 복구상태 값은 유지하고 CMS에 대응하는 코드명만 표시한다.
|
||||
const { restoreStatusLabels } = useSinkholeCommonCodes();
|
||||
const [pipeUseCodeMap, setPipeUseCodeMap] = useState<Map<string, string>>(
|
||||
new Map(),
|
||||
);
|
||||
const canShowNearby = String(detail.coordStatus ?? "").trim() === "SUCCESS";
|
||||
|
||||
useEffect(() => {
|
||||
@@ -83,7 +92,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);
|
||||
@@ -117,7 +128,9 @@ export default function SinkholeDetailPanel({
|
||||
}, [nearbyPipes]);
|
||||
|
||||
const summaryTableData = useMemo(
|
||||
() => [
|
||||
() => {
|
||||
const restoreStatus = String(detail.restoreStatus ?? "").trim();
|
||||
return [
|
||||
{
|
||||
acdntNo: display(detail.acdntNo),
|
||||
ocrnYmd: display(detail.ocrnYmd),
|
||||
@@ -126,11 +139,14 @@ export default function SinkholeDetailPanel({
|
||||
sizeLength: display(detail.sizeLength),
|
||||
sizeDepth: display(detail.sizeDepth),
|
||||
detailCause: display(detail.detailCause),
|
||||
restoreStatus: display(detail.restoreStatus),
|
||||
restoreStatus: display(
|
||||
restoreStatusLabels[restoreStatus] || restoreStatus,
|
||||
),
|
||||
restoreCompletedYmd: display(detail.restoreCompletedYmd),
|
||||
},
|
||||
],
|
||||
[detail],
|
||||
];
|
||||
},
|
||||
[detail, restoreStatusLabels],
|
||||
);
|
||||
|
||||
const summaryHeadList = [
|
||||
@@ -148,11 +164,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 +176,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 +202,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 +228,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 +243,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 +264,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>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
/**
|
||||
* 화면명: 지반침하정보 레이어 설정
|
||||
* 발생연도와 위험도 등 지도 표시 조건을 설정한다.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { SelectBox } from "@common/business_components/ui";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import {
|
||||
normalizeSinkholeYearRange,
|
||||
SINKHOLE_MIN_SEARCH_YEAR,
|
||||
@@ -9,6 +14,7 @@ import {
|
||||
type SinkholeSyncSummary,
|
||||
type SinkholeYearRange,
|
||||
} from "./sinkholeTypes";
|
||||
import LayerSettingCard from "../layerSetting/LayerSettingCard";
|
||||
|
||||
type MaybePromise<T = void> = T | Promise<T>;
|
||||
|
||||
@@ -20,6 +26,9 @@ type Props = {
|
||||
lastSyncSummary?: SinkholeSyncSummary | null;
|
||||
isAdmin?: boolean;
|
||||
compact?: boolean;
|
||||
enabled?: boolean;
|
||||
onEnabledChange?: (enabled: boolean) => void;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export default function SinkholeLayerSettingPanel({
|
||||
@@ -30,7 +39,11 @@ export default function SinkholeLayerSettingPanel({
|
||||
lastSyncSummary,
|
||||
isAdmin = false,
|
||||
compact = false,
|
||||
enabled = true,
|
||||
onEnabledChange = () => {},
|
||||
onClose = () => {},
|
||||
}: Props) {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [radiusText, setRadiusText] = useState(String(radiusMeters));
|
||||
const [yearRangeDraft, setYearRangeDraft] = useState<SinkholeYearRange>(() =>
|
||||
@@ -70,47 +83,64 @@ 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-[26px] !w-full !rounded border border-input bg-background px-2 py-0 text-center text-[12px] 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 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 inputClass =
|
||||
"h-[26px] !rounded border border-input bg-background px-2 py-0 text-center text-[12px] leading-none text-foreground";
|
||||
const numberInputClass = inputClass + " min-w-0 flex-1";
|
||||
const applyButtonClass =
|
||||
"h-[26px] shrink-0 rounded border border-[#A9C5E5] bg-[#EDF4FC] px-2 py-0 text-[12px] font-medium leading-none text-[#2463A6]";
|
||||
const settingRows = (
|
||||
<div className="space-y-1.5 text-[1.05rem] font-medium text-slate-700">
|
||||
<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>
|
||||
<div className="space-y-1.5 text-[1.15rem] font-medium text-label">
|
||||
<div className="flex min-h-[26px] flex-col items-stretch gap-1.5 whitespace-nowrap">
|
||||
<div className="flex min-w-0 items-center">
|
||||
<label htmlFor="sinkhole-radius" className="shrink-0">
|
||||
지반침하반경(m)
|
||||
</label>
|
||||
</div>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
<div className="flex w-full items-center gap-1">
|
||||
<input
|
||||
id="sinkhole-radius"
|
||||
className={numberInputClass}
|
||||
@@ -120,7 +150,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();
|
||||
}}
|
||||
@@ -132,37 +164,44 @@ export default function SinkholeLayerSettingPanel({
|
||||
title="적용"
|
||||
aria-label="반경 적용"
|
||||
>
|
||||
✓
|
||||
확인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<div className="flex min-h-[26px] flex-col items-stretch gap-1.5 whitespace-nowrap">
|
||||
<div className="flex min-w-0 items-center">
|
||||
<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]">
|
||||
<div className="flex w-full items-center gap-1.5">
|
||||
<div id="sinkhole-start-year" className="min-w-0 flex-1">
|
||||
<SelectBox
|
||||
className="w-full"
|
||||
selectClass={yearSelectClass}
|
||||
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>
|
||||
<div id="sinkhole-end-year" className="w-[62px]">
|
||||
<span className="px-[1px] text-[0.95rem] font-normal text-muted-foreground">
|
||||
~
|
||||
</span>
|
||||
<div id="sinkhole-end-year" className="min-w-0 flex-1">
|
||||
<SelectBox
|
||||
className="w-full"
|
||||
selectClass={yearSelectClass}
|
||||
label=""
|
||||
selectArray={endYearOptions}
|
||||
value={yearRangeDraft.endYear}
|
||||
onSelectValue={(value) => updateYearRangeDraft("endYear", String(value))}
|
||||
onSelectValue={(value) =>
|
||||
updateYearRangeDraft("endYear", String(value))
|
||||
}
|
||||
selectContentClass="z-[101]"
|
||||
labelClass="hidden"
|
||||
/>
|
||||
@@ -174,7 +213,7 @@ export default function SinkholeLayerSettingPanel({
|
||||
title="적용"
|
||||
aria-label="연도범위 적용"
|
||||
>
|
||||
✓
|
||||
확인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -183,24 +222,32 @@ 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)]">
|
||||
<LayerSettingCard
|
||||
title="지반침하정보"
|
||||
enabled={enabled}
|
||||
onEnabledChange={onEnabledChange}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="overflow-visible">{settingRows}</div>
|
||||
</div>
|
||||
</LayerSettingCard>
|
||||
);
|
||||
}
|
||||
|
||||
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 +255,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}
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
/**
|
||||
* 화면명: 지반침하 목록
|
||||
* 지반침하 정보를 검색하고 목록·엑셀·지도 보기 기능을 제공한다.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Button, Input } from "@common/components";
|
||||
import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
|
||||
import {
|
||||
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 { fetchAllPages } from "@/shared/lib/fetch-all-pages";
|
||||
import useStore from "@/store/useStore";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
|
||||
import PopupSearchPanel, {
|
||||
PopupSearchActions,
|
||||
PopupSearchField,
|
||||
} from "@/features/windowPopup/ui/PopupSearchPanel";
|
||||
import type { SelectDataType } from "@common/business_components";
|
||||
import {
|
||||
buildSinkholeDefaultFilters,
|
||||
@@ -14,17 +29,16 @@ import {
|
||||
formatDateInputToYmd,
|
||||
formatYmdToDateInput,
|
||||
normalizeSinkholeFilters,
|
||||
SINKHOLE_COORD_STATUS_OPTIONS,
|
||||
SINKHOLE_DOWNLOAD_EXCEL_HEADERS,
|
||||
SINKHOLE_FILTER_SYNC_MESSAGE,
|
||||
SINKHOLE_MIN_SEARCH_YMD,
|
||||
SINKHOLE_RESTORE_STATUS_OPTIONS,
|
||||
SINKHOLE_ROW_SELECT_MESSAGE,
|
||||
type SinkholeFilters,
|
||||
type SinkholeListItem,
|
||||
} from "./sinkholeTypes";
|
||||
import { useSinkholeCommonCodes } from "./useSinkholeCommonCodes";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const PAGE_SIZE = 10;
|
||||
const DOWNLOAD_BATCH_SIZE = 2000;
|
||||
|
||||
type SinkholeTableRow = SinkholeListItem & {
|
||||
@@ -33,37 +47,49 @@ type SinkholeTableRow = SinkholeListItem & {
|
||||
sizeLengthDisplay: string;
|
||||
sizeDepthDisplay: string;
|
||||
detailCauseDisplay: string;
|
||||
restoreStatusDisplay: string;
|
||||
restoreCompletedYmdDisplay: string;
|
||||
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: "restoreStatusDisplay", title: "복구상태", widthPercent: 8 },
|
||||
{ id: "coordStatusDisplay", title: "좌표보정상태", widthPercent: 9 },
|
||||
{ id: "restoreCompletedYmdDisplay", title: "복구완료일자", widthPercent: 11 },
|
||||
] as const;
|
||||
];
|
||||
|
||||
const toDisplay = (value: unknown) => {
|
||||
const normalized = String(value ?? "").trim();
|
||||
return normalized ? normalized : "-";
|
||||
};
|
||||
|
||||
const toCoordStatusDisplay = (value: unknown) => {
|
||||
const normalized = String(value ?? "").trim().toUpperCase();
|
||||
const toCoordStatusDisplay = (
|
||||
value: unknown,
|
||||
labels: Record<string, string> = {},
|
||||
) => {
|
||||
const normalized = String(value ?? "")
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (labels[normalized]) return labels[normalized];
|
||||
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,
|
||||
@@ -71,10 +97,29 @@ const toTableRows = (items: SinkholeListItem[], page: number, pageSize = PAGE_SI
|
||||
sizeLengthDisplay: toDisplay(item.sizeLength),
|
||||
sizeDepthDisplay: toDisplay(item.sizeDepth),
|
||||
detailCauseDisplay: toDisplay(item.detailCause),
|
||||
restoreStatusDisplay: toDisplay(item.restoreStatus),
|
||||
coordStatusDisplay: toCoordStatusDisplay(item.coordStatus),
|
||||
restoreCompletedYmdDisplay: toDisplay(item.restoreCompletedYmd),
|
||||
}));
|
||||
|
||||
const applyStatusDisplayNames = (
|
||||
rows: SinkholeTableRow[],
|
||||
restoreStatusLabels: Record<string, string>,
|
||||
coordStatusLabels: Record<string, string>,
|
||||
) =>
|
||||
rows.map((row) => {
|
||||
const restoreStatus = String(row.restoreStatus ?? "").trim();
|
||||
return {
|
||||
...row,
|
||||
restoreStatusDisplay:
|
||||
restoreStatusLabels[restoreStatus] || restoreStatus || "-",
|
||||
coordStatusDisplay: toCoordStatusDisplay(
|
||||
row.coordStatus,
|
||||
coordStatusLabels,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const buildDownloadRows = (rows: SinkholeTableRow[]) =>
|
||||
rows.map((row) => ({
|
||||
rowNo: row.rowNo,
|
||||
@@ -84,7 +129,7 @@ const buildDownloadRows = (rows: SinkholeTableRow[]) =>
|
||||
sizeLength: row.sizeLengthDisplay,
|
||||
sizeDepth: row.sizeDepthDisplay,
|
||||
detailCause: row.detailCauseDisplay,
|
||||
restoreStatus: row.restoreStatus || "-",
|
||||
restoreStatus: row.restoreStatusDisplay,
|
||||
restoreCompletedYmd: row.restoreCompletedYmdDisplay,
|
||||
}));
|
||||
|
||||
@@ -92,6 +137,14 @@ const unwrapPayload = <T,>(response: any): T =>
|
||||
(response?.data ?? response) as T;
|
||||
|
||||
export default function SinkholePopupPage() {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
// 검색 파라미터 계약은 유지하고 선택지·목록 표시명만 CMS 공통코드로 구성한다.
|
||||
const {
|
||||
restoreStatusOptions,
|
||||
coordStatusOptions,
|
||||
restoreStatusLabels,
|
||||
coordStatusLabels,
|
||||
} = useSinkholeCommonCodes();
|
||||
const searchParams = useSearchParams();
|
||||
const hasInitialFilterParams =
|
||||
searchParams.has("startYmd") ||
|
||||
@@ -140,13 +193,18 @@ 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 displayRows = useMemo(
|
||||
() => applyStatusDisplayNames(rows, restoreStatusLabels, coordStatusLabels),
|
||||
[coordStatusLabels, restoreStatusLabels, rows],
|
||||
);
|
||||
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 +230,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 +248,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 +268,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 +292,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,
|
||||
@@ -249,22 +316,27 @@ export default function SinkholePopupPage() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const normalized = normalizeSinkholeFilters(appliedFilters);
|
||||
const downloadItems: SinkholeListItem[] = [];
|
||||
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, {
|
||||
const response = await fetchAllPages({
|
||||
batchSize: DOWNLOAD_BATCH_SIZE,
|
||||
fetchPage: (page, size) =>
|
||||
fetchRows(page + 1, normalized, size, {
|
||||
includeCoordStatus: false,
|
||||
}),
|
||||
getItems: (page) => page.items,
|
||||
getTotal: (page) => page.total,
|
||||
});
|
||||
downloadItems.push(...response.items);
|
||||
}
|
||||
const downloadItems: SinkholeListItem[] = response.items;
|
||||
|
||||
if (downloadItems.length === 0) {
|
||||
alert("다운로드할 데이터가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const downloadRows = toTableRows(downloadItems, 0, 1);
|
||||
const downloadRows = applyStatusDisplayNames(
|
||||
toTableRows(downloadItems, 0, 1),
|
||||
restoreStatusLabels,
|
||||
coordStatusLabels,
|
||||
);
|
||||
await downloadExcelWithKoreanHeaders({
|
||||
data: buildDownloadRows(downloadRows) as unknown as JSON[],
|
||||
fileName: `지반침하정보_${new Date().toISOString().slice(0, 10)}.xlsx`,
|
||||
@@ -274,48 +346,54 @@ 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>
|
||||
<PopupSearchPanel>
|
||||
<PopupSearchField label="발생기간(시작)">
|
||||
<Input
|
||||
className="h-10 !text-[1.1rem]"
|
||||
className="h-[3.2rem] w-[12rem] !text-[1.3rem]"
|
||||
type="date"
|
||||
min={formatYmdToDateInput(SINKHOLE_MIN_SEARCH_YMD)}
|
||||
value={formatYmdToDateInput(filters.startYmd)}
|
||||
onChange={(event) => setFilters({ ...filters, startYmd: formatDateInputToYmd(event.target.value) })}
|
||||
onChange={(event) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
startYmd: formatDateInputToYmd(event.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</PopupSearchField>
|
||||
|
||||
<label className="block md:max-w-[170px]">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">발생기간(종료)</div>
|
||||
<PopupSearchField label="발생기간(종료)">
|
||||
<Input
|
||||
className="h-10 !text-[1.1rem]"
|
||||
className="h-[3.2rem] w-[12rem] !text-[1.3rem]"
|
||||
type="date"
|
||||
min={formatYmdToDateInput(SINKHOLE_MIN_SEARCH_YMD)}
|
||||
value={formatYmdToDateInput(filters.endYmd)}
|
||||
onChange={(event) => setFilters({ ...filters, endYmd: formatDateInputToYmd(event.target.value) })}
|
||||
onChange={(event) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
endYmd: formatDateInputToYmd(event.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</PopupSearchField>
|
||||
|
||||
<label className="block md:max-w-[220px]">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시도</div>
|
||||
<PopupSearchField label="시도">
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
|
||||
className="h-[3.2rem] w-[12rem] rounded-md border border-input bg-field px-3 text-[1.3rem] text-foreground"
|
||||
value={filters.ctpvCd}
|
||||
onChange={(event) =>
|
||||
setFilters({
|
||||
@@ -332,14 +410,15 @@ export default function SinkholePopupPage() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</PopupSearchField>
|
||||
|
||||
<label className="block md:max-w-[220px]">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시군구</div>
|
||||
<PopupSearchField label="시군구">
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
|
||||
className="h-[3.2rem] w-[12rem] rounded-md border border-input bg-field px-3 text-[1.3rem] text-foreground"
|
||||
value={filters.sggCd}
|
||||
onChange={(event) => setFilters({ ...filters, sggCd: event.target.value })}
|
||||
onChange={(event) =>
|
||||
setFilters({ ...filters, sggCd: event.target.value })
|
||||
}
|
||||
>
|
||||
<option value="0">전체</option>
|
||||
{sggOptions.map((item) => (
|
||||
@@ -348,74 +427,93 @@ export default function SinkholePopupPage() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</PopupSearchField>
|
||||
|
||||
<label className="block md:max-w-[180px]">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">복구상태</div>
|
||||
<PopupSearchField label="복구상태">
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
|
||||
className="h-[3.2rem] w-[10rem] rounded-md border border-input bg-field px-3 text-[1.3rem] text-foreground"
|
||||
value={filters.restoreStatus}
|
||||
onChange={(event) => setFilters({ ...filters, restoreStatus: event.target.value })}
|
||||
onChange={(event) =>
|
||||
setFilters({ ...filters, restoreStatus: event.target.value })
|
||||
}
|
||||
>
|
||||
{SINKHOLE_RESTORE_STATUS_OPTIONS.map((option) => (
|
||||
{restoreStatusOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</PopupSearchField>
|
||||
|
||||
<label className="block md:max-w-[180px]">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">좌표보정상태</div>
|
||||
<PopupSearchField label="좌표보정상태">
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
|
||||
className="h-[3.2rem] w-[10rem] rounded-md border border-input bg-field px-3 text-[1.3rem] text-foreground"
|
||||
value={filters.coordStatus}
|
||||
onChange={(event) => setFilters({ ...filters, coordStatus: event.target.value })}
|
||||
onChange={(event) =>
|
||||
setFilters({ ...filters, coordStatus: event.target.value })
|
||||
}
|
||||
>
|
||||
{SINKHOLE_COORD_STATUS_OPTIONS.map((option) => (
|
||||
{coordStatusOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</PopupSearchField>
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2 md:pr-3">
|
||||
<PopupSearchActions>
|
||||
<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}
|
||||
color="white"
|
||||
onClick={() => {
|
||||
const resetFilters = normalizeSinkholeFilters(
|
||||
buildSinkholeDefaultFilters(),
|
||||
undefined,
|
||||
);
|
||||
setFilters(resetFilters);
|
||||
void loadRows(1, resetFilters);
|
||||
}}
|
||||
>
|
||||
다운로드
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopupSearchActions>
|
||||
</PopupSearchPanel>
|
||||
|
||||
<UiTable<SinkholeTableRow>
|
||||
headName={<>지반침하 조회 결과 총 {total.toLocaleString()}건</>}
|
||||
tableData={rows}
|
||||
headlist={HEADLIST as any}
|
||||
pageSize={PAGE_SIZE}
|
||||
<MapPopupDataTable
|
||||
rows={displayRows as Array<SinkholeTableRow & Record<string, unknown>>}
|
||||
headlist={HEADLIST}
|
||||
title="지반침하 목록"
|
||||
total={total}
|
||||
cellClick={(index) => handleRowClick(index)}
|
||||
onPageChange={(page) => void handleSearch(page)}
|
||||
curPage={currentPage}
|
||||
index={selectedIndex}
|
||||
widthFixed={true}
|
||||
isHideCount={true}
|
||||
page={currentPage}
|
||||
pageSize={PAGE_SIZE}
|
||||
onPageChange={(page) => void handlePageChange(page)}
|
||||
onRowClick={handleRowClick}
|
||||
rowId={(row, index) => String(row.fid ?? row.acdntNo ?? index)}
|
||||
renderCell={(row, headId) =>
|
||||
headId === "fullAddress" ? (
|
||||
<span
|
||||
className="block whitespace-nowrap"
|
||||
title={toDisplay(row.fullAddress)}
|
||||
>
|
||||
{toDisplay(row.fullAddress)}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
scrollMode="horizontal"
|
||||
minTableWidth="150rem"
|
||||
actions={
|
||||
<Button type="button" onClick={handleDownload}>
|
||||
엑셀다운로드
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/** 지반침하 화면에서 사용하는 타입·상수·검색 조건을 정의한다. */
|
||||
"use client";
|
||||
|
||||
import type { SelectDataType } from "@common/business_components";
|
||||
import type { IUserInfo } from "@/shared/store";
|
||||
import { appendApiSearchParams } from "@/shared/util/util";
|
||||
|
||||
export const SINKHOLE_CONTROL_NODE_ID = "SN_SINKHOLE";
|
||||
export const SINKHOLE_LAYER_TYPE = "tb_sn_sinkhole_info";
|
||||
@@ -14,7 +16,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 +97,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 +111,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 +129,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 +160,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 +177,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 +221,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 +238,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 +255,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,
|
||||
@@ -258,23 +286,19 @@ export const buildSinkholeListQueryString = (
|
||||
size: String(size),
|
||||
});
|
||||
|
||||
if (filters.startYmd) params.set("startYmd", filters.startYmd);
|
||||
if (filters.endYmd) params.set("endYmd", filters.endYmd);
|
||||
|
||||
const ctpvNm = resolveRegionNameByCode(filters.ctpvCd, ctpvOptions, 2);
|
||||
const sggNm = resolveRegionNameByCode(filters.sggCd, sggOptions, 5);
|
||||
if (ctpvNm) params.set("ctpvNm", ctpvNm);
|
||||
if (sggNm) params.set("sggNm", sggNm);
|
||||
if (filters.restoreStatus && filters.restoreStatus !== "0") {
|
||||
params.set("restoreStatus", filters.restoreStatus);
|
||||
}
|
||||
if (filters.coordStatus && filters.coordStatus !== "0") {
|
||||
params.set("coordStatus", filters.coordStatus);
|
||||
}
|
||||
appendApiSearchParams(params, {
|
||||
startYmd: filters.startYmd,
|
||||
endYmd: filters.endYmd,
|
||||
ctpvNm,
|
||||
sggNm,
|
||||
restoreStatus: filters.restoreStatus,
|
||||
coordStatus: filters.coordStatus,
|
||||
});
|
||||
if (options?.includeCoordStatus === false) {
|
||||
params.set("includeCoordStatus", "false");
|
||||
}
|
||||
|
||||
return params.toString();
|
||||
};
|
||||
|
||||
@@ -282,7 +306,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 +321,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 +340,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 +353,7 @@ export const SINKHOLE_EXCEL_HEADERS: Record<string, string> = {
|
||||
};
|
||||
|
||||
export const SINKHOLE_DOWNLOAD_EXCEL_HEADERS: Record<string, string> = {
|
||||
rowNo: "NO",
|
||||
rowNo: "순번",
|
||||
ocrnYmd: "발생일자",
|
||||
fullAddress: "주소",
|
||||
sizeWidth: "발생규모폭",
|
||||
|
||||