RDII 분석결과 팝업 지도연계 및 결과전용 팝업 적용

This commit is contained in:
kiminchan
2026-07-02 11:38:40 +09:00
parent d5814043d9
commit 516d93d6fa
13 changed files with 1263 additions and 379 deletions
@@ -1,11 +1,10 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState, type ComponentType, type ReactNode } from "react";
import { import {
ChartColumnBig, ChartColumnBig,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Droplets,
PanelsTopLeft, PanelsTopLeft,
PencilRuler, PencilRuler,
Search, Search,
@@ -23,10 +22,40 @@ type Props = {
isInfoSearchActive?: boolean; isInfoSearchActive?: boolean;
}; };
type MenuIconProps = {
isActive: boolean;
};
const InftwtMenuIcon = ({ isActive }: MenuIconProps) => (
<svg
viewBox="0 0 24 24"
className={`h-[15px] w-[15px] shrink-0 ${isActive ? "text-[#1e88e5]" : "text-[#64748b]"}`}
aria-hidden="true"
fill="none"
>
<path
d="M12 2.8C9.5 6.5 6.7 9.8 6.7 13.3A5.3 5.3 0 0 0 12 18.6a5.3 5.3 0 0 0 5.3-5.3c0-3.5-2.8-6.8-5.3-10.5Z"
fill="currentColor"
fillOpacity="0.14"
stroke="currentColor"
strokeWidth="1.8"
strokeLinejoin="round"
/>
<path
d="M9.5 13.4c0-1.2.8-2.6 1.9-4.2"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
opacity="0.9"
/>
</svg>
);
const ANALYSIS_POPUP_ITEMS: Array<{ const ANALYSIS_POPUP_ITEMS: Array<{
key: WindowPopupKey | "infoSearch"; key: WindowPopupKey | "infoSearch";
label: string; label: string;
icon: typeof ChartColumnBig; icon?: ComponentType<{ className?: string }>;
renderIcon?: (props: MenuIconProps) => ReactNode;
}> = [ }> = [
{ key: "infoSearch", label: "정보검색", icon: Search }, { key: "infoSearch", label: "정보검색", icon: Search },
{ key: "pipeAnalysisPopup", label: "관로분석", icon: ChartColumnBig }, { key: "pipeAnalysisPopup", label: "관로분석", icon: ChartColumnBig },
@@ -34,7 +63,8 @@ const ANALYSIS_POPUP_ITEMS: Array<{
{ key: "gisEditPopup", label: "GIS 편집", icon: PencilRuler }, { key: "gisEditPopup", label: "GIS 편집", icon: PencilRuler },
{ key: "gisManagementPopup", label: "GIS 관리", icon: Settings2 }, { key: "gisManagementPopup", label: "GIS 관리", icon: Settings2 },
{ key: "flowRiskPopup", label: "통수 위험", icon: TriangleAlert }, { key: "flowRiskPopup", label: "통수 위험", icon: TriangleAlert },
{ key: "inftwtResultPopup", label: "침입수 분석", icon: Droplets }, { key: "inftwtResultPopup", label: "침입수 분석결과", renderIcon: InftwtMenuIcon },
{ key: "rdiiResultPopup", label: "RDII 분석결과", icon: ChartColumnBig },
]; ];
export default function AnalysisPopupMenu({ export default function AnalysisPopupMenu({
@@ -63,7 +93,7 @@ export default function AnalysisPopupMenu({
<div className="absolute right-[0.55rem] top-[20px] z-[45]"> <div className="absolute right-[0.55rem] top-[20px] z-[45]">
<div className="relative"> <div className="relative">
{isOpen ? ( {isOpen ? (
<div className="absolute right-[28px] w-[132px] overflow-hidden rounded-[6px] border border-[#d8dee8] bg-[rgba(255,255,255,0.96)] shadow-[0_8px_18px_rgba(15,23,42,0.14)]"> <div className="absolute right-[28px] w-[154px] overflow-hidden rounded-[6px] border border-[#d8dee8] bg-[rgba(255,255,255,0.96)] shadow-[0_8px_18px_rgba(15,23,42,0.14)]">
<div className="flex h-[36px] items-center gap-2 border-b border-[#e5eaf1] bg-[#f8fafc] px-[10px] text-[13px] font-bold text-[#1f2937]"> <div className="flex h-[36px] items-center gap-2 border-b border-[#e5eaf1] bg-[#f8fafc] px-[10px] text-[13px] font-bold text-[#1f2937]">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<PanelsTopLeft className="h-4 w-4 text-[#1e88e5]" /> <PanelsTopLeft className="h-4 w-4 text-[#1e88e5]" />
@@ -116,12 +146,15 @@ export default function AnalysisPopupMenu({
}, 300); }, 300);
}} }}
> >
<Icon {item.renderIcon ? item.renderIcon({ isActive }) : null}
className={`h-[15px] w-[15px] shrink-0 ${ {Icon ? (
isActive ? "text-[#1e88e5]" : "text-[#64748b]" <Icon
}`} className={`h-[15px] w-[15px] shrink-0 ${
/> isActive ? "text-[#1e88e5]" : "text-[#64748b]"
<span className="break-keep whitespace-normal">{item.label}</span> }`}
/>
) : null}
<span className="whitespace-nowrap">{item.label}</span>
</button> </button>
); );
})} })}
@@ -141,4 +174,4 @@ export default function AnalysisPopupMenu({
</div> </div>
</div> </div>
); );
} }
@@ -1,9 +1,9 @@
import {useState, useEffect} from 'react'; import { useState, useEffect } from "react";
import { getData } from '@/components/lib/api';
import moment from "moment"; import moment from "moment";
import { getData } from "@/components/lib/api";
export type TInftwtAnls = { export type TInftwtAnls = {
inftwtAnlsSn:number; inftwtAnlsSn: number;
aplcnYm: string; aplcnYm: string;
ctpvCd: string; ctpvCd: string;
sggCd: string; sggCd: string;
@@ -15,18 +15,20 @@ export type TInftwtAnls = {
atflMpngNm: string; atflMpngNm: string;
atflOilqNm: string; atflOilqNm: string;
atflNm: string; atflNm: string;
drtwSwtcRt:number; drtwSwtcRt: number;
anlsInfo: string; anlsInfo: string;
scsYn: string; scsYn: string;
}; };
export type TInftwtAnlsRslt = { export type TInftwtAnlsRslt = {
inftwtAnlsSn: number; inftwtAnlsSn: number;
pplMngNo: string; pplMngNo: string;
anlsTm: string; anlsTm: string;
predcOilq:number; predcOilq: number;
} };
export type TRdiiAnls = { export type TRdiiAnls = {
rainInfwtAnlsSn:number; rainInfwtAnlsSn: number;
aplcnYm: string; aplcnYm: string;
ctpvCd: string; ctpvCd: string;
sggCd: string; sggCd: string;
@@ -38,234 +40,259 @@ export type TRdiiAnls = {
atflMpngNm: string; atflMpngNm: string;
atflRainNm: string; atflRainNm: string;
atflNm: string; atflNm: string;
drtwSwtcRt:number; drtwSwtcRt: number;
anlsInfo: string; anlsInfo: string;
scsYn: string; scsYn: string;
srcNm: string; // 출처이름 srcNm: string;
}; };
export type TRdiiAnlsRslt = { export type TRdiiAnlsRslt = {
rainInfwtAnlsSn: number; rainInfwtAnlsSn: number;
pplMngNo: string; pplMngNo: string;
anlsTm: string; anlsTm: string;
predcOilq:number; predcOilq: number;
minq:number; minq: number;
} };
export type TOilqData = { export type TOilqData = {
inftwtAnlsSn: number; // 침입수분석 일련번호 inftwtAnlsSn: number;
pplMngNo: string; // 관로관리번호 pplMngNo: string;
mtrngDt: string; // 계측일시 mtrngDt: string;
oilq: number; // 유량 oilq: number;
} };
export type TRainData = { export type TRainData = {
rainInfwtAnlsSn: number; rainInfwtAnlsSn: number;
mtrngDt: string; mtrngDt: string;
amrf: number; // 강우량 amrf: number;
} };
function roundTo(n: number, digits: number): number { function roundTo(n: number, digits: number): number {
const factor = Math.pow(10, digits); const factor = Math.pow(10, digits);
return Math.round(n * factor) / factor; return Math.round(n * factor) / factor;
} }
const getOptionsAplcnYm = (yyyymms: string[]): { text: string; val: string, hidden?: boolean }[] => { const getOptionsAplcnYm = (yyyymms: string[]): { text: string; val: string; hidden?: boolean }[] => {
const yyyymms1 : { text: string; val: string; hidden?: boolean }[] = yyyymms.map(dateStr => { const mapped: { text: string; val: string; hidden?: boolean }[] = yyyymms.map((dateStr) => ({
const year = dateStr.slice(0, 4); text: `${dateStr.slice(0, 4)}-${dateStr.slice(4, 6)}`,
const month = dateStr.slice(4, 6); val: dateStr,
return { }));
text: `${year}-${month}`, mapped.unshift({ val: "0", text: "선택", hidden: true });
val: dateStr return mapped;
};
});
yyyymms1.unshift({ val: '0', text: '선택', hidden: true });
return yyyymms1;
};
const selectAplcnYms = async(gubun:string) => { // 상수사용월
try {
const queryString = '';
const result = await getData(`v1/${gubun}/aplcnYm${queryString ? `?${queryString}` : ''}`);
if (result && result.length > 0) {
return getOptionsAplcnYm(result);
}
} catch (error) {
console.error(error);
}
}; };
export const useAplcnYms = (gubun:string) => { const selectAplcnYms = async (gubun: string) => {
const [data, setData] = useState<{ text: string; val: string }[]>([]); if (!gubun) return [];
try {
const result = await getData(`v1/${gubun}/aplcnYm`);
if (Array.isArray(result) && result.length > 0) {
return getOptionsAplcnYm(result);
}
} catch {
return [];
}
return [];
};
export const useAplcnYms = (gubun: string) => {
const [data, setData] = useState<{ text: string; val: string; hidden?: boolean }[]>([]);
useEffect(() => { useEffect(() => {
const fetch = async (gubun:string) => { if (!gubun) {
setData([]);
return;
}
const fetch = async () => {
const result = await selectAplcnYms(gubun); const result = await selectAplcnYms(gubun);
if (result) setData(result); setData(result ?? []);
}; };
fetch(gubun);
void fetch();
}, [gubun]); }, [gubun]);
return data; return data;
}; };
export const getOptionsInftwtAnlsInfo = (anlsArr: TInftwtAnls[]): { text: string; val: number, hidden?: boolean }[] => {
const options : { text: string; val: number; hidden?: boolean }[] = anlsArr.map(anls => ({ export const getOptionsInftwtAnlsInfo = (anlsArr: TInftwtAnls[]): { text: string; val: number; hidden?: boolean }[] => {
const options: { text: string; val: number; hidden?: boolean }[] = anlsArr.map((anls) => ({
text: anls.anlsInfo, text: anls.anlsInfo,
val: anls.inftwtAnlsSn, val: anls.inftwtAnlsSn,
})); }));
options.unshift({ val: 0, text: '선택', hidden: true }); options.unshift({ val: 0, text: "선택", hidden: true });
return options; return options;
}; };
export const selectInftwtAnls = async(aplcnYm:string) => { // 침입수분석 export const selectInftwtAnls = async (aplcnYm: string) => {
try { try {
const queryString = aplcnYm ? `aplcnYmFrom=${aplcnYm}&aplcnYmTo=${aplcnYm}` : ''; const queryString = aplcnYm ? `aplcnYmFrom=${aplcnYm}&aplcnYmTo=${aplcnYm}` : "";
const result = await getData(`v1/inftwtAnls${queryString ? `?${queryString}` : ''}`); const result = await getData(`v1/inftwtAnls${queryString ? `?${queryString}` : ""}`);
if (result && result.numberOfElements > 0) { if (result && result.numberOfElements > 0) {
return result.content; return result.content;
} }
} catch (error) { } catch {
console.error(error); return [];
} }
return [];
}; };
export const getChartDataInftwtAnlsRslt = (arr: TInftwtAnlsRslt[]): { label: string; val: number }[] => {
const options = arr.map(anlsRslt => ({ export const getChartDataInftwtAnlsRslt = (arr: TInftwtAnlsRslt[]) => {
return arr.map((anlsRslt) => ({
label: moment(anlsRslt.anlsTm, "YYYYMMDDHHmmss").format("HH시"), label: moment(anlsRslt.anlsTm, "YYYYMMDDHHmmss").format("HH시"),
val: roundTo(anlsRslt.predcOilq, 2), val: roundTo(anlsRslt.predcOilq, 2),
})); }));
return options;
}; };
export const selectInftwtAnlsRslt = async(inftwtAnlsSn:number, pplMngNo:string) => { // 침입수분석 결과
export const selectInftwtAnlsRslt = async (inftwtAnlsSn: number, pplMngNo: string) => {
try { try {
if ((inftwtAnlsSn === undefined || inftwtAnlsSn === 0) || !pplMngNo) { if (!inftwtAnlsSn || !pplMngNo) return [];
console.warn("inftwtAnlsSn or pplMngNo is not provided");
return [];
}
const result = await getData(`v1/inftwtAnlsRslt/ppl/${inftwtAnlsSn}/${pplMngNo}`); const result = await getData(`v1/inftwtAnlsRslt/ppl/${inftwtAnlsSn}/${pplMngNo}`);
if (result && result.numberOfElements > 0) { if (result && result.numberOfElements > 0) {
return result.content; return result.content;
} }
} catch (error) { } catch {
console.error(error); return [];
} }
return [];
}; };
export const getChartDataOilq = (arr: TOilqData[]): { label: string; val: number }[] => {
const options = arr.map(anlsRslt => ({ export const getChartDataOilq = (arr: TOilqData[]) => {
return arr.map((anlsRslt) => ({
label: anlsRslt.mtrngDt, label: anlsRslt.mtrngDt,
val: roundTo(anlsRslt.oilq, 2), val: roundTo(anlsRslt.oilq, 2),
})); }));
return options;
};
export const selectOilq = async(inftwtAnlsSn:number, pplMngNo:string) => { // 유량
try {
if ((inftwtAnlsSn === undefined || inftwtAnlsSn === 0) || !pplMngNo) {
console.warn("inftwtAnlsSn or pplMngNo is not provided");
return [];
}
const result = await getData(`v1/inftwtAnlsRslt/oilqPpl/${inftwtAnlsSn}/${pplMngNo}`);
if (result && result.numberOfElements > 0) {
return result.content;
}
} catch (error) {
console.error(error);
}
};
export const selectPplInftwtQty = async(inftwtAnlsSn:number, pplMngNo:string) => { // 침입수량
try {
if ((inftwtAnlsSn === undefined || inftwtAnlsSn === 0) || !pplMngNo) {
console.warn("inftwtAnlsSn or pplMngNo is not provided");
return [];
}
const result = await getData(`v1/inftwtAnlsRslt/pplInftwtQty/${inftwtAnlsSn}/${pplMngNo}`);
if (result.length > 0) {
return result[0].avg_day_flow;
} else {
return 0; // 데이터가 없을 경우 0 반환
}
} catch (error) {
console.error(error);
}
};
//----------
export const selectPplRdiiQty = async(rainInfwtAnlsSn:number, pplMngNo:string) => { // rdii값
try {
if ((rainInfwtAnlsSn === undefined || rainInfwtAnlsSn === 0) || !pplMngNo) {
console.warn("rainInfwtAnlsSn or pplMngNo is not provided");
return [];
}
const result = await getData(`v1/rdiiAnlsRslt/pplRdiiQty/${rainInfwtAnlsSn}/${pplMngNo}`);
if (result?.sustaintime > 0) {
return result;
}
} catch (error) {
console.error(error);
}
}; };
export const getOptionsRdiiAnlsInfo = (anlsArr: TRdiiAnls[]): { text: string; val: number, hidden?: boolean }[] => { export const selectOilq = async (inftwtAnlsSn: number, pplMngNo: string) => {
const options : { text: string; val: number; hidden?: boolean }[] = anlsArr.map(anls => ({ try {
if (!inftwtAnlsSn || !pplMngNo) return [];
const result = await getData(`v1/inftwtAnlsRslt/oilqPpl/${inftwtAnlsSn}/${pplMngNo}`);
if (result && result.numberOfElements > 0) {
return result.content;
}
} catch {
return [];
}
return [];
};
export const selectPplInftwtQty = async (inftwtAnlsSn: number, pplMngNo: string) => {
try {
if (!inftwtAnlsSn || !pplMngNo) return 0;
const result = await getData(`v1/inftwtAnlsRslt/pplInftwtQty/${inftwtAnlsSn}/${pplMngNo}`);
if (Array.isArray(result) && result.length > 0) {
return result[0].avg_day_flow;
}
} catch {
return 0;
}
return 0;
};
const buildPplMngNoCandidates = (pplMngNo: string) => {
const normalized = String(pplMngNo ?? "").trim();
if (!normalized) return [];
const withoutPrefix = normalized.replace(/^L/, "");
const withPrefix = withoutPrefix ? `L${withoutPrefix}` : "";
return Array.from(new Set([normalized, withPrefix, withoutPrefix].filter(Boolean)));
};
export const selectPplRdiiQty = async (rainInfwtAnlsSn: number, pplMngNo: string) => {
if (!rainInfwtAnlsSn || !pplMngNo) return null;
for (const candidate of buildPplMngNoCandidates(pplMngNo)) {
try {
const result = await getData(`v1/rdiiAnlsRslt/pplRdiiQty/${rainInfwtAnlsSn}/${candidate}`);
if (result) {
return result;
}
} catch {
// resultOnly mode fallback candidates should not raise console noise
}
}
return null;
};
export const getOptionsRdiiAnlsInfo = (anlsArr: TRdiiAnls[]): { text: string; val: number; hidden?: boolean }[] => {
const options: { text: string; val: number; hidden?: boolean }[] = anlsArr.map((anls) => ({
text: anls.anlsInfo, text: anls.anlsInfo,
val: anls.rainInfwtAnlsSn, val: anls.rainInfwtAnlsSn,
})); }));
options.unshift({ val: 0, text: '선택', hidden: true }); options.unshift({ val: 0, text: "선택", hidden: true });
return options; return options;
}; };
export const selectRdiiAnls = async(aplcnYm:string) => { // 침입수분석 export const selectRdiiAnls = async (aplcnYm: string) => {
try { try {
const queryString = aplcnYm ? `aplcnYmFrom=${aplcnYm}&aplcnYmTo=${aplcnYm}` : ''; const queryString = aplcnYm ? `aplcnYmFrom=${aplcnYm}&aplcnYmTo=${aplcnYm}` : "";
const result = await getData(`v1/rdiiAnls${queryString ? `?${queryString}` : ''}`); const result = await getData(`v1/rdiiAnls${queryString ? `?${queryString}` : ""}`);
if (result && result.numberOfElements > 0) { if (result && result.numberOfElements > 0) {
return result.content; return result.content;
} }
} catch (error) { } catch {
console.error(error); return [];
} }
return [];
}; };
export const getChartDataRdiiAnlsRslt = (arr: TRdiiAnlsRslt[]): { label: string; lineVal0: number, barVal0:number }[] => {
const options = arr.map(anlsRslt => ({ export const getChartDataRdiiAnlsRslt = (arr: TRdiiAnlsRslt[]) => {
return arr.map((anlsRslt) => ({
label: moment(anlsRslt.anlsTm, "YYYYMMDDHHmmss").format("MM-DD HH시"), label: moment(anlsRslt.anlsTm, "YYYYMMDDHHmmss").format("MM-DD HH시"),
lineVal0: roundTo(anlsRslt.predcOilq, 2), lineVal0: roundTo(anlsRslt.predcOilq, 2),
barVal0: 0 barVal0: 0,
})); }));
return options;
}; };
export const getChartDataRdiiAnlsRsltMinq = (arr: TRdiiAnlsRslt[]): { label: string; val: number }[] => {
const options = arr.map(anlsRslt => ({ export const getChartDataRdiiAnlsRsltMinq = (arr: TRdiiAnlsRslt[]) => {
return arr.map((anlsRslt) => ({
label: moment(anlsRslt.anlsTm, "YYYYMMDDHHmmss").format("MM-DD HH시"), label: moment(anlsRslt.anlsTm, "YYYYMMDDHHmmss").format("MM-DD HH시"),
val: roundTo(anlsRslt.minq, 2), val: roundTo(anlsRslt.minq, 2),
})); }));
return options;
}; };
export const selectRdiiAnlsRslt = async(rainInfwtAnlsSn:number, pplMngNo:string) => { // RDII분석 결과
try { export const selectRdiiAnlsRslt = async (rainInfwtAnlsSn: number, pplMngNo: string) => {
if ((rainInfwtAnlsSn === undefined || rainInfwtAnlsSn === 0) || !pplMngNo) { if (!rainInfwtAnlsSn || !pplMngNo) return [];
console.warn("RdiiAnlsSn or pplMngNo is not provided");
return []; for (const candidate of buildPplMngNoCandidates(pplMngNo)) {
} try {
const result = await getData(`v1/rdiiAnlsRslt/ppl/${rainInfwtAnlsSn}/${pplMngNo}`); const result = await getData(`v1/rdiiAnlsRslt/ppl/${rainInfwtAnlsSn}/${candidate}`);
if (result && result.numberOfElements > 0) { if (result && result.numberOfElements > 0) {
return result.content; return result.content;
} }
} catch (error) { } catch {
console.error(error); // resultOnly mode fallback candidates should not raise console noise
}
};
export const selectRainData = async(rainInfwtAnlsSn:number) => { // 강우데이터
try {
if ((rainInfwtAnlsSn === undefined || rainInfwtAnlsSn === 0)) {
console.warn("RdiiAnlsSn or pplMngNo is not provided");
return [];
} }
}
return [];
};
export const selectRainData = async (rainInfwtAnlsSn: number) => {
try {
if (!rainInfwtAnlsSn) return [];
const result = await getData(`v1/rdiiAnlsRslt/rainData/${rainInfwtAnlsSn}`); const result = await getData(`v1/rdiiAnlsRslt/rainData/${rainInfwtAnlsSn}`);
if (result && result.numberOfElements > 0) { if (result && result.numberOfElements > 0) {
return result.content; return result.content;
} }
} catch (error) { } catch {
console.error(error); return [];
} }
return [];
}; };
export const getChartDataRainData = (arr: TRainData[]): { label: string; lineVal0: number, barVal0: number }[] => {
const options = arr.map(data => ({ export const getChartDataRainData = (arr: TRainData[]) => {
return arr.map((data) => ({
label: moment(data.mtrngDt, "YYYY-MM-DD HH:mm:ss").format("MM-DD HH시"), label: moment(data.mtrngDt, "YYYY-MM-DD HH:mm:ss").format("MM-DD HH시"),
lineVal0: 0, lineVal0: 0,
barVal0: roundTo(data.amrf,2), barVal0: roundTo(data.amrf, 2),
})); }));
return options;
}; };
@@ -1,21 +1,25 @@
'use client' "use client";
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from "react";
import moment from 'moment'; import moment from "moment";
import { import {
CHART_FORM, CHART_FORM,
type ChartConfig, type ChartConfig,
MainChart, MainChart,
} from '@common/business_components'; } from "@common/business_components";
import { SelectBox } from '@common/business_components/ui'; import { SelectBox } from "@common/business_components/ui";
import { Button, Input, Label } from '@common/components'; import { Button, Input, Label } from "@common/components";
import type { TRdiiAnls } from './aplcnYms'; import type { TRdiiAnls } from "./aplcnYms";
import * as fn from './aplcnYms'; import * as fn from "./aplcnYms";
type ModalOpenProp = { type ModalOpenProp = {
pplMngNo?: string; // 관로관리번호 pplMngNo?: string;
rainInfwtAnlsSn?: number;
aplcnYm?: string;
analysisInfo?: string;
resultOnly?: boolean;
}; };
type LineBarChartData = { type LineBarChartData = {
@@ -24,112 +28,77 @@ type LineBarChartData = {
barVal0: number; barVal0: number;
}; };
const INITIAL_ANALYSIS_OPTIONS = [{ val: 0, text: '선택' }]; const INITIAL_ANALYSIS_OPTIONS = [{ val: 0, text: "선택" }];
const RdiiChart = ({ pplMngNo = '' }: ModalOpenProp) => { const normalizePplMngNo = (value: string) => {
const trimmed = String(value ?? "").trim();
if (!trimmed) return "";
return trimmed.startsWith("L") ? trimmed : `L${trimmed}`;
};
const [aplcnYm, setAplcnYm] = useState(''); const RdiiChart = ({
pplMngNo = "",
rainInfwtAnlsSn = 0,
aplcnYm = "",
analysisInfo = "",
resultOnly = false,
}: ModalOpenProp) => {
const [selectedAplcnYm, setSelectedAplcnYm] = useState("");
const [rdiiAnlss, setRdiiAnlss] = useState<TRdiiAnls[]>([]); const [rdiiAnlss, setRdiiAnlss] = useState<TRdiiAnls[]>([]);
const [rdiiAnlsInfos, setRdiiAnlsInfos] = useState<{ text: string; val: number; hidden?: boolean }[]>( const [rdiiAnlsInfos, setRdiiAnlsInfos] = useState<{ text: string; val: number; hidden?: boolean }[]>(
INITIAL_ANALYSIS_OPTIONS, INITIAL_ANALYSIS_OPTIONS,
); );
const [rdiiAnls, setRdiiAnls] = useState<TRdiiAnls | null>(null); // 강우유입수분석정보 const [rdiiAnls, setRdiiAnls] = useState<TRdiiAnls | null>(null);
const [rainInfwtAnlsSn, setRainInfwtAnlsSn] = useState<number>(0);// 강우유입수분석 정보 선택시 사용 const [selectedRainInfwtAnlsSn, setSelectedRainInfwtAnlsSn] = useState<number>(0);
const [chartData, setChartData] = useState<LineBarChartData[]>([]); const [chartData, setChartData] = useState<LineBarChartData[]>([]);
const [rdiiQty, setRdiiQty] = useState<string>('0'); // 강우유입수량 const [rdiiQty, setRdiiQty] = useState<string>("0");
const [rainTotal, setRainTotal] = useState<string>('0'); // 적용강우량 const [rainTotal, setRainTotal] = useState<string>("0");
const [totalVolume, setTotalVolume] = useState<string>('0');// 총체적 m³/mm const [totalVolume, setTotalVolume] = useState<string>("0");
const aplcnYms = fn.useAplcnYms('rdiiAnlsRslt'); // 상수사용월 데이터 가져오기 const normalizedPplMngNo = normalizePplMngNo(pplMngNo);
const normalizedPplMngNo = pplMngNo ? `L${pplMngNo}` : ''; const isResultOnlyMode = resultOnly && rainInfwtAnlsSn > 0 && Boolean(normalizedPplMngNo);
const aplcnYms = fn.useAplcnYms(isResultOnlyMode ? "" : "rdiiAnlsRslt");
const chartForm = CHART_FORM.LINEBAR2; const chartForm = CHART_FORM.LINEBAR2;
const chartConfig = { const chartConfig = {
barVal0: { barVal0: {
label: '강수량', label: "강수량",
color: '#33AAFF', color: "#33AAFF",
}, },
lineVal0: { lineVal0: {
label: 'RDII', label: "RDII",
color: '#FF0000', color: "#FF0000",
}, },
} as ChartConfig; } as ChartConfig;
const hasChartData = useMemo(() => chartData.length > 0, [chartData]); const hasChartData = useMemo(() => chartData.length > 0, [chartData]);
const resetState = () => { const resetState = () => {
setAplcnYm(''); // 상수사용월 초기화 setSelectedAplcnYm("");
setRdiiAnlss([]); // 강우유입수분석 초기화
setRdiiAnlsInfos(INITIAL_ANALYSIS_OPTIONS); // 강우유입수분석정보 초기화
setRdiiAnls(null); // 강우유입수분석정보 초기화
setRainInfwtAnlsSn(0); // 강우유입수분석 일련번호 초기화
setChartData([]);
setRdiiQty('0'); // 강우유입수량 초기화
setRainTotal('0');// 적용강우량 초기화
setTotalVolume('0');// 총체적 m³/mm 초기화
};
const handleAplcnYmChange = async (val: string | number) => {
const selectedValue = String(val);
setAplcnYm(selectedValue);
setRdiiAnlss([]); setRdiiAnlss([]);
setRdiiAnlsInfos(INITIAL_ANALYSIS_OPTIONS);
setRdiiAnls(null); setRdiiAnls(null);
setRainInfwtAnlsSn(0); setSelectedRainInfwtAnlsSn(0);
setChartData([]); setChartData([]);
setRdiiQty('0'); setRdiiQty("0");
setRainTotal('0'); setRainTotal("0");
setTotalVolume('0'); setTotalVolume("0");
if (!selectedValue || selectedValue === '0') {
setRdiiAnlsInfos(INITIAL_ANALYSIS_OPTIONS);
return;
}
try {
const data = await fn.selectRdiiAnls(selectedValue); // 상수사용월 변경시 분석 정보 리스트 가져오기
const list = data ?? [];
setRdiiAnlss(list);
setRdiiAnlsInfos(fn.getOptionsRdiiAnlsInfo(list));
} catch (error) {
console.error('selectRdiiAnls 에러:', error);
setRdiiAnlss([]);
setRdiiAnlsInfos(INITIAL_ANALYSIS_OPTIONS);
}
}; };
const handleRdiiAnlsSnChange = (val: string | number) => { const loadResultChart = async (targetRainInfwtAnlsSn: number) => {
const selectedValue = Number(val); if (!targetRainInfwtAnlsSn || !normalizedPplMngNo) {
setRainInfwtAnlsSn(selectedValue);
setChartData([]);
setRdiiQty('0');
setRainTotal('0');
setTotalVolume('0');
if (selectedValue === 0) {
setRdiiAnls(null);
return;
}
const selectedAnls = rdiiAnlss.find((anls) => anls.rainInfwtAnlsSn === selectedValue) ?? null;
setRdiiAnls(selectedAnls);
};
const fetchFilteredData = async () => {
if (!rainInfwtAnlsSn || !normalizedPplMngNo) {
setChartData([]); setChartData([]);
setRdiiQty('0'); setRdiiQty("0");
setRainTotal('0'); setRainTotal("0");
setTotalVolume('0'); setTotalVolume("0");
return; return;
} }
try { try {
const [rsltData, rainData, qtyData] = await Promise.all([ const [rsltData, rainData, qtyData] = await Promise.all([
fn.selectRdiiAnlsRslt(rainInfwtAnlsSn, normalizedPplMngNo), fn.selectRdiiAnlsRslt(targetRainInfwtAnlsSn, normalizedPplMngNo),
fn.selectRainData(rainInfwtAnlsSn), fn.selectRainData(targetRainInfwtAnlsSn),
fn.selectPplRdiiQty(rainInfwtAnlsSn, normalizedPplMngNo), fn.selectPplRdiiQty(targetRainInfwtAnlsSn, normalizedPplMngNo),
]); ]);
const rsltDataFormatted = fn.getChartDataRdiiAnlsRslt(rsltData ?? []); const rsltDataFormatted = fn.getChartDataRdiiAnlsRslt(rsltData ?? []);
@@ -143,7 +112,7 @@ const RdiiChart = ({ pplMngNo = '' }: ModalOpenProp) => {
} }
}); });
setChartData(rsltDataFormatted);// 차트 데이터 설정 setChartData(rsltDataFormatted);
const avgRdii = Number(qtyData?.rdiih ?? 0); const avgRdii = Number(qtyData?.rdiih ?? 0);
const appliedRainfall = Number(qtyData?.raintotal ?? 0); const appliedRainfall = Number(qtyData?.raintotal ?? 0);
@@ -152,18 +121,80 @@ const RdiiChart = ({ pplMngNo = '' }: ModalOpenProp) => {
? Math.round(avgRdii * Number(qtyData?.sustaintime ?? 0) / qtyData.raintotal) ? Math.round(avgRdii * Number(qtyData?.sustaintime ?? 0) / qtyData.raintotal)
: 0; : 0;
setRdiiQty(Math.round(avgRdii * 24).toLocaleString('ko-KR')); setRdiiQty(Math.round(avgRdii * 24).toLocaleString("ko-KR"));
setRainTotal(appliedRainfall.toLocaleString('ko-KR')); setRainTotal(appliedRainfall.toLocaleString("ko-KR"));
setTotalVolume(rdiiPerRainfall.toLocaleString('ko-KR')); setTotalVolume(rdiiPerRainfall.toLocaleString("ko-KR"));
} catch (error) { } catch (error) {
console.error('rdiiRsltChart 에러:', error); console.error("RDII 분석결과 차트 조회 중 오류:", error);
setChartData([]); setChartData([]);
setRdiiQty('0'); setRdiiQty("0");
setRainTotal('0'); setRainTotal("0");
setTotalVolume('0'); setTotalVolume("0");
} }
}; };
useEffect(() => {
if (!isResultOnlyMode) return;
setSelectedAplcnYm(aplcnYm);
setSelectedRainInfwtAnlsSn(rainInfwtAnlsSn);
setRdiiAnlsInfos(INITIAL_ANALYSIS_OPTIONS);
void loadResultChart(rainInfwtAnlsSn);
}, [aplcnYm, isResultOnlyMode, normalizedPplMngNo, rainInfwtAnlsSn]);
const handleAplcnYmChange = async (val: string | number) => {
const selectedValue = String(val);
setSelectedAplcnYm(selectedValue);
setRdiiAnlss([]);
setRdiiAnls(null);
setSelectedRainInfwtAnlsSn(0);
setChartData([]);
setRdiiQty("0");
setRainTotal("0");
setTotalVolume("0");
if (!selectedValue || selectedValue === "0") {
setRdiiAnlsInfos(INITIAL_ANALYSIS_OPTIONS);
return;
}
const data = await fn.selectRdiiAnls(selectedValue);
const list = data ?? [];
setRdiiAnlss(list);
setRdiiAnlsInfos(fn.getOptionsRdiiAnlsInfo(list));
};
const handleRdiiAnlsSnChange = (val: string | number) => {
const selectedValue = Number(val);
setSelectedRainInfwtAnlsSn(selectedValue);
setChartData([]);
setRdiiQty("0");
setRainTotal("0");
setTotalVolume("0");
if (selectedValue === 0) {
setRdiiAnls(null);
return;
}
const selectedAnls = rdiiAnlss.find((anls) => anls.rainInfwtAnlsSn === selectedValue) ?? null;
setRdiiAnls(selectedAnls);
};
const fetchFilteredData = async () => {
if (!selectedRainInfwtAnlsSn || !normalizedPplMngNo) {
setChartData([]);
setRdiiQty("0");
setRainTotal("0");
setTotalVolume("0");
return;
}
await loadResultChart(selectedRainInfwtAnlsSn);
};
const renderInfoInput = (label: string, value?: string | number | null) => ( const renderInfoInput = (label: string, value?: string | number | null) => (
<div className="grid grid-cols-[minmax(0,32%)_minmax(0,68%)] items-center gap-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 className="w-auto whitespace-nowrap text-[14px] font-semibold text-[#5c6470]">
@@ -172,166 +203,128 @@ const RdiiChart = ({ pplMngNo = '' }: ModalOpenProp) => {
<Input <Input
className="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white px-3 text-xl text-[#2b3440]" className="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white px-3 text-xl text-[#2b3440]"
placeholder="" placeholder=""
value={value ?? ''} value={value ?? ""}
readOnly readOnly
/> />
</div> </div>
); );
const renderBody = () => ( const renderResultPanel = () => (
<div className="text-black"> <section className="relative rounded-[12px] border border-[#b1b1b1] bg-white p-4">
<div className="mb-4 flex items-start justify-between"> <h3 className="mb-4 text-[20px] font-extrabold tracking-[-0.02em] text-black">RDII 분석 결과</h3>
<div className="text-[18px] font-semibold text-[#111827]" /> <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") : "-"}
</div>
</div>
<div className="flex min-h-[98px] items-center justify-between rounded-[14px] border border-[#4f88c6] bg-white px-6 py-4">
<div className="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>
<div className="rounded-[10px] bg-white px-2 pb-2 pt-3">
{hasChartData ? (
<MainChart
chartConfig={chartConfig}
chartForm={chartForm}
chartData={chartData}
chartTitle={"RDII 그래프"}
yFieldText2="강수량(mm)"
yFieldText="유량(m³/h)"
/>
) : (
<div className="flex h-[464px] items-center justify-center text-[18px] text-[#2c2f33]">데이터가 없습니다.</div>
)}
</div>
</section>
);
const renderDefaultBody = () => (
<div className="text-black">
<div className="grid min-h-[68vh] grid-cols-[minmax(0,36%)_minmax(0,64%)] gap-5"> <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"> <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 className="mb-6 text-[20px] font-extrabold tracking-[-0.02em] text-black">강우유입수량 선택 조건</h3>
강우유입수분석 선택 조건
</h3>
<div className="rounded-[6px] border border-[#d9dde3] bg-[#eff1f4] px-4 py-6"> <div className="rounded-[6px] border border-[#d9dde3] bg-[#eff1f4] px-4 py-6">
<div className="space-y-3"> <div className="space-y-3">
<div className="grid grid-cols-[minmax(0,32%)_minmax(0,68%)] items-center gap-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 className="w-auto whitespace-nowrap text-[14px] font-semibold text-[#5c6470]">상수사용월</Label>
상수사용월:
</Label>
<SelectBox <SelectBox
className="w-full" className="w-full"
selectClass="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white text-xl" selectClass="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white text-xl"
label="" label=""
selectArray={aplcnYms} selectArray={aplcnYms}
value={aplcnYm} value={selectedAplcnYm}
onSelectValue={handleAplcnYmChange} onSelectValue={handleAplcnYmChange}
/> />
</div> </div>
<div className="grid grid-cols-[minmax(0,32%)_minmax(0,68%)] items-center gap-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 className="w-auto whitespace-nowrap text-[14px] font-semibold text-[#5c6470]">분석정보:</Label>
분석정보:
</Label>
<SelectBox <SelectBox
className="w-full" className="w-full"
selectClass="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white text-xl" selectClass="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white text-xl"
label="" label=""
selectArray={rdiiAnlsInfos} selectArray={rdiiAnlsInfos}
value={rainInfwtAnlsSn} value={selectedRainInfwtAnlsSn}
onSelectValue={handleRdiiAnlsSnChange} onSelectValue={handleRdiiAnlsSnChange}
/> />
</div> </div>
{renderInfoInput('오수전환율:', rdiiAnls?.drtwSwtcRt ?? '')} {renderInfoInput("오수전환율", rdiiAnls?.drtwSwtcRt ?? "")}
{renderInfoInput('수용가-맨홀 매칭:', rdiiAnls?.atflMpngNm ?? '')} {renderInfoInput("수용가-맨홀 매칭:", rdiiAnls?.atflMpngNm ?? "")}
{renderInfoInput('관망도 파일명:', rdiiAnls?.atflInpNm ?? '')} {renderInfoInput("관망도 파일명", rdiiAnls?.atflInpNm ?? "")}
{renderInfoInput('강우 파일명:', rdiiAnls?.atflRainNm ?? '')} {renderInfoInput("강우 파일명", rdiiAnls?.atflRainNm ?? "")}
{renderInfoInput('RTK계수 출처:', rdiiAnls?.srcNm ?? '')} {renderInfoInput("RTK계수 출처:", rdiiAnls?.srcNm ?? "")}
</div> </div>
</div> </div>
<div className="absolute inset-x-0 bottom-0 mb-[40px] flex justify-center"> <div className="absolute inset-x-0 bottom-0 mb-[40px] flex justify-center">
<Button size="lg" onClick={fetchFilteredData}> <Button size="lg" onClick={fetchFilteredData}>조회</Button>
조회
</Button>
&nbsp;&nbsp; &nbsp;&nbsp;
<Button size="lg" color="gray" onClick={resetState}> <Button size="lg" color="gray" onClick={resetState}>초기화</Button>
초기화
</Button>
</div> </div>
</section> </section>
<section className="relative rounded-[12px] border border-[#b1b1b1] bg-white p-5"> {renderResultPanel()}
<h3 className="mb-6 text-[20px] font-extrabold tracking-[-0.02em] text-black">
강우유입수분석 결과
</h3>
<div className="mb-6 flex gap-5">
{/* 좌측 카드 */}
<div className="flex min-h-[98px] min-w-0 basis-2/5 grow shrink-[1.4] items-center justify-between rounded-[16px] border border-[#5fa3ea] bg-white px-6 py-4">
<div className="text-[16px] font-bold text-[#ef4444]">
상수사용량 적용연월
</div>
<div className="text-[24px] font-extrabold leading-none text-[#111]">
{aplcnYm ? moment(aplcnYm, 'YYYYMM').format('YYYY-MM') : '-'}
</div>
</div>
{/* 우측 카드 */}
<div className="flex min-h-[98px] min-w-0 basis-3/5 grow shrink items-center gap-5 rounded-[16px] border border-[#5fa3ea] bg-white px-6 py-4">
{/* 좌측: 제목 (세로 가운데 정렬) */}
<div className="flex min-w-0 basis-[32%] items-center">
<div className="text-[16px] font-bold text-[#ef4444] break-keep">
강우유입수량 분석결과
</div>
</div>
{/* 우측: 지표 영역 */}
<div className="flex min-w-0 basis-[68%] 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">
{hasChartData ? (
<MainChart
chartConfig={chartConfig}
chartForm={chartForm}
chartData={chartData}
chartTitle={'RDII 그래프'}
yFieldText2="강수량(mm)"
yFieldText="유량(m³/h)"
/>
) : (
<div className="flex h-[464px] items-center justify-center text-[18px] text-[#2c2f33]">
데이터가 없습니다.
</div>
)}
</div>
</section>
</div> </div>
</div> </div>
); );
if (isResultOnlyMode) {
return <div className="min-h-dvh bg-white p-3">{renderResultPanel()}</div>;
}
return ( return (
<div className="min-h-dvh bg-white p-4"> <div className="min-h-dvh bg-white p-4">
<h1 className="mb-3 text-2xl font-semibold text-gray-900">RDII 분석</h1> <h1 className="mb-3 text-2xl font-semibold text-gray-900">RDII 분석</h1>
{renderBody()} {renderDefaultBody()}
</div> </div>
); );
}; };
export default React.memo(RdiiChart); export default React.memo(RdiiChart);
@@ -0,0 +1,327 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { DateMonthPicker, UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
import { Button } from "@common/components";
import { getData } from "@/components/lib/api";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue";
import useStore from "@/store/useStore";
import {
RDII_RESULT_ROW_SELECT_MESSAGE,
type RdiiResultRow,
} from "./rdiiResultTypes";
const RDII_RESULT_PAGE_SIZE = 10;
const RDII_RESULT_HEADLIST = [
{ id: "rowNumber", title: "일련번호" },
{ 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 분석정보" },
{ id: "regDt", title: "분석일자" },
{ id: "rgtrNm", title: "분석자" },
{ id: "scsYn", title: "분석성공여부" },
] as const;
type RdiiResultFilters = {
ctpvCd: string;
sggCd: string;
sggName: string;
swtrmtFcltNm: string;
aplcnYmFrom: string;
aplcnYmTo: string;
};
const createDefaultAplcnYm = () => {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
return `${year}${month}`;
};
const DEFAULT_FILTERS: RdiiResultFilters = {
ctpvCd: "0",
sggCd: "0",
sggName: "",
swtrmtFcltNm: "",
aplcnYmFrom: `${new Date().getFullYear()}01`,
aplcnYmTo: createDefaultAplcnYm(),
};
const postRowSelectMessage = (row: RdiiResultRow) => {
if (typeof window === "undefined" || !window.opener) return;
window.opener.postMessage(
{
type: RDII_RESULT_ROW_SELECT_MESSAGE,
row,
},
window.location.origin,
);
};
export default function RdiiResultPopupPage() {
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const [filters, setFilters] = useState<RdiiResultFilters>(DEFAULT_FILTERS);
const [selectedSwagePrcsFclt, setSelectedSwagePrcsFclt] = useState("0");
const [selectedRow, setSelectedRow] = useState<RdiiResultRow | null>(null);
const [rows, setRows] = useState<RdiiResultRow[]>([]);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (ctpv.length === 0) void fetchCtpv();
if (sgg.length === 0) void fetchSgg();
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
const fetchRows = useCallback(
async (page: number, nextFilters: RdiiResultFilters) => {
const queryParams = new URLSearchParams({
page: String(page - 1),
size: String(RDII_RESULT_PAGE_SIZE),
sort: "rainInfwtAnlsSn,desc",
});
if (nextFilters.aplcnYmFrom) queryParams.set("aplcnYmFrom", nextFilters.aplcnYmFrom);
if (nextFilters.aplcnYmTo) queryParams.set("aplcnYmTo", nextFilters.aplcnYmTo);
if (nextFilters.ctpvCd !== "0") queryParams.set("ctpvCd", nextFilters.ctpvCd);
if (nextFilters.sggCd !== "0") queryParams.set("sggCd", nextFilters.sggCd);
if (nextFilters.swtrmtFcltNm.trim()) queryParams.set("swtrmtFcltNm", nextFilters.swtrmtFcltNm.trim());
const response = await getData(`v1/rdiiAnls?${queryParams.toString()}`);
const content = Array.isArray(response?.content) ? response.content : [];
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 sggOption = sgg.find((code) => code.val === item?.sggCd);
return {
...(formattedItem as Record<string, unknown>),
rowNumber: (page - 1) * RDII_RESULT_PAGE_SIZE + index + 1,
ctpvNm: ctpvOption ? ctpvOption.text : String(item?.ctpvCd ?? "-"),
sggNm: sggOption ? sggOption.text : String(item?.sggCd ?? "-"),
scsYn: String(item?.scsYn ?? "N"),
} as RdiiResultRow;
})
.filter((item: RdiiResultRow) => item.scsYn === "Y");
setRows(nextRows);
setTotal(Number(response?.totalElements ?? nextRows.length));
setCurrentPage(page);
},
[ctpv, sgg],
);
const loadRows = useCallback(
async (page = 1, nextFilters = filters) => {
setIsLoading(true);
try {
await fetchRows(page, nextFilters);
} catch (error) {
console.error("RDII 분석결과 목록 조회 중 오류:", error);
alert("RDII 분석결과 목록을 불러오지 못했습니다.");
setRows([]);
setTotal(0);
} finally {
setIsLoading(false);
}
},
[fetchRows, filters],
);
useEffect(() => {
void loadRows(1, DEFAULT_FILTERS);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleSearch = async (page = 1) => {
setSelectedIndex(-1);
setSelectedRow(null);
await loadRows(page, filters);
};
const handleRowClick = (index?: number) => {
if (typeof index !== "number") return;
const row = rows[index];
if (!row) return;
setSelectedIndex(index);
setSelectedRow(row);
postRowSelectMessage(row);
};
const handleDownload = async () => {
if (!selectedRow?.rainInfwtAnlsSn) {
alert("다운로드할 분석결과를 먼저 선택해주세요.");
return;
}
try {
setIsLoading(true);
const response = await getData(`v1/rdiiAnlsRslt/ppl/${selectedRow.rainInfwtAnlsSn}`);
const excelRows = Array.isArray(response?.content) ? response.content : [];
await downloadExcelWithKoreanHeaders({
data: excelRows as JSON[],
fileName: `RDII분석결과_${selectedRow.swtrmtFcltNm}_${selectedRow.aplcnYm}_${selectedRow.rainInfwtAnlsSn}`,
sheetNameArray: ["RDII 분석결과"],
headerRange: "A1:D1",
headerMapping: {
rainInfwtAnlsSn: "RDII분석\n일련번호",
pplMngNo: "관로\n관리번호",
anlsTm: "분석시각",
predcOilq: "예측유량\n(m³/h)",
},
});
} catch (error) {
console.error("RDII 분석결과 다운로드 중 오류:", error);
alert("RDII 분석결과 다운로드 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
};
return (
<main className="flex h-screen flex-col overflow-hidden bg-white">
<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">RDII 분석결과</h1>
</header>
<section className="overflow-y-auto p-4">
<div className="rounded-md border border-slate-200 bg-slate-50 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-slate-700">상수사용기간</span>
<DateMonthPicker
field={{
value: filters.aplcnYmFrom,
onChange: (value: string) => {
setFilters((current) => ({
...current,
aplcnYmFrom: value,
}));
},
}}
selectClass="w-[145px]"
startYear={1900}
/>
<span className="text-[1.1rem] font-semibold text-slate-500">~</span>
<DateMonthPicker
field={{
value: filters.aplcnYmTo,
onChange: (value: string) => {
setFilters((current) => ({
...current,
aplcnYmTo: value,
}));
},
}}
selectClass="w-[145px]"
startYear={1900}
/>
</div>
<div className="min-w-0 flex-1 overflow-x-auto">
<CtpvSggPrcsFcltSelectBox
sido={filters.ctpvCd}
sigungu={filters.sggCd}
sggName={filters.sggName}
onSidoChange={(value) => {
setSelectedSwagePrcsFclt("0");
setFilters((current) => ({
...current,
ctpvCd: value,
sggCd: "0",
sggName: "",
swtrmtFcltNm: "",
}));
}}
onSigunguChange={(value) => {
setSelectedSwagePrcsFclt("0");
setFilters((current) => ({
...current,
sggCd: value,
swtrmtFcltNm: "",
}));
}}
setSggName={(value) => {
setSelectedSwagePrcsFclt("0");
setFilters((current) => ({
...current,
sggName: value ?? "",
swtrmtFcltNm: "",
}));
}}
selectedSwagePrcsFclt={selectedSwagePrcsFclt}
onSwagePrcsFcltChange={setSelectedSwagePrcsFclt}
setSwtrmtFcltNm={(value) =>
setFilters((current) => ({
...current,
swtrmtFcltNm: value ?? "",
}))
}
setIsLoading={setIsLoading}
/>
</div>
<div className="ml-auto flex items-center gap-2">
<Button
type="button"
size="sm"
color="primary"
className="h-10 px-5 !text-[1.1rem]"
onClick={() => void handleSearch(1)}
>
조회
</Button>
<Button
type="button"
color="green"
size="sm"
className="h-10 px-5 !text-[1.1rem]"
onClick={() => void handleDownload()}
>
다운로드
</Button>
</div>
</div>
</div>
<div className="mt-4">
<UiTable<RdiiResultRow>
headName={<>{`분석이력 총 ${total.toLocaleString()}건`}</>}
tableData={rows}
headlist={RDII_RESULT_HEADLIST as unknown as { id: string; title: string }[]}
pageSize={RDII_RESULT_PAGE_SIZE}
total={total}
cellClick={(index) => handleRowClick(index)}
onPageChange={(page) => void handleSearch(page)}
curPage={currentPage}
index={selectedIndex}
widthFixed={true}
/>
</div>
{selectedRow ? (
<div className="mt-3 rounded-md border border-slate-200 bg-slate-50 px-4 py-3 text-[1rem] text-slate-600">
{"선택 분석: "}
<span className="font-semibold text-slate-800">{selectedRow.anlsInfo}</span>
</div>
) : null}
</section>
</main>
);
}
@@ -0,0 +1,46 @@
"use client";
export const RDII_RESULT_ROW_SELECT_MESSAGE = "SN_RDII_RESULT_ROW_SELECT";
export const RDII_RESULT_OVERLAY_TYPE = "RDII_RESULT";
export type RdiiResultRow = {
rowNumber: number;
rainInfwtAnlsSn: number;
ctpvCd?: string;
ctpvNm: string;
sggCd?: string;
sggNm: string;
swtrmtFcltNm: string;
aplcnYm: string;
drtwSwtcRt?: number | string;
srcNm?: string;
cntbArea?: number | string;
anlsInfo: string;
regDt?: string;
rgtrNm?: string;
scsYn?: string;
};
export type RdiiResultMapViewPipe = {
pplMngNo: string;
fcltMngNo: string;
};
export type RdiiResultMapViewResponse = {
analysis: {
rainInfwtAnlsSn: number;
aplcnYm: string;
ctpvCd?: string;
sggCd?: string;
swtrmtFcltNm?: string;
anlsInfo?: string;
};
focusTargetType: "PIPE_EXTENT";
focusTargetName: string;
focusLayerType?: "tb_ms_swage_ppl";
focusFid?: string;
focusFallbackApplied?: boolean;
pipeGeometry: Record<string, unknown>;
pipes: RdiiResultMapViewPipe[];
pipeCount: number;
};
@@ -0,0 +1,349 @@
"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";
import VectorSource from "ol/source/Vector.js";
import Stroke from "ol/style/Stroke.js";
import Style from "ol/style/Style.js";
import { getRawJson } from "@/components/lib/api";
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import { getSigunguCenter, moveMapToCenter, sidoCenter } from "@common/business_components/ui/map/searchBar/utils";
import {
RDII_RESULT_OVERLAY_TYPE,
RDII_RESULT_ROW_SELECT_MESSAGE,
type RdiiResultMapViewResponse,
type RdiiResultRow,
} from "./rdiiResultTypes";
type UseRdiiResultMapParams = {
gisMaps: MutableRefObject<any | null>;
setFeatureData: (value: any) => void;
setIsOpen: (value: boolean) => void;
setIsSearchModalOpen: (value: boolean) => void;
};
const RDII_PIPE_LAYER_ID = "SN_RDII_RESULT_PIPE_LAYER";
const DEFAULT_RESULT_FIT_PADDING: [number, number, number, number] = [120, 120, 120, 120];
const PROC_SEARCH_EVENT = "SN_INFTWT_PROC_SEARCH";
const pipeStyle = [
new Style({
stroke: new Stroke({
color: "rgba(15, 23, 42, 0.56)",
width: 9,
lineCap: "round",
lineJoin: "round",
}),
zIndex: 20020,
}),
new Style({
stroke: new Stroke({
color: "rgba(255, 255, 255, 0.98)",
width: 5,
lineCap: "round",
lineJoin: "round",
}),
zIndex: 20021,
}),
new Style({
stroke: new Stroke({
color: "rgba(59, 130, 246, 0.98)",
width: 2.5,
lineCap: "round",
lineJoin: "round",
}),
zIndex: 20022,
}),
];
const getFeatureCollection = (payload: any) => {
if (payload?.type === "FeatureCollection") return payload;
if (payload?.data?.type === "FeatureCollection") return payload.data;
return { type: "FeatureCollection", features: [] };
};
const normalizeProjectionName = (value: string) => {
const trimmed = String(value ?? "").trim();
if (!trimmed) return "EPSG:4326";
if (trimmed.startsWith("EPSG:")) return trimmed;
const match = trimmed.match(/EPSG[:/]{1,2}(\d{4,5})/i) || trimmed.match(/(\d{4,5})$/);
return match?.[1] ? `EPSG:${match[1]}` : trimmed;
};
const resolveFeatureCollectionProjection = (payload: any) => {
const featureCollection = getFeatureCollection(payload);
const crsName = String(featureCollection?.crs?.properties?.name ?? "").trim();
return normalizeProjectionName(crsName || GisApp.TO_PROJ);
};
const getOverlayFlag = (data: any) =>
String(
data?.get?.("analysisOverlayType") ??
data?.values_?.analysisOverlayType ??
data?.analysisOverlayType ??
"",
).trim();
const getOverlayBasePplMngNo = (data: any) =>
String(
data?.get?.("fcltMngNo") ??
data?.values_?.fcltMngNo ??
data?.values_?.fclt_mng_no ??
data?.fcltMngNo ??
data?.fclt_mng_no ??
"",
).trim();
const getOverlayAnalysisPplMngNo = (data: any) =>
String(
data?.get?.("pplMngNo") ??
data?.values_?.pplMngNo ??
data?.pplMngNo ??
"",
).trim();
const getOverlayAnalysisSn = (data: any) =>
Number(
data?.get?.("rainInfwtAnlsSn") ??
data?.values_?.rainInfwtAnlsSn ??
data?.rainInfwtAnlsSn ??
0,
);
const getOverlayAplcnYm = (data: any) =>
String(
data?.get?.("aplcnYm") ??
data?.values_?.aplcnYm ??
data?.aplcnYm ??
"",
).trim();
const getOverlayAnalysisInfo = (data: any) =>
String(
data?.get?.("analysisInfo") ??
data?.values_?.analysisInfo ??
data?.analysisInfo ??
"",
).trim();
export const useRdiiResultMap = ({
gisMaps,
setFeatureData,
setIsOpen,
setIsSearchModalOpen,
}: UseRdiiResultMapParams) => {
const activeResultRowRef = useRef<RdiiResultRow | null>(null);
const pipeLayerRef = useRef<VectorLayer<any> | null>(null);
const pipeSourceRef = useRef<VectorSource | null>(null);
const mapViewCacheRef = useRef<Map<number, RdiiResultMapViewResponse>>(new Map());
const [activeMapView, setActiveMapView] = useState<RdiiResultMapViewResponse | null>(null);
const clearLayers = useCallback(() => {
pipeSourceRef.current?.clear?.();
pipeLayerRef.current?.changed?.();
gisMaps.current?.renderSync?.();
}, [gisMaps]);
const ensurePipeLayer = useCallback(() => {
const map = gisMaps.current;
if (!map) return null;
if (pipeLayerRef.current && pipeSourceRef.current) {
return pipeSourceRef.current;
}
const source = new VectorSource();
const layer = new VectorLayer({
source,
zIndex: 20019,
});
layer.set("id", RDII_PIPE_LAYER_ID);
layer.set("lyrNm", "RDII 결과관로");
layer.set("lyrTyNm", "tb_ms_swage_ppl");
layer.set("clickPsblYn", "Y");
layer.setVisible?.(true);
map.addLayer(layer);
pipeLayerRef.current = layer;
pipeSourceRef.current = source;
return source;
}, [gisMaps]);
const fitToRenderedFeatures = useCallback(() => {
const map = gisMaps.current;
const pipeExtent = pipeSourceRef.current?.getExtent?.();
if (!map || !pipeExtent || pipeExtent.some((value: number) => !Number.isFinite(value))) {
return;
}
map.getView?.().fit?.(pipeExtent, {
duration: 350,
padding: DEFAULT_RESULT_FIT_PADDING,
maxZoom: 16,
constrainResolution: true,
nearest: true,
});
}, [gisMaps]);
const moveByRegionCodes = useCallback(
(ctpvCd?: string, sggCd?: string) => {
const normalizedSggCd = String(sggCd ?? "").trim();
if (normalizedSggCd && normalizedSggCd !== "0") {
const sigunguCenter = getSigunguCenter(normalizedSggCd);
if (sigunguCenter) {
moveMapToCenter(gisMaps, sigunguCenter, 12);
return true;
}
}
const normalizedCtpvCd = String(ctpvCd ?? "").trim();
if (normalizedCtpvCd && normalizedCtpvCd !== "0") {
const sidoCenterCoordinate = sidoCenter[normalizedCtpvCd.slice(0, 2)];
if (sidoCenterCoordinate) {
moveMapToCenter(gisMaps, sidoCenterCoordinate, 11);
return true;
}
}
return false;
},
[gisMaps],
);
const moveToStableFocus = useCallback(
async (mapView: RdiiResultMapViewResponse) => {
const activeRow = activeResultRowRef.current;
const movedByRegion = moveByRegionCodes(
activeRow?.ctpvCd ?? mapView.analysis?.ctpvCd,
activeRow?.sggCd ?? mapView.analysis?.sggCd,
);
if (movedByRegion) return;
fitToRenderedFeatures();
},
[fitToRenderedFeatures, moveByRegionCodes],
);
const renderMapView = useCallback(
async (mapView: RdiiResultMapViewResponse) => {
const pipeSource = ensurePipeLayer();
if (!pipeSource) return;
clearLayers();
const pipeDataProjection = resolveFeatureCollectionProjection(mapView.pipeGeometry);
const pipeFeatures = new GeoJSON().readFeatures(getFeatureCollection(mapView.pipeGeometry), {
dataProjection: pipeDataProjection,
featureProjection: GisApp.TO_PROJ,
});
pipeFeatures.forEach((feature: any) => {
feature.setStyle(pipeStyle);
});
pipeSource.addFeatures(pipeFeatures);
pipeSource.changed?.();
pipeLayerRef.current?.setVisible?.(true);
pipeLayerRef.current?.changed?.();
gisMaps.current?.renderSync?.();
await moveToStableFocus(mapView);
},
[clearLayers, ensurePipeLayer, gisMaps, moveToStableFocus],
);
const handleRowSelect = useCallback(
async (row: RdiiResultRow) => {
try {
activeResultRowRef.current = row;
setFeatureData(null);
setIsOpen(false);
setIsSearchModalOpen(false);
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent(PROC_SEARCH_EVENT, {
detail: {
ctpvCd: row.ctpvCd ?? "0",
sggCd: row.sggCd ?? "0",
swtrmtFcltNm: row.swtrmtFcltNm ?? "",
},
}),
);
}
const cachedMapView = mapViewCacheRef.current.get(row.rainInfwtAnlsSn);
if (cachedMapView) {
setActiveMapView(cachedMapView);
void renderMapView(cachedMapView);
return;
}
const payload = await getRawJson(`/sewerage-service/api/v1/rdiiAnlsRslt/mapView/${row.rainInfwtAnlsSn}`);
const mapView = (payload?.data ?? payload) as RdiiResultMapViewResponse;
mapViewCacheRef.current.set(row.rainInfwtAnlsSn, mapView);
setActiveMapView(mapView);
void renderMapView(mapView);
} catch (error) {
console.error("RDII 분석결과 지도 연계 중 오류:", error);
alert("RDII 분석결과를 지도에 연계하지 못했습니다.");
}
},
[renderMapView, setFeatureData, setIsOpen, setIsSearchModalOpen],
);
useEffect(() => {
const handlePopupMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type !== RDII_RESULT_ROW_SELECT_MESSAGE || !event.data?.row) return;
void handleRowSelect(event.data.row as RdiiResultRow);
};
window.addEventListener("message", handlePopupMessage);
return () => window.removeEventListener("message", handlePopupMessage);
}, [handleRowSelect]);
useEffect(() => {
return () => {
clearLayers();
};
}, [clearLayers]);
const handleOverlayFeatureSelect = useCallback((data: any) => {
if (getOverlayFlag(data) !== RDII_RESULT_OVERLAY_TYPE) {
return false;
}
const overlayAnalysisSn = getOverlayAnalysisSn(data);
const basePplMngNo = getOverlayBasePplMngNo(data);
const analysisPplMngNo = getOverlayAnalysisPplMngNo(data);
const currentRow = activeResultRowRef.current;
const popupWindow = openWindowPopup("rdiiChart", {
pplMngNo: basePplMngNo || analysisPplMngNo.replace(/^L/, ""),
rainInfwtAnlsSn: overlayAnalysisSn || currentRow?.rainInfwtAnlsSn || 0,
aplcnYm: getOverlayAplcnYm(data) || currentRow?.aplcnYm || activeMapView?.analysis?.aplcnYm || "",
analysisInfo:
getOverlayAnalysisInfo(data) ||
currentRow?.anlsInfo ||
activeMapView?.analysis?.anlsInfo ||
"",
resultOnly: "Y",
});
if (!popupWindow) {
alert("팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.");
}
return true;
}, [activeMapView]);
return {
activeMapView,
clearLayers,
handleOverlayFeatureSelect,
};
};
+13
View File
@@ -79,6 +79,7 @@ import {
} from "./components/agedPipe/agedPipeUtils"; } from "./components/agedPipe/agedPipeUtils";
import { downloadAgedPipeCsv } from "./components/agedPipe/agedPipeExport"; import { downloadAgedPipeCsv } from "./components/agedPipe/agedPipeExport";
import { useInftwtResultMap } from "./components/inftwtResult/useInftwtResultMap"; import { useInftwtResultMap } from "./components/inftwtResult/useInftwtResultMap";
import { useRdiiResultMap } from "./components/rdiiResult/useRdiiResultMap";
import SinkholeDetailPanel from "./components/sinkhole/SinkholeDetailPanel"; import SinkholeDetailPanel from "./components/sinkhole/SinkholeDetailPanel";
import SinkholeLayerSettingPanel from "./components/sinkhole/SinkholeLayerSettingPanel"; import SinkholeLayerSettingPanel from "./components/sinkhole/SinkholeLayerSettingPanel";
import FloodHistoryDetailPanel from "./components/floodHistory/FloodHistoryDetailPanel"; import FloodHistoryDetailPanel from "./components/floodHistory/FloodHistoryDetailPanel";
@@ -2459,6 +2460,12 @@ const Map = () => {
setIsOpen, setIsOpen,
setIsSearchModalOpen, setIsSearchModalOpen,
}); });
const rdiiResultMap = useRdiiResultMap({
gisMaps,
setFeatureData,
setIsOpen,
setIsSearchModalOpen,
});
const shouldFocusAgedPipeOnNextSelectRef = useRef(false); const shouldFocusAgedPipeOnNextSelectRef = useRef(false);
const agedPipePopupWindowRef = useRef<Window | null>(null); const agedPipePopupWindowRef = useRef<Window | null>(null);
const sinkholePopupWindowRef = useRef<Window | null>(null); const sinkholePopupWindowRef = useRef<Window | null>(null);
@@ -3584,6 +3591,12 @@ const Map = () => {
const clickedLayerTypeName = getSelectedFeatureLayerTypeName(data); const clickedLayerTypeName = getSelectedFeatureLayerTypeName(data);
// 지형에 있는 피처를 직접 선택하면 지반침하 반경 내에서 만든 임시 관로 강조는 해제한다. // 지형에 있는 피처를 직접 선택하면 지반침하 반경 내에서 만든 임시 관로 강조는 해제한다.
clearSinkholeFocusedPipeLayer(); clearSinkholeFocusedPipeLayer();
if (rdiiResultMap.handleOverlayFeatureSelect(data)) {
setFeatureData(null);
setIsOpen(false);
setIsSearchModalOpen(false);
return;
}
if (inftwtResultMap.handleOverlayFeatureSelect(data)) { if (inftwtResultMap.handleOverlayFeatureSelect(data)) {
setFeatureData(null); setFeatureData(null);
setIsOpen(false); setIsOpen(false);
@@ -0,0 +1,46 @@
# SN RDII분석결과 지도연계 유지보수 FE 통합구현기록
## 1. 작업 목적
- RDII 분석결과를 좌측 LNB 대신 하수관망도 `분석도구` popup으로 옮기고, 침입수 분석결과와 같은 지도 연계 흐름으로 정리한다.
## 2. 실제 수정 파일
- `FE_APP_ROOT/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx`
- `FE_APP_ROOT/app/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx`
- `FE_APP_ROOT/app/(map-layout)/map/components/rdiiResult/**`
- `FE_APP_ROOT/app/(map-layout)/map/page.tsx`
- `FE_APP_ROOT/src/features/windowPopup/**`
- `FE_APP_ROOT/src/app/styles/components.css`
## 3. 구현 내용
- `rdiiResultPopup` popup 정의와 registry를 추가했다.
- `RdiiResultPopupPage`를 만들어 기존 `ii/rdiiResult`의 조회/다운로드 흐름을 popup에서 재사용하도록 옮겼다.
- row click 시 `SN_RDII_RESULT_ROW_SELECT` 메시지를 부모 창으로 전달하고, `useRdiiResultMap`에서 `/sewerage-service/api/v1/rdiiAnlsRslt/mapView/{rainInfwtAnlsSn}`를 호출해 결과 관로 overlay를 렌더하도록 연결했다.
- row click 직후 지도 이동은 `SN_INFTWT_PROC_SEARCH` 검색바 동기화 이벤트를 재사용해 시도/시군구 중심 이동 규칙을 그대로 따르도록 맞췄다.
- overlay 관로 click 시 `rdiiChart` popup을 `resultOnly` 모드로 열고, `rainInfwtAnlsSn/aplcnYm/analysisInfo`를 함께 넘겨 좌측 선택조건 없이 결과만 보이도록 확장했다.
- `분석도구` 메뉴에는 popup key를 `rdiiResultPopup`으로 연결하되, 실제 노출 라벨은 `RDII 분석`으로 맞췄고 legacy `ii/rdiiResult`는 CSS로만 숨겨 route는 유지했다.
## 4. 검증
- FE 변경분 기준 `node node_modules\typescript\bin\tsc --noEmit --ignoreDeprecations 5.0 -p tsconfig.json`을 실행했다.
- 이번 변경으로 새로 생긴 오류는 없었고, 아래 선행 타입 오류 때문에 전체 통과는 하지 못했다.
- `app/(map-layout)/map/components/common/index.ts`
- `app/(map-layout)/map/components/floodHistory/floodHistoryTypes.ts`
- `app/(map-layout)/map/page.tsx`
- `app/html/(map-layout)/map/components/common/index.ts`
- `app/html/(with-base-layout)/layout.tsx`
- `packages/common/business_components/src/ui/chart/*`
## 5. 2026-07-02 후속 UI 보정
- 사용자의 피드백에 따라 침입수 분석 메뉴 아이콘은 기존 물방울 표시로 되돌렸다.
- RDII 분석결과 메뉴 문구는 실제 노출 기준 RDII 분석으로 조정했다.
## 6. 남은 이슈
- 브라우저 실동작 검증은 이번 세션에서 수행하지 못했다.
- LNB 숨김 규칙은 현재 메뉴 순서 기준 `nth-child(4)`를 사용하므로, 추후 I/I 메뉴 순서가 바뀌면 selector를 함께 조정해야 한다.
## 7. 2026-07-02 resultOnly popup 후속 보정
- `rdiiChart` resultOnly 우측 요약 카드의 제목 라벨을 `shrink-0 + whitespace-nowrap` 기준으로 보정해 `강우유입수량 분석결과`가 한 줄로 유지되도록 맞췄다.
- 차트/요약 조회 Promise 묶음에 예외 방어를 추가해 조회 실패 시 콘솔 오류를 남기고 상태를 빈 값으로 명확히 초기화하도록 정리했다.
- FE가 계속 호출하던 `/api/v1/rdiiAnlsRslt/ppl/{rainInfwtAnlsSn}/{pplMngNo}`, `/api/v1/rdiiAnlsRslt/ppl/{rainInfwtAnlsSn}` 경로가 BE에 실제로 필요함을 기준으로 연동 계약을 다시 점검했다.
- `node node_modules\typescript\bin\tsc --noEmit --ignoreDeprecations 5.0 -p tsconfig.json` 재실행 결과, 이번 변경과 무관한 기존 선행 오류만 남고 RDII 변경 파일 기준 신규 오류는 확인되지 않았다.
## 8. 최종 확인 메모
- 사용자 실동작 기준으로 `분석도구 > RDII 분석` popup, row click 지도 이동, 관로 highlight, resultOnly popup 흐름이 정상 동작하는 것으로 마감한다.
- FE 전체 타입 오류는 이번 RDII 변경과 무관한 기존 선행 이슈만 남아 별도 정리 대상으로 유지한다.
+4
View File
@@ -19,6 +19,10 @@
} }
} }
/* `I/I 분석 > 침입수 분석결과`는 나중에 다시 사용할 수 있도록 라우트는 유지하고 LNB에서만 임시 숨김 처리한다. */ /* `I/I 분석 > 침입수 분석결과`는 나중에 다시 사용할 수 있도록 라우트는 유지하고 LNB에서만 임시 숨김 처리한다. */
/* `I/I 분석 > 침입수 분석결과`, `RDII 분석결과`는 지도 popup 흐름으로 대체하고 레거시 라우트는 유지한 채 LNB에서만 숨긴다. */
[data-sidebar="menu"] > *:nth-child(2) [data-sidebar="menu-sub"] > li:nth-child(2) { [data-sidebar="menu"] > *:nth-child(2) [data-sidebar="menu-sub"] > li:nth-child(2) {
display: none; display: none;
}
[data-sidebar="menu"] > *:nth-child(2) [data-sidebar="menu-sub"] > li:nth-child(4) {
display: none;
} }
@@ -52,6 +52,12 @@ export const windowPopupDefinitions = {
width: 1280, width: 1280,
height: 900, height: 900,
}, },
rdiiResultPopup: {
key: "rdiiResultPopup",
title: "RDII 분석결과",
width: 1280,
height: 900,
},
infilChart: { infilChart: {
key: "infilChart", key: "infilChart",
title: "침입수 분석", title: "침입수 분석",
@@ -66,8 +72,12 @@ export const windowPopupDefinitions = {
rdiiChart: { rdiiChart: {
key: "rdiiChart", key: "rdiiChart",
title: "RDII 분석", title: "RDII 분석",
width: 1360, width: 940,
height: 980, height: 860,
placement: "browser-left",
alignTop: true,
offsetLeft: 0,
offsetTop: 70,
requiredParams: ["pplMngNo"], requiredParams: ["pplMngNo"],
}, },
sscDetail: { sscDetail: {
@@ -8,6 +8,7 @@ import InftwtResultPopupContent from "./ui/InftwtResultPopupContent";
import OldPpUploadPopupContent from "./ui/OldPpUploadPopupContent"; import OldPpUploadPopupContent from "./ui/OldPpUploadPopupContent";
import OldPpTablePopupContent from "./ui/OldPpTablePopupContent"; import OldPpTablePopupContent from "./ui/OldPpTablePopupContent";
import RdiiChartPopupContent from "./ui/RdiiChartPopupContent"; import RdiiChartPopupContent from "./ui/RdiiChartPopupContent";
import RdiiResultPopupContent from "./ui/RdiiResultPopupContent";
import ReportFilesPopupContent from "./ui/ReportFilesPopupContent"; import ReportFilesPopupContent from "./ui/ReportFilesPopupContent";
import ReportTabPopupContent from "./ui/ReportTabPopupContent"; import ReportTabPopupContent from "./ui/ReportTabPopupContent";
import SearchModalPopupContent from "./ui/SearchModalPopupContent"; import SearchModalPopupContent from "./ui/SearchModalPopupContent";
@@ -65,6 +66,10 @@ export const windowPopupRegistry = {
...windowPopupDefinitions.inftwtResultPopup, ...windowPopupDefinitions.inftwtResultPopup,
component: InftwtResultPopupContent, component: InftwtResultPopupContent,
}, },
rdiiResultPopup: {
...windowPopupDefinitions.rdiiResultPopup,
component: RdiiResultPopupContent,
},
infilChart: { infilChart: {
...windowPopupDefinitions.infilChart, ...windowPopupDefinitions.infilChart,
component: InfilChartPopupContent, component: InfilChartPopupContent,
@@ -8,6 +8,10 @@ export default function RdiiChartPopupContent({
searchParams, searchParams,
}: WindowPopupContentProps) { }: WindowPopupContentProps) {
const pplMngNo = getPopupParamValue(searchParams.pplMngNo); const pplMngNo = getPopupParamValue(searchParams.pplMngNo);
const resultOnly = getPopupParamValue(searchParams.resultOnly) === "Y";
const aplcnYm = getPopupParamValue(searchParams.aplcnYm);
const analysisInfo = getPopupParamValue(searchParams.analysisInfo);
const rainInfwtAnlsSn = Number(getPopupParamValue(searchParams.rainInfwtAnlsSn) || "0");
if (!pplMngNo) { if (!pplMngNo) {
return ( return (
@@ -18,5 +22,13 @@ export default function RdiiChartPopupContent({
); );
} }
return <RdiiChart pplMngNo={pplMngNo} />; return (
} <RdiiChart
pplMngNo={pplMngNo}
rainInfwtAnlsSn={Number.isFinite(rainInfwtAnlsSn) ? rainInfwtAnlsSn : 0}
aplcnYm={aplcnYm}
analysisInfo={analysisInfo}
resultOnly={resultOnly}
/>
);
}
@@ -0,0 +1,19 @@
"use client";
import { useEffect, useState } from "react";
import RdiiResultPopupPage from "@/app/(map-layout)/map/components/rdiiResult/RdiiResultPopupPage";
export default function RdiiResultPopupContent() {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
if (!isMounted) {
return <div className="h-dvh bg-white" suppressHydrationWarning />;
}
return <RdiiResultPopupPage />;
}