diff --git a/apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx index f1834d748..75c059648 100644 --- a/apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx @@ -281,11 +281,6 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { }; }, [resultPage.items]); - useEffect(() => { - if (typeof window === "undefined" || !window.resizeTo) return; - window.resizeTo(1360, 600); - }, []); - /** 현재 탭의 표시·조회 기준시간을 매 정시에 새 현재시간으로 변경한다. */ useEffect(() => { if (resultMode !== "current" || typeof window === "undefined") return; diff --git a/apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx b/apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx index 898b33d44..a1c782952 100644 --- a/apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx +++ b/apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx @@ -1,14 +1,21 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { + StandardPopupLayout, + type UiHeadProps, +} from "@common/business_components"; import { Button } from "@common/components"; -import { UiTable } from "@common/business_components/ui/table"; -import type { UiHeadProps, UiTableSort } from "@common/business_components/ui"; -import ListDiv from "@common/business_components/ui/list_div"; + import { getBlobData, getRawJson } from "@/components/lib/api"; import { fileDownload } from "@/components/lib/utils/fileDownload"; import LoadingOverlay from "@/components/ui/LoadingBar"; +import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefinitions"; 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 { useAlertStore } from "@/shared/lib"; import useStore from "@/store/useStore"; type ApiRow = { @@ -18,21 +25,24 @@ type ApiRow = { sggNm: string; rainBeginningDateTime: string; analysisDateTime: string; - observedAccumulatedRainfall: number; - rainHour: number; - rainfall: number; - exceededFrequencyYear: number; + observedAccumulatedRainfall: number | null; + rainHour: number | null; + rainfall: number | null; + exceededFrequencyYear: number | null; floodRiskGrade: string; }; -type TableRow = ApiRow & { - rowNo: number; - rainBeginningDateTimeDisplay: string; - analysisDateTimeDisplay: string; - observedAccumulatedRainfallDisplay: string; - rainHourDisplay: string; - rainfallDisplay: string; - exceededFrequencyYearDisplay: string; -}; + +type TableRow = ApiRow & + Record & { + rowNo: number; + rainBeginningDateTimeDisplay: string; + analysisDateTimeDisplay: string; + observedAccumulatedRainfallDisplay: string; + rainHourDisplay: string; + rainfallDisplay: string; + exceededFrequencyYearDisplay: string; + }; + type Filters = { ctpvCd: string; sggCd: string; @@ -43,84 +53,62 @@ type Filters = { frequencyYear: string; }; -const PAGE_SIZE = 20; -const MAP_FREQUENCIES = new Set([30, 50, 80]); +const PAGE_SIZE = 10; +const MAP_FREQUENCIES = new Set([30, 50, 80, 100]); +const FREQUENCY_OPTIONS = [30, 50, 80, 100] as const; + const HEADS: UiHeadProps[] = [ - { id: "rowNo", title: "NO", widthPercent: 5, sortable: false }, - { - id: "ctpvNm", - title: "시도", - widthPercent: 9, - sortable: true, - sortKey: "ctpvCd", - }, - { - id: "sggNm", - title: "시군구", - widthPercent: 10, - sortable: true, - sortKey: "sggCd", - }, + { id: "rowNo", title: "순번", widthPercent: 6 }, + { id: "ctpvNm", title: "시도", widthPercent: 8 }, + { id: "sggNm", title: "시군구", widthPercent: 9 }, { id: "rainBeginningDateTimeDisplay", - title: "강우 시작시각", + title: "강우시각", widthPercent: 13, - sortable: true, - sortKey: "rainBeginningDateTime", }, { id: "analysisDateTimeDisplay", title: "분석시각", widthPercent: 13, - sortable: true, - sortKey: "analysisDateTime", }, { id: "observedAccumulatedRainfallDisplay", title: "관측 누적강수량(mm)", widthPercent: 13, - sortable: true, - sortKey: "observedAccumulatedRainfall", + align: "right", }, { id: "rainHourDisplay", title: "강우시간", - widthPercent: 9, - sortable: true, - sortKey: "rainHour", + widthPercent: 8, }, { id: "rainfallDisplay", title: "강수량(mm)", widthPercent: 10, - sortable: false, + align: "right", }, { id: "exceededFrequencyYearDisplay", - title: "초과빈도년도", - widthPercent: 9, - sortable: true, - sortKey: "exceededFrequencyYear", - }, - { - id: "floodRiskGrade", - title: "위험등급", - widthPercent: 9, - sortable: true, - sortKey: "floodRiskGrade", + title: "초과 빈도(년)", + widthPercent: 10, + align: "right", }, + { id: "floodRiskGrade", title: "위험등급", widthPercent: 10 }, ]; +/** 브라우저 로컬시각을 datetime-local 입력값으로 변환한다. */ const localDateTime = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}T${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`; -const initialFilters = (): Filters => { + +/** 최초·초기화 조회조건은 금일 분석결과를 기준으로 생성한다. */ +const buildInitialFilters = (): Filters => { const now = new Date(); const from = new Date(now); from.setHours(0, 0, 0, 0); return { ctpvCd: "0", sggCd: "0", - // 최초 조회에서는 강우 시작시각을 제한하지 않고 전체 강우사상을 대상으로 한다. rainBgngFrom: "", rainBgngTo: "", analysisFrom: localDateTime(from), @@ -128,47 +116,103 @@ const initialFilters = (): Filters => { frequencyYear: "", }; }; + const apiDate = (value: string) => value.replace(/[^0-9]/g, "").slice(0, 12); + const displayDate = (value: string) => /^\d{12}$/.test(value) ? `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)} ${value.slice(8, 10)}:${value.slice(10, 12)}` - : value; -const rainHourText = (value: number) => - value === 0 ? "현재" : `${value > 0 ? "+" : "-"}${Math.abs(value)}시간`; -const query = (filters: Filters, page?: number, sort?: UiTableSort) => { - const p = new URLSearchParams(); + : value || "-"; + +const rainHourText = (value: number | null) => { + const hour = Number(value ?? 0); + return hour === 0 ? "현재" : `${hour > 0 ? "+" : "-"}${Math.abs(hour)}시간`; +}; + +const formatNumber = (value: number | null, digits: number) => + value == null || !Number.isFinite(Number(value)) + ? "-" + : Number(value).toFixed(digits); + +/** 화면 조회조건을 목록·Excel API 쿼리로 변환한다. */ +const buildQuery = (filters: Filters, page?: number) => { + const params = new URLSearchParams(); if (page !== undefined) { - p.set("page", String(page - 1)); - p.set("size", String(PAGE_SIZE)); + params.set("page", String(page - 1)); + params.set("size", String(PAGE_SIZE)); } - if (filters.ctpvCd !== "0") p.set("ctpvCd", filters.ctpvCd.slice(0, 2)); - if (filters.sggCd !== "0") p.set("sggCd", filters.sggCd.slice(0, 5)); - ["rainBgngFrom", "rainBgngTo", "analysisFrom", "analysisTo"].forEach( - (key) => { - const value = apiDate(filters[key as keyof Filters]); - if (value) p.set(key, value); - }, - ); - if (filters.frequencyYear) p.set("frequencyYear", filters.frequencyYear); - if (sort?.key && sort.direction) - p.set("sort", `${sort.key},${sort.direction}`); - return p.toString(); + if (filters.ctpvCd !== "0") params.set("ctpvCd", filters.ctpvCd.slice(0, 2)); + if (filters.sggCd !== "0") params.set("sggCd", filters.sggCd.slice(0, 5)); + ( + ["rainBgngFrom", "rainBgngTo", "analysisFrom", "analysisTo"] as const + ).forEach((key) => { + const value = apiDate(filters[key]); + if (value) params.set(key, value); + }); + if (filters.frequencyYear) params.set("frequencyYear", filters.frequencyYear); + return params.toString(); +}; + +/** 시작·종료시각과 빈도 조건을 API 호출 전에 검증한다. */ +const validateFilters = (filters: Filters) => { + if ( + filters.rainBgngFrom && + filters.rainBgngTo && + filters.rainBgngFrom > filters.rainBgngTo + ) { + return "강우 시작시각의 시작은 종료보다 늦을 수 없습니다."; + } + if ( + filters.analysisFrom && + filters.analysisTo && + filters.analysisFrom > filters.analysisTo + ) { + return "분석시각의 시작은 종료보다 늦을 수 없습니다."; + } + if ( + filters.frequencyYear && + !FREQUENCY_OPTIONS.includes( + Number(filters.frequencyYear) as 30 | 50 | 80 | 100, + ) + ) { + return "초과 빈도는 30년, 50년, 80년, 100년 중에서 선택해야 합니다."; + } + return null; +}; + +const buildRowId = (row: ApiRow) => + `${row.ctpvCd}:${row.sggCd}:${row.rainBeginningDateTime}:${row.analysisDateTime}:${row.rainHour}`; + +const isDangerGrade = (value: string) => { + const grade = String(value ?? "") + .trim() + .toUpperCase(); + if (!grade || grade === "-" || grade.includes("정상") || grade === "NORMAL") { + return false; + } + return true; }; export default function FldngPredcPrstPopupContent() { + const alert = useAlertStore((state) => state.setMessage); const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore(); - const [filters, setFilters] = useState(() => initialFilters()); - const [applied, setApplied] = useState(() => initialFilters()); + const [filters, setFilters] = useState(() => buildInitialFilters()); + const [appliedFilters, setAppliedFilters] = useState(() => + buildInitialFilters(), + ); const [rows, setRows] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); - const [sort, setSort] = useState(); const [loading, setLoading] = useState(false); - const [selected, setSelected] = useState(-1); + const [selectedRowId, setSelectedRowId] = useState(""); + // 개발 StrictMode에서도 최초 목록 API를 한 번만 호출하기 위한 ref다. + const didInitialLoadRef = useRef(false); + useEffect(() => { if (!ctpv.length) void fetchCtpv(); if (!sgg.length) void fetchSgg(); }, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]); + const sggOptions = useMemo( () => filters.ctpvCd === "0" @@ -178,229 +222,336 @@ export default function FldngPredcPrstPopupContent() { ), [filters.ctpvCd, sgg], ); - const load = useCallback( - async (targetPage: number, condition: Filters, nextSort?: UiTableSort) => { + + /** 조회조건과 페이지에 맞는 침수예측현황 목록을 불러온다. */ + const loadRows = useCallback( + async (targetPage: number, condition: Filters) => { setLoading(true); try { const response = await getRawJson( - `/sewerage-service/api/v1/fldng/predc/prst?${query(condition, targetPage, nextSort)}`, + `/sewerage-service/api/v1/fldng/predc/prst?${buildQuery(condition, targetPage)}`, ); const payload = response?.data ?? response; const content: ApiRow[] = Array.isArray(payload?.content) ? payload.content : []; setRows( - content.map((item, index) => ({ + content.map((item) => ({ ...item, - rowNo: (targetPage - 1) * PAGE_SIZE + index + 1, + rowNo: 0, rainBeginningDateTimeDisplay: displayDate( item.rainBeginningDateTime, ), analysisDateTimeDisplay: displayDate(item.analysisDateTime), - observedAccumulatedRainfallDisplay: Number( - item.observedAccumulatedRainfall ?? 0, - ).toFixed(4), - rainHourDisplay: rainHourText(Number(item.rainHour ?? 0)), - rainfallDisplay: Number(item.rainfall ?? 0).toFixed(1), - exceededFrequencyYearDisplay: String( - item.exceededFrequencyYear ?? "", + observedAccumulatedRainfallDisplay: formatNumber( + item.observedAccumulatedRainfall, + 4, ), + rainHourDisplay: rainHourText(item.rainHour), + rainfallDisplay: formatNumber(item.rainfall, 1), + exceededFrequencyYearDisplay: + item.exceededFrequencyYear == null + ? "-" + : String(item.exceededFrequencyYear), })), ); setTotal(Number(payload?.totalElements ?? 0)); setPage(targetPage); - setApplied(condition); - setSort(nextSort); - setSelected(-1); + setAppliedFilters(condition); + setSelectedRowId(""); } catch (error) { - console.error(error); - alert( - error instanceof Error - ? error.message - : "침수예측현황 조회에 실패했습니다.", - ); + console.error("침수예측현황 조회 중 오류:", error); + setRows([]); + setTotal(0); + alert("침수예측현황 조회에 실패했습니다."); } finally { setLoading(false); } }, - [], + [alert], ); + useEffect(() => { - const defaults = initialFilters(); + if (didInitialLoadRef.current) return; + didInitialLoadRef.current = true; + const defaults = buildInitialFilters(); setFilters(defaults); - void load(1, defaults); - }, [load]); - const mapRow = async (index?: number) => { - if (typeof index !== "number") return; - const row = rows[index]; - if (!row) return; - setSelected(index); - if (!MAP_FREQUENCIES.has(row.exceededFrequencyYear)) { + void loadRows(1, defaults); + }, [loadRows]); + + const handleSearch = () => { + const message = validateFilters(filters); + if (message) { + alert(message); + return; + } + void loadRows(1, filters); + }; + + const handleReset = () => { + const defaults = buildInitialFilters(); + setFilters(defaults); + void loadRows(1, defaults); + }; + + /** 선택 행과 동일한 시군구·빈도의 도시침수지도 범위를 부모 지도에 전달한다. */ + const handleRowClick = async (row: TableRow) => { + const frequencyYear = Number(row.exceededFrequencyYear ?? 0); + if (!MAP_FREQUENCIES.has(frequencyYear)) { alert( - `${row.exceededFrequencyYear}년 빈도 도시침수지도는 제공되지 않습니다. (30·50·80년 빈도만 제공)`, + `${frequencyYear || "해당"}년 빈도 도시침수지도는 제공되지 않습니다. (30·50·80·100년 빈도만 제공)`, ); return; } + if (!row.sggCd) { + alert("지도 이동에 필요한 시군구 정보가 없습니다."); + return; + } + if (!window.opener || window.opener.closed) { + alert("하수관망도 분석도구에서 침수예측현황을 다시 열어주세요."); + return; + } + + setSelectedRowId(buildRowId(row)); setLoading(true); try { - const sbmrgFrqExpln = `${row.exceededFrequencyYear}년`; - const p = new URLSearchParams({ sggCd: row.sggCd, sbmrgFrqExpln }); + const sbmrgFrqExpln = `${frequencyYear}년`; + const params = new URLSearchParams({ + sggCd: row.sggCd, + sbmrgFrqExpln, + }); const extentData = await getRawJson( - `/sewerage-service/api/v1/fldng/flood-dngr-map/group/extent?${p}`, + `/sewerage-service/api/v1/fldng/flood-dngr-map/group/extent?${params.toString()}`, ); const extent = [ - extentData.minX, - extentData.minY, - extentData.maxX, - extentData.maxY, + extentData?.minX, + extentData?.minY, + extentData?.maxX, + extentData?.maxY, ].map(Number); - if (extent.some((v) => !Number.isFinite(v))) + if (extent.some((value) => !Number.isFinite(value))) { throw new Error("도시침수지도 위치 정보가 없습니다."); - window.opener?.postMessage( + } + window.opener.postMessage( { type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_FLOOD_GROUP, payload: { extent, sggCd: row.sggCd, sbmrgFrqExpln }, }, window.location.origin, ); + window.opener.focus(); } catch (error) { + console.error("침수예측현황 지도 이동 중 오류:", error); alert( - error instanceof Error ? error.message : "지도 이동에 실패했습니다.", + error instanceof Error && error.message.includes("위치 정보") + ? error.message + : "도시침수지도 이동에 실패했습니다.", ); } finally { setLoading(false); } }; - const download = async () => { - if (!total) return alert("다운로드할 데이터가 없습니다."); + + const handleDownload = async () => { + if (!total) { + alert("다운로드할 데이터가 없습니다."); + return; + } setLoading(true); try { const blob = await getBlobData( - `/sewerage-service/api/v1/fldng/predc/prst/excel?${query(applied, undefined, sort)}`, + `/sewerage-service/api/v1/fldng/predc/prst/excel?${buildQuery(appliedFilters)}`, ); fileDownload(blob, "침수예측현황.xlsx"); } catch (error) { - alert( - error instanceof Error - ? error.message - : "Excel 다운로드에 실패했습니다.", - ); + console.error("침수예측현황 Excel 다운로드 중 오류:", error); + alert("침수예측현황 Excel 다운로드에 실패했습니다."); } finally { setLoading(false); } }; - const field = (key: keyof Filters, label: string) => ( -