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:
2026-08-20 17:16:33 +09:00
8 changed files with 255 additions and 156 deletions
@@ -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[];
+68 -106
View File
@@ -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 = {