From f7218fc98a94dd5089e2beb213b439b466be3c6a Mon Sep 17 00:00:00 2001 From: ljpark Date: Tue, 18 Aug 2026 13:31:22 +0900 Subject: [PATCH] =?UTF-8?q?sn:=20=EC=B9=A8=EC=88=98=EC=98=88=EC=B8=A1?= =?UTF-8?q?=ED=98=84=ED=99=A9-=EB=8B=A8=EC=9C=84=20=EC=97=91=EC=85=80?= =?UTF-8?q?=EB=8B=A4=EC=9A=B4=EB=A1=9C=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../analysisMenu/AnalysisPopupMenu.tsx | 2 +- .../001-fe-SFR-011-008-침수예측현황-작업계획.md | 68 +++ .../ui/FldngPredcPrstPopupContent.tsx | 406 ++++++++++++++++++ .../windowPopup/model/popupDefinitions.ts | 8 +- apps/sn/src/features/windowPopup/registry.tsx | 16 +- 5 files changed, 483 insertions(+), 17 deletions(-) create mode 100644 apps/sn/docs/작업계획/SFR-011-008-침수예측/001-fe-SFR-011-008-침수예측현황-작업계획.md create mode 100644 apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx diff --git a/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx index fe16774ba..41cb25af6 100644 --- a/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx +++ b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx @@ -60,7 +60,7 @@ const ANALYSIS_POPUP_ITEMS: Array<{ }> = [ { key: "infoSearch", label: "정보검색", icon: Search }, { key: "pipeAnalysisPopup", label: "관로분석", icon: ChartColumnBig }, - { key: "floodPredictionMapPopup", label: "침수예측현황", icon: Waves }, + { key: "fldngPredcPrst", label: "침수예측현황", icon: Waves }, { key: "agedPipeStatusByCtpvPopup", label: "시도별 노후관로현황", diff --git a/apps/sn/docs/작업계획/SFR-011-008-침수예측/001-fe-SFR-011-008-침수예측현황-작업계획.md b/apps/sn/docs/작업계획/SFR-011-008-침수예측/001-fe-SFR-011-008-침수예측현황-작업계획.md new file mode 100644 index 000000000..bb425c07b --- /dev/null +++ b/apps/sn/docs/작업계획/SFR-011-008-침수예측/001-fe-SFR-011-008-침수예측현황-작업계획.md @@ -0,0 +1,68 @@ +# SFR-011-008 침수예측현황 FE 작업계획 + +## 작업 목적 + +지도 분석도구의 기존 침수예측현황 placeholder를 검색·목록·Excel·지도연계가 가능한 실제 WindowPopup으로 전환한다. + +## 근거 + +- 요구사항: `SFR-011-008` +- 화면: `SN_MN010205 침수예측현황` +- route: `/popup/fldngPredcPrst` +- 참고: `PipeAnalysisPopupPage`, `FloodDngrMapPopupPage`, `/oper/obsltPplBiz` + +## 수정 예정 범위 + +- `src/features/fldngPredcPrst/{api,model,ui}/**` +- `src/features/windowPopup/model/popupDefinitions.ts` +- `src/features/windowPopup/registry.tsx` +- `app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx` +- 공통 `app/(popupLayout)/popup/[popupKey]` route는 재사용하고 popup key를 `fldngPredcPrst`로 변경한다. +- 공통 패키지는 수정하지 않는다. + +## 화면·상태 계약 + +- 강우시작시각은 최초 진입 시 미입력으로 두고, 분석일시는 오늘 00:00~현재 KST를 기본값으로 설정한다. +- 시도 변경 시 시군구를 전체로 초기화한다. +- 입력 조건과 실제 적용 조건을 분리하여 페이지 이동이 마지막 조회조건을 유지한다. +- 20건 서버 페이징, 허용 컬럼 서버 정렬, 전체건수 기반 no를 제공한다. +- 시도 헤더는 `ctpvCd` 서버 정렬키를 사용한다. +- 강수량은 소수점 한 자리로 표시하고 `초과빈도년도` 값은 `년` 접미사 없이 숫자로 표시한다. +- `ListDiv` 내부에서 최소 테이블 폭과 가로 스크롤을 제공한다. +- Excel은 현재 적용 검색조건을 동일하게 전달한다. +- 신규 파일과 함수에 화면/API 역할 한글 주석을 작성한다. + +## 지도 연계 + +- 행 선택 시 기존 도시침수지도 group extent API를 호출한다. +- 부모 창에 기존 `MAP_SEARCH_SHOW_FLOOD_GROUP` 메시지를 보내 범위 이동·레이어 표시를 요청한다. +- 도시침수지도에 없는 빈도는 자동 변환하지 않고 화면 안내를 표시한다. + +## 검증 + +- TypeScript 정적검사 및 `sn` 빌드(실행 중 개발서버가 없거나 사용자 확인 후) +- `/popup/fldngPredcPrst` 직접 진입 +- 분석도구 클릭 별도 창 진입 +- 기본 날짜·종속 시군구·조회·페이지·정렬·가로스크롤 +- Excel 파일명/열/검색조건 +- 행 클릭 후 부모 지도 Network·extent·레이어 동작 + +## 롤백과 위험 + +- 신규 route/feature를 제거하고 registry를 placeholder로 복원한다. +- 팝업 경로와 기존 popup key가 다르므로 route 연결 누락을 회귀 확인한다. +- 행정구역 코드와 도시침수지도 빈도 자료가 없는 행은 지도 이동이 제한될 수 있다. + +## 구현 결과 (2026-08-13) + +- 분석도구 메뉴와 WindowPopup key를 `fldngPredcPrst`로 변경하고 placeholder를 실제 화면으로 교체했다. +- 강우시작시각 미입력·분석일시 당일 00:00~현재 기본값, 시도·시군구 종속조건, 두 날짜 범위, 빈도년도 검색을 구현했다. +- 20건 서버 페이징·정렬, `ListDiv` 가로 스크롤, 적용조건 기준 Excel 다운로드를 구현했다. +- 행 클릭 시 기존 extent API와 `MAP_SEARCH_SHOW_FLOOD_GROUP` 메시지를 사용한다. +- 도시침수지도가 없는 빈도는 30·50·80년 중 임의 근사하지 않고 안내한다. +- 2026-08-18 시도 헤더를 정렬 가능 상태로 전환하고 `ctpvCd` 서버 정렬을 연결했다. +- `pnpm --filter sn run build`를 통과했다. +- 독립 `tsc --noEmit`은 현재 수정과 무관한 조사사업·침수이력·지도·노후관로·팝업 registry 등의 기존 타입 오류로 실패했다. 이번 변경 파일에서는 오류가 보고되지 않았다. +- 2026-08-18 목록 강수량을 소수점 한 자리로, 헤더를 `초과빈도년도`로 변경하고 값의 `년` 접미사를 제거했다. +- 기존 `.next` 산출물을 재생성한 뒤 `pnpm --filter sn run build`를 최종 통과했다. +- TypeScript 정적검사는 저장소 기존 `ignoreDeprecations` 설정과 실행 중 산출물 잠금 때문에 완료하지 못했고, Next 빌드는 최적화 단계 장기 대기 후 중단했다. diff --git a/apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx b/apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx new file mode 100644 index 000000000..898b33d44 --- /dev/null +++ b/apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx @@ -0,0 +1,406 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +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 { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types"; +import useStore from "@/store/useStore"; + +type ApiRow = { + sggCd: string; + ctpvCd: string; + ctpvNm: string; + sggNm: string; + rainBeginningDateTime: string; + analysisDateTime: string; + observedAccumulatedRainfall: number; + rainHour: number; + rainfall: number; + exceededFrequencyYear: number; + floodRiskGrade: string; +}; +type TableRow = ApiRow & { + rowNo: number; + rainBeginningDateTimeDisplay: string; + analysisDateTimeDisplay: string; + observedAccumulatedRainfallDisplay: string; + rainHourDisplay: string; + rainfallDisplay: string; + exceededFrequencyYearDisplay: string; +}; +type Filters = { + ctpvCd: string; + sggCd: string; + rainBgngFrom: string; + rainBgngTo: string; + analysisFrom: string; + analysisTo: string; + frequencyYear: string; +}; + +const PAGE_SIZE = 20; +const MAP_FREQUENCIES = new Set([30, 50, 80]); +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: "rainBeginningDateTimeDisplay", + 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", + }, + { + id: "rainHourDisplay", + title: "강우시간", + widthPercent: 9, + sortable: true, + sortKey: "rainHour", + }, + { + id: "rainfallDisplay", + title: "강수량(mm)", + widthPercent: 10, + sortable: false, + }, + { + id: "exceededFrequencyYearDisplay", + title: "초과빈도년도", + widthPercent: 9, + sortable: true, + sortKey: "exceededFrequencyYear", + }, + { + id: "floodRiskGrade", + title: "위험등급", + widthPercent: 9, + sortable: true, + sortKey: "floodRiskGrade", + }, +]; + +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 now = new Date(); + const from = new Date(now); + from.setHours(0, 0, 0, 0); + return { + ctpvCd: "0", + sggCd: "0", + // 최초 조회에서는 강우 시작시각을 제한하지 않고 전체 강우사상을 대상으로 한다. + rainBgngFrom: "", + rainBgngTo: "", + analysisFrom: localDateTime(from), + analysisTo: localDateTime(now), + 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(); + if (page !== undefined) { + p.set("page", String(page - 1)); + p.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(); +}; + +export default function FldngPredcPrstPopupContent() { + const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore(); + const [filters, setFilters] = useState(() => initialFilters()); + const [applied, setApplied] = useState(() => initialFilters()); + 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); + useEffect(() => { + if (!ctpv.length) void fetchCtpv(); + if (!sgg.length) void fetchSgg(); + }, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]); + const sggOptions = useMemo( + () => + filters.ctpvCd === "0" + ? sgg + : sgg.filter((item) => + String(item.val).startsWith(filters.ctpvCd.slice(0, 2)), + ), + [filters.ctpvCd, sgg], + ); + const load = useCallback( + async (targetPage: number, condition: Filters, nextSort?: UiTableSort) => { + setLoading(true); + try { + const response = await getRawJson( + `/sewerage-service/api/v1/fldng/predc/prst?${query(condition, targetPage, nextSort)}`, + ); + const payload = response?.data ?? response; + const content: ApiRow[] = Array.isArray(payload?.content) + ? payload.content + : []; + setRows( + content.map((item, index) => ({ + ...item, + rowNo: (targetPage - 1) * PAGE_SIZE + index + 1, + 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 ?? "", + ), + })), + ); + setTotal(Number(payload?.totalElements ?? 0)); + setPage(targetPage); + setApplied(condition); + setSort(nextSort); + setSelected(-1); + } catch (error) { + console.error(error); + alert( + error instanceof Error + ? error.message + : "침수예측현황 조회에 실패했습니다.", + ); + } finally { + setLoading(false); + } + }, + [], + ); + useEffect(() => { + const defaults = initialFilters(); + 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)) { + alert( + `${row.exceededFrequencyYear}년 빈도 도시침수지도는 제공되지 않습니다. (30·50·80년 빈도만 제공)`, + ); + return; + } + setLoading(true); + try { + const sbmrgFrqExpln = `${row.exceededFrequencyYear}년`; + const p = new URLSearchParams({ sggCd: row.sggCd, sbmrgFrqExpln }); + const extentData = await getRawJson( + `/sewerage-service/api/v1/fldng/flood-dngr-map/group/extent?${p}`, + ); + const extent = [ + extentData.minX, + extentData.minY, + extentData.maxX, + extentData.maxY, + ].map(Number); + if (extent.some((v) => !Number.isFinite(v))) + throw new Error("도시침수지도 위치 정보가 없습니다."); + window.opener?.postMessage( + { + type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_FLOOD_GROUP, + payload: { extent, sggCd: row.sggCd, sbmrgFrqExpln }, + }, + window.location.origin, + ); + } catch (error) { + alert( + error instanceof Error ? error.message : "지도 이동에 실패했습니다.", + ); + } finally { + setLoading(false); + } + }; + const download = async () => { + if (!total) return alert("다운로드할 데이터가 없습니다."); + setLoading(true); + try { + const blob = await getBlobData( + `/sewerage-service/api/v1/fldng/predc/prst/excel?${query(applied, undefined, sort)}`, + ); + fileDownload(blob, "침수예측현황.xlsx"); + } catch (error) { + alert( + error instanceof Error + ? error.message + : "Excel 다운로드에 실패했습니다.", + ); + } finally { + setLoading(false); + } + }; + const field = (key: keyof Filters, label: string) => ( + + ); + return ( +
+ +
+

침수예측현황

+
+
+
+
+ + + {field("rainBgngFrom", "강우 시작시각 From")} + {field("rainBgngTo", "강우 시작시각 To")} + {field("analysisFrom", "분석시각 From")} + {field("analysisTo", "분석시각 To")} + +
+ + +
+
+ +
+ + headName={ + <>침수예측현황 조회 결과 총 {total.toLocaleString()}건 + } + tableData={rows} + headlist={HEADS} + pageSize={PAGE_SIZE} + total={total} + curPage={page} + onPageChange={(p) => void load(p, applied, sort)} + cellClick={(i) => void mapRow(i)} + index={selected} + widthFixed={true} + isHideCount={true} + sort={sort} + onSortChange={(s) => void load(1, applied, s)} + /> +
+
+
+ 행 클릭 시 해당 시군구·빈도의 도시침수지도로 이동합니다. +
+
+
+ ); +} diff --git a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts index 8fcacc05d..02ec2f094 100644 --- a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts +++ b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts @@ -188,11 +188,11 @@ export const windowPopupDefinitions = { height: 760, requiredParams: ["zoneId"], }, - floodPredictionMapPopup: { - key: "floodPredictionMapPopup", + fldngPredcPrst: { + key: "fldngPredcPrst", title: "침수예측현황", - width: 1080, - height: 760, + width: 1500, + height: 860, }, agedPipeStatusByCtpvPopup: { key: "agedPipeStatusByCtpvPopup", diff --git a/apps/sn/src/features/windowPopup/registry.tsx b/apps/sn/src/features/windowPopup/registry.tsx index da5b58080..ae2e525b8 100644 --- a/apps/sn/src/features/windowPopup/registry.tsx +++ b/apps/sn/src/features/windowPopup/registry.tsx @@ -27,8 +27,8 @@ import SinkholePopupContent from "./ui/SinkholePopupContent"; import SbmrgDngrMapPopupContent from "./ui/SbmrgDngrMapPopupContent"; import FloodDngrMapPopupContent from "./ui/FloodDngrMapPopupContent"; import SscDetailPopupContent from "./ui/SscDetailPopupContent"; -import type { WindowPopupContentProps } from "./model/types"; import FeaturePlaceholderPopupContent from "./ui/FeaturePlaceholderPopupContent"; +import FldngPredcPrstPopupContent from "../fldngPredcPrst/ui/FldngPredcPrstPopupContent"; import FldngHstryPopupContent from "./ui/FldngHstryPopupContent"; import GisEditSessionPopupContent from "./ui/GisEditSessionPopupContent"; import GisManagementPopupContent from "./ui/GisManagementPopupContent"; @@ -38,14 +38,6 @@ import RepairHistoryPopupContent from "./ui/RepairHistoryPopupContent"; import WaterRiskPopupContent from "./ui/WaterRiskPopupContent"; import CfmtnBgtPopupContent from "../cfmtnBgt/ui/CfmtnBgtPopupContent"; -const FloodPredictionPlaceholderPopupContent = ( - _props: WindowPopupContentProps, -) => ( - -); - const GisEditPopupContent = (_props: WindowPopupContentProps) => (