From 9e71f529e7ed2893d832442257af7f46eebf31b4 Mon Sep 17 00:00:00 2001 From: ljpark Date: Thu, 22 Oct 2026 09:33:27 +0900 Subject: [PATCH] =?UTF-8?q?sn:=20=ED=86=B5=EC=88=98=EC=9C=84=ED=97=98=20?= =?UTF-8?q?=EC=9C=84=ED=97=98=EC=97=AC=EB=B6=80=EB=9E=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fldng-hstry-layer-setting-panel.tsx | 8 +- .../fldng-hstry/fldng-hstry-popup-page.tsx | 11 +- .../flood-dngr-map-popup-page.tsx | 6 + .../pipe-analysis-popup-page.tsx | 8 + .../sbmrg-dngr-map-popup-page.tsx | 6 + .../sinkhole/sinkhole-popup-page.tsx | 8 + .../water-risk/water-risk-client.ts | 31 +- .../water-risk/water-risk-date-range.ts | 97 ++++-- .../water-risk/water-risk-popup-page.tsx | 321 ++++++++++++------ .../components/water-risk/water-risk-types.ts | 33 +- .../map/lib/use-water-risk-layer.ts | 12 +- .../(map-layout)/map/lib/water-risk-styles.ts | 26 +- .../(map-layout)/map/ui/map-page-client.tsx | 8 +- .../002-fe-SFR-011-007-통수위험분석-실API연동및검증.md | 38 +++ .../001-fe-SFR-011-008-침수예측현황-작업계획.md | 50 +++ .../001-fe-SN-팝업자료출처표시-작업결과.md | 44 +++ .../flood-history/lib/flood-history-utils.ts | 8 +- .../model/flood-history-types.ts | 2 + .../lib/fldng-predc-prst-excel.ts | 2 +- .../ui/fldng-predc-prst-popup-content.tsx | 65 ++-- .../window-popup/ui/popup-footer-note.tsx | 35 ++ 21 files changed, 630 insertions(+), 189 deletions(-) create mode 100644 apps/sn/docs/작업계획/SN-인수테스트-팝업자료출처표시/001-fe-SN-팝업자료출처표시-작업결과.md create mode 100644 apps/sn/src/features/window-popup/ui/popup-footer-note.tsx diff --git a/apps/sn/app/(map-layout)/map/components/fldng-hstry/fldng-hstry-layer-setting-panel.tsx b/apps/sn/app/(map-layout)/map/components/fldng-hstry/fldng-hstry-layer-setting-panel.tsx index cc4fce0ca..3b503b3b5 100644 --- a/apps/sn/app/(map-layout)/map/components/fldng-hstry/fldng-hstry-layer-setting-panel.tsx +++ b/apps/sn/app/(map-layout)/map/components/fldng-hstry/fldng-hstry-layer-setting-panel.tsx @@ -5,7 +5,7 @@ "use client"; import { SelectBox } from "@common/business_components/ui"; -import { FLDNG_HSTRY_MIN_YEAR, type FldngHstryYearRange } from "@/entities/flood-history/model/flood-history-types"; +import { FLDNG_HSTRY_MIN_YEAR, FLDNG_HSTRY_MAX_YEAR, type FldngHstryYearRange } from "@/entities/flood-history/model/flood-history-types"; import { normalizeFldngHstryYearRange } from "@/entities/flood-history/lib/flood-history-utils"; import LayerSettingCard from "../layer-setting/layer-setting-card"; @@ -26,10 +26,10 @@ export default function FldngHstryLayerSettingPanel({ onClose, }: Props) { const normalizedRange = normalizeFldngHstryYearRange(yearRange); - const currentYear = new Date().getFullYear(); + // 제공 자료의 최대 연도부터 최소 연도까지 선택 목록을 구성한다. const yearOptions = Array.from( - { length: currentYear - FLDNG_HSTRY_MIN_YEAR + 1 }, - (_, index) => String(currentYear - index), + { length: FLDNG_HSTRY_MAX_YEAR - FLDNG_HSTRY_MIN_YEAR + 1 }, + (_, index) => String(FLDNG_HSTRY_MAX_YEAR - index), ); const startYearOptions = yearOptions .filter((year) => Number(year) <= Number(normalizedRange.endYear)) diff --git a/apps/sn/app/(map-layout)/map/components/fldng-hstry/fldng-hstry-popup-page.tsx b/apps/sn/app/(map-layout)/map/components/fldng-hstry/fldng-hstry-popup-page.tsx index dd25bbb67..f2fa3f1d3 100644 --- a/apps/sn/app/(map-layout)/map/components/fldng-hstry/fldng-hstry-popup-page.tsx +++ b/apps/sn/app/(map-layout)/map/components/fldng-hstry/fldng-hstry-popup-page.tsx @@ -14,6 +14,7 @@ import { useAlertStore } from "@/shared/lib"; import useStore from "@/entities/reference-data/model/reference-data-store"; import LoadingOverlay from "@/components/ui/loading-bar"; import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table"; +import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note"; import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel"; import { FLDNG_HSTRY_LAYER_TYPE, FLDNG_HSTRY_DEFAULT_FILTERS, FLDNG_HSTRY_LIST_HEADLIST, FLDNG_HSTRY_PAGE_SIZE, FLDNG_HSTRY_ROW_SELECT_MESSAGE, type FldngHstryApiItem, type FldngHstryFilters, type FldngHstryTableRow } from "@/entities/flood-history/model/flood-history-types"; import { attachFldngHstryGeometry, buildFldngHstryListQueryString, buildFldngHstryYearOptions, normalizeFldngHstryFilters, toFldngHstryTableRows } from "@/entities/flood-history/lib/flood-history-utils"; @@ -421,9 +422,13 @@ export default function FldngHstryPopupPage() { } /> -
- 행을 클릭하면 메인 지도에서 해당 침수흔적을 표시합니다. -
+ {/* 기존 행 클릭 안내에 인수테스트 요구(자료 원천 표시) 출처 문구를 글머리표로 더한다. */} + ); } diff --git a/apps/sn/app/(map-layout)/map/components/flood-dngr-map/flood-dngr-map-popup-page.tsx b/apps/sn/app/(map-layout)/map/components/flood-dngr-map/flood-dngr-map-popup-page.tsx index 52220d34d..8eadb6495 100644 --- a/apps/sn/app/(map-layout)/map/components/flood-dngr-map/flood-dngr-map-popup-page.tsx +++ b/apps/sn/app/(map-layout)/map/components/flood-dngr-map/flood-dngr-map-popup-page.tsx @@ -11,6 +11,7 @@ import { getData } from "@/shared/api/sewerage-api"; import LoadingOverlay from "@/components/ui/loading-bar"; import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types"; import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table"; +import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note"; import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel"; import useStore from "@/entities/reference-data/model/reference-data-store"; import { @@ -398,6 +399,11 @@ export default function FloodDngrMapPopupPage() { onRowClick={(row) => void handleRowClick(row)} rowId={(row, index) => `${row.sggCd}-${row.sbmrgFrqExpln}-${index}`} /> + + {/* 인수테스트 요구: 팝업 자료의 원천을 목록 아래에 표시한다. */} + ); } diff --git a/apps/sn/app/(map-layout)/map/components/pipe-analysis/pipe-analysis-popup-page.tsx b/apps/sn/app/(map-layout)/map/components/pipe-analysis/pipe-analysis-popup-page.tsx index c6b50038d..9570ce698 100644 --- a/apps/sn/app/(map-layout)/map/components/pipe-analysis/pipe-analysis-popup-page.tsx +++ b/apps/sn/app/(map-layout)/map/components/pipe-analysis/pipe-analysis-popup-page.tsx @@ -17,6 +17,7 @@ import { useUserStore } from "@/shared/lib/use-user-store"; import { openWindowPopup } from "@/features/window-popup/lib/open-window-popup"; import { windowPopupDefinitions } from "@/features/window-popup/model/popup-definitions"; import PipeAnalysisSummaryTable from "./pipe-analysis-summary-table"; +import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note"; import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel"; import { PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS, @@ -522,6 +523,13 @@ export default function PipeAnalysisPopupPage() { } /> + + {/* 인수테스트 요구: 관로분석의 기반 자료(원천)를 목록 아래에 표시한다. */} + ); } diff --git a/apps/sn/app/(map-layout)/map/components/sbmrg-dngr-map/sbmrg-dngr-map-popup-page.tsx b/apps/sn/app/(map-layout)/map/components/sbmrg-dngr-map/sbmrg-dngr-map-popup-page.tsx index 0d1b3d15f..6dd5edb9a 100644 --- a/apps/sn/app/(map-layout)/map/components/sbmrg-dngr-map/sbmrg-dngr-map-popup-page.tsx +++ b/apps/sn/app/(map-layout)/map/components/sbmrg-dngr-map/sbmrg-dngr-map-popup-page.tsx @@ -10,6 +10,7 @@ import { StandardPopupLayout } from "@common/business_components"; import { getRawJson } from "@/shared/api/sewerage-api"; import LoadingOverlay from "@/components/ui/loading-bar"; import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table"; +import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note"; import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel"; import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types"; import { useAlertStore } from "@/shared/lib"; @@ -309,6 +310,11 @@ export default function SbmrgDngrMapPopupPage() { ].join(":") } /> + + {/* 인수테스트 요구: 팝업 자료의 원천을 목록 아래에 표시한다. */} + ); } diff --git a/apps/sn/app/(map-layout)/map/components/sinkhole/sinkhole-popup-page.tsx b/apps/sn/app/(map-layout)/map/components/sinkhole/sinkhole-popup-page.tsx index cb360bc07..e2c16cefb 100644 --- a/apps/sn/app/(map-layout)/map/components/sinkhole/sinkhole-popup-page.tsx +++ b/apps/sn/app/(map-layout)/map/components/sinkhole/sinkhole-popup-page.tsx @@ -18,6 +18,7 @@ import { fetchAllPages } from "@/shared/lib/fetch-all-pages"; import useStore from "@/entities/reference-data/model/reference-data-store"; import LoadingOverlay from "@/components/ui/loading-bar"; import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table"; +import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note"; import PopupSearchPanel, { PopupSearchActions, PopupSearchField, @@ -514,6 +515,13 @@ export default function SinkholePopupPage() { } /> + + {/* 인수테스트 요구: 지반침하 정보의 연계 원천과 연계 주기를 목록 아래에 표시한다. */} + ); } diff --git a/apps/sn/app/(map-layout)/map/components/water-risk/water-risk-client.ts b/apps/sn/app/(map-layout)/map/components/water-risk/water-risk-client.ts index e0ec85408..42bbc8948 100644 --- a/apps/sn/app/(map-layout)/map/components/water-risk/water-risk-client.ts +++ b/apps/sn/app/(map-layout)/map/components/water-risk/water-risk-client.ts @@ -13,7 +13,19 @@ import type { export type WaterRiskResultQuery = { startDate: string; endDate: string; + /** 현재 탭 분석기준시간(정시) 정확 일치 조건. 분석시각 기간과 함께 보내지 않는다. */ analysisBaseAt?: string; + /** 과거이력 분석시각 기간 시작(yyyy-MM-ddTHH:00:00, 포함). 종료와 함께 보낸다. */ + analysisBaseFrom?: string; + /** 과거이력 분석시각 기간 종료(yyyy-MM-ddTHH:00:00, 포함). 시작과 함께 보낸다. */ + analysisBaseTo?: string; + /** + * 과거이력 강우시각(강우 시작 일시) 기간 시작(yyyy-MM-ddTHH:00:00, 포함). 종료와 함께 보낸다. + * 보내면 백엔드가 startDate·endDate보다 이 기간을 우선한다. + */ + rainStartedFrom?: string; + /** 과거이력 강우시각(강우 시작 일시) 기간 종료(yyyy-MM-ddTHH:00:00, 포함). 시작과 함께 보낸다. */ + rainStartedTo?: string; ctpvCd?: string; sggCd?: string; areaId?: string; @@ -54,6 +66,14 @@ export const fetchWaterRiskResults = async ( }); if (query.ctpvCd) params.set("ctpvCd", query.ctpvCd); if (query.analysisBaseAt) params.set("analysisBaseAt", query.analysisBaseAt); + if (query.analysisBaseFrom && query.analysisBaseTo) { + params.set("analysisBaseFrom", query.analysisBaseFrom); + params.set("analysisBaseTo", query.analysisBaseTo); + } + if (query.rainStartedFrom && query.rainStartedTo) { + params.set("rainStartedFrom", query.rainStartedFrom); + params.set("rainStartedTo", query.rainStartedTo); + } if (query.sggCd) params.set("sggCd", query.sggCd); if (query.areaId) params.set("areaId", query.areaId); if (query.riskStatus) params.set("riskStatus", query.riskStatus); @@ -78,15 +98,12 @@ export const fetchWaterRiskResults = async ( export const fetchWaterRiskMapView = async ( row: WaterRiskResultRow, ): Promise => { - if (!row.analysisDate || !row.ctpvCd || !row.sggCd) { - throw new Error("지도 조회에 필요한 분석일·행정구역 정보가 없습니다."); + // 날짜·지역이 아니라 누른 행의 SWMM 실행 ID로 조회해야 같은 날 같은 지역 결과가 여러 건이어도 같은 결과를 그린다. + if (!row.executionId) { + throw new Error("지도 조회에 필요한 SWMM 실행 ID가 없습니다."); } - const params = new URLSearchParams({ - ctpvCd: row.ctpvCd, - sggCd: row.sggCd, - }); const response = await getData( - `v1/waterRisk/results/${encodeURIComponent(row.analysisDate)}/areas/${encodeURIComponent(row.areaId)}/mapView?${params.toString()}`, + `v1/waterRisk/executions/${encodeURIComponent(row.executionId)}/mapView`, ); return { ...response, diff --git a/apps/sn/app/(map-layout)/map/components/water-risk/water-risk-date-range.ts b/apps/sn/app/(map-layout)/map/components/water-risk/water-risk-date-range.ts index 79583ccc4..ad7689bb0 100644 --- a/apps/sn/app/(map-layout)/map/components/water-risk/water-risk-date-range.ts +++ b/apps/sn/app/(map-layout)/map/components/water-risk/water-risk-date-range.ts @@ -1,4 +1,4 @@ -/** 백엔드의 양 끝 날짜 포함 최대 366일 계약을 캘린더와 조회 검증에서 함께 사용한다. */ +/** 백엔드의 양 끝 날짜 포함 최대 366일 계약을 기본값과 조회 검증에서 함께 사용한다. */ export const WATER_RISK_MAX_SEARCH_DAYS = 366; const DAY_MS = 24 * 60 * 60 * 1000; @@ -24,31 +24,82 @@ export const getWaterRiskMinStartDate = ( .slice(0, 10); }; -/** 달력 제한을 우회한 직접 입력도 API 요청 전에 검증한다. */ -export const validateWaterRiskDateRange = ( - startDate: string, - endDate: string, - today: string, +/** + * 시간 단위 검색 입력(`datetime-local`, `step=3600`)의 값 형식 `yyyy-MM-ddTHH:00`이다. + * 분은 항상 00이며, 첫 번째 그룹은 날짜(yyyy-MM-dd)다. + */ +const HOUR_VALUE_PATTERN = /^(\d{4}-\d{2}-\d{2})T\d{2}:00$/; + +/** + * `datetime-local` 입력값의 분을 00으로 맞춘다. + * 브라우저는 값의 분이 00이면 분 칸을 고정하지만, 빈 값에서 처음 입력하거나 직접 입력해 분이 들어온 경우는 + * 스스로 고치지 않으므로 저장 전에 시간 단위로 내린다. 형식이 아니면(빈 값 포함) 그대로 둔다. + */ +export const toWaterRiskHourValue = (value: string) => + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value) + ? `${value.slice(0, 13)}:00` + : value; + +/** 로컬 시각을 시간 단위 입력값(yyyy-MM-ddTHH:00)으로 내린다. 기본 종료값과 미래 시각 제한에 쓴다. */ +export const formatWaterRiskHourValue = (date: Date) => { + const pad = (value: number) => String(value).padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:00`; +}; + +/** 시간 단위 입력값을 API 정시 일시(yyyy-MM-ddTHH:00:00)로 바꾼다. 빈 값은 빈 문자열이다. */ +export const toWaterRiskApiDateTime = (value: string) => + value ? `${value}:00` : ""; + +/** + * 시간 단위 기간을 백엔드 계약(시작·종료 함께, 정시, 시작 ≤ 종료, 날짜 기준 최대 366일)과 + * 화면 조건(종료 ≤ 현재 정시)으로 검증한다. 오류가 없으면 빈 문자열을 반환한다. + * `required`가 아니면 시작·종료를 모두 비웠을 때 조건 없이 조회한다. + */ +const validateHourPeriod = ( + from: string, + to: string, + currentHourValue: string, + { label, required }: { label: string; required: boolean }, ): string => { - if (!startDate || !endDate) return "조회 시작일과 종료일을 입력해야 합니다."; - const start = parseDate(startDate); - const end = parseDate(endDate); - if (start === null || end === null) - return "올바른 조회 날짜를 입력해 주세요."; - if (endDate > today) return "조회 종료일은 오늘보다 늦을 수 없습니다."; - if (start > end) return "조회 시작일은 종료일보다 늦을 수 없습니다."; + if (!from && !to) { + return required ? `${label} 시작과 종료를 입력해야 합니다.` : ""; + } + if (!from || !to) { + return required + ? `${label} 시작과 종료를 입력해야 합니다.` + : `${label}은 시작과 종료를 모두 입력하거나 모두 비워야 합니다.`; + } + const fromMatch = HOUR_VALUE_PATTERN.exec(from); + const toMatch = HOUR_VALUE_PATTERN.exec(to); + const start = fromMatch ? parseDate(fromMatch[1]!) : null; + const end = toMatch ? parseDate(toMatch[1]!) : null; + if (start === null || end === null) return `올바른 ${label}을 입력해 주세요.`; + if (to > currentHourValue) return `${label} 종료는 현재 시각보다 늦을 수 없습니다.`; + if (from > to) return `${label} 시작은 종료보다 늦을 수 없습니다.`; if ((end - start) / DAY_MS + 1 > WATER_RISK_MAX_SEARCH_DAYS) { - return "강우 발생기간은 종료일 기준 최대 1년(366일)까지 조회할 수 있습니다."; + return `${label}은 최대 1년(366일)까지 조회할 수 있습니다.`; } return ""; }; -/** 종료일 변경으로 기존 시작일이 허용 범위를 벗어난 경우 최근 1년 시작일로 맞춘다. */ -export const adjustWaterRiskStartDate = ( - startDate: string, - endDate: string, -): string => { - const min = getWaterRiskMinStartDate(endDate); - if (!min) return startDate; - return !startDate || startDate < min || startDate > endDate ? min : startDate; -}; +/** 과거이력 강우시각(강우 시작 일시) 기간을 검증한다. 필수 조건이다. */ +export const validateWaterRiskRainStartedPeriod = ( + from: string, + to: string, + currentHourValue: string, +) => + validateHourPeriod(from, to, currentHourValue, { + label: "강우시각", + required: true, + }); + +/** 과거이력 분석시각 기간을 검증한다. 선택 조건이며 모두 비우면 적용하지 않는다. */ +export const validateWaterRiskAnalysisPeriod = ( + from: string, + to: string, + currentHourValue: string, +) => + validateHourPeriod(from, to, currentHourValue, { + label: "분석시각", + required: false, + }); diff --git a/apps/sn/app/(map-layout)/map/components/water-risk/water-risk-popup-page.tsx b/apps/sn/app/(map-layout)/map/components/water-risk/water-risk-popup-page.tsx index bb8d92730..414b08392 100644 --- a/apps/sn/app/(map-layout)/map/components/water-risk/water-risk-popup-page.tsx +++ b/apps/sn/app/(map-layout)/map/components/water-risk/water-risk-popup-page.tsx @@ -12,6 +12,7 @@ import { type WindowPopupContentProps, } from "@/features/window-popup/model/types"; import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table"; +import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note"; import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel"; import { StandardPopupLayout, @@ -21,9 +22,12 @@ import { import type { UiTableSort } from "@common/business_components/ui/table"; import { Button } from "@common/components"; import { - adjustWaterRiskStartDate, + formatWaterRiskHourValue, getWaterRiskMinStartDate, - validateWaterRiskDateRange, + toWaterRiskApiDateTime, + toWaterRiskHourValue, + validateWaterRiskAnalysisPeriod, + validateWaterRiskRainStartedPeriod, } from "./water-risk-date-range"; import { @@ -34,25 +38,42 @@ import { import { buildWaterRiskStatusLabel, EMPTY_WATER_RISK_SESSION_SNAPSHOT, + isWaterRiskDanger, type WaterRiskAreaOption, type WaterRiskResultPage, type WaterRiskResultRow, type WaterRiskSessionSnapshot, + type WaterRiskStatus, } from "./water-risk-types"; import LoadingOverlay from "@/components/ui/loading-bar"; type WaterRiskResultMode = "current" | "history"; +/** + * 과거이력 검색조건이다. 시각 값은 모두 시간 단위 입력값(yyyy-MM-ddTHH:00)이며 + * 시작·종료 정시를 모두 포함해 조회한다. 현재 탭은 중점관리지역만 이 값을 쓴다. + */ type ResultFilters = { - startDate: string; - endDate: string; ctpvCd: string; sggCd: string; areaId: string; - riskStatus: "ALL" | "DANGER" | "NORMAL"; + riskStatus: "ALL" | WaterRiskStatus; + /** 과거이력 분석시각 기간 시작. 비우면 분석시각 조건을 적용하지 않는다. */ + analysisFrom: string; + /** 과거이력 분석시각 기간 종료. 시작과 함께 입력한다. */ + analysisTo: string; + /** 과거이력 강우시각(강우 시작 일시) 기간 시작. 필수이며 기본은 최근 1년 시작일 00시다. */ + rainStartedFrom: string; + /** 과거이력 강우시각(강우 시작 일시) 기간 종료. 필수이며 기본은 현재 정시다. */ + rainStartedTo: string; }; +/** 과거이력 시각 범위 입력 키. 분석시각·강우시각 입력을 같은 모양으로 그릴 때 쓴다. */ +type HourRangeKeys = + | { fromKey: "analysisFrom"; toKey: "analysisTo" } + | { fromKey: "rainStartedFrom"; toKey: "rainStartedTo" }; + const WATER_RISK_PAGE_SIZE = 10; const EMPTY_RESULT_PAGE: WaterRiskResultPage = { items: [], @@ -69,6 +90,8 @@ type WaterRiskTableRow = Record & { sidoName: string; sggName: string; areaName: string; + /** 대표실행 분석기준시각(excn.anls_crtr_dt)을 yyyy.MM.dd HH:mm으로 표시한 값 */ + analysisBaseAt: string; rainStartedAt: string; currentAccumRainQty: string; forecastRainQty: string; @@ -81,53 +104,56 @@ const WATER_RISK_RESULT_HEADLIST: UiHeadProps[] = [ { id: "rowNo", title: "순번", - widthPercent: 6, + widthPercent: 5, sortable: true, sortKey: "rowNumber", }, { id: "sidoName", title: "시도", - widthPercent: 8, + widthPercent: 7, sortable: true, sortKey: "ctpvName", }, - { id: "sggName", title: "시군구", widthPercent: 8, sortable: true }, - { id: "areaName", title: "중점관리지역", widthPercent: 14, sortable: true }, + { id: "sggName", title: "시군구", widthPercent: 7, sortable: true }, + { id: "areaName", title: "중점관리지역", widthPercent: 12, sortable: true }, + // 대표실행 분석기준시각. 컬럼 id가 백엔드 정렬 키(analysisBaseAt)와 같다. + { id: "analysisBaseAt", title: "분석시각", widthPercent: 11, sortable: true }, { id: "rainStartedAt", - title: "강우시작시간", - widthPercent: 13, + title: "강우시작시각", + widthPercent: 11, sortable: true, }, { id: "currentAccumRainQty", title: "누적강우량(mm)", - widthPercent: 11, + widthPercent: 9, align: "right", sortable: true, }, { id: "forecastRainQty", title: "예측강우량(mm)", - widthPercent: 11, + widthPercent: 9, align: "right", sortable: true, }, { id: "maxFloodingAt", title: "최대 침수 예상시점", - widthPercent: 13, + widthPercent: 11, sortable: true, }, { id: "maxFloodingFlow", title: "최대 침수유량(m³/s)", - widthPercent: 10, + widthPercent: 9, align: "right", sortable: true, }, - { id: "riskStatus", title: "위험 여부", widthPercent: 7 }, + // "위험(매핑관로없음)" 문구가 들어가도록 기존보다 넓게 둔다. + { id: "riskStatus", title: "위험 여부", widthPercent: 9 }, ]; const formatOptionalNumber = ( @@ -155,17 +181,20 @@ const formatAnalysisBaseAt = (value?: string | null) => { return `${date.replaceAll("-", ".")} ${time.slice(0, 5)}`.trim(); }; -/** 현재 위험 조회와 검색 초기화에 사용하는 최근 1년의 기본 조회조건이다. */ +/** 최초 진입과 검색 초기화에 사용하는 기본 조회조건이다. 강우시각은 최근 1년이다. */ const buildDefaultResultFilters = (): ResultFilters => { - const endDate = new Date(); - const endDateValue = formatLocalDate(endDate); + const now = new Date(); return { - startDate: getWaterRiskMinStartDate(endDateValue)!, - endDate: endDateValue, ctpvCd: "ALL", sggCd: "ALL", areaId: "ALL", riskStatus: "ALL", + // 분석시각 기간은 선택 조건이므로 기본·초기화 상태는 비워 둔다. + analysisFrom: "", + analysisTo: "", + // 강우시각은 필수 조건이다. 365일 전 00시 ~ 현재 정시(종료일 포함 최대 366일)를 기본으로 한다. + rainStartedFrom: `${getWaterRiskMinStartDate(formatLocalDate(now))!}T00:00`, + rainStartedTo: formatWaterRiskHourValue(now), }; }; @@ -178,12 +207,16 @@ const postMessageToOpener = ( window.opener.postMessage({ type, payload }, window.location.origin); }; -/** 위험 여부에 맞는 기존 팝업 badge 스타일을 반환한다. */ -const getRiskBadgeClassName = (riskStatus: "DANGER" | "NORMAL") => - riskStatus === "DANGER" ? "sn-popup-badge-danger" : "sn-popup-badge-normal"; +/** 위험 여부에 맞는 기존 팝업 badge 스타일을 반환한다. 위험(매핑관로없음)도 위험 배지를 쓴다. */ +const getRiskBadgeClassName = (riskStatus: WaterRiskStatus) => + isWaterRiskDanger(riskStatus) ? "sn-popup-badge-danger" : "sn-popup-badge-normal"; -/** 동일 날짜·지역에 여러 강우이벤트가 있어도 실행별 행 키가 겹치지 않게 식별한다. */ +/** + * 목록 행을 대표 SWMM 실행 ID로 식별한다. 행 하나가 실행 하나이므로 같은 날·지역 행도 겹치지 않는다. + * 실행 ID가 없는 응답(구 BE)에서는 기존 날짜·시각·지역 조합으로 대체한다. + */ const buildResultRowKey = (row: WaterRiskResultRow) => + row.executionId || [ row.analysisDate ?? "", row.rainStartedAt ?? "", @@ -195,10 +228,10 @@ const buildResultRowKey = (row: WaterRiskResultRow) => /** 실제 통수위험 API를 조회하고 선택 결과를 부모 지도에 전달하는 팝업이다. */ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { - // 최초 진입과 초기화 모두 오늘을 종료일로 하는 최근 1년을 사용한다. + // 최초 진입과 초기화 모두 현재 정시를 종료로 하는 최근 1년 강우시각을 사용한다. const initialFilters = useMemo(() => buildDefaultResultFilters(), []); - // 과거분석은 오늘까지 선택 가능하며, 시작일의 범위는 선택 종료일에 따라 달라진다. - const today = formatLocalDate(new Date()); + // 과거이력 시각 입력은 현재 정시까지 고를 수 있다. 렌더링마다 다시 계산해 정시가 바뀌면 따라간다. + const currentHourValue = formatWaterRiskHourValue(new Date()); const [resultMode, setResultMode] = useState("current"); const [draftResultFilters, setDraftResultFilters] = useState(initialFilters); @@ -241,15 +274,38 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { let active = true; setIsLoading(true); setErrorMessage(""); - // 과거 이력의 고정 기본기간이 현재 진행 중인 강우 조회를 제한하지 않게 분리한다. - const dateFilters = resultMode === "current" - ? buildDefaultResultFilters() - : appliedResultFilters; + const isHistory = resultMode === "history"; + // 현재 탭은 진행 중 강우를 오늘까지 최근 1년 강우시작일로 찾고, 과거이력의 적용 기간이 이를 제한하지 않게 분리한다. + // 과거이력은 적용 시 검증을 통과한 강우시각 기간을 보내며, 강우시작일도 그 날짜로 맞춘다. + const requestToday = formatLocalDate(new Date()); + const startDate = isHistory + ? appliedResultFilters.rainStartedFrom.slice(0, 10) + : getWaterRiskMinStartDate(requestToday)!; + const endDate = isHistory + ? appliedResultFilters.rainStartedTo.slice(0, 10) + : requestToday; + // 과거이력의 분석시각 기간은 적용 시 검증을 통과한 값이며, 비어 있으면 조건 없이 조회한다. + const analysisBaseFrom = isHistory + ? toWaterRiskApiDateTime(appliedResultFilters.analysisFrom) + : ""; + const analysisBaseTo = isHistory + ? toWaterRiskApiDateTime(appliedResultFilters.analysisTo) + : ""; + const rainStartedFrom = isHistory + ? toWaterRiskApiDateTime(appliedResultFilters.rainStartedFrom) + : ""; + const rainStartedTo = isHistory + ? toWaterRiskApiDateTime(appliedResultFilters.rainStartedTo) + : ""; fetchWaterRiskResults({ - startDate: dateFilters.startDate, - endDate: dateFilters.endDate, + startDate, + endDate, analysisBaseAt: resultMode === "current" ? currentAnalysisBaseAt : undefined, + analysisBaseFrom: analysisBaseFrom || undefined, + analysisBaseTo: analysisBaseTo || undefined, + rainStartedFrom: rainStartedFrom || undefined, + rainStartedTo: rainStartedTo || undefined, ctpvCd: resultMode === "current" || appliedResultFilters.ctpvCd === "ALL" ? undefined @@ -313,9 +369,14 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { 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, - ); + // 지도에 표시 중인 실행 ID가 있으면 그 행만 선택한다. 같은 지역 첫 행으로 바꾸면 다른 결과가 선택돼 보인다. + const matchingRow = nextSnapshot.selectedExecutionId + ? resultPage.items.find( + (row) => row.executionId === nextSnapshot.selectedExecutionId, + ) + : resultPage.items.find( + (row) => row.areaId === nextSnapshot.selectedAreaId, + ); if (matchingRow) setSelectedResultKey(buildResultRowKey(matchingRow)); } }; @@ -418,6 +479,7 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { sidoName: row.sidoName || "-", sggName: row.sggName || "-", areaName: row.areaName, + analysisBaseAt: formatAnalysisBaseAt(row.analysisBaseAt), rainStartedAt: formatAnalysisBaseAt(row.rainStartedAt), currentAccumRainQty: formatOptionalNumber(row.currentAccumRainQty, 1), forecastRainQty: formatOptionalNumber(row.forecastRainQty, 1), @@ -432,11 +494,25 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { /** 검증된 검색조건을 적용하고 첫 페이지를 새로 조회한다. */ const handleApplyResultFilters = () => { if (resultMode === "history") { - const dateError = validateWaterRiskDateRange( - draftResultFilters.startDate, draftResultFilters.endDate, today, + const nowHourValue = formatWaterRiskHourValue(new Date()); + // 분석시각 기간은 선택 조건이다. 입력한 경우에만 시작·종료 짝, 순서, 기간, 현재 정시 이하를 확인한다. + const analysisPeriodError = validateWaterRiskAnalysisPeriod( + draftResultFilters.analysisFrom, + draftResultFilters.analysisTo, + nowHourValue, ); - if (dateError) { - setErrorMessage(dateError); + if (analysisPeriodError) { + setErrorMessage(analysisPeriodError); + return; + } + // 강우시각 기간은 필수 조건이다. 시작·종료 짝, 순서, 최대 366일, 현재 정시 이하를 확인한다. + const rainStartedPeriodError = validateWaterRiskRainStartedPeriod( + draftResultFilters.rainStartedFrom, + draftResultFilters.rainStartedTo, + nowHourValue, + ); + if (rainStartedPeriodError) { + setErrorMessage(rainStartedPeriodError); return; } } @@ -496,6 +572,44 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { } }; + /** + * 침수예측현황과 같은 형태(라벨 + 시작 `~` 종료 `datetime-local`)로 과거이력 시각 범위를 그린다. + * `step=3600`이라 값의 분이 00이면 브라우저가 분 칸을 고정하고, 분이 들어온 경우는 저장 전에 00으로 내린다. + * 현재 정시 이후는 고를 수 없게 `max`만 둔다(`min`은 step 기준점을 바꾸므로 두지 않는다). + */ + const renderHourRange = (label: string, { fromKey, toKey }: HourRangeKeys) => ( +
+
+ {label} +
+ { + const nextValue = toWaterRiskHourValue(event.target.value); + setDraftResultFilters((current) => ({ ...current, [fromKey]: nextValue })); + }} + aria-label={`${label} 시작`} + className="h-[3.2rem] w-[17rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring" + /> + ~ + { + const nextValue = toWaterRiskHourValue(event.target.value); + setDraftResultFilters((current) => ({ ...current, [toKey]: nextValue })); + }} + aria-label={`${label} 종료`} + className="h-[3.2rem] w-[17rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring" + /> +
+ ); + return (
+ {/* 과거이력 검색조건 순서: 시도 → 시군구 → 중점관리지역 → 분석시각 → 강우시각 → 위험상태 */} {resultMode === "history" ? ( <> - -
diff --git a/apps/sn/app/(map-layout)/map/components/water-risk/water-risk-types.ts b/apps/sn/app/(map-layout)/map/components/water-risk/water-risk-types.ts index 00bf70dff..7444b7831 100644 --- a/apps/sn/app/(map-layout)/map/components/water-risk/water-risk-types.ts +++ b/apps/sn/app/(map-layout)/map/components/water-risk/water-risk-types.ts @@ -1,7 +1,13 @@ /** 통수위험 분석 화면에서 사용하는 타입·상수와 표시값 변환을 정의한다. */ "use client"; -export type WaterRiskStatus = "DANGER" | "NORMAL"; +/** + * 백엔드 위험상태 코드이다. + * DANGER: 유효 GIS 매핑 관로 중 Capacity 기준 이상 관로가 있음. + * DANGER_UNMAPPED: 매핑 위험 관로는 없고, 유효 GIS 매핑이 없는 관로만 기준 이상임. + * NORMAL: 기준 이상 관로가 없음. + */ +export type WaterRiskStatus = "DANGER" | "DANGER_UNMAPPED" | "NORMAL"; export type WaterRiskCalculationMethod = "SWMM"; /** 대표실행에서 capacity가 가장 큰 관로의 실제 SWMM 결과이다. */ @@ -14,6 +20,9 @@ export type WaterRiskPipeSummary = { /** 강우이벤트 대표실행 한 건을 팝업 목록에 표시하는 모델이다. */ export type WaterRiskResultRow = { + /** 대표 SWMM 실행 ID(UUID). 목록 행 식별과 지도 조회(executions/{executionId}/mapView) 키다. */ + executionId: string; + /** 강우시작일(yyyy-MM-dd). 분석시각(analysisBaseAt)과 다른 값이다. */ analysisDate?: string; analysisBaseAt?: string | null; rainStartedAt?: string | null; @@ -36,14 +45,25 @@ export type WaterRiskResultRow = { /** 부모 지도와 별도 팝업 사이에서 공유하는 최소 선택 상태이다. */ export type WaterRiskSessionSnapshot = { selectedAreaId: string | null; + /** 지도에 표시 중인 SWMM 실행 ID. 같은 지역 행이 여러 개여도 팝업이 정확한 행을 다시 선택하게 한다. */ + selectedExecutionId: string | null; }; export const EMPTY_WATER_RISK_SESSION_SNAPSHOT: WaterRiskSessionSnapshot = { selectedAreaId: null, + selectedExecutionId: null, }; -export const buildWaterRiskStatusLabel = (status: WaterRiskStatus) => - status === "DANGER" ? "위험" : "정상"; +/** 위험상태 코드를 목록 배지·지도 라벨·정보카드에 쓰는 화면 문구로 바꾼다. */ +export const buildWaterRiskStatusLabel = (status: WaterRiskStatus) => { + if (status === "DANGER") return "위험"; + if (status === "DANGER_UNMAPPED") return "위험(매핑관로없음)"; + return "정상"; +}; + +/** 배지·지도 색을 위험 계열(빨강)로 표시할 상태인지 판별한다. 매핑관로없음도 위험 색을 쓴다. */ +export const isWaterRiskDanger = (status: WaterRiskStatus | null | undefined) => + status === "DANGER" || status === "DANGER_UNMAPPED"; export type WaterRiskAreaOption = { areaId: string; @@ -70,7 +90,14 @@ export type WaterRiskGeoJsonGeometry = { /** 선택한 대표실행의 실제 중점관리지역 및 관로 지도 결과이다. */ export type WaterRiskMapView = { + /** 지도에 표시한 SWMM 실행 ID. 요청한 목록 행의 executionId와 같다. */ + executionId?: string; + /** 강우시작일(yyyy-MM-dd) */ analysisDate: string; + /** 분석기준시각 */ + analysisBaseAt?: string | null; + /** 강우시작시각 */ + rainStartedAt?: string | null; areaId: string; areaName: string; ctpvCd: string; diff --git a/apps/sn/app/(map-layout)/map/lib/use-water-risk-layer.ts b/apps/sn/app/(map-layout)/map/lib/use-water-risk-layer.ts index 808f661dc..1dda666a1 100644 --- a/apps/sn/app/(map-layout)/map/lib/use-water-risk-layer.ts +++ b/apps/sn/app/(map-layout)/map/lib/use-water-risk-layer.ts @@ -101,7 +101,8 @@ export function useWaterRiskLayer({ [selectedWaterRiskApiResult], ); - // 지도카드는 실제 GIS geometry까지 결합된 mapView 위험상태를 목록 상태보다 우선한다. + // 지도카드는 mapView 위험상태를 우선하고, mapView 응답 전에는 목록 상태를 쓴다. + // 2026-10-20부터 mapView 위험상태는 목록과 같은 대표결과 판정값이므로 두 값이 다르지 않다. const selectedWaterRiskDisplayStatus = useMemo< WaterRiskResultRow["riskStatus"] >( @@ -419,11 +420,20 @@ export function useWaterRiskLayer({ | undefined; const mapView = data.payload?.mapView as WaterRiskMapView | undefined; if (!resultRow || !mapView || mapView.areaId !== areaId) return; + // 지도 응답이 누른 행과 다른 실행이면 표시하지 않는다(실행 ID 기준 조회의 안전장치). + if ( + mapView.executionId && + resultRow.executionId && + mapView.executionId !== resultRow.executionId + ) { + return; + } setSelectedWaterRiskApiResult(resultRow); setSelectedWaterRiskMapView(mapView); setWaterRiskSession((current) => ({ ...current, selectedAreaId: areaId, + selectedExecutionId: resultRow.executionId || null, })); onSelectResult(); return; diff --git a/apps/sn/app/(map-layout)/map/lib/water-risk-styles.ts b/apps/sn/app/(map-layout)/map/lib/water-risk-styles.ts index d5e97d841..fc2b18f55 100644 --- a/apps/sn/app/(map-layout)/map/lib/water-risk-styles.ts +++ b/apps/sn/app/(map-layout)/map/lib/water-risk-styles.ts @@ -4,19 +4,21 @@ import CircleStyle from "ol/style/Circle.js"; import Stroke from "ol/style/Stroke.js"; import Style from "ol/style/Style.js"; import Text from "ol/style/Text.js"; -import { buildWaterRiskStatusLabel } from "@/app/(map-layout)/map/components/water-risk/water-risk-types"; +import { + buildWaterRiskStatusLabel, + isWaterRiskDanger, + type WaterRiskStatus, +} from "@/app/(map-layout)/map/components/water-risk/water-risk-types"; -export const buildWaterRiskStatusFillColor = ( - riskStatus: "DANGER" | "NORMAL", -) => - riskStatus === "DANGER" +/** 위험·위험(매핑관로없음)은 빨강, 정상은 파랑 반투명으로 중점관리지역 면을 채운다(화면설계서 21쪽). */ +export const buildWaterRiskStatusFillColor = (riskStatus: WaterRiskStatus) => + isWaterRiskDanger(riskStatus) ? "rgba(198, 40, 40, 0.24)" : "rgba(21, 101, 192, 0.2)"; -export const buildWaterRiskStatusStrokeColor = ( - riskStatus: "DANGER" | "NORMAL", -) => - riskStatus === "DANGER" +/** 중점관리지역 경계선·라벨 배경색. 위험 계열 판정은 면 색과 같다. */ +export const buildWaterRiskStatusStrokeColor = (riskStatus: WaterRiskStatus) => + isWaterRiskDanger(riskStatus) ? "rgba(198, 40, 40, 0.92)" : "rgba(21, 101, 192, 0.92)"; @@ -31,9 +33,9 @@ export const buildWaterRiskCapacityColor = (capacityRatio: number) => { export const createWaterRiskAreaStyle = (feature: any) => { const areaName = String(feature.get("areaName") ?? ""); - const riskStatus = String(feature.get("riskStatus") ?? "NORMAL") as - | "DANGER" - | "NORMAL"; + const riskStatus = String( + feature.get("riskStatus") ?? "NORMAL", + ) as WaterRiskStatus; return new Style({ fill: new Fill({ color: buildWaterRiskStatusFillColor(riskStatus) }), stroke: new Stroke({ diff --git a/apps/sn/app/(map-layout)/map/ui/map-page-client.tsx b/apps/sn/app/(map-layout)/map/ui/map-page-client.tsx index b801bb665..b1350fd7d 100644 --- a/apps/sn/app/(map-layout)/map/ui/map-page-client.tsx +++ b/apps/sn/app/(map-layout)/map/ui/map-page-client.tsx @@ -182,7 +182,10 @@ import { WATER_DEPTH_LAYER_TYPES, extractWaterDepthLegendItems, } from "../water-depth/water-depth"; -import { buildWaterRiskStatusLabel } from "../components/water-risk/water-risk-types"; +import { + buildWaterRiskStatusLabel, + isWaterRiskDanger, +} from "../components/water-risk/water-risk-types"; import GisToolButtons from "../components/gis-tools/gis-tool-buttons"; @@ -3152,7 +3155,8 @@ const MapPageClient = () => { 종료로 강우시각을 넣어 바뀐 오류 문구 확인, 조회 요청의 시각 파라미터 끝 두 자리가 `00`인지 확인, Excel 다운로드 머리글 확인. `tsc --noEmit`·`git diff --check`. +- 영향: 침수예측현황 팝업의 검색 입력 4개, 오류 문구, Excel 머리글 1칸. API·DB·지도 영향 없음. +- 롤백: 위 2개 파일을 되돌린다. + +### 구현 결과 (2026-10-21) + +- 계획 1~3을 그대로 반영했다. `ui/fldng-predc-prst-popup-content.tsx`: 오류 문구 `강우시각의 시작은 종료보다 늦을 수 없습니다.`, 분석시각·강우시각 입력 4개에 `step={3600}`과 분 00 고정(`toHourInputValue`). `lib/fldng-predc-prst-excel.ts`: Excel 머리글 `강우시작시각`. +- 검증: `tsc --noEmit`(sn 전체) 통과, `git diff --check` 통과. ESLint는 수정 전과 같은 1건(기존 미사용 import `createPredictionAnalysisTimeRange`, 이번 범위 밖). 로그인이 필요해 실제 팝업 화면과 Excel 다운로드는 미확인. +- BE·API·DB 변경 없음. diff --git a/apps/sn/docs/작업계획/SN-인수테스트-팝업자료출처표시/001-fe-SN-팝업자료출처표시-작업결과.md b/apps/sn/docs/작업계획/SN-인수테스트-팝업자료출처표시/001-fe-SN-팝업자료출처표시-작업결과.md new file mode 100644 index 000000000..99d59785b --- /dev/null +++ b/apps/sn/docs/작업계획/SN-인수테스트-팝업자료출처표시/001-fe-SN-팝업자료출처표시-작업결과.md @@ -0,0 +1,44 @@ +# SN 인수테스트 팝업 자료 출처 표시 FE 작업 결과 + +## 1. 작업 목적 + +- 인수테스트(2026-10-16) 고객 요구: "팝업화면 자료의 원천 표시 및 자료 업데이트 주기 표시". +- 대상 팝업 7개(침수예측, 침수흔적도, 지반침하정보, 통수위험, 침수위험지도, 도시침수지도, 관로분석)의 목록 아래에 자료 출처·분석 주기 문구를 넣는다. +- 기존 하단 설명이 있는 팝업은 그 설명을 유지하고 글머리표로 문구를 더한다(사용자 지시 2026-10-21). + +## 2. 근거 + +- `인수테스트_요구사항_1016.pptx` 1쪽(공통 요청), 2~8쪽(화면별 문구). 원본 파일은 사용자 공유 드라이브에 있으며 저장소에는 넣지 않았다. +- 문구는 슬라이드 내용을 그대로 옮겼다. 줄 끝 쉼표 제거, `출처 :` 띄어쓰기 통일, 문장 끝 마침표 정도만 맞췄다. + +## 3. 화면별 반영 내용 + +| 슬라이드 | 팝업 | 수정 파일 | 하단 글머리표 | +| --- | --- | --- | --- | +| 2 | 침수예측현황 | `src/features/fldng-predc-prst/ui/fldng-predc-prst-popup-content.tsx` | (기존) 빈도별 위험·경계·주의 기준, (기존) 행 선택 안내, 분석 실행 시기, 분석 방법(확률강우량 출처 map.wamis.go.kr, 도시침수지도 출처 한강홍수통제소) | +| 3 | 통수위험 | `app/(map-layout)/map/components/water-risk/water-risk-popup-page.tsx` | 판정 기준(위험·위험(매핑관로없음)·정상), 지도에 빨간 관로가 안 보일 수 있는 경우, 분석 실행 시기, 분석 방법(SWMM), 최대 침수예상시점·최대침수유량 설명 | +| 4 | 침수발생이력(침수흔적도) | `app/(map-layout)/map/components/fldng-hstry/fldng-hstry-popup-page.tsx` | (기존) 행 클릭 안내, 출처 : 행정안전부 재난보험과(2002년~2025년) | +| 5 | 도시침수지도 목록 | `app/(map-layout)/map/components/flood-dngr-map/flood-dngr-map-popup-page.tsx` | 출처 : 한강홍수통제소, 행정구역별 도시침수지도(2025.09) | +| 6 | 침수위험지도 목록 | `app/(map-layout)/map/components/sbmrg-dngr-map/sbmrg-dngr-map-popup-page.tsx` | 출처 : 하수도정비 중점관리지역 20개소 과업 산출물 | +| 7 | 지반침하 목록 | `app/(map-layout)/map/components/sinkhole/sinkhole-popup-page.tsx` | 출처 : 지하안전정보시스템 지반침하사고 정보 연계 표출(연계 주기 : 매 시간 연계) | +| 8 | 관로분석 | `app/(map-layout)/map/components/pipe-analysis/pipe-analysis-popup-page.tsx` | 국토교통부로부터 제공받은 GIS 관로정보(2025년)를 기반으로 분석하고 있음 | + +- 공통 표시 컴포넌트 `src/features/window-popup/ui/popup-footer-note.tsx`(신규): 침수예측현황 하단 안내와 같은 글꼴(`text-xl text-muted-foreground`)의 글머리표 목록. 7개 팝업이 같은 모양을 쓴다. +- 통수위험: 판정 기준 문구를 목록 머리글 오른쪽에서 하단으로 옮겼다(슬라이드 3 "침수예측과 동일한 위치에 표시할것"). +- 침수발생이력: 기존 회색 상자 안내를 공통 글머리표 모양으로 바꿨다(문구 유지). + +## 4. 검증 + +- `tsc --noEmit`(sn 전체) 통과. +- ESLint: 변경 8개 파일 모두 수정 전과 같은 결과(침수예측현황의 기존 미사용 import `createPredictionAnalysisTimeRange` 1건 외 0건). +- `git diff --check` 통과. +- 미실행: 로그인이 필요해 실제 팝업 화면 확인 못 함. `next build` 미실행. + +## 5. 영향·롤백·남은 위험 + +- 영향: 7개 팝업의 목록 아래 문구 표시만 바뀐다. API·상태·데이터 변경 없음. +- 롤백: 위 7개 파일을 되돌리고 `popup-footer-note.tsx`를 삭제한다. +- 남은 위험 + - 팝업 높이가 낮으면 하단 문구가 길어(통수위험 5줄) 목록 영역이 그만큼 줄어든다. 화면 확인 후 필요하면 글꼴·간격을 조정한다. + - 슬라이드 문구 중 "관측강우가 0mm이상으로 강우가 발생시"는 고객 문구 그대로 두었다. 의미상 "0mm 초과"인지 고객 확인이 필요할 수 있다. + - 슬라이드 4의 "25년까지 데이터가 있는데 자동으로 26년으로 설정되는 문제"(지도 레이어 침수흔적도 연도 기본값)는 이번 범위(하단 문구)가 아니라 수정하지 않았다. diff --git a/apps/sn/src/entities/flood-history/lib/flood-history-utils.ts b/apps/sn/src/entities/flood-history/lib/flood-history-utils.ts index 0e361c0d5..d3c18b0c0 100644 --- a/apps/sn/src/entities/flood-history/lib/flood-history-utils.ts +++ b/apps/sn/src/entities/flood-history/lib/flood-history-utils.ts @@ -9,6 +9,7 @@ import { FLDNG_HSTRY_LAYER_NAME, FLDNG_HSTRY_LAYER_TYPE, FLDNG_HSTRY_MIN_YEAR, + FLDNG_HSTRY_MAX_YEAR, FLDNG_HSTRY_PREVIEW_LAYER_TYPE, FLDNG_HSTRY_ROOT_NAME, type FldngHstryApiItem, @@ -50,11 +51,10 @@ export const formatFldngHstryDateTime = ( return [normalizedDate, normalizedTime].filter(Boolean).join(" "); }; -export const buildFldngHstryDefaultYearRange = ( - currentYear = new Date().getFullYear(), -): FldngHstryYearRange => ({ +/** 침수흔적도 초기 조회 범위를 제공 자료의 연도 범위로 설정한다. */ +export const buildFldngHstryDefaultYearRange = (): FldngHstryYearRange => ({ startYear: String(FLDNG_HSTRY_MIN_YEAR), - endYear: String(currentYear), + endYear: String(FLDNG_HSTRY_MAX_YEAR), }); export const normalizeFldngHstryYearRange = ( diff --git a/apps/sn/src/entities/flood-history/model/flood-history-types.ts b/apps/sn/src/entities/flood-history/model/flood-history-types.ts index cc156c004..ab550c15b 100644 --- a/apps/sn/src/entities/flood-history/model/flood-history-types.ts +++ b/apps/sn/src/entities/flood-history/model/flood-history-types.ts @@ -14,6 +14,8 @@ export const FLDNG_HSTRY_DEPTH_LAYER_ID = "SN_FLOOD_HISTORY_DEPTH"; export const FLDNG_HSTRY_PAGE_SIZE = 10; export const FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE = 2000; export const FLDNG_HSTRY_MIN_YEAR = 2002; +/** 침수흔적도 연도 선택 목록과 초기 조회 범위의 최대 연도다. */ +export const FLDNG_HSTRY_MAX_YEAR = 2025; export type FldngHstryDetailCacheValue = { rainAnalysis: Record; diff --git a/apps/sn/src/features/fldng-predc-prst/lib/fldng-predc-prst-excel.ts b/apps/sn/src/features/fldng-predc-prst/lib/fldng-predc-prst-excel.ts index 4408e8e07..5535ff05a 100644 --- a/apps/sn/src/features/fldng-predc-prst/lib/fldng-predc-prst-excel.ts +++ b/apps/sn/src/features/fldng-predc-prst/lib/fldng-predc-prst-excel.ts @@ -17,7 +17,7 @@ const HEADERS: Record = { ctpvNm: "시도", sggNm: "시군구", analysisDateTime: "분석시각", - rainBeginningDateTime: "강우 시작시각", + rainBeginningDateTime: "강우시작시각", observedAccumulatedRainfall: "관측 누적강수량(mm)", predictionHour: "예측시간", predictedAccumulatedRainfall: "예측 누적강수량(mm)", diff --git a/apps/sn/src/features/fldng-predc-prst/ui/fldng-predc-prst-popup-content.tsx b/apps/sn/src/features/fldng-predc-prst/ui/fldng-predc-prst-popup-content.tsx index 5f213234a..d2d690276 100644 --- a/apps/sn/src/features/fldng-predc-prst/ui/fldng-predc-prst-popup-content.tsx +++ b/apps/sn/src/features/fldng-predc-prst/ui/fldng-predc-prst-popup-content.tsx @@ -14,6 +14,7 @@ import LoadingOverlay from "@/components/ui/loading-bar"; import { windowPopupDefinitions } from "@/features/window-popup/model/popup-definitions"; import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types"; import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table"; +import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note"; import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel"; import { useAlertStore } from "@/shared/lib"; import useStore from "@/entities/reference-data/model/reference-data-store"; @@ -135,6 +136,16 @@ const buildInitialFilters = (): Filters => ({ const apiDate = (value: string) => value.replace(/[^0-9]/g, "").slice(0, 12); +/** + * 검색 시각 입력(`datetime-local`, `step=3600`)의 분을 00으로 맞춘다. + * 브라우저는 값의 분이 00이면 분 칸을 고정하지만, 빈 값에서 처음 입력하거나 직접 입력해 분이 들어온 경우는 + * 스스로 고치지 않으므로 저장 전에 시간 단위로 내린다. 형식이 아니면(빈 값 포함) 그대로 둔다. + */ +const toHourInputValue = (value: string) => + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value) + ? `${value.slice(0, 13)}:00` + : value; + 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)}` @@ -184,7 +195,7 @@ const validateFilters = (filters: Filters) => { filters.rainBgngTo && filters.rainBgngFrom > filters.rainBgngTo ) { - return "강우 시작시각의 시작은 종료보다 늦을 수 없습니다."; + return "강우시각의 시작은 종료보다 늦을 수 없습니다."; } if ( filters.analysisFrom && @@ -477,6 +488,11 @@ export default function FldngPredcPrstPopupContent() { } }; + /** + * 분석시각·강우시각 범위를 시간 단위로만 고르게 그린다. + * `step=3600`이라 값의 분이 00이면 브라우저가 분 칸을 고정해 바꿀 수 없고, 분이 들어온 경우는 저장 전에 00으로 내린다. + * 침수예측 자료의 분석·강우시작 시각은 정시 단위이므로 시간 단위 조건으로 빠지는 자료는 없다. + */ const renderDateTimeRange = ( label: string, fromKey: "rainBgngFrom" | "analysisFrom", @@ -489,27 +505,31 @@ export default function FldngPredcPrstPopupContent() { + onChange={(event) => { + const nextValue = toHourInputValue(event.target.value); setFilters((current) => ({ ...current, - [fromKey]: event.target.value, - })) - } + [fromKey]: nextValue, + })); + }} /> ~ + onChange={(event) => { + const nextValue = toHourInputValue(event.target.value); setFilters((current) => ({ ...current, - [toKey]: event.target.value, - })) - } + [toKey]: nextValue, + })); + }} /> ); @@ -667,17 +687,22 @@ export default function FldngPredcPrstPopupContent() { } /> -
-

- 30년빈도 이상인 경우{" "} - 위험, 10년빈도 - 이상인 경우{" "} - 경계, 2년빈도 - 이상인 경우{" "} - 주의 -

-

행을 선택하면 해당 시군구·빈도의 도시침수지도로 이동합니다.

-
+ {/* 판정 기준·행 선택 안내에 인수테스트 요구(자료 원천·분석 주기 표시) 문구를 글머리표로 더한다. */} + + 30년빈도 이상인 경우{" "} + 위험, + 10년빈도 이상인 경우{" "} + 경계, + 2년빈도 이상인 경우{" "} + 주의 + , + "행을 선택하면 해당 시군구·빈도의 도시침수지도로 이동합니다.", + "분석 실행 시기 : 침수예측은 관측강우가 10mm이상으로 강우가 발생시 1시간 주기로 분석 자동실행", + "분석 방법 : 기상청 관측강우와 향후 6시간 예측강우를 누적하여 지속시간별 빈도별 확률강우량(출처 : map.wamis.go.kr)과 근접한 강우량을 찾아 해당되는 빈도의 도시침수지도(출처 : 한강홍수통제소)를 표출", + ]} + /> + {items.map((item, index) => ( + // 문구 목록은 화면마다 고정 순서라 순번을 키로 쓴다. +
  • {item}
  • + ))} + + ); +} + +export type { PopupFooterNoteProps };