738 lines
26 KiB
TypeScript
738 lines
26 KiB
TypeScript
/**
|
|
* 화면명: 통수위험 분석
|
|
* 통수위험 분석 결과와 과거 분석이력을 조회하고 지도에 표시한다.
|
|
*/
|
|
"use client";
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
|
|
import { windowPopupDefinitions } from "@/features/window-popup/model/popup-definitions";
|
|
import {
|
|
WINDOW_POPUP_MESSAGE_TYPE,
|
|
type WindowPopupContentProps,
|
|
} from "@/features/window-popup/model/types";
|
|
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
|
|
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
|
|
import {
|
|
StandardPopupLayout,
|
|
type UiHeadProps,
|
|
UITab,
|
|
} from "@common/business_components";
|
|
import type { UiTableSort } from "@common/business_components/ui/table";
|
|
import { Button } from "@common/components";
|
|
import {
|
|
adjustWaterRiskStartDate,
|
|
getWaterRiskMinStartDate,
|
|
validateWaterRiskDateRange,
|
|
} from "./water-risk-date-range";
|
|
|
|
import {
|
|
fetchWaterRiskAreas,
|
|
fetchWaterRiskMapView,
|
|
fetchWaterRiskResults,
|
|
} from "./water-risk-client";
|
|
import {
|
|
buildWaterRiskStatusLabel,
|
|
EMPTY_WATER_RISK_SESSION_SNAPSHOT,
|
|
type WaterRiskAreaOption,
|
|
type WaterRiskResultPage,
|
|
type WaterRiskResultRow,
|
|
type WaterRiskSessionSnapshot,
|
|
} from "./water-risk-types";
|
|
|
|
import LoadingOverlay from "@/components/ui/loading-bar";
|
|
|
|
type WaterRiskResultMode = "current" | "history";
|
|
|
|
type ResultFilters = {
|
|
startDate: string;
|
|
endDate: string;
|
|
ctpvCd: string;
|
|
sggCd: string;
|
|
areaId: string;
|
|
riskStatus: "ALL" | "DANGER" | "NORMAL";
|
|
};
|
|
|
|
const WATER_RISK_PAGE_SIZE = 10;
|
|
const EMPTY_RESULT_PAGE: WaterRiskResultPage = {
|
|
items: [],
|
|
analysisBaseAt: null,
|
|
total: 0,
|
|
page: 0,
|
|
size: WATER_RISK_PAGE_SIZE,
|
|
totalPages: 0,
|
|
};
|
|
|
|
type WaterRiskTableRow = Record<string, unknown> & {
|
|
sourceRow: WaterRiskResultRow;
|
|
rowNo: number;
|
|
sidoName: string;
|
|
sggName: string;
|
|
areaName: string;
|
|
rainStartedAt: string;
|
|
currentAccumRainQty: string;
|
|
forecastRainQty: string;
|
|
maxFloodingAt: string;
|
|
maxFloodingFlow: string;
|
|
riskStatus: WaterRiskResultRow["riskStatus"];
|
|
};
|
|
|
|
const WATER_RISK_RESULT_HEADLIST: UiHeadProps[] = [
|
|
{
|
|
id: "rowNo",
|
|
title: "순번",
|
|
widthPercent: 6,
|
|
sortable: true,
|
|
sortKey: "rowNumber",
|
|
},
|
|
{
|
|
id: "sidoName",
|
|
title: "시도",
|
|
widthPercent: 8,
|
|
sortable: true,
|
|
sortKey: "ctpvName",
|
|
},
|
|
{ id: "sggName", title: "시군구", widthPercent: 8, sortable: true },
|
|
{ id: "areaName", title: "중점관리지역", widthPercent: 14, sortable: true },
|
|
{
|
|
id: "rainStartedAt",
|
|
title: "강우시작시간",
|
|
widthPercent: 13,
|
|
sortable: true,
|
|
},
|
|
{
|
|
id: "currentAccumRainQty",
|
|
title: "누적강우량(mm)",
|
|
widthPercent: 11,
|
|
align: "right",
|
|
sortable: true,
|
|
},
|
|
{
|
|
id: "forecastRainQty",
|
|
title: "예측강우량(mm)",
|
|
widthPercent: 11,
|
|
align: "right",
|
|
sortable: true,
|
|
},
|
|
{
|
|
id: "maxFloodingAt",
|
|
title: "최대 침수 예상시점",
|
|
widthPercent: 13,
|
|
sortable: true,
|
|
},
|
|
{
|
|
id: "maxFloodingFlow",
|
|
title: "최대 침수유량(m³/s)",
|
|
widthPercent: 10,
|
|
align: "right",
|
|
sortable: true,
|
|
},
|
|
{ id: "riskStatus", title: "위험 여부", widthPercent: 7 },
|
|
];
|
|
|
|
const formatOptionalNumber = (
|
|
value: number | null | undefined,
|
|
digits: number,
|
|
) => (value == null ? "-" : value.toFixed(digits));
|
|
|
|
/** 브라우저 로컬 날짜를 API의 yyyy-MM-dd 형식으로 변환한다. */
|
|
const formatLocalDate = (date: Date) => {
|
|
const year = date.getFullYear();
|
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
const day = String(date.getDate()).padStart(2, "0");
|
|
return `${year}-${month}-${day}`;
|
|
};
|
|
|
|
/** 현재 로컬시각을 API가 조회할 정시 yyyy-MM-ddTHH:00:00으로 내림한다. */
|
|
const formatCurrentAnalysisHour = (date: Date) =>
|
|
`${formatLocalDate(date)}T${String(date.getHours()).padStart(2, "0")}:00:00`;
|
|
|
|
/** API 분석기준시간을 화면의 yyyy.MM.dd HH:mm 형식으로 표시한다. */
|
|
const formatAnalysisBaseAt = (value?: string | null) => {
|
|
if (!value || value === "-") return "-";
|
|
const normalized = value.replace("T", " ");
|
|
const [date = "", time = ""] = normalized.split(" ");
|
|
return `${date.replaceAll("-", ".")} ${time.slice(0, 5)}`.trim();
|
|
};
|
|
|
|
/** 현재 위험 조회와 검색 초기화에 사용하는 최근 1년의 기본 조회조건이다. */
|
|
const buildDefaultResultFilters = (): ResultFilters => {
|
|
const endDate = new Date();
|
|
const endDateValue = formatLocalDate(endDate);
|
|
return {
|
|
startDate: getWaterRiskMinStartDate(endDateValue)!,
|
|
endDate: endDateValue,
|
|
ctpvCd: "ALL",
|
|
sggCd: "ALL",
|
|
areaId: "ALL",
|
|
riskStatus: "ALL",
|
|
};
|
|
};
|
|
|
|
/** 부모 지도 창으로 통수위험 선택 메시지를 전달한다. */
|
|
const postMessageToOpener = (
|
|
type: string,
|
|
payload?: Record<string, unknown>,
|
|
) => {
|
|
if (typeof window === "undefined" || !window.opener) return;
|
|
window.opener.postMessage({ type, payload }, window.location.origin);
|
|
};
|
|
|
|
/** 위험 여부에 맞는 기존 팝업 badge 스타일을 반환한다. */
|
|
const getRiskBadgeClassName = (riskStatus: "DANGER" | "NORMAL") =>
|
|
riskStatus === "DANGER" ? "sn-popup-badge-danger" : "sn-popup-badge-normal";
|
|
|
|
/** 동일 날짜·지역에 여러 강우이벤트가 있어도 실행별 행 키가 겹치지 않게 식별한다. */
|
|
const buildResultRowKey = (row: WaterRiskResultRow) =>
|
|
[
|
|
row.analysisDate ?? "",
|
|
row.rainStartedAt ?? "",
|
|
row.analysisBaseAt ?? "",
|
|
row.ctpvCd ?? "",
|
|
row.sggCd ?? "",
|
|
row.areaId,
|
|
].join(":");
|
|
|
|
/** 실제 통수위험 API를 조회하고 선택 결과를 부모 지도에 전달하는 팝업이다. */
|
|
export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
|
// 최초 진입과 초기화 모두 오늘을 종료일로 하는 최근 1년을 사용한다.
|
|
const initialFilters = useMemo(() => buildDefaultResultFilters(), []);
|
|
// 과거분석은 오늘까지 선택 가능하며, 시작일의 범위는 선택 종료일에 따라 달라진다.
|
|
const today = formatLocalDate(new Date());
|
|
const [resultMode, setResultMode] = useState<WaterRiskResultMode>("current");
|
|
const [draftResultFilters, setDraftResultFilters] =
|
|
useState<ResultFilters>(initialFilters);
|
|
const [appliedResultFilters, setAppliedResultFilters] =
|
|
useState<ResultFilters>(initialFilters);
|
|
const [areaOptions, setAreaOptions] = useState<WaterRiskAreaOption[]>([]);
|
|
const [resultPage, setResultPage] =
|
|
useState<WaterRiskResultPage>(EMPTY_RESULT_PAGE);
|
|
const [selectedResultKey, setSelectedResultKey] = useState("");
|
|
const [resultCurrentPage, setResultCurrentPage] = useState(1);
|
|
const [resultSort, setResultSort] = useState<UiTableSort>();
|
|
const [reloadVersion, setReloadVersion] = useState(0);
|
|
const [currentAnalysisBaseAt, setCurrentAnalysisBaseAt] = useState(() =>
|
|
formatCurrentAnalysisHour(new Date()),
|
|
);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [isMapLoading, setIsMapLoading] = useState(false);
|
|
const [errorMessage, setErrorMessage] = useState("");
|
|
|
|
useEffect(() => {
|
|
let active = true;
|
|
fetchWaterRiskAreas()
|
|
.then((options) => {
|
|
if (active) setAreaOptions(options);
|
|
})
|
|
.catch((error) => {
|
|
if (!active) return;
|
|
const message =
|
|
error instanceof Error
|
|
? error.message
|
|
: "중점관리지역 목록을 조회하지 못했습니다.";
|
|
setErrorMessage(message);
|
|
});
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
let active = true;
|
|
setIsLoading(true);
|
|
setErrorMessage("");
|
|
// 과거 이력의 고정 기본기간이 현재 진행 중인 강우 조회를 제한하지 않게 분리한다.
|
|
const dateFilters = resultMode === "current"
|
|
? buildDefaultResultFilters()
|
|
: appliedResultFilters;
|
|
fetchWaterRiskResults({
|
|
startDate: dateFilters.startDate,
|
|
endDate: dateFilters.endDate,
|
|
analysisBaseAt:
|
|
resultMode === "current" ? currentAnalysisBaseAt : undefined,
|
|
ctpvCd:
|
|
resultMode === "current" || appliedResultFilters.ctpvCd === "ALL"
|
|
? undefined
|
|
: appliedResultFilters.ctpvCd,
|
|
sggCd:
|
|
resultMode === "current" || appliedResultFilters.sggCd === "ALL"
|
|
? undefined
|
|
: appliedResultFilters.sggCd,
|
|
areaId:
|
|
appliedResultFilters.areaId === "ALL"
|
|
? undefined
|
|
: appliedResultFilters.areaId,
|
|
riskStatus:
|
|
resultMode === "current" || appliedResultFilters.riskStatus === "ALL"
|
|
? undefined
|
|
: appliedResultFilters.riskStatus,
|
|
rainEventStatus: resultMode === "current" ? "IN_PROGRESS" : "ENDED",
|
|
page: resultCurrentPage - 1,
|
|
size: WATER_RISK_PAGE_SIZE,
|
|
sort: resultSort,
|
|
})
|
|
.then((page) => {
|
|
if (!active) return;
|
|
setResultPage(page);
|
|
if (page.items.length > 0) {
|
|
setSelectedResultKey(
|
|
(current) => current || buildResultRowKey(page.items[0]!),
|
|
);
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
if (!active) return;
|
|
setResultPage(EMPTY_RESULT_PAGE);
|
|
setErrorMessage(
|
|
error instanceof Error
|
|
? error.message
|
|
: "통수위험 분석 결과를 조회하지 못했습니다.",
|
|
);
|
|
})
|
|
.finally(() => {
|
|
if (active) setIsLoading(false);
|
|
});
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [
|
|
appliedResultFilters,
|
|
currentAnalysisBaseAt,
|
|
reloadVersion,
|
|
resultCurrentPage,
|
|
resultMode,
|
|
resultSort,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
const handleMessage = (event: MessageEvent) => {
|
|
if (event.origin !== window.location.origin) return;
|
|
if (event.data?.type !== WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SYNC)
|
|
return;
|
|
|
|
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,
|
|
);
|
|
if (matchingRow) setSelectedResultKey(buildResultRowKey(matchingRow));
|
|
}
|
|
};
|
|
|
|
window.addEventListener("message", handleMessage);
|
|
postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_READY);
|
|
return () => {
|
|
window.removeEventListener("message", handleMessage);
|
|
};
|
|
}, [resultPage.items]);
|
|
|
|
/** 브라우저 X·새로고침 등으로 팝업 문서가 종료되면 부모 지도의 선택결과를 즉시 정리한다. */
|
|
useEffect(() => {
|
|
const notifyPopupClosed = () => {
|
|
postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_CLOSED);
|
|
};
|
|
window.addEventListener("pagehide", notifyPopupClosed);
|
|
window.addEventListener("beforeunload", notifyPopupClosed);
|
|
return () => {
|
|
window.removeEventListener("pagehide", notifyPopupClosed);
|
|
window.removeEventListener("beforeunload", notifyPopupClosed);
|
|
};
|
|
}, []);
|
|
|
|
/** 현재 탭의 표시·조회 기준시간을 매 정시에 새 현재시간으로 변경한다. */
|
|
useEffect(() => {
|
|
if (resultMode !== "current" || typeof window === "undefined") return;
|
|
|
|
let intervalId: number | undefined;
|
|
const applyCurrentHour = () =>
|
|
setCurrentAnalysisBaseAt(formatCurrentAnalysisHour(new Date()));
|
|
applyCurrentHour();
|
|
const now = new Date();
|
|
const nextRefreshAt = new Date(now);
|
|
nextRefreshAt.setHours(now.getHours() + 1, 0, 0, 0);
|
|
const timeoutId = window.setTimeout(
|
|
() => {
|
|
applyCurrentHour();
|
|
intervalId = window.setInterval(applyCurrentHour, 60 * 60 * 1000);
|
|
},
|
|
Math.max(0, nextRefreshAt.getTime() - now.getTime()),
|
|
);
|
|
|
|
return () => {
|
|
window.clearTimeout(timeoutId);
|
|
if (intervalId != null) window.clearInterval(intervalId);
|
|
};
|
|
}, [resultMode]);
|
|
|
|
const ctpvOptions = useMemo(() => {
|
|
const unique = new Map<string, string>();
|
|
areaOptions.forEach((option) => {
|
|
if (option.ctpvCd)
|
|
unique.set(option.ctpvCd, option.ctpvName || option.ctpvCd);
|
|
});
|
|
return Array.from(unique, ([code, name]) => ({ code, name })).sort((a, b) =>
|
|
a.name.localeCompare(b.name, "ko"),
|
|
);
|
|
}, [areaOptions]);
|
|
|
|
const sggOptions = useMemo(() => {
|
|
const unique = new Map<string, string>();
|
|
areaOptions
|
|
.filter(
|
|
(option) =>
|
|
draftResultFilters.ctpvCd !== "ALL" &&
|
|
option.ctpvCd === draftResultFilters.ctpvCd,
|
|
)
|
|
.forEach((option) => {
|
|
if (option.sggCd)
|
|
unique.set(option.sggCd, option.sggName || option.sggCd);
|
|
});
|
|
return Array.from(unique, ([code, name]) => ({ code, name })).sort((a, b) =>
|
|
a.name.localeCompare(b.name, "ko"),
|
|
);
|
|
}, [areaOptions, draftResultFilters.ctpvCd]);
|
|
|
|
const filteredAreaOptions = useMemo(
|
|
() =>
|
|
areaOptions.filter(
|
|
(option) =>
|
|
(draftResultFilters.ctpvCd !== "ALL" &&
|
|
option.ctpvCd === draftResultFilters.ctpvCd) &&
|
|
(draftResultFilters.sggCd === "ALL" ||
|
|
option.sggCd === draftResultFilters.sggCd),
|
|
),
|
|
[areaOptions, draftResultFilters.ctpvCd, draftResultFilters.sggCd],
|
|
);
|
|
|
|
const resultListTitle =
|
|
resultMode === "current"
|
|
? "통수위험 분석 결과 목록"
|
|
: "통수위험 과거 이력 목록";
|
|
const tableRows = useMemo<WaterRiskTableRow[]>(
|
|
() =>
|
|
resultPage.items.map((row) => {
|
|
return {
|
|
sourceRow: row,
|
|
rowNo: 0,
|
|
sidoName: row.sidoName || "-",
|
|
sggName: row.sggName || "-",
|
|
areaName: row.areaName,
|
|
rainStartedAt: formatAnalysisBaseAt(row.rainStartedAt),
|
|
currentAccumRainQty: formatOptionalNumber(row.currentAccumRainQty, 1),
|
|
forecastRainQty: formatOptionalNumber(row.forecastRainQty, 1),
|
|
maxFloodingAt: formatAnalysisBaseAt(row.maxFloodingAt),
|
|
maxFloodingFlow: formatOptionalNumber(row.maxFloodingFlow, 2),
|
|
riskStatus: row.riskStatus,
|
|
};
|
|
}),
|
|
[resultPage.items],
|
|
);
|
|
|
|
/** 검증된 검색조건을 적용하고 첫 페이지를 새로 조회한다. */
|
|
const handleApplyResultFilters = () => {
|
|
if (resultMode === "history") {
|
|
const dateError = validateWaterRiskDateRange(
|
|
draftResultFilters.startDate, draftResultFilters.endDate, today,
|
|
);
|
|
if (dateError) {
|
|
setErrorMessage(dateError);
|
|
return;
|
|
}
|
|
}
|
|
|
|
setErrorMessage("");
|
|
setResultCurrentPage(1);
|
|
setAppliedResultFilters({ ...draftResultFilters });
|
|
setReloadVersion((version) => version + 1);
|
|
};
|
|
|
|
/** 공통 결과표 정렬상태를 적용하고 첫 페이지를 다시 조회한다. */
|
|
const handleSortChange = (nextSort?: UiTableSort) => {
|
|
setResultCurrentPage(1);
|
|
setResultSort(nextSort);
|
|
};
|
|
|
|
/** 강우이벤트 상태에 따라 진행중 현재 위험과 종료된 과거 이력을 다시 조회한다. */
|
|
const handleChangeResultMode = (mode: WaterRiskResultMode) => {
|
|
setResultMode(mode);
|
|
setResultCurrentPage(1);
|
|
setResultSort(undefined);
|
|
setResultPage(EMPTY_RESULT_PAGE);
|
|
setSelectedResultKey("");
|
|
};
|
|
|
|
/** 선택 행의 실제 GeoJSON을 조회한 뒤 부모 지도 창에 전달한다. */
|
|
const handleSelectResultRow = async (row: WaterRiskResultRow) => {
|
|
setSelectedResultKey(buildResultRowKey(row));
|
|
setIsMapLoading(true);
|
|
setErrorMessage("");
|
|
try {
|
|
const mapView = await fetchWaterRiskMapView(row);
|
|
postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SELECT_RESULT, {
|
|
areaId: row.areaId,
|
|
resultRow: row,
|
|
mapView,
|
|
});
|
|
} catch (error) {
|
|
setErrorMessage(
|
|
error instanceof Error
|
|
? error.message
|
|
: "통수위험 지도 결과를 조회하지 못했습니다.",
|
|
);
|
|
} finally {
|
|
setIsMapLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<StandardPopupLayout title={windowPopupDefinitions.flowRiskPopup.title}>
|
|
<section
|
|
className={`relative flex min-h-0 flex-1 flex-col gap-[1.2rem] ${
|
|
tableRows.length === 0
|
|
? "[&_.standard-data-table>.card]:!flex [&_.standard-data-table>.card]:!min-h-0 [&_.standard-data-table>.card]:!flex-1 [&_.standard-data-table>.card]:!flex-col [&_.standard-data-table]:!flex [&_.standard-data-table]:!min-h-0 [&_.standard-data-table]:!flex-1 [&_.standard-data-table]:!flex-col [&_.standard-data-table__scroll>div]:!h-full [&_.standard-data-table__scroll]:!min-h-96 [&_.standard-data-table__scroll]:!flex-1"
|
|
: ""
|
|
}`}
|
|
>
|
|
<LoadingOverlay isLoading={isLoading || isMapLoading} />
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<UITab
|
|
variant="underline"
|
|
defaultValue="current"
|
|
tabList={[
|
|
{ value: "current", label: "현재" },
|
|
{ value: "history", label: "과거분석이력" },
|
|
]}
|
|
onValueChange={(value) =>
|
|
handleChangeResultMode(value as WaterRiskResultMode)
|
|
}
|
|
/>
|
|
{resultMode === "current" ? (
|
|
<div className="flex items-center gap-2 text-xl">
|
|
<span className="font-medium text-label">분석기준시간</span>
|
|
<strong className="font-semibold text-primary">
|
|
{formatAnalysisBaseAt(currentAnalysisBaseAt)}
|
|
</strong>
|
|
</div>
|
|
) : null}
|
|
</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">
|
|
시도
|
|
</div>
|
|
<select
|
|
value={draftResultFilters.ctpvCd}
|
|
onChange={(event) =>
|
|
setDraftResultFilters((current) => ({
|
|
...current,
|
|
ctpvCd: event.target.value,
|
|
sggCd: "ALL",
|
|
areaId: "ALL",
|
|
}))
|
|
}
|
|
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>
|
|
{ctpvOptions.map((option) => (
|
|
<option key={option.code} value={option.code}>
|
|
{option.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
|
|
<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.sggCd}
|
|
onChange={(event) =>
|
|
setDraftResultFilters((current) => ({
|
|
...current,
|
|
sggCd: event.target.value,
|
|
areaId: "ALL",
|
|
}))
|
|
}
|
|
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>
|
|
{sggOptions.map((option) => (
|
|
<option key={option.code} value={option.code}>
|
|
{option.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</>
|
|
) : null}
|
|
|
|
<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.areaId}
|
|
onChange={(event) =>
|
|
setDraftResultFilters((current) => ({
|
|
...current,
|
|
areaId: event.target.value,
|
|
}))
|
|
}
|
|
aria-label="중점관리지역"
|
|
className="h-[3.2rem] w-[18rem] 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>
|
|
{(resultMode === "current"
|
|
? areaOptions
|
|
: filteredAreaOptions
|
|
).map((option) => (
|
|
<option
|
|
key={`${option.ctpvCd}:${option.sggCd}:${option.areaId}`}
|
|
value={option.areaId}
|
|
>
|
|
{option.areaName}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</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>
|
|
) : null}
|
|
|
|
<div className="flex min-w-fit flex-1 items-center justify-end gap-2">
|
|
<Button
|
|
type="button"
|
|
color="primary"
|
|
onClick={handleApplyResultFilters}
|
|
>
|
|
조회
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
color="white"
|
|
onClick={() => {
|
|
// 초기화 시점의 기본기간·전체 조건으로 첫 페이지를 다시 조회한다.
|
|
const defaultFilters = buildDefaultResultFilters();
|
|
setDraftResultFilters(defaultFilters);
|
|
setAppliedResultFilters(defaultFilters);
|
|
setResultCurrentPage(1);
|
|
setSelectedResultKey("");
|
|
setErrorMessage("");
|
|
setReloadVersion((version) => version + 1);
|
|
}}
|
|
>
|
|
초기화
|
|
</Button>
|
|
</div>
|
|
</PopupSearchPanel>
|
|
|
|
{errorMessage ? (
|
|
<div role="alert" className="shrink-0 px-1 text-xl text-destructive">
|
|
{errorMessage}
|
|
</div>
|
|
) : null}
|
|
|
|
<MapPopupDataTable
|
|
rows={tableRows}
|
|
headlist={WATER_RISK_RESULT_HEADLIST}
|
|
title={resultListTitle}
|
|
total={resultPage.total}
|
|
page={resultCurrentPage}
|
|
pageSize={WATER_RISK_PAGE_SIZE}
|
|
onPageChange={setResultCurrentPage}
|
|
rowId={(row) => buildResultRowKey(row.sourceRow)}
|
|
selectedRowId={selectedResultKey}
|
|
onRowClick={(row) => void handleSelectResultRow(row.sourceRow)}
|
|
renderCell={(row, headId) =>
|
|
headId === "riskStatus" ? (
|
|
<span
|
|
className={`sn-popup-badge min-w-[5.6rem] ${getRiskBadgeClassName(row.riskStatus)}`}
|
|
>
|
|
{buildWaterRiskStatusLabel(row.riskStatus)}
|
|
</span>
|
|
) : undefined
|
|
}
|
|
scrollMode="horizontal"
|
|
minTableWidth="124rem"
|
|
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>
|
|
}
|
|
/>
|
|
</section>
|
|
</StandardPopupLayout>
|
|
);
|
|
}
|