"use client"; import { useEffect, useMemo, useState } from "react"; import { StandardPopupLayout, UITab, type UiHeadProps, } from "@common/business_components"; import { Button } from "@common/components"; import LoadingOverlay from "@/components/ui/LoadingBar"; import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefinitions"; import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable"; import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel"; import { WINDOW_POPUP_MESSAGE_TYPE, type WindowPopupContentProps, } from "@/features/windowPopup/model/types"; import { fetchWaterRiskAreas, fetchWaterRiskMapView, fetchWaterRiskResults, } from "./waterRiskClient"; import { buildWaterRiskStatusLabel, EMPTY_WATER_RISK_SESSION_SNAPSHOT, type WaterRiskAreaOption, type WaterRiskResultPage, type WaterRiskResultRow, type WaterRiskSessionSnapshot, } from "./waterRiskTypes"; type WaterRiskResultMode = "current" | "history"; type ResultFilters = { startDate: string; endDate: string; ctpvCd: string; sggCd: string; areaId: string; riskStatus: "ALL" | "DANGER" | "NORMAL"; }; const WATER_RISK_PAGE_SIZE = 10; const EMPTY_RESULT_PAGE: WaterRiskResultPage = { items: [], total: 0, page: 0, size: WATER_RISK_PAGE_SIZE, totalPages: 0, }; type WaterRiskTableRow = Record & { sourceRow: WaterRiskResultRow; rowNo: number; sidoName: string; sggName: string; areaName: string; currentAccumRainQty: string; forecastRainQty: string; maxRainIntensity: string; runoffFlowQty: string; conveyQty: string; analysisDisplay: string; riskStatus: WaterRiskResultRow["riskStatus"]; }; const CURRENT_RESULT_HEADLIST: UiHeadProps[] = [ { id: "rowNo", title: "순번", widthPercent: 6 }, { id: "sidoName", title: "시도", widthPercent: 9 }, { id: "sggName", title: "시군구", widthPercent: 9 }, { id: "areaName", title: "중점관리지역", widthPercent: 14 }, { id: "currentAccumRainQty", title: "현재누적강우량(mm)", widthPercent: 13, align: "right", }, { id: "forecastRainQty", title: "예측강우량(mm)", widthPercent: 12, align: "right", }, { id: "runoffFlowQty", title: "유출유량(㎥/s)", widthPercent: 11, align: "right", }, { id: "conveyQty", title: "통수량(㎥/s)", widthPercent: 10, align: "right", }, { id: "analysisDisplay", title: "분석일", widthPercent: 10 }, { id: "riskStatus", title: "통수위험", widthPercent: 7 }, ]; const HISTORY_RESULT_HEADLIST: UiHeadProps[] = [ { id: "rowNo", title: "순번", widthPercent: 6 }, { id: "sidoName", title: "시도", widthPercent: 9 }, { id: "sggName", title: "시군구", widthPercent: 9 }, { id: "areaName", title: "중점관리지역", widthPercent: 15 }, { id: "maxRainIntensity", title: "최대강우강도(mm/hr)", widthPercent: 15, align: "right", }, { id: "runoffFlowQty", title: "유출유량(㎥/s)", widthPercent: 12, align: "right", }, { id: "conveyQty", title: "통수량(㎥/s)", widthPercent: 11, align: "right", }, { id: "analysisDisplay", title: "분석일", widthPercent: 12 }, { id: "riskStatus", title: "통수위험", widthPercent: 11 }, ]; const formatOptionalNumber = ( value: number | null | undefined, digits: number, ) => (value == null ? "-" : value.toFixed(digits)); /** 브라우저 로컬 날짜를 API의 yyyy-MM-dd 형식으로 변환한다. */ const formatLocalDate = (date: Date) => { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); return `${year}-${month}-${day}`; }; /** 화면을 연 날짜를 현재 위험 기본 조회일로 사용한다. */ const buildDefaultResultFilters = (): ResultFilters => { const today = formatLocalDate(new Date()); return { startDate: today, endDate: today, ctpvCd: "ALL", sggCd: "ALL", areaId: "ALL", riskStatus: "ALL", }; }; /** 부모 지도 창으로 통수위험 선택 메시지를 전달한다. */ const postMessageToOpener = ( type: string, payload?: Record, ) => { if (typeof window === "undefined" || !window.opener) return; window.opener.postMessage({ type, payload }, window.location.origin); }; /** 위험 여부에 맞는 기존 팝업 badge 스타일을 반환한다. */ const getRiskBadgeClassName = (riskStatus: "DANGER" | "NORMAL") => riskStatus === "DANGER" ? "sn-popup-badge-danger" : "sn-popup-badge-normal"; /** 날짜까지 포함해 과거 이력에서도 결과 행 선택을 유일하게 식별한다. */ const buildResultRowKey = (row: WaterRiskResultRow) => `${row.analysisDate ?? ""}:${row.ctpvCd ?? ""}:${row.sggCd ?? ""}:${row.areaId}`; /** 실제 통수위험 API를 조회하고 선택 결과를 부모 지도에 전달하는 팝업이다. */ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { const initialFilters = useMemo(() => buildDefaultResultFilters(), []); const [resultMode, setResultMode] = useState("current"); const [draftResultFilters, setDraftResultFilters] = useState(initialFilters); const [appliedResultFilters, setAppliedResultFilters] = useState(initialFilters); const [areaOptions, setAreaOptions] = useState([]); const [resultPage, setResultPage] = useState(EMPTY_RESULT_PAGE); const [selectedResultKey, setSelectedResultKey] = useState(""); const [resultCurrentPage, setResultCurrentPage] = useState(1); const [reloadVersion, setReloadVersion] = useState(0); const [isLoading, setIsLoading] = useState(false); const [isMapLoading, setIsMapLoading] = useState(false); const [noticeMessage, setNoticeMessage] = useState(""); const [errorMessage, setErrorMessage] = useState(""); useEffect(() => { let active = true; fetchWaterRiskAreas() .then((options) => { if (active) setAreaOptions(options); }) .catch((error) => { if (!active) return; const message = error instanceof Error ? error.message : "중점관리지역 목록을 조회하지 못했습니다."; setErrorMessage(message); }); return () => { active = false; }; }, []); useEffect(() => { let active = true; setIsLoading(true); setErrorMessage(""); fetchWaterRiskResults({ startDate: appliedResultFilters.startDate, endDate: appliedResultFilters.endDate, ctpvCd: appliedResultFilters.ctpvCd === "ALL" ? undefined : appliedResultFilters.ctpvCd, sggCd: appliedResultFilters.sggCd === "ALL" ? undefined : appliedResultFilters.sggCd, areaId: appliedResultFilters.areaId === "ALL" ? undefined : appliedResultFilters.areaId, riskStatus: appliedResultFilters.riskStatus === "ALL" ? undefined : appliedResultFilters.riskStatus, page: resultCurrentPage - 1, size: WATER_RISK_PAGE_SIZE, }) .then((page) => { if (!active) return; setResultPage(page); if (page.items.length > 0) { setSelectedResultKey( (current) => current || buildResultRowKey(page.items[0]!), ); } }) .catch((error) => { if (!active) return; setResultPage(EMPTY_RESULT_PAGE); setErrorMessage( error instanceof Error ? error.message : "통수위험 분석 결과를 조회하지 못했습니다.", ); }) .finally(() => { if (active) setIsLoading(false); }); return () => { active = false; }; }, [appliedResultFilters, reloadVersion, resultCurrentPage]); useEffect(() => { const handleMessage = (event: MessageEvent) => { if (event.origin !== window.location.origin) return; if (event.data?.type !== WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SYNC) return; const nextSnapshot = (event.data.payload ?? EMPTY_WATER_RISK_SESSION_SNAPSHOT) as WaterRiskSessionSnapshot; if (nextSnapshot.selectedAreaId) { const matchingRow = resultPage.items.find( (row) => row.areaId === nextSnapshot.selectedAreaId, ); if (matchingRow) setSelectedResultKey(buildResultRowKey(matchingRow)); } }; window.addEventListener("message", handleMessage); postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_READY); return () => { window.removeEventListener("message", handleMessage); }; }, [resultPage.items]); useEffect(() => { if (typeof window === "undefined" || !window.resizeTo) return; window.resizeTo(1180, 720); }, []); const ctpvOptions = useMemo(() => { const unique = new Map(); areaOptions.forEach((option) => { if (option.ctpvCd) unique.set(option.ctpvCd, option.ctpvName || option.ctpvCd); }); return Array.from(unique, ([code, name]) => ({ code, name })).sort((a, b) => a.name.localeCompare(b.name, "ko"), ); }, [areaOptions]); const sggOptions = useMemo(() => { const unique = new Map(); areaOptions .filter( (option) => draftResultFilters.ctpvCd === "ALL" || option.ctpvCd === draftResultFilters.ctpvCd, ) .forEach((option) => { if (option.sggCd) unique.set(option.sggCd, option.sggName || option.sggCd); }); return Array.from(unique, ([code, name]) => ({ code, name })).sort((a, b) => a.name.localeCompare(b.name, "ko"), ); }, [areaOptions, draftResultFilters.ctpvCd]); const filteredAreaOptions = useMemo( () => areaOptions.filter( (option) => (draftResultFilters.ctpvCd === "ALL" || option.ctpvCd === draftResultFilters.ctpvCd) && (draftResultFilters.sggCd === "ALL" || option.sggCd === draftResultFilters.sggCd), ), [areaOptions, draftResultFilters.ctpvCd, draftResultFilters.sggCd], ); const resultListTitle = resultMode === "current" ? "통수위험 분석 결과 목록" : "통수위험 과거 이력 목록"; const tableRows = useMemo( () => resultPage.items.map((row) => { const representativePipe = row.pipeSummaries.find((pipe) => pipe.riskStatus === "DANGER") ?? row.pipeSummaries[0]; return { sourceRow: row, rowNo: 0, sidoName: row.sidoName || "-", sggName: row.sggName || "-", areaName: row.areaName, currentAccumRainQty: formatOptionalNumber(row.currentAccumRainQty, 1), forecastRainQty: formatOptionalNumber(row.forecastRainQty, 1), maxRainIntensity: formatOptionalNumber(row.maxRainIntensity, 1), runoffFlowQty: formatOptionalNumber( representativePipe?.runoffFlowQty, 2, ), conveyQty: formatOptionalNumber(representativePipe?.conveyQty, 3), analysisDisplay: row.maxFloodingAt || row.analysisDate || "-", riskStatus: row.riskStatus, }; }), [resultPage.items], ); /** 검증된 검색조건을 적용하고 첫 페이지를 새로 조회한다. */ const handleApplyResultFilters = () => { if (!draftResultFilters.startDate || !draftResultFilters.endDate) { setNoticeMessage("조회 시작일과 종료일을 입력해야 합니다."); return; } if (draftResultFilters.startDate > draftResultFilters.endDate) { setNoticeMessage("조회 시작일은 종료일보다 늦을 수 없습니다."); return; } setResultCurrentPage(1); setAppliedResultFilters({ ...draftResultFilters }); setReloadVersion((version) => version + 1); setNoticeMessage("통수위험 분석 결과를 조회했습니다."); }; /** 현재 위험과 과거 이력 표시열만 전환하고 같은 API 검색조건은 유지한다. */ const handleChangeResultMode = (mode: WaterRiskResultMode) => { setResultMode(mode); setResultCurrentPage(1); setNoticeMessage(""); }; /** 선택 행의 실제 GeoJSON을 조회한 뒤 부모 지도 창에 전달한다. */ const handleSelectResultRow = async (row: WaterRiskResultRow) => { setSelectedResultKey(buildResultRowKey(row)); setIsMapLoading(true); setErrorMessage(""); try { const mapView = await fetchWaterRiskMapView(row); postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SELECT_RESULT, { areaId: row.areaId, resultRow: row, mapView, }); setNoticeMessage(`${row.areaName}을 지도에서 강조했습니다.`); } catch (error) { setErrorMessage( error instanceof Error ? error.message : "통수위험 지도 결과를 조회하지 못했습니다.", ); } finally { setIsMapLoading(false); } }; return (
.card]:!flex [&_.standard-data-table>.card]:!min-h-0 [&_.standard-data-table>.card]:!flex-1 [&_.standard-data-table>.card]:!flex-col [&_.standard-data-table]:!flex [&_.standard-data-table]:!min-h-0 [&_.standard-data-table]:!flex-1 [&_.standard-data-table]:!flex-col [&_.standard-data-table__scroll>div]:!h-full [&_.standard-data-table__scroll]:!min-h-96 [&_.standard-data-table__scroll]:!flex-1" : "" }`} > handleChangeResultMode(value as WaterRiskResultMode) } />
buildResultRowKey(row.sourceRow)} selectedRowId={selectedResultKey} onRowClick={(row) => void handleSelectResultRow(row.sourceRow)} renderCell={(row, headId) => headId === "riskStatus" ? ( {buildWaterRiskStatusLabel(row.riskStatus)} ) : undefined } scrollMode="horizontal" minTableWidth={resultMode === "current" ? "114rem" : "106rem"} emptyMessage="조회 조건에 해당하는 통수위험 결과가 없습니다." actions={ 합리식 유출유량과 Manning 통수량 비교 결과 } />
{errorMessage ? ( {errorMessage} ) : ( noticeMessage )}
); }