Merge remote-tracking branch 'origin/iipbs/sn/dev' into iipbs/sn/dev
# Conflicts: # apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx # apps/sn/src/features/windowPopup/model/popupDefinitions.ts
This commit is contained in:
@@ -1,22 +1,21 @@
|
||||
"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 MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
|
||||
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
|
||||
import {
|
||||
StandardPopupLayout,
|
||||
type UiHeadProps,
|
||||
UITab,
|
||||
} from "@common/business_components";
|
||||
import type { UiTableSort } from "@common/business_components/ui/table";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
import {
|
||||
fetchWaterRiskAreas,
|
||||
@@ -32,6 +31,8 @@ import {
|
||||
type WaterRiskSessionSnapshot,
|
||||
} from "./waterRiskTypes";
|
||||
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
|
||||
type WaterRiskResultMode = "current" | "history";
|
||||
|
||||
type ResultFilters = {
|
||||
@@ -63,38 +64,59 @@ type WaterRiskTableRow = Record<string, unknown> & {
|
||||
currentAccumRainQty: string;
|
||||
forecastRainQty: string;
|
||||
maxFloodingAt: string;
|
||||
maxFloodingVolume: string;
|
||||
maxFloodingFlow: string;
|
||||
riskStatus: WaterRiskResultRow["riskStatus"];
|
||||
};
|
||||
|
||||
const WATER_RISK_RESULT_HEADLIST: UiHeadProps[] = [
|
||||
{ id: "rowNo", title: "순번", widthPercent: 6 },
|
||||
{ id: "sidoName", title: "시도", widthPercent: 8 },
|
||||
{ id: "sggName", title: "시군구", widthPercent: 8 },
|
||||
{ id: "areaName", title: "중점관리지역", widthPercent: 14 },
|
||||
{ id: "rainStartedAt", title: "강우시작시간", widthPercent: 13 },
|
||||
{
|
||||
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: "maxFloodingVolume",
|
||||
title: "최대침수량(m³)",
|
||||
id: "maxFloodingFlow",
|
||||
title: "최대 침수유량(m³/s)",
|
||||
widthPercent: 10,
|
||||
align: "right",
|
||||
sortable: true,
|
||||
},
|
||||
{ id: "riskStatus", title: "위험 여부", widthPercent: 7 },
|
||||
];
|
||||
@@ -169,13 +191,13 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
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 [noticeMessage, setNoticeMessage] = useState("");
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -225,6 +247,7 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
rainEventStatus: resultMode === "current" ? "IN_PROGRESS" : "ENDED",
|
||||
page: resultCurrentPage - 1,
|
||||
size: WATER_RISK_PAGE_SIZE,
|
||||
sort: resultSort,
|
||||
})
|
||||
.then((page) => {
|
||||
if (!active) return;
|
||||
@@ -256,6 +279,7 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
reloadVersion,
|
||||
resultCurrentPage,
|
||||
resultMode,
|
||||
resultSort,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -281,6 +305,19 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
};
|
||||
}, [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;
|
||||
@@ -363,8 +400,7 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
currentAccumRainQty: formatOptionalNumber(row.currentAccumRainQty, 1),
|
||||
forecastRainQty: formatOptionalNumber(row.forecastRainQty, 1),
|
||||
maxFloodingAt: formatAnalysisBaseAt(row.maxFloodingAt),
|
||||
// 목록 API에는 최대 침수유량만 있고 침수량 합계는 아직 제공하지 않는다.
|
||||
maxFloodingVolume: "-",
|
||||
maxFloodingFlow: formatOptionalNumber(row.maxFloodingFlow, 2),
|
||||
riskStatus: row.riskStatus,
|
||||
};
|
||||
}),
|
||||
@@ -374,18 +410,23 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
/** 검증된 검색조건을 적용하고 첫 페이지를 새로 조회한다. */
|
||||
const handleApplyResultFilters = () => {
|
||||
if (!draftResultFilters.startDate || !draftResultFilters.endDate) {
|
||||
setNoticeMessage("조회 시작일과 종료일을 입력해야 합니다.");
|
||||
setErrorMessage("조회 시작일과 종료일을 입력해야 합니다.");
|
||||
return;
|
||||
}
|
||||
if (draftResultFilters.startDate > draftResultFilters.endDate) {
|
||||
setNoticeMessage("조회 시작일은 종료일보다 늦을 수 없습니다.");
|
||||
setErrorMessage("조회 시작일은 종료일보다 늦을 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
setResultCurrentPage(1);
|
||||
setAppliedResultFilters({ ...draftResultFilters });
|
||||
setReloadVersion((version) => version + 1);
|
||||
setNoticeMessage("통수위험 분석 결과를 조회했습니다.");
|
||||
};
|
||||
|
||||
/** 공통 결과표 정렬상태를 적용하고 첫 페이지를 다시 조회한다. */
|
||||
const handleSortChange = (nextSort?: UiTableSort) => {
|
||||
setResultCurrentPage(1);
|
||||
setResultSort(nextSort);
|
||||
};
|
||||
|
||||
/** 강우이벤트 상태에 따라 진행중 현재 위험과 종료된 과거 이력을 다시 조회한다. */
|
||||
@@ -394,7 +435,6 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
setResultCurrentPage(1);
|
||||
setResultPage(EMPTY_RESULT_PAGE);
|
||||
setSelectedResultKey("");
|
||||
setNoticeMessage("");
|
||||
};
|
||||
|
||||
/** 선택 행의 실제 GeoJSON을 조회한 뒤 부모 지도 창에 전달한다. */
|
||||
@@ -409,7 +449,6 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
resultRow: row,
|
||||
mapView,
|
||||
});
|
||||
setNoticeMessage(`${row.areaName}을 지도에서 강조했습니다.`);
|
||||
} catch (error) {
|
||||
setErrorMessage(
|
||||
error instanceof Error
|
||||
@@ -436,8 +475,8 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
variant="underline"
|
||||
defaultValue="current"
|
||||
tabList={[
|
||||
{ value: "current", label: "현재 위험" },
|
||||
{ value: "history", label: "과거 이력" },
|
||||
{ value: "current", label: "현재" },
|
||||
{ value: "history", label: "과거분석이력" },
|
||||
]}
|
||||
onValueChange={(value) =>
|
||||
handleChangeResultMode(value as WaterRiskResultMode)
|
||||
@@ -458,7 +497,7 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
<>
|
||||
<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
|
||||
@@ -601,7 +640,6 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
type="button"
|
||||
color="primary"
|
||||
onClick={handleApplyResultFilters}
|
||||
disabled={isLoading}
|
||||
>
|
||||
조회
|
||||
</Button>
|
||||
@@ -642,23 +680,20 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
}
|
||||
scrollMode="horizontal"
|
||||
minTableWidth="124rem"
|
||||
emptyMessage="조회 조건에 해당하는 통수위험 결과가 없습니다."
|
||||
emptyMessage="조회된 결과가 없습니다."
|
||||
sort={resultSort}
|
||||
onSortChange={handleSortChange}
|
||||
actions={
|
||||
<span className="text-xl font-normal text-muted-foreground">
|
||||
SWMM 시뮬레이션 기반 통수위험 분석 결과
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="min-h-[20px] px-1 text-xl text-label"
|
||||
>
|
||||
{errorMessage ? (
|
||||
<span className="text-destructive">{errorMessage}</span>
|
||||
) : (
|
||||
noticeMessage
|
||||
)}
|
||||
</div>
|
||||
{errorMessage ? (
|
||||
<div aria-live="polite" className="px-1 text-xl text-destructive">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getData } from "@/app/components/lib/api";
|
||||
import type { UiTableSort } from "@common/business_components/ui/table";
|
||||
|
||||
import type {
|
||||
WaterRiskAreaOption,
|
||||
@@ -17,6 +18,7 @@ export type WaterRiskResultQuery = {
|
||||
areaId?: string;
|
||||
riskStatus?: WaterRiskStatus;
|
||||
rainEventStatus?: "IN_PROGRESS" | "ENDED";
|
||||
sort?: UiTableSort;
|
||||
page: number;
|
||||
size: number;
|
||||
};
|
||||
@@ -55,6 +57,7 @@ export const fetchWaterRiskResults = async (
|
||||
if (query.areaId) params.set("areaId", query.areaId);
|
||||
if (query.riskStatus) params.set("riskStatus", query.riskStatus);
|
||||
if (query.rainEventStatus) params.set("rainEventStatus", query.rainEventStatus);
|
||||
if (query.sort) params.set("sort", `${query.sort.key},${query.sort.direction}`);
|
||||
|
||||
const response = await getData(`v1/waterRisk/results?${params.toString()}`);
|
||||
const items = Array.isArray(response?.items)
|
||||
|
||||
@@ -26,6 +26,7 @@ export type WaterRiskResultRow = {
|
||||
forecastRainQty: number | null;
|
||||
maxRainIntensity: number | null;
|
||||
maxFloodingAt: string;
|
||||
maxFloodingFlow: number | null;
|
||||
riskStatus: WaterRiskStatus;
|
||||
calculationMethod: WaterRiskCalculationMethod;
|
||||
pipeSummaries: WaterRiskPipeSummary[];
|
||||
|
||||
@@ -3416,7 +3416,7 @@ const Map = () => {
|
||||
[selectedWaterRiskApiResult],
|
||||
);
|
||||
|
||||
/** 선택 영역 오른쪽에 흰색 강우·침수 요약카드를 배치한다. */
|
||||
/** 선택 영역 오른쪽에 중점관리지역 강우·침수 요약카드를 배치한다. */
|
||||
const syncWaterRiskInfoCardPosition = useCallback(() => {
|
||||
const map = gisMaps.current;
|
||||
if (!map || !waterRiskInfoAnchor) {
|
||||
@@ -3896,6 +3896,16 @@ const Map = () => {
|
||||
waterRiskManholeSourceRef.current?.clear();
|
||||
}, []);
|
||||
|
||||
/** 통수위험 팝업 종료 시 지도 강조·범례·정보카드 선택상태를 모두 초기화한다. */
|
||||
const clearWaterRiskSelection = useCallback(() => {
|
||||
clearWaterRiskLayers();
|
||||
setWaterRiskSession(EMPTY_WATER_RISK_SESSION_SNAPSHOT);
|
||||
setSelectedWaterRiskApiResult(null);
|
||||
setSelectedWaterRiskMapView(null);
|
||||
setWaterRiskInfoAnchor(null);
|
||||
setWaterRiskInfoCardStyle(null);
|
||||
}, [clearWaterRiskLayers]);
|
||||
|
||||
const renderWaterRiskSelection = useCallback(
|
||||
(areaId: string | null, mapView: WaterRiskMapView | null) => {
|
||||
const map = gisMaps.current;
|
||||
@@ -4059,13 +4069,23 @@ const Map = () => {
|
||||
setFeatureData(null);
|
||||
return;
|
||||
}
|
||||
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_CLOSED) {
|
||||
clearWaterRiskPopupCloseWatcher();
|
||||
waterRiskPopupWindowRef.current = null;
|
||||
clearWaterRiskSelection();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleWaterRiskPopupMessage);
|
||||
return () => {
|
||||
window.removeEventListener("message", handleWaterRiskPopupMessage);
|
||||
};
|
||||
}, [syncWaterRiskPopup]);
|
||||
}, [
|
||||
clearWaterRiskPopupCloseWatcher,
|
||||
clearWaterRiskSelection,
|
||||
syncWaterRiskPopup,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -6597,12 +6617,13 @@ const Map = () => {
|
||||
if (!popup.closed) return;
|
||||
clearWaterRiskPopupCloseWatcher();
|
||||
waterRiskPopupWindowRef.current = null;
|
||||
clearWaterRiskSelection();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
return popup;
|
||||
},
|
||||
[clearWaterRiskPopupCloseWatcher],
|
||||
[clearWaterRiskPopupCloseWatcher, clearWaterRiskSelection],
|
||||
);
|
||||
|
||||
const openGisEditSessionPopup = useCallback(() => {
|
||||
@@ -8729,7 +8750,7 @@ const Map = () => {
|
||||
style={waterRiskInfoCardStyle}
|
||||
className="absolute z-[61] w-[244px] overflow-hidden rounded-[12px] border border-border bg-background shadow-blur"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border bg-secondary px-[9px] py-[7px]">
|
||||
<div className="flex items-center justify-between bg-secondary px-[9px] py-[7px]">
|
||||
<div>
|
||||
<div className="text-[10px] font-semibold text-muted-foreground">
|
||||
중점관리지역
|
||||
@@ -8750,109 +8771,50 @@ const Map = () => {
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="px-[8px] py-[8px]">
|
||||
<div className="overflow-hidden rounded-[10px] border border-border bg-background shadow-blur">
|
||||
<table className="w-full table-fixed border-collapse">
|
||||
<colgroup>
|
||||
<col className="w-[46%]" />
|
||||
<col className="w-[30%]" />
|
||||
<col className="w-[24%]" />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
<tr className="border-b border-border">
|
||||
<td
|
||||
colSpan={2}
|
||||
className="border-r border-border bg-secondary px-[9px] py-[7px] text-[10px] font-semibold leading-[1.2] text-label"
|
||||
>
|
||||
예측강우량(mm/hr)
|
||||
</td>
|
||||
<td className="px-[9px] py-[7px] text-right text-[12px] font-bold text-foreground">
|
||||
{selectedWaterRiskResultRow.forecastRainQty == null
|
||||
? "-"
|
||||
: selectedWaterRiskResultRow.forecastRainQty.toFixed(
|
||||
1,
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{selectedWaterRiskResultRow.pipeSummaries.map(
|
||||
(pipe, index) => {
|
||||
const isDanger =
|
||||
pipe.riskStatus === "DANGER" ||
|
||||
(pipe.runoffFlowQty != null &&
|
||||
pipe.conveyQty != null &&
|
||||
pipe.runoffFlowQty > pipe.conveyQty);
|
||||
const isLast =
|
||||
index ===
|
||||
selectedWaterRiskResultRow.pipeSummaries.length - 1;
|
||||
|
||||
return (
|
||||
<Fragment key={`${pipe.pipeId}-row`}>
|
||||
<tr>
|
||||
<td
|
||||
rowSpan={2}
|
||||
className={`border-r border-border px-[9px] py-[8px] align-top ${!isLast ? "border-b border-border" : ""}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] font-semibold leading-[1.15] text-label">
|
||||
관로
|
||||
<br />
|
||||
ID {index + 1}
|
||||
</div>
|
||||
<div className="mt-[5px] whitespace-nowrap text-[11px] font-bold leading-none text-foreground">
|
||||
{pipe.pipeId}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`inline-flex min-w-[40px] shrink-0 items-center justify-center rounded-[6px] px-[5px] py-[4px] text-[9px] font-bold ${
|
||||
isDanger
|
||||
? "bg-[#ff1c15] text-white"
|
||||
: "bg-[#eaf4ff] text-[#1565c0]"
|
||||
}`}
|
||||
>
|
||||
{isDanger ? "위험" : "정상"}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="border-b border-r border-border bg-secondary px-[9px] py-[7px] text-[10px] font-semibold leading-[1.15] text-label">
|
||||
통수량
|
||||
<br />
|
||||
(㎥/s)
|
||||
</td>
|
||||
<td className="border-b border-border px-[9px] py-[7px] text-right text-[11px] font-medium text-foreground">
|
||||
{pipe.conveyQty == null
|
||||
? "-"
|
||||
: pipe.conveyQty.toFixed(1)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
className={
|
||||
!isLast ? "border-b border-border" : ""
|
||||
}
|
||||
>
|
||||
<td className="border-r border-border bg-secondary px-[9px] py-[7px] text-[10px] font-semibold leading-[1.15] text-label">
|
||||
유출유량
|
||||
<br />
|
||||
(㎥/s)
|
||||
</td>
|
||||
<td
|
||||
className={`px-[9px] py-[7px] text-right text-[11px] ${isDanger ? "font-bold text-[#d32f2f]" : "font-medium text-foreground"}`}
|
||||
>
|
||||
{pipe.runoffFlowQty == null
|
||||
? "-"
|
||||
: pipe.runoffFlowQty.toFixed(1)}
|
||||
</td>
|
||||
</tr>
|
||||
</Fragment>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="bg-background px-[9px] py-[6px] text-[9px] text-muted-foreground">
|
||||
capacity 0.8 초과 관로 존재 시{" "}
|
||||
<span className="font-bold text-[#ff1c15]">위험</span>, 없으면{" "}
|
||||
<span className="font-bold text-[#1677ff]">정상</span>
|
||||
</div>
|
||||
<table className="w-full table-fixed border-collapse border-t border-border">
|
||||
<colgroup>
|
||||
<col className="w-[68%]" />
|
||||
<col className="w-[32%]" />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
{[
|
||||
[
|
||||
"현재 누적강우량(mm)",
|
||||
selectedWaterRiskMapView?.currentAccumRainQty,
|
||||
1,
|
||||
],
|
||||
[
|
||||
"예측강우량(mm)",
|
||||
selectedWaterRiskMapView?.forecastRainQty,
|
||||
1,
|
||||
],
|
||||
[
|
||||
"최대 침수유량(m³/s)",
|
||||
selectedWaterRiskMapView?.maxFloodingFlow,
|
||||
2,
|
||||
],
|
||||
].map(([label, value, digits], index) => (
|
||||
<tr
|
||||
key={String(label)}
|
||||
className={index < 2 ? "border-b border-border" : ""}
|
||||
>
|
||||
<td className="border-r border-border bg-secondary px-[9px] py-[8px] text-[10px] font-semibold text-label">
|
||||
{label}
|
||||
</td>
|
||||
<td className="px-[9px] py-[7px] text-right text-[12px] font-bold text-foreground">
|
||||
{typeof value === "number"
|
||||
? value.toFixed(Number(digits))
|
||||
: "-"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
{selectedWaterRiskMapView ? (
|
||||
|
||||
@@ -138,20 +138,53 @@
|
||||
|
||||
## 10. 2026-08-18 팝업 크기·시간갱신 정적검증
|
||||
|
||||
- 팝업 정의와 컴포넌트 크기가 `1360×600`으로 일치한다.
|
||||
- 팝업 생성 정의는 `1380×900`이고 페이지 크기는 10건이다. 화면 컴포넌트에서는 창 크기를 다시 변경하지 않는다.
|
||||
- 현재 탭 전용 자동갱신은 다음 `HH:00`에 시작하고 이후 1시간 간격이며 effect cleanup에서 timeout·interval을 모두 해제한다.
|
||||
- 현재 탭 설명문 제거와 FE 대상 ESLint 통과를 확인했다.
|
||||
- 과거분석이력에서 현재로 전환할 때 결과페이지는 비우되 분석기준시간은 현재 정시를 계속 표시하며 DB 0건에도 사라지지 않는다.
|
||||
- 분석기준시간 라벨·값이 현재 탭 행 오른쪽 끝에 표시되고 검색패널에서 제거된 것을 정적 확인했다.
|
||||
- 행 클릭 성공 안내문 제거와 오류 메시지 조건부 렌더링을 확인했다.
|
||||
- 보령 동대궁촌의 목록 최대 침수유량과 지도카드 값이 모두 `121.48m³/s`로 일치함을 실 API로 확인했다.
|
||||
- 지도카드 위험/정상 판정 설명과 `capacity > 0.8` 서비스 기준이 일치한다.
|
||||
- 페이지별 데이터 길이·스크롤바에 따른 전체표 확대축소를 제거하고 `1320px` 고정폭·10개 열 비율·긴 행정명 말줄임 처리를 확인했다.
|
||||
- 팝업 컴포넌트에 `window.resizeTo` 호출이 없고 창 크기는 `popupDefinitions`의 `1380×900`으로만 결정되는 것을 정적 확인했다. 검색·결과영역의 overflow 숨김과 결과영역 좌우 여백 보정은 유지한다.
|
||||
|
||||
## 9. 2026-08-18 결과표 헤더·강우시작시간 검증
|
||||
|
||||
- `rainStartedAt`이 실제 강우이벤트 시작시각으로 반환되는 것을 확인했다.
|
||||
- 사천 `2026-07-01 03:00`, 완도 `2026-07-01 01:00`과 누적·예측강우 값이 같은 행에 표시되는 계약을 확인했다.
|
||||
- 2단 그룹헤더는 총 10개 데이터 열과 일치하며 로딩·빈 결과 colSpan도 10으로 통일했다.
|
||||
- 2단 그룹헤더는 총 10개 데이터 열과 일치하며 로딩·빈 결과 colSpan도 10으로 통일했다. 최대 침수 열은 `m³/s` 유량으로 정정했다.
|
||||
- Java Service 테스트와 FE 대상 ESLint가 통과했다.
|
||||
- 관리번호 15 완도는 MS 중점관리지역 geometry가 없어 임시 공간매핑이 불가능하다.
|
||||
|
||||
## 11. 2026-08-20 공통 결과 그리드·정렬 검증
|
||||
|
||||
- 통수위험 전용 `<table>`과 `UiPageNations` 직접 사용이 제거되고 main 공통 `MapPopupDataTable`로 단일화된 것을 정적 확인했다.
|
||||
- 2단 그룹 헤더의 실제 데이터 열은 10개이며 `위험 여부`를 제외한 요청 9개 열에만 `sortable`이 설정됐다.
|
||||
- 공통 빈 결과 문구는 `조회된 결과가 없습니다.`이고 공통 회색 헤더·선택행·페이지네이션을 그대로 사용한다.
|
||||
- 변경한 FE 2개 파일의 ESLint는 오류·경고 없이 통과했다.
|
||||
- Java `WaterRiskServiceTest` 5건이 통과했다.
|
||||
- 개발 백엔드와 프론트 프록시 API에서 정렬 파라미터 전달 및 오름차순·내림차순 결과 변화를 확인했다.
|
||||
- 자동 브라우저에는 로그인 세션이 없어 로그인 화면까지만 확인했다. 로그인된 사용자 Chrome에서 최종 표 렌더링 체감은 수동 확인 대상으로 남긴다.
|
||||
- 페이지 조회 중 조회 버튼의 disabled 속성이 변경되지 않아 버튼 색상 깜빡임이 발생하지 않는 구조를 확인했다.
|
||||
- 표·열 layout과 데이터행 높이 고정, 본문 한 줄 말줄임을 코드에 반영했다.
|
||||
- 사용자 Chrome 수동 확인에서는 1·2페이지 전환 시 그리드가 미세하게 달라 보이는 현상이 계속 관찰됐다. 기능 오류는 아니며 사용자 결정으로 추가 보정 없이 알려진 UI 잔여사항으로 남긴다.
|
||||
|
||||
## 13. 2026-08-20 main 디자인 통합 충돌 검증
|
||||
|
||||
- `page.tsx`, `WaterRiskPopupPage.tsx`의 충돌 마커가 0건임을 확인했다.
|
||||
- `page.tsx`는 main 대비 통수위험 선택초기화·SWMM 카드 변경만 남고 비통수위험 충돌은 upstream 구조로 복원됐다.
|
||||
- `WaterRiskPopupPage.tsx`는 main 공통 팝업 구조를 유지하면서 정렬·최대 침수유량·종료 메시지를 포함한다.
|
||||
- 통수위험 팝업·클라이언트·공통 지도팝업 테이블·메시지 타입 대상 ESLint는 오류 0건이다. `page.tsx`에는 main HEAD에도 존재하는 import 순서 경고 1건만 남는다.
|
||||
- 임시 TypeScript 검사에서 이번 변경 파일 신규 오류는 없으며 기존 준설·도시침수예방·조사·공통 테이블의 `unknown` 타입 오류와 공통 패키지 모듈 해석 오류가 유지된다.
|
||||
|
||||
## 12. 2026-08-20 지도 상태카드·팝업 종료 검증
|
||||
|
||||
- 정보카드의 강우·침수 수치 표와 표 내부 구분선, 위험상태 배지가 유지되는 것을 정적 확인했다.
|
||||
- capacity 설명영역 자체에는 구분선을 두지 않고 수치표 시작 위치에 상단 경계선을 표시하며 설명의 `위험=#ff1c15`, `정상=#1677ff` 색상 계약을 사용한다.
|
||||
- 팝업 `pagehide`·`beforeunload` 종료 메시지와 예비 close watcher가 지역·관로·맨홀 source와 결과행·mapView·anchor·card style을 초기화하는 흐름을 확인했다.
|
||||
- `page.tsx` 대상 ESLint와 FE `git diff --check`가 통과했다.
|
||||
|
||||
## 8. 2026-08-18 탭별 검색조건·분석기준시간 검증
|
||||
|
||||
- 목록 API에서 대표실행 `ANLS_CRTR_DT`가 `analysisBaseAt`으로 반환되는 것을 확인했다.
|
||||
|
||||
@@ -205,13 +205,43 @@
|
||||
|
||||
- 관측강우 2개 열과 통수위험 3개 열을 묶는 2단 그룹 헤더로 변경했다.
|
||||
- 목록 행은 강우시작시간, 누적강우량, 예측강우량, 최대 침수 예상시점, 최대침수량, 위험 여부 순으로 표시한다.
|
||||
- 최대침수량 체적값은 현재 API 원천이 없어 `-`로 표시한다.
|
||||
- 최대 침수유량은 목록 API의 `maxFloodingFlow`를 `m³/s`로 표시해 지도카드와 동일한 값을 사용한다.
|
||||
|
||||
## 13. 2026-08-18 팝업 크기·현재 분석기준시간 자동갱신
|
||||
|
||||
- 팝업 정의와 화면 `resizeTo`를 모두 `1360×600`으로 통일했다.
|
||||
- 팝업 생성 정의를 10행과 페이지네이션이 한 화면에 표시되는 `1380×900`으로 지정했다.
|
||||
- 현재 탭 결과 제목 우측의 별도 SWMM 설명문을 제거했다.
|
||||
- 현재 탭이 열린 동안 매시간 `HH:00`에 현재 정시를 새 분석기준시간으로 적용하고 동일 `ANLS_CRTR_DT` 결과를 자동 재조회한다.
|
||||
- 탭을 과거분석이력으로 전환하거나 팝업을 닫으면 자동갱신 타이머를 해제한다.
|
||||
- 분석기준시간은 DB 결과와 독립된 현재 정시이므로 진행중 데이터가 없어도 사라지지 않는다. 입력창으로 오해하지 않도록 테두리·배경 없이 글씨만 표시한다.
|
||||
- `분석기준시간` 라벨과 시각은 현재 탭 행의 오른쪽 끝으로 이동하고 검색패널에는 중점관리지역 선택과 우측 조회 버튼만 유지한다.
|
||||
- 행 선택 성공 후 왼쪽 아래 안내문은 제거하고 오류 메시지만 조건부 표시한다.
|
||||
- 지도 요약카드에는 capacity 0.8 초과 관로 존재 여부가 위험/정상을 결정한다는 설명을 추가했다.
|
||||
- 결과표 `No.`를 `No`로 바꾸고 표 전체를 `1320px` 고정폭으로 지정한 뒤 `table-fixed`·`colgroup` 비율을 적용해 1·2페이지의 절대 열 너비를 동일하게 고정했다.
|
||||
- 공통 그리드 교체 후 검색영역과 결과영역의 가로·세로 overflow를 숨기고 결과영역 좌우 여백을 보정해 팝업 내부 스크롤바가 나타나지 않게 했다.
|
||||
|
||||
## 14. 2026-08-20 통수위험 결과표 공통 그리드 교체
|
||||
|
||||
- main 디자인 통합 이후 `StandardPopupLayout`, `PopupSearchPanel`, `MapPopupDataTable`을 사용하는 공통 팝업 구조로 전환했다.
|
||||
- main의 검색패널·결과카드·공통 페이지네이션·테마 토큰을 유지하고 기존 10개 데이터 열, 위험/정상 배지, 행 클릭 지도연계를 연결했다.
|
||||
- 사용자 지정 9개 열에만 정렬 아이콘을 표시하고 `sort=field,direction`으로 전체 결과 서버 정렬 후 첫 페이지를 다시 조회한다.
|
||||
- 위험 여부는 정렬 대상에서 제외했다.
|
||||
- 공통 결과카드의 헤더, 선택행 스타일, 페이지네이션과 `조회된 결과가 없습니다.` 빈 결과 표현을 적용했다.
|
||||
- 페이지 캡처의 전체 창 너비가 1페이지 `1367px`, 2페이지 `1372px`로 달라지는 것을 확인해 컴포넌트의 `window.resizeTo` 호출을 완전히 제거했다. 창 크기는 팝업 생성 정의에서만 결정한다.
|
||||
- 페이지 조회 중에는 조회 버튼의 disabled 스타일을 변경하지 않아 버튼 깜빡임을 제거했다.
|
||||
- 페이지별 지역명·숫자 길이가 달라도 표가 재계산되지 않도록 표 너비와 행 높이를 고정하고 모든 본문 셀을 한 줄 말줄임 처리했다.
|
||||
- 사용자 Chrome 재확인에서는 위 보정 후에도 1·2페이지 전환 시 그리드가 미세하게 달라 보이는 현상이 남았다. 조회·정렬·행 선택 기능에는 영향이 없으며 사용자 결정에 따라 현 상태로 마감한다.
|
||||
|
||||
## 16. 2026-08-20 main 디자인 통합 충돌 해소
|
||||
|
||||
- `page.tsx`의 `Updated upstream / Stashed changes` 충돌 마커 9개를 모두 제거했다.
|
||||
- 비통수위험 import·GIS·팝업 구조는 main upstream을 선택하고 통수위험 SWMM 카드, 지도 초기화와 종료 메시지만 다시 적용했다.
|
||||
- `WaterRiskPopupPage.tsx`는 main HEAD 파일로 복원한 뒤 최대 침수유량, 서버 정렬, 탭명·검색문구, 성공 안내 제거와 팝업 종료 메시지를 재적용했다.
|
||||
- main의 `MapPopupDataTable`에 선택 정렬 props를 추가해 다른 팝업의 기존 호출은 변경하지 않고 통수위험에서만 정렬을 사용한다.
|
||||
|
||||
## 15. 2026-08-20 지도 상태카드·팝업 종료 초기화
|
||||
|
||||
- 지도 정보카드의 현재 누적강우량·예측강우량·최대 침수유량 표와 표 내부 구분선은 유지한다.
|
||||
- 중점관리지역 아래 capacity 설명 자체에는 구분선을 두지 않고 문장 안의 `위험`은 빨간색, `정상`은 파란색 텍스트로 강조한다. 설명과 수치표의 경계인 표 상단선은 유지한다.
|
||||
- 기존 위험상태 배지와 카드 `290px` 폭을 유지한다.
|
||||
- 팝업은 `pagehide`·`beforeunload`에서 `WATER_RISK_CLOSED` 메시지를 부모로 전달한다. 부모는 메시지 수신 즉시 중점관리지역·관로·맨홀 레이어, capacity/Flooding 범례, 정보카드와 선택상태를 모두 초기화하며 300ms close watcher는 예비 감시로 유지한다.
|
||||
|
||||
@@ -64,6 +64,7 @@ export const WINDOW_POPUP_MESSAGE_TYPE = {
|
||||
WATER_RISK_READY: "sn:popup:waterRiskReady",
|
||||
WATER_RISK_SYNC: "sn:popup:waterRiskSync",
|
||||
WATER_RISK_SELECT_RESULT: "sn:popup:waterRiskSelectResult",
|
||||
WATER_RISK_CLOSED: "sn:popup:waterRiskClosed",
|
||||
REPAIR_HISTORY_SHOW_RESULTS: "sn:popup:repairHistoryShowResults",
|
||||
REPAIR_HISTORY_SELECT_PIPE: "sn:popup:repairHistorySelectPipe",
|
||||
REPAIR_HISTORY_CLEAR: "sn:popup:repairHistoryClear",
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
|
||||
import {
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
type PaginationState,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import {
|
||||
StandardDataTable,
|
||||
getStandardRowNumber,
|
||||
type ScrollMode,
|
||||
StandardDataTable,
|
||||
type UiHeadProps,
|
||||
} from "@common/business_components";
|
||||
import type { UiTableSort } from "@common/business_components/ui/table";
|
||||
|
||||
type MapPopupDataTableProps<T extends Record<string, unknown>> = {
|
||||
rows: T[];
|
||||
@@ -40,6 +42,8 @@ type MapPopupDataTableProps<T extends Record<string, unknown>> = {
|
||||
headId: string,
|
||||
rowIndex: number,
|
||||
) => ReactNode | undefined;
|
||||
sort?: UiTableSort;
|
||||
onSortChange?: (sort: UiTableSort | undefined) => void;
|
||||
};
|
||||
|
||||
const ROW_NUMBER_IDS = new Set(["no", "rowNo", "rowNumber"]);
|
||||
@@ -72,6 +76,8 @@ export default function MapPopupDataTable<T extends Record<string, unknown>>({
|
||||
onCellClick,
|
||||
clickableHeadIds,
|
||||
renderCell,
|
||||
sort,
|
||||
onSortChange,
|
||||
}: MapPopupDataTableProps<T>) {
|
||||
const visibleHeads = useMemo(
|
||||
() =>
|
||||
@@ -85,7 +91,33 @@ export default function MapPopupDataTable<T extends Record<string, unknown>>({
|
||||
visibleHeads.map((head) => ({
|
||||
id: head.id,
|
||||
accessorFn: (row) => row[head.id],
|
||||
header: ROW_NUMBER_IDS.has(head.id) ? "순번" : head.title,
|
||||
header: () => {
|
||||
const title = ROW_NUMBER_IDS.has(head.id) ? "순번" : head.title;
|
||||
if (!head.sortable || !onSortChange) return title;
|
||||
const key = head.sortKey ?? head.id;
|
||||
const direction = sort?.key === key ? sort.direction : undefined;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex w-full items-center justify-center gap-1.5"
|
||||
aria-label={`${title} 정렬`}
|
||||
onClick={() => {
|
||||
if (direction === undefined) {
|
||||
onSortChange({ key, direction: "asc" });
|
||||
} else if (direction === "asc") {
|
||||
onSortChange({ key, direction: "desc" });
|
||||
} else {
|
||||
onSortChange(undefined);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span>{title}</span>
|
||||
<span className="text-[8px] leading-none" aria-hidden="true">
|
||||
{direction === "asc" ? "▲" : direction === "desc" ? "▼" : "↕"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
cell: ({ row, getValue }) => {
|
||||
const rowIndex = row.index;
|
||||
const rendered = renderCell?.(row.original, head.id, rowIndex);
|
||||
@@ -132,8 +164,10 @@ export default function MapPopupDataTable<T extends Record<string, unknown>>({
|
||||
page,
|
||||
pageSize,
|
||||
renderCell,
|
||||
sort,
|
||||
total,
|
||||
visibleHeads,
|
||||
onSortChange,
|
||||
],
|
||||
);
|
||||
const pagination: PaginationState = {
|
||||
|
||||
Reference in New Issue
Block a user