sn: 침수예측현황-단위 엑셀다운로드 수정

This commit is contained in:
2026-08-18 13:31:22 +09:00
parent c9e3ff0d91
commit f7218fc98a
5 changed files with 483 additions and 17 deletions
@@ -60,7 +60,7 @@ const ANALYSIS_POPUP_ITEMS: Array<{
}> = [
{ key: "infoSearch", label: "정보검색", icon: Search },
{ key: "pipeAnalysisPopup", label: "관로분석", icon: ChartColumnBig },
{ key: "floodPredictionMapPopup", label: "침수예측현황", icon: Waves },
{ key: "fldngPredcPrst", label: "침수예측현황", icon: Waves },
{
key: "agedPipeStatusByCtpvPopup",
label: "시도별 노후관로현황",
@@ -0,0 +1,68 @@
# SFR-011-008 침수예측현황 FE 작업계획
## 작업 목적
지도 분석도구의 기존 침수예측현황 placeholder를 검색·목록·Excel·지도연계가 가능한 실제 WindowPopup으로 전환한다.
## 근거
- 요구사항: `SFR-011-008`
- 화면: `SN_MN010205 침수예측현황`
- route: `/popup/fldngPredcPrst`
- 참고: `PipeAnalysisPopupPage`, `FloodDngrMapPopupPage`, `/oper/obsltPplBiz`
## 수정 예정 범위
- `src/features/fldngPredcPrst/{api,model,ui}/**`
- `src/features/windowPopup/model/popupDefinitions.ts`
- `src/features/windowPopup/registry.tsx`
- `app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx`
- 공통 `app/(popupLayout)/popup/[popupKey]` route는 재사용하고 popup key를 `fldngPredcPrst`로 변경한다.
- 공통 패키지는 수정하지 않는다.
## 화면·상태 계약
- 강우시작시각은 최초 진입 시 미입력으로 두고, 분석일시는 오늘 00:00~현재 KST를 기본값으로 설정한다.
- 시도 변경 시 시군구를 전체로 초기화한다.
- 입력 조건과 실제 적용 조건을 분리하여 페이지 이동이 마지막 조회조건을 유지한다.
- 20건 서버 페이징, 허용 컬럼 서버 정렬, 전체건수 기반 no를 제공한다.
- 시도 헤더는 `ctpvCd` 서버 정렬키를 사용한다.
- 강수량은 소수점 한 자리로 표시하고 `초과빈도년도` 값은 `년` 접미사 없이 숫자로 표시한다.
- `ListDiv` 내부에서 최소 테이블 폭과 가로 스크롤을 제공한다.
- Excel은 현재 적용 검색조건을 동일하게 전달한다.
- 신규 파일과 함수에 화면/API 역할 한글 주석을 작성한다.
## 지도 연계
- 행 선택 시 기존 도시침수지도 group extent API를 호출한다.
- 부모 창에 기존 `MAP_SEARCH_SHOW_FLOOD_GROUP` 메시지를 보내 범위 이동·레이어 표시를 요청한다.
- 도시침수지도에 없는 빈도는 자동 변환하지 않고 화면 안내를 표시한다.
## 검증
- TypeScript 정적검사 및 `sn` 빌드(실행 중 개발서버가 없거나 사용자 확인 후)
- `/popup/fldngPredcPrst` 직접 진입
- 분석도구 클릭 별도 창 진입
- 기본 날짜·종속 시군구·조회·페이지·정렬·가로스크롤
- Excel 파일명/열/검색조건
- 행 클릭 후 부모 지도 Network·extent·레이어 동작
## 롤백과 위험
- 신규 route/feature를 제거하고 registry를 placeholder로 복원한다.
- 팝업 경로와 기존 popup key가 다르므로 route 연결 누락을 회귀 확인한다.
- 행정구역 코드와 도시침수지도 빈도 자료가 없는 행은 지도 이동이 제한될 수 있다.
## 구현 결과 (2026-08-13)
- 분석도구 메뉴와 WindowPopup key를 `fldngPredcPrst`로 변경하고 placeholder를 실제 화면으로 교체했다.
- 강우시작시각 미입력·분석일시 당일 00:00~현재 기본값, 시도·시군구 종속조건, 두 날짜 범위, 빈도년도 검색을 구현했다.
- 20건 서버 페이징·정렬, `ListDiv` 가로 스크롤, 적용조건 기준 Excel 다운로드를 구현했다.
- 행 클릭 시 기존 extent API와 `MAP_SEARCH_SHOW_FLOOD_GROUP` 메시지를 사용한다.
- 도시침수지도가 없는 빈도는 30·50·80년 중 임의 근사하지 않고 안내한다.
- 2026-08-18 시도 헤더를 정렬 가능 상태로 전환하고 `ctpvCd` 서버 정렬을 연결했다.
- `pnpm --filter sn run build`를 통과했다.
- 독립 `tsc --noEmit`은 현재 수정과 무관한 조사사업·침수이력·지도·노후관로·팝업 registry 등의 기존 타입 오류로 실패했다. 이번 변경 파일에서는 오류가 보고되지 않았다.
- 2026-08-18 목록 강수량을 소수점 한 자리로, 헤더를 `초과빈도년도`로 변경하고 값의 `년` 접미사를 제거했다.
- 기존 `.next` 산출물을 재생성한 뒤 `pnpm --filter sn run build`를 최종 통과했다.
- TypeScript 정적검사는 저장소 기존 `ignoreDeprecations` 설정과 실행 중 산출물 잠금 때문에 완료하지 못했고, Next 빌드는 최적화 단계 장기 대기 후 중단했다.
@@ -0,0 +1,406 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@common/components";
import { UiTable } from "@common/business_components/ui/table";
import type { UiHeadProps, UiTableSort } from "@common/business_components/ui";
import ListDiv from "@common/business_components/ui/list_div";
import { getBlobData, getRawJson } from "@/components/lib/api";
import { fileDownload } from "@/components/lib/utils/fileDownload";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import useStore from "@/store/useStore";
type ApiRow = {
sggCd: string;
ctpvCd: string;
ctpvNm: string;
sggNm: string;
rainBeginningDateTime: string;
analysisDateTime: string;
observedAccumulatedRainfall: number;
rainHour: number;
rainfall: number;
exceededFrequencyYear: number;
floodRiskGrade: string;
};
type TableRow = ApiRow & {
rowNo: number;
rainBeginningDateTimeDisplay: string;
analysisDateTimeDisplay: string;
observedAccumulatedRainfallDisplay: string;
rainHourDisplay: string;
rainfallDisplay: string;
exceededFrequencyYearDisplay: string;
};
type Filters = {
ctpvCd: string;
sggCd: string;
rainBgngFrom: string;
rainBgngTo: string;
analysisFrom: string;
analysisTo: string;
frequencyYear: string;
};
const PAGE_SIZE = 20;
const MAP_FREQUENCIES = new Set([30, 50, 80]);
const HEADS: UiHeadProps[] = [
{ id: "rowNo", title: "NO", widthPercent: 5, sortable: false },
{
id: "ctpvNm",
title: "시도",
widthPercent: 9,
sortable: true,
sortKey: "ctpvCd",
},
{
id: "sggNm",
title: "시군구",
widthPercent: 10,
sortable: true,
sortKey: "sggCd",
},
{
id: "rainBeginningDateTimeDisplay",
title: "강우 시작시각",
widthPercent: 13,
sortable: true,
sortKey: "rainBeginningDateTime",
},
{
id: "analysisDateTimeDisplay",
title: "분석시각",
widthPercent: 13,
sortable: true,
sortKey: "analysisDateTime",
},
{
id: "observedAccumulatedRainfallDisplay",
title: "관측 누적강수량(mm)",
widthPercent: 13,
sortable: true,
sortKey: "observedAccumulatedRainfall",
},
{
id: "rainHourDisplay",
title: "강우시간",
widthPercent: 9,
sortable: true,
sortKey: "rainHour",
},
{
id: "rainfallDisplay",
title: "강수량(mm)",
widthPercent: 10,
sortable: false,
},
{
id: "exceededFrequencyYearDisplay",
title: "초과빈도년도",
widthPercent: 9,
sortable: true,
sortKey: "exceededFrequencyYear",
},
{
id: "floodRiskGrade",
title: "위험등급",
widthPercent: 9,
sortable: true,
sortKey: "floodRiskGrade",
},
];
const localDateTime = (date: Date) =>
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}T${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
const initialFilters = (): Filters => {
const now = new Date();
const from = new Date(now);
from.setHours(0, 0, 0, 0);
return {
ctpvCd: "0",
sggCd: "0",
// 최초 조회에서는 강우 시작시각을 제한하지 않고 전체 강우사상을 대상으로 한다.
rainBgngFrom: "",
rainBgngTo: "",
analysisFrom: localDateTime(from),
analysisTo: localDateTime(now),
frequencyYear: "",
};
};
const apiDate = (value: string) => value.replace(/[^0-9]/g, "").slice(0, 12);
const displayDate = (value: string) =>
/^\d{12}$/.test(value)
? `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)} ${value.slice(8, 10)}:${value.slice(10, 12)}`
: value;
const rainHourText = (value: number) =>
value === 0 ? "현재" : `${value > 0 ? "+" : "-"}${Math.abs(value)}시간`;
const query = (filters: Filters, page?: number, sort?: UiTableSort) => {
const p = new URLSearchParams();
if (page !== undefined) {
p.set("page", String(page - 1));
p.set("size", String(PAGE_SIZE));
}
if (filters.ctpvCd !== "0") p.set("ctpvCd", filters.ctpvCd.slice(0, 2));
if (filters.sggCd !== "0") p.set("sggCd", filters.sggCd.slice(0, 5));
["rainBgngFrom", "rainBgngTo", "analysisFrom", "analysisTo"].forEach(
(key) => {
const value = apiDate(filters[key as keyof Filters]);
if (value) p.set(key, value);
},
);
if (filters.frequencyYear) p.set("frequencyYear", filters.frequencyYear);
if (sort?.key && sort.direction)
p.set("sort", `${sort.key},${sort.direction}`);
return p.toString();
};
export default function FldngPredcPrstPopupContent() {
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const [filters, setFilters] = useState<Filters>(() => initialFilters());
const [applied, setApplied] = useState<Filters>(() => initialFilters());
const [rows, setRows] = useState<TableRow[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [sort, setSort] = useState<UiTableSort>();
const [loading, setLoading] = useState(false);
const [selected, setSelected] = useState(-1);
useEffect(() => {
if (!ctpv.length) void fetchCtpv();
if (!sgg.length) void fetchSgg();
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
const sggOptions = useMemo(
() =>
filters.ctpvCd === "0"
? sgg
: sgg.filter((item) =>
String(item.val).startsWith(filters.ctpvCd.slice(0, 2)),
),
[filters.ctpvCd, sgg],
);
const load = useCallback(
async (targetPage: number, condition: Filters, nextSort?: UiTableSort) => {
setLoading(true);
try {
const response = await getRawJson(
`/sewerage-service/api/v1/fldng/predc/prst?${query(condition, targetPage, nextSort)}`,
);
const payload = response?.data ?? response;
const content: ApiRow[] = Array.isArray(payload?.content)
? payload.content
: [];
setRows(
content.map((item, index) => ({
...item,
rowNo: (targetPage - 1) * PAGE_SIZE + index + 1,
rainBeginningDateTimeDisplay: displayDate(
item.rainBeginningDateTime,
),
analysisDateTimeDisplay: displayDate(item.analysisDateTime),
observedAccumulatedRainfallDisplay: Number(
item.observedAccumulatedRainfall ?? 0,
).toFixed(4),
rainHourDisplay: rainHourText(Number(item.rainHour ?? 0)),
rainfallDisplay: Number(item.rainfall ?? 0).toFixed(1),
exceededFrequencyYearDisplay: String(
item.exceededFrequencyYear ?? "",
),
})),
);
setTotal(Number(payload?.totalElements ?? 0));
setPage(targetPage);
setApplied(condition);
setSort(nextSort);
setSelected(-1);
} catch (error) {
console.error(error);
alert(
error instanceof Error
? error.message
: "침수예측현황 조회에 실패했습니다.",
);
} finally {
setLoading(false);
}
},
[],
);
useEffect(() => {
const defaults = initialFilters();
setFilters(defaults);
void load(1, defaults);
}, [load]);
const mapRow = async (index?: number) => {
if (typeof index !== "number") return;
const row = rows[index];
if (!row) return;
setSelected(index);
if (!MAP_FREQUENCIES.has(row.exceededFrequencyYear)) {
alert(
`${row.exceededFrequencyYear}년 빈도 도시침수지도는 제공되지 않습니다. (30·50·80년 빈도만 제공)`,
);
return;
}
setLoading(true);
try {
const sbmrgFrqExpln = `${row.exceededFrequencyYear}년`;
const p = new URLSearchParams({ sggCd: row.sggCd, sbmrgFrqExpln });
const extentData = await getRawJson(
`/sewerage-service/api/v1/fldng/flood-dngr-map/group/extent?${p}`,
);
const extent = [
extentData.minX,
extentData.minY,
extentData.maxX,
extentData.maxY,
].map(Number);
if (extent.some((v) => !Number.isFinite(v)))
throw new Error("도시침수지도 위치 정보가 없습니다.");
window.opener?.postMessage(
{
type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_FLOOD_GROUP,
payload: { extent, sggCd: row.sggCd, sbmrgFrqExpln },
},
window.location.origin,
);
} catch (error) {
alert(
error instanceof Error ? error.message : "지도 이동에 실패했습니다.",
);
} finally {
setLoading(false);
}
};
const download = async () => {
if (!total) return alert("다운로드할 데이터가 없습니다.");
setLoading(true);
try {
const blob = await getBlobData(
`/sewerage-service/api/v1/fldng/predc/prst/excel?${query(applied, undefined, sort)}`,
);
fileDownload(blob, "침수예측현황.xlsx");
} catch (error) {
alert(
error instanceof Error
? error.message
: "Excel 다운로드에 실패했습니다.",
);
} finally {
setLoading(false);
}
};
const field = (key: keyof Filters, label: string) => (
<label className="sn-popup-field">
<div className="sn-popup-field-label">{label}</div>
<input
className="sn-popup-input"
type="datetime-local"
value={filters[key]}
onChange={(e) => setFilters((v) => ({ ...v, [key]: e.target.value }))}
/>
</label>
);
return (
<main className="sn-popup-page w-full max-w-full overflow-hidden">
<LoadingOverlay isLoading={loading} />
<header className="sn-popup-header">
<h1 className="sn-popup-title">침수예측현황</h1>
<div />
</header>
<section className="flex min-h-0 flex-1 flex-col px-4 pb-3 pt-3">
<div className="sn-popup-filter-panel-compact grid grid-cols-4 gap-3">
<label className="sn-popup-field">
<div className="sn-popup-field-label">시도</div>
<select
className="sn-popup-input"
value={filters.ctpvCd}
onChange={(e) =>
setFilters((v) => ({
...v,
ctpvCd: e.target.value,
sggCd: "0",
}))
}
>
<option value="0">전체</option>
{ctpv.map((x) => (
<option key={x.val} value={String(x.val)}>
{x.text}
</option>
))}
</select>
</label>
<label className="sn-popup-field">
<div className="sn-popup-field-label">시군구</div>
<select
className="sn-popup-input"
value={filters.sggCd}
onChange={(e) =>
setFilters((v) => ({ ...v, sggCd: e.target.value }))
}
>
<option value="0">전체</option>
{sggOptions.map((x) => (
<option key={x.val} value={String(x.val)}>
{x.text}
</option>
))}
</select>
</label>
{field("rainBgngFrom", "강우 시작시각 From")}
{field("rainBgngTo", "강우 시작시각 To")}
{field("analysisFrom", "분석시각 From")}
{field("analysisTo", "분석시각 To")}
<label className="sn-popup-field">
<div className="sn-popup-field-label">초과 빈도년도</div>
<input
className="sn-popup-input"
type="number"
min="0"
value={filters.frequencyYear}
onChange={(e) =>
setFilters((v) => ({ ...v, frequencyYear: e.target.value }))
}
/>
</label>
<div className="sn-popup-actions self-end">
<Button
size="sm"
color="primary"
onClick={() => void load(1, filters, sort)}
>
조회
</Button>
<Button size="sm" color="primary" onClick={() => void download()}>
다운로드
</Button>
</div>
</div>
<ListDiv className="mt-3 min-h-0 flex-1 overflow-x-auto">
<div className="min-w-[1450px]">
<UiTable<TableRow>
headName={
<>침수예측현황 조회 결과 총 {total.toLocaleString()}건</>
}
tableData={rows}
headlist={HEADS}
pageSize={PAGE_SIZE}
total={total}
curPage={page}
onPageChange={(p) => void load(p, applied, sort)}
cellClick={(i) => void mapRow(i)}
index={selected}
widthFixed={true}
isHideCount={true}
sort={sort}
onSortChange={(s) => void load(1, applied, s)}
/>
</div>
</ListDiv>
<div className="sn-popup-help-text">
행 클릭 시 해당 시군구·빈도의 도시침수지도로 이동합니다.
</div>
</section>
</main>
);
}
@@ -188,11 +188,11 @@ export const windowPopupDefinitions = {
height: 760,
requiredParams: ["zoneId"],
},
floodPredictionMapPopup: {
key: "floodPredictionMapPopup",
fldngPredcPrst: {
key: "fldngPredcPrst",
title: "침수예측현황",
width: 1080,
height: 760,
width: 1500,
height: 860,
},
agedPipeStatusByCtpvPopup: {
key: "agedPipeStatusByCtpvPopup",
+4 -12
View File
@@ -27,8 +27,8 @@ import SinkholePopupContent from "./ui/SinkholePopupContent";
import SbmrgDngrMapPopupContent from "./ui/SbmrgDngrMapPopupContent";
import FloodDngrMapPopupContent from "./ui/FloodDngrMapPopupContent";
import SscDetailPopupContent from "./ui/SscDetailPopupContent";
import type { WindowPopupContentProps } from "./model/types";
import FeaturePlaceholderPopupContent from "./ui/FeaturePlaceholderPopupContent";
import FldngPredcPrstPopupContent from "../fldngPredcPrst/ui/FldngPredcPrstPopupContent";
import FldngHstryPopupContent from "./ui/FldngHstryPopupContent";
import GisEditSessionPopupContent from "./ui/GisEditSessionPopupContent";
import GisManagementPopupContent from "./ui/GisManagementPopupContent";
@@ -38,14 +38,6 @@ import RepairHistoryPopupContent from "./ui/RepairHistoryPopupContent";
import WaterRiskPopupContent from "./ui/WaterRiskPopupContent";
import CfmtnBgtPopupContent from "../cfmtnBgt/ui/CfmtnBgtPopupContent";
const FloodPredictionPlaceholderPopupContent = (
_props: WindowPopupContentProps,
) => (
<FeaturePlaceholderPopupContent
title={windowPopupDefinitions.floodPredictionMapPopup.title}
/>
);
const GisEditPopupContent = (_props: WindowPopupContentProps) => (
<FeaturePlaceholderPopupContent
title={windowPopupDefinitions.gisEditPopup.title}
@@ -161,9 +153,9 @@ export const windowPopupRegistry = {
...windowPopupDefinitions.repairHistoryPopup,
component: RepairHistoryPopupContent,
},
floodPredictionMapPopup: {
...windowPopupDefinitions.floodPredictionMapPopup,
component: FloodPredictionPlaceholderPopupContent,
fldngPredcPrst: {
...windowPopupDefinitions.fldngPredcPrst,
component: FldngPredcPrstPopupContent,
},
agedPipeStatusByCtpvPopup: {
...windowPopupDefinitions.agedPipeStatusByCtpvPopup,