Files
frontend/apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx
T

636 lines
22 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState } from "react";
import {
StandardPopupLayout,
UITab,
type UiHeadProps,
} from "@common/business_components";
import { Button } from "@common/components";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefinitions";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import {
WINDOW_POPUP_MESSAGE_TYPE,
type WindowPopupContentProps,
} from "@/features/windowPopup/model/types";
import {
fetchWaterRiskAreas,
fetchWaterRiskMapView,
fetchWaterRiskResults,
} from "./waterRiskClient";
import {
buildWaterRiskStatusLabel,
EMPTY_WATER_RISK_SESSION_SNAPSHOT,
type WaterRiskAreaOption,
type WaterRiskResultPage,
type WaterRiskResultRow,
type WaterRiskSessionSnapshot,
} from "./waterRiskTypes";
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: [],
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;
currentAccumRainQty: string;
forecastRainQty: string;
maxRainIntensity: string;
runoffFlowQty: string;
conveyQty: string;
analysisDisplay: string;
riskStatus: WaterRiskResultRow["riskStatus"];
};
const CURRENT_RESULT_HEADLIST: UiHeadProps[] = [
{ id: "rowNo", title: "순번", widthPercent: 6 },
{ id: "sidoName", title: "시도", widthPercent: 9 },
{ id: "sggName", title: "시군구", widthPercent: 9 },
{ id: "areaName", title: "중점관리지역", widthPercent: 14 },
{
id: "currentAccumRainQty",
title: "현재누적강우량(mm)",
widthPercent: 13,
align: "right",
},
{
id: "forecastRainQty",
title: "예측강우량(mm)",
widthPercent: 12,
align: "right",
},
{
id: "runoffFlowQty",
title: "유출유량(㎥/s)",
widthPercent: 11,
align: "right",
},
{
id: "conveyQty",
title: "통수량(㎥/s)",
widthPercent: 10,
align: "right",
},
{ id: "analysisDisplay", title: "분석일", widthPercent: 10 },
{ id: "riskStatus", title: "통수위험", widthPercent: 7 },
];
const HISTORY_RESULT_HEADLIST: UiHeadProps[] = [
{ id: "rowNo", title: "순번", widthPercent: 6 },
{ id: "sidoName", title: "시도", widthPercent: 9 },
{ id: "sggName", title: "시군구", widthPercent: 9 },
{ id: "areaName", title: "중점관리지역", widthPercent: 15 },
{
id: "maxRainIntensity",
title: "최대강우강도(mm/hr)",
widthPercent: 15,
align: "right",
},
{
id: "runoffFlowQty",
title: "유출유량(㎥/s)",
widthPercent: 12,
align: "right",
},
{
id: "conveyQty",
title: "통수량(㎥/s)",
widthPercent: 11,
align: "right",
},
{ id: "analysisDisplay", title: "분석일", widthPercent: 12 },
{ id: "riskStatus", title: "통수위험", widthPercent: 11 },
];
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}`;
};
/** 화면을 연 날짜를 현재 위험 기본 조회일로 사용한다. */
const buildDefaultResultFilters = (): ResultFilters => {
const today = formatLocalDate(new Date());
return {
startDate: today,
endDate: today,
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.ctpvCd ?? ""}:${row.sggCd ?? ""}:${row.areaId}`;
/** 실제 통수위험 API를 조회하고 선택 결과를 부모 지도에 전달하는 팝업이다. */
export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
const initialFilters = useMemo(() => buildDefaultResultFilters(), []);
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 [reloadVersion, setReloadVersion] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [isMapLoading, setIsMapLoading] = useState(false);
const [noticeMessage, setNoticeMessage] = useState("");
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("");
fetchWaterRiskResults({
startDate: appliedResultFilters.startDate,
endDate: appliedResultFilters.endDate,
ctpvCd:
appliedResultFilters.ctpvCd === "ALL"
? undefined
: appliedResultFilters.ctpvCd,
sggCd:
appliedResultFilters.sggCd === "ALL"
? undefined
: appliedResultFilters.sggCd,
areaId:
appliedResultFilters.areaId === "ALL"
? undefined
: appliedResultFilters.areaId,
riskStatus:
appliedResultFilters.riskStatus === "ALL"
? undefined
: appliedResultFilters.riskStatus,
page: resultCurrentPage - 1,
size: WATER_RISK_PAGE_SIZE,
})
.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, reloadVersion, resultCurrentPage]);
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]);
useEffect(() => {
if (typeof window === "undefined" || !window.resizeTo) return;
window.resizeTo(1180, 720);
}, []);
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) => {
const representativePipe =
row.pipeSummaries.find((pipe) => pipe.riskStatus === "DANGER") ??
row.pipeSummaries[0];
return {
sourceRow: row,
rowNo: 0,
sidoName: row.sidoName || "-",
sggName: row.sggName || "-",
areaName: row.areaName,
currentAccumRainQty: formatOptionalNumber(row.currentAccumRainQty, 1),
forecastRainQty: formatOptionalNumber(row.forecastRainQty, 1),
maxRainIntensity: formatOptionalNumber(row.maxRainIntensity, 1),
runoffFlowQty: formatOptionalNumber(
representativePipe?.runoffFlowQty,
2,
),
conveyQty: formatOptionalNumber(representativePipe?.conveyQty, 3),
analysisDisplay: row.maxFloodingAt || row.analysisDate || "-",
riskStatus: row.riskStatus,
};
}),
[resultPage.items],
);
/** 검증된 검색조건을 적용하고 첫 페이지를 새로 조회한다. */
const handleApplyResultFilters = () => {
if (!draftResultFilters.startDate || !draftResultFilters.endDate) {
setNoticeMessage("조회 시작일과 종료일을 입력해야 합니다.");
return;
}
if (draftResultFilters.startDate > draftResultFilters.endDate) {
setNoticeMessage("조회 시작일은 종료일보다 늦을 수 없습니다.");
return;
}
setResultCurrentPage(1);
setAppliedResultFilters({ ...draftResultFilters });
setReloadVersion((version) => version + 1);
setNoticeMessage("통수위험 분석 결과를 조회했습니다.");
};
/** 현재 위험과 과거 이력 표시열만 전환하고 같은 API 검색조건은 유지한다. */
const handleChangeResultMode = (mode: WaterRiskResultMode) => {
setResultMode(mode);
setResultCurrentPage(1);
setNoticeMessage("");
};
/** 선택 행의 실제 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,
});
setNoticeMessage(`${row.areaName}을 지도에서 강조했습니다.`);
} 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} />
<UITab
variant="underline"
defaultValue="current"
tabList={[
{ value: "current", label: "현재 위험" },
{ value: "history", label: "과거 이력" },
]}
onValueChange={(value) =>
handleChangeResultMode(value as WaterRiskResultMode)
}
/>
<PopupSearchPanel contentClassName="flex flex-wrap items-center gap-x-5 gap-y-3">
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
{resultMode === "current" ? "조회기간" : "강우발생일"}
</div>
<span className="flex shrink-0 items-center gap-2">
<input
type="date"
value={draftResultFilters.startDate}
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}
onChange={(event) =>
setDraftResultFilters((current) => ({
...current,
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>
<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>
{filteredAreaOptions.map((option) => (
<option
key={`${option.ctpvCd}:${option.sggCd}:${option.areaId}`}
value={option.areaId}
>
{option.areaName}
</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.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>
<div className="flex min-w-fit flex-1 items-center justify-end gap-2">
<Button
type="button"
color="primary"
onClick={handleApplyResultFilters}
disabled={isLoading}
>
조회
</Button>
<Button
type="button"
color="white"
onClick={() => {
setDraftResultFilters(initialFilters);
setAppliedResultFilters(initialFilters);
setResultCurrentPage(1);
setSelectedResultKey("");
}}
>
초기화
</Button>
</div>
</PopupSearchPanel>
<MapPopupDataTable
rows={tableRows}
headlist={
resultMode === "current"
? CURRENT_RESULT_HEADLIST
: HISTORY_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={resultMode === "current" ? "114rem" : "106rem"}
emptyMessage="조회 조건에 해당하는 통수위험 결과가 없습니다."
actions={
<span className="text-xl font-normal text-muted-foreground">
합리식 유출유량과 Manning 통수량 비교 결과
</span>
}
/>
<div
aria-live="polite"
className="min-h-[20px] px-1 text-xl text-label"
>
{errorMessage ? (
<span className="text-destructive">{errorMessage}</span>
) : (
noticeMessage
)}
</div>
</section>
</StandardPopupLayout>
);
}