sn: 통수위험 위험여부란 수정 외

This commit is contained in:
2026-10-22 09:33:27 +09:00
parent 79f70d8b4b
commit 9e71f529e7
21 changed files with 630 additions and 189 deletions
@@ -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<string, unknown> & {
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<WaterRiskResultMode>("current");
const [draftResultFilters, setDraftResultFilters] =
useState<ResultFilters>(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) => (
<div className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
{label}
</div>
<input
type="datetime-local"
step={3600}
max={currentHourValue}
value={draftResultFilters[fromKey]}
onChange={(event) => {
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"
/>
<span className="text-xl font-medium text-label">~</span>
<input
type="datetime-local"
step={3600}
max={currentHourValue}
value={draftResultFilters[toKey]}
onChange={(event) => {
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"
/>
</div>
);
return (
<StandardPopupLayout title={windowPopupDefinitions.flowRiskPopup.title}>
<section
@@ -529,45 +643,9 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
</div>
<PopupSearchPanel contentClassName="flex flex-wrap items-center gap-x-5 gap-y-3">
{/* 과거이력 검색조건 순서: 시도 → 시군구 → 중점관리지역 → 분석시각 → 강우시각 → 위험상태 */}
{resultMode === "history" ? (
<>
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
강우 발생기간
</div>
<span className="flex shrink-0 items-center gap-2">
<input
type="date"
value={draftResultFilters.startDate}
min={getWaterRiskMinStartDate(draftResultFilters.endDate)}
max={draftResultFilters.endDate || today}
onChange={(event) =>
setDraftResultFilters((current) => ({
...current,
startDate: event.target.value,
}))
}
aria-label="조회 시작일"
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
/>
<span className="text-xl font-medium text-label">~</span>
<input
type="date"
value={draftResultFilters.endDate}
max={today}
onChange={(event) =>
setDraftResultFilters((current) => ({
...current,
startDate: adjustWaterRiskStartDate(current.startDate, event.target.value),
endDate: event.target.value,
}))
}
aria-label="조회 종료일"
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
/>
</span>
</label>
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
시도
@@ -652,27 +730,39 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
</label>
{resultMode === "history" ? (
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
위험상태
</div>
<select
value={draftResultFilters.riskStatus}
onChange={(event) =>
setDraftResultFilters((current) => ({
...current,
riskStatus: event.target
.value as ResultFilters["riskStatus"],
}))
}
aria-label="위험상태"
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
>
<option value="ALL">전체</option>
<option value="DANGER">위험</option>
<option value="NORMAL">정상</option>
</select>
</label>
<>
{/* 분석시각(선택)·강우시각(필수): 시간 단위로만 고르며 시작·종료 정시를 모두 포함해 조회한다. */}
{renderHourRange("분석시각", {
fromKey: "analysisFrom",
toKey: "analysisTo",
})}
{renderHourRange("강우시각", {
fromKey: "rainStartedFrom",
toKey: "rainStartedTo",
})}
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
위험상태
</div>
<select
value={draftResultFilters.riskStatus}
onChange={(event) =>
setDraftResultFilters((current) => ({
...current,
riskStatus: event.target
.value as ResultFilters["riskStatus"],
}))
}
aria-label="위험상태"
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
>
<option value="ALL">전체</option>
<option value="DANGER">위험</option>
<option value="DANGER_UNMAPPED">위험(매핑관로없음)</option>
<option value="NORMAL">정상</option>
</select>
</label>
</>
) : null}
<div className="flex min-w-fit flex-1 items-center justify-end gap-2">
@@ -722,25 +812,38 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
renderCell={(row, headId) =>
headId === "riskStatus" ? (
<span
className={`sn-popup-badge min-w-[5.6rem] ${getRiskBadgeClassName(row.riskStatus)}`}
className={`sn-popup-badge min-w-[5.6rem] whitespace-nowrap ${getRiskBadgeClassName(row.riskStatus)}`}
>
{buildWaterRiskStatusLabel(row.riskStatus)}
</span>
) : undefined
}
scrollMode="horizontal"
minTableWidth="124rem"
// 분석시각 컬럼 추가로 기존 124rem보다 넓힌다.
minTableWidth="136rem"
emptyMessage="조회된 결과가 없습니다."
sort={resultSort}
onSortChange={handleSortChange}
actions={
<span className="text-xl font-normal text-muted-foreground">
SWMM 시뮬레이션 기반 통수위험 분석 결과 (GIS 매핑 관로 중
Capacity 0.8 이상이 1개라도 있으면{" "}
<span className="font-bold text-[#ff1c15]">위험</span>, 없으면{" "}
<span className="font-bold text-[#1677ff]">정상</span>)
</span>
}
/>
{/*
인수테스트 요구: 판정 기준을 목록 머리글 오른쪽에서 침수예측현황과 같은 하단 위치로 옮기고,
분석 원천·실행 주기·침수 항목 설명을 글머리표로 함께 표시한다.
*/}
<PopupFooterNote
items={[
<>
분석 결과 관로의 Capacity(통수능) 값이 0.8 이상이 1개라도 있으면{" "}
<span className="font-bold text-[#ff1c15]">위험</span>, 매핑되지
않은 관로만 0.8 이상이면{" "}
<span className="font-bold text-[#ff1c15]">위험(매핑관로없음)</span>,
없으면 <span className="font-bold text-[#1677ff]">정상</span>
</>,
"통수능이 위험에 해당하지만 지도화면에 해당 관로를 매핑할 수 없을 때, 빨간색 관로는 보이지 않을 수 있습니다.",
"분석 실행 시기 : 통수위험은 관측강우가 0mm이상으로 강우가 발생시 1시간 주기로 분석 자동실행",
"분석 방법 : 기상청 향후 6시간 예측강우를 20개소 중점관리지역의 관망모델(SWMM모델)에 연동하여 관로 통수량을 분석하여 통수위험여부를 분석",
"최대 침수예상시점과 최대침수유량은 맨홀 월류량이 있을때 그 시점과 값이 나타나는것입니다.",
]}
/>
</section>
</StandardPopupLayout>