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" ? (
<>
-
-