From e5f7778861a048e7bcca4ea78fb47c5c48655c03 Mon Sep 17 00:00:00 2001
From: ljpark
Date: Fri, 2 Oct 2026 18:07:00 +0900
Subject: [PATCH 1/2] =?UTF-8?q?sn:=20=ED=99=95=EB=A5=A0=EA=B0=95=EC=9A=B0?=
=?UTF-8?q?=EB=9F=89=20=EB=AA=A8=EB=8B=AC=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
...ce-SFR-011-008-침수예측현황-확률강우량-작업계획.md | 41 ++++
...fe-SFR-011-008-침수예측현황-확률강우량-구현기록.md | 19 ++
.../fldngPredcPrst/ui/PrbbtRnflDialog.tsx | 204 ++++++++++++++++++
3 files changed, 264 insertions(+)
create mode 100644 apps/sn/docs/작업계획/SFR-011-008-침수예측/002-maintenance-SFR-011-008-침수예측현황-확률강우량-작업계획.md
create mode 100644 apps/sn/docs/작업내용/SFR-011-008-침수예측/005-fe-SFR-011-008-침수예측현황-확률강우량-구현기록.md
create mode 100644 apps/sn/src/features/fldngPredcPrst/ui/PrbbtRnflDialog.tsx
diff --git a/apps/sn/docs/작업계획/SFR-011-008-침수예측/002-maintenance-SFR-011-008-침수예측현황-확률강우량-작업계획.md b/apps/sn/docs/작업계획/SFR-011-008-침수예측/002-maintenance-SFR-011-008-침수예측현황-확률강우량-작업계획.md
new file mode 100644
index 000000000..18497c7c6
--- /dev/null
+++ b/apps/sn/docs/작업계획/SFR-011-008-침수예측/002-maintenance-SFR-011-008-침수예측현황-확률강우량-작업계획.md
@@ -0,0 +1,41 @@
+# SFR-011-008 침수예측현황 확률강우량 FE 유지보수 작업계획
+
+## 작업 목적
+
+침수예측현황의 선택 시군구가 어떤 확률강우량 기준으로 위험도가 산정되는지 확인할 수 있도록 목록 열과 기준표 팝업을 추가한다.
+
+## 승인 상태
+
+- `maintenance` 작업계획
+- 2026-10-02 사용자 승인, 코드 구현 진행
+
+## 구현 계획
+
+- `FldngPredcPrstPopupContent`의 `침수위험도` 오른쪽에 `확률강우량` 액션을 추가한다.
+- 기준표 팝업 컴포넌트 파일은 표준용어를 사용한 `PrbbtRnflDialog.tsx`로 둔다.
+- 목록의 확률강우량은 `predc_rain_hr` 기준 `N시간` 형식의 클릭 가능한 지속시간으로 표시한다. 기존 `predictionHour`(`predc_hr`)와 구분한다.
+- 선택 행의 `sggCd`로 BE 확률강우량 조회 API를 호출한다.
+- API 경로는 사용자 지정 `GET /api/v1/fldng/predc/prst/prbbt-rnfl`을 사용한다.
+- 팝업 표는 재현기간을 행, `1시간`, `2시간`, `3시간` 형식의 지속시간을 열로 표시한다.
+- 팝업 하단에 확률관측소코드·관측소명·면적비율(%) 비율표를 표시한다.
+- 팝업 맨 아래에 `시간은 강우가 지속된 시간입니다` 안내를 표시한다.
+- 지속시간 헤더는 기존 팝업 표 헤더 스타일을 유지한다.
+- 선택 행의 대표 지속시간에 해당하는 열의 확률강우량 기준값 셀들을 강조 색상으로 표시한다. 표 헤더는 기존 스타일을 유지한다.
+- 강수량은 mm 단위로 표시하고 로딩·빈 결과·오류 상태를 제공한다.
+- 기존 행 클릭의 도시침수지도 이동과 별도 액션으로 동작시킨다.
+
+## 검증 계획
+
+- 제주·서울 등 서로 다른 시군구 기준표 조회
+- 1~72시간 행과 빈도년도 열 표시
+- API 오류·빈 결과 표시
+- 기존 지도 이동 회귀
+- `pnpm --filter sn run build`
+
+## 구현 결과 (2026-10-02)
+
+- 현황 목록에 `확률강우량` 열을 추가하고 대표 지속시간 `predcRainHr`를 `N시간` 링크로 표시한다.
+- 링크 클릭은 행 선택 지도 이동을 실행하지 않으며, 시군구별 기준표 팝업을 연다.
+- 팝업은 재현기간 행×지속시간 열의 확률강우량, 하단 관측소 면적비율과 API 합계를 표시한다.
+- 선택 지속시간 열의 값 셀만 강조하고 헤더는 기존 스타일을 유지한다.
+- `pnpm --filter sn run typecheck`, `git diff --check`가 통과했다. 3002 포트 개발 서버 실행 중이므로 build는 실행하지 않았다.
diff --git a/apps/sn/docs/작업내용/SFR-011-008-침수예측/005-fe-SFR-011-008-침수예측현황-확률강우량-구현기록.md b/apps/sn/docs/작업내용/SFR-011-008-침수예측/005-fe-SFR-011-008-침수예측현황-확률강우량-구현기록.md
new file mode 100644
index 000000000..a7071cccf
--- /dev/null
+++ b/apps/sn/docs/작업내용/SFR-011-008-침수예측/005-fe-SFR-011-008-침수예측현황-확률강우량-구현기록.md
@@ -0,0 +1,19 @@
+# SFR-011-008 침수예측현황 확률강우량 FE 구현기록
+
+## 구현 내용
+- 확률강우량 팝업 컴포넌트는 `PrbbtRnflDialog.tsx`에 둔다.
+- 침수위험도 오른쪽에 확률강우량 열을 추가하고 대표 누적강우 지속시간 `predcRainHr`를 `N시간` 링크로 표시했다.
+- 링크는 `GET /api/v1/fldng/predc/prst/prbbt-rnfl?sggCd=...`를 호출한다.
+- 팝업 본문에 재현기간 행×지속시간 열의 확률강우량표를 표시한다.
+- 선택 지속시간 열의 값 셀만 강조하고 표 헤더는 기존 스타일을 유지한다.
+- 팝업 하단에 확률관측소코드·관측소명·면적비율 및 API 합계를 표시한다.
+- 팝업 맨 아래에 `시간은 강우가 지속된 시간입니다` 안내를 표시한다.
+- 링크 클릭 이벤트는 기존 행 선택의 지도 이동으로 전파하지 않는다.
+
+## 검증
+- `pnpm --filter sn run typecheck` 성공
+- `git diff --check` 성공
+- 개발 서버(3002)가 실행 중이어서 build는 실행하지 않았다.
+
+## 수동 확인 필요
+- 실제 로그인 화면에서 팝업 크기·가로 스크롤·강조 색상 및 지도 이동 회귀 확인
diff --git a/apps/sn/src/features/fldngPredcPrst/ui/PrbbtRnflDialog.tsx b/apps/sn/src/features/fldngPredcPrst/ui/PrbbtRnflDialog.tsx
new file mode 100644
index 000000000..c870d5ab2
--- /dev/null
+++ b/apps/sn/src/features/fldngPredcPrst/ui/PrbbtRnflDialog.tsx
@@ -0,0 +1,204 @@
+"use client";
+
+import { useMemo } from "react";
+
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@common/components";
+
+/** 시군구별 면적가중 확률강우량 한 셀의 API 응답이다. */
+export type PrbbtRnflCell = {
+ frequencyYear: number;
+ durationHour: number;
+ rainfall: number;
+};
+
+/** 확률관측소의 시군구 교차면적 비율 API 응답이다. */
+export type PrbbtRnflStation = {
+ prbbtObsvtrCd: string;
+ prbbtObsvtrNm: string;
+ areaRt: number;
+};
+
+/** 확률강우량 표와 그 계산에 사용한 관측소 비율을 함께 받는다. */
+export type PrbbtRnflResponse = {
+ sggCd: string;
+ rainfall: PrbbtRnflCell[];
+ stations: PrbbtRnflStation[];
+ areaRtTotal: number;
+};
+
+type PrbbtRnflDialogProps = {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ sggName: string;
+ selectedDurationHour: number | null;
+ data: PrbbtRnflResponse | null;
+ loading: boolean;
+ error: string | null;
+};
+
+const formatRainfall = (value: number) =>
+ Number.isFinite(Number(value)) ? Number(value).toFixed(4) : "-";
+
+const formatAreaRatio = (value: number) =>
+ Number.isFinite(Number(value)) ? Number(value).toFixed(3) : "-";
+
+/** 재현기간을 행, 강우지속시간을 열로 구성하고 목록에서 선택한 지속시간의 기준값을 강조한다. */
+export default function PrbbtRnflDialog({
+ open,
+ onOpenChange,
+ sggName,
+ selectedDurationHour,
+ data,
+ loading,
+ error,
+}: PrbbtRnflDialogProps) {
+ const { frequencies, durations, rainfallByKey } = useMemo(() => {
+ const rainfall = data?.rainfall ?? [];
+ return {
+ frequencies: [...new Set(rainfall.map((item) => item.frequencyYear))].sort(
+ (a, b) => a - b,
+ ),
+ durations: [...new Set(rainfall.map((item) => item.durationHour))].sort(
+ (a, b) => a - b,
+ ),
+ rainfallByKey: new Map(
+ rainfall.map((item) => [
+ `${item.frequencyYear}:${item.durationHour}`,
+ item.rainfall,
+ ]),
+ ),
+ };
+ }, [data]);
+
+ return (
+
+ );
+}
From 8ed4f959cbc3c3de4066747841e6fef5c4475a0c Mon Sep 17 00:00:00 2001
From: ljpark
Date: Fri, 2 Oct 2026 18:12:03 +0900
Subject: [PATCH 2/2] =?UTF-8?q?sn:=20=ED=99=95=EB=A5=A0=EA=B0=95=EC=9A=B0?=
=?UTF-8?q?=EB=9F=89=20=EB=AA=A8=EB=8B=AC=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
apps/dm/AGENTS.md | 13 ++
apps/fo/AGENTS.md | 13 ++
apps/sn/AGENTS.md | 38 +++++-
...ce-SFR-011-008-침수예측현황-확률강우량-작업계획.md | 10 +-
...fe-SFR-011-008-침수예측현황-확률강우량-구현기록.md | 5 +-
.../api/cty-fldng-prvtn-biz-api.ts | 6 +
.../ui/CtyFldngPrvtnBizFormPage.tsx | 118 +++++++++++++++---
.../ui/CtyFldngPrvtnBizListPage.tsx | 20 +--
.../ui/FldngPredcPrstPopupContent.tsx | 95 +++++++++++++-
.../fldngPredcPrst/ui/PrbbtRnflDialog.tsx | 45 +++++--
.../windowPopup/ui/MapPopupDataTable.tsx | 13 +-
apps/wr/AGENTS.md | 16 ++-
12 files changed, 347 insertions(+), 45 deletions(-)
diff --git a/apps/dm/AGENTS.md b/apps/dm/AGENTS.md
index 7eefb1676..ef14f5097 100644
--- a/apps/dm/AGENTS.md
+++ b/apps/dm/AGENTS.md
@@ -1,6 +1,19 @@
## Project
This repository contains a Next.js web application.
+## Portable paths
+
+Resolve paths from this `apps/dm/AGENTS.md` file, not from a developer-specific drive or the current shell directory.
+
+- `FE_APP_ROOT`: directory containing this file (`apps/dm`)
+- `FE_REPO_ROOT`: `FE_APP_ROOT/../..` (`frontend`)
+- `WORKSPACE_ROOT`: `FE_REPO_ROOT/..`
+- `COMMON_ROOT_AGENTS`: `WORKSPACE_ROOT/AGENTS.md`
+- `BE_REPO_ROOT`: `WORKSPACE_ROOT/IIPBS` (Backend repository root)
+- `BE_SERVICE_ROOT`: `BE_REPO_ROOT/sewerage-service`
+
+Use an explicitly supplied repository path when the repositories are installed under different parent directories. Verify a calculated path exists before using it.
+
Main technologies:
- Next.js
- TypeScript
diff --git a/apps/fo/AGENTS.md b/apps/fo/AGENTS.md
index 7eefb1676..14b50f71b 100644
--- a/apps/fo/AGENTS.md
+++ b/apps/fo/AGENTS.md
@@ -1,6 +1,19 @@
## Project
This repository contains a Next.js web application.
+## Portable paths
+
+Resolve paths from this `apps/fo/AGENTS.md` file, not from a developer-specific drive or the current shell directory.
+
+- `FE_APP_ROOT`: directory containing this file (`apps/fo`)
+- `FE_REPO_ROOT`: `FE_APP_ROOT/../..` (`frontend`)
+- `WORKSPACE_ROOT`: `FE_REPO_ROOT/..`
+- `COMMON_ROOT_AGENTS`: `WORKSPACE_ROOT/AGENTS.md`
+- `BE_REPO_ROOT`: `WORKSPACE_ROOT/IIPBS` (Backend repository root)
+- `BE_SERVICE_ROOT`: `BE_REPO_ROOT/sewerage-service`
+
+Use an explicitly supplied repository path when the repositories are installed under different parent directories. Verify a calculated path exists before using it.
+
Main technologies:
- Next.js
- TypeScript
diff --git a/apps/sn/AGENTS.md b/apps/sn/AGENTS.md
index f8b84ffa9..76b511b8f 100644
--- a/apps/sn/AGENTS.md
+++ b/apps/sn/AGENTS.md
@@ -43,14 +43,46 @@
## 공통 시작 기준
-Codex 작업 루트는 가능하면 `../../../../../`로 잡고, 공통 시작 기준은 `AGENTS.md`를 따른다.
+개발자마다 드라이브와 상위 작업 폴더 위치가 다르므로 특정 드라이브·사용자 디렉터리의 절대경로를 기준으로 사용하지 않는다.
+
+경로 별칭은 `apps/sn/AGENTS.md`가 있는 디렉터리(`FE_APP_ROOT`)를 기준으로 다음과 같이 해석한다. 상대경로는 현재 셸의 실행 위치가 아니라 이 파일의 위치를 기준으로 계산한다.
+
+- `FE_APP_ROOT`: 이 파일이 있는 `apps/sn` 디렉터리
+- `FE_REPO_ROOT`: `FE_APP_ROOT/../..`
+- `WORKSPACE_ROOT`: `FE_APP_ROOT/../../..`
+- `COMMON_ROOT_AGENTS`: `WORKSPACE_ROOT/AGENTS.md`
+- `BE_REPO_ROOT`: `WORKSPACE_ROOT/IIPBS` (Backend Git 저장소 루트)
+- `BE_SERVICE_ROOT`: `BE_REPO_ROOT/sewerage-service` (하수관로 백엔드 서비스 모듈)
+- `COMMON_DOCS_ROOT`: `BE_REPO_ROOT/docs`
+- `FE_APP_DOCS_ROOT`: `FE_APP_ROOT/docs`
+
+경로 결정은 아래 우선순위를 따른다.
+
+1. 작업 환경이나 사용자가 `WORKSPACE_ROOT`, `COMMON_DOCS_ROOT`, `BE_REPO_ROOT`, `BE_SERVICE_ROOT` 등의 유효한 경로를 명시한 경우 그 값을 우선한다. 저장소를 서로 다른 상위 폴더에 배치한 개발자도 이 방식으로 사용할 수 있다.
+2. 명시값이 없으면 위의 `FE_APP_ROOT` 기준 상대경로로 계산한다. 드라이브 문자나 사용자별 상위 디렉터리 이름이 달라도 같은 저장소 배치 구조를 사용할 수 있다.
+3. 계산한 경로는 사용 전에 대상 파일이나 디렉터리의 존재 여부를 확인한다. 없거나 후보가 여러 개인 경우 특정 절대경로를 추측하지 말고 현재 저장소에서 확인 가능한 작업만 진행한 뒤 필요한 경로를 사용자에게 확인한다.
+4. 작업 문서와 결과 보고에는 특별한 사유가 없으면 개발자별 절대경로 대신 위 경로 별칭이나 저장소 기준 상대경로를 기록한다.
+
+PowerShell에서 현재 프론트엔드 저장소를 기준으로 확인할 때는 다음처럼 동적으로 계산할 수 있다. 각 단계는 앞 단계에서 찾은 저장소 위치를 기반으로 하므로 개발자별 설치 경로에 의존하지 않는다.
+
+```powershell
+$feRepoRoot = (git rev-parse --show-toplevel).Trim()
+$feAppRoot = Join-Path $feRepoRoot 'apps/sn'
+$workspaceRoot = Split-Path $feRepoRoot -Parent
+$commonRootAgents = Join-Path $workspaceRoot 'AGENTS.md'
+$beRepoRoot = Join-Path $workspaceRoot 'IIPBS'
+$beServiceRoot = Join-Path $beRepoRoot 'sewerage-service'
+$commonDocsRoot = Join-Path $beRepoRoot 'docs'
+```
+
+공통 시작 기준은 `COMMON_ROOT_AGENTS`를 따른다.
이 파일은 프론트엔드 `sn` 앱 세부 기준이다.
백엔드(`sewerage-service`)와 함께 수정하거나 API/응답/DB/인증/권한 연동 영향이 있는 작업은 아래 파일을 함께 확인한다.
- 공통 시작 기준: `AGENTS.md`
-- 백엔드 세부 기준: `BE_REPO_ROOT/AGENTS.md`
+- 백엔드 세부 기준: `BE_SERVICE_ROOT/AGENTS.md`
- 프론트엔드 `sn` 앱 기준: `FE_APP_ROOT/AGENTS.md`
---
@@ -230,7 +262,7 @@ VSCode나 별도 터미널에서 `sn` Next 개발 서버가 실행 중이면 `ne
### 프론트엔드 실행 프로세스 관리
-- 모든 세션·역할·문서/검증 섹션에 공통 D:/workspace/AGENTS.md의 테스트 파일 정리 기준을 적용한다. 에이전트가 테스트를 위해 만든 파일은 정식 회귀 테스트·재사용 가능한 소스·fixture·mock·임시 라우트를 포함해 위치와 관계없이 결과를 요약한 뒤 작업 종료 전에 삭제한다(2026-09-23 사용자 지시). 전용 빈 폴더와 끊어진 실행 명령도 정리한다. 명시 보존 요청 파일·최종 산출물·사용자 원본/복구 백업은 유지한다. 기존 타 작업자 테스트 자산은 삭제 권한을 먼저 확인한다. 사용자 dev server의 .next·공유 캐시·node_modules는 포괄 삭제하지 않는다.
+- 모든 세션·역할·문서/검증 섹션에 `COMMON_ROOT_AGENTS`의 테스트 파일 정리 기준을 적용한다. 에이전트가 테스트를 위해 만든 파일은 정식 회귀 테스트·재사용 가능한 소스·fixture·mock·임시 라우트를 포함해 위치와 관계없이 결과를 요약한 뒤 작업 종료 전에 삭제한다(2026-09-23 사용자 지시). 전용 빈 폴더와 끊어진 실행 명령도 정리한다. 명시 보존 요청 파일·최종 산출물·사용자 원본/복구 백업은 유지한다. 기존 타 작업자 테스트 자산은 삭제 권한을 먼저 확인한다. 사용자 dev server의 .next·공유 캐시·node_modules는 포괄 삭제하지 않는다.
- Codex 에이전트가 작업 또는 검증을 위해 직접 기동한 `next dev`, `dev:sn`, Turborepo dev와 기타 프론트엔드 장시간 실행 프로세스는 작업 종료 전에 반드시 종료한다.
- 셸, 백그라운드, 숨김 창 등 VSCode 밖에서 에이전트가 기동한 프로세스도 종료 대상이다.
diff --git a/apps/sn/docs/작업계획/SFR-011-008-침수예측/002-maintenance-SFR-011-008-침수예측현황-확률강우량-작업계획.md b/apps/sn/docs/작업계획/SFR-011-008-침수예측/002-maintenance-SFR-011-008-침수예측현황-확률강우량-작업계획.md
index 18497c7c6..3a0fd344d 100644
--- a/apps/sn/docs/작업계획/SFR-011-008-침수예측/002-maintenance-SFR-011-008-침수예측현황-확률강우량-작업계획.md
+++ b/apps/sn/docs/작업계획/SFR-011-008-침수예측/002-maintenance-SFR-011-008-침수예측현황-확률강우량-작업계획.md
@@ -20,9 +20,12 @@
- 팝업 하단에 확률관측소코드·관측소명·면적비율(%) 비율표를 표시한다.
- 팝업 맨 아래에 `시간은 강우가 지속된 시간입니다` 안내를 표시한다.
- 지속시간 헤더는 기존 팝업 표 헤더 스타일을 유지한다.
-- 선택 행의 대표 지속시간에 해당하는 열의 확률강우량 기준값 셀들을 강조 색상으로 표시한다. 표 헤더는 기존 스타일을 유지한다.
+- 선택 행의 대표 지속시간과 초과빈도년도가 교차하는 확률강우량 기준값 한 셀만 강조 색상으로 표시한다. 표 헤더는 기존 스타일을 유지한다.
+- 팝업 표가 표시되면 선택한 지속시간 열이 보이도록 표 내부 가로 스크롤을 자동으로 맞춘다.
+- 선택 행의 초과빈도년도에 해당하는 재현기간 행은 년도와 강수량 글씨를 굵게 표시한다.
- 강수량은 mm 단위로 표시하고 로딩·빈 결과·오류 상태를 제공한다.
- 기존 행 클릭의 도시침수지도 이동과 별도 액션으로 동작시킨다.
+- 현황 목록의 분석시각·강우시작시각은 분까지 말줄임 없이 표시하며, 좁은 팝업에서는 표의 가로 스크롤을 사용한다.
## 검증 계획
@@ -37,5 +40,8 @@
- 현황 목록에 `확률강우량` 열을 추가하고 대표 지속시간 `predcRainHr`를 `N시간` 링크로 표시한다.
- 링크 클릭은 행 선택 지도 이동을 실행하지 않으며, 시군구별 기준표 팝업을 연다.
- 팝업은 재현기간 행×지속시간 열의 확률강우량, 하단 관측소 면적비율과 API 합계를 표시한다.
-- 선택 지속시간 열의 값 셀만 강조하고 헤더는 기존 스타일을 유지한다.
+- 선택 지속시간·초과빈도년도의 교차값 한 셀만 색으로 강조하고 헤더는 기존 스타일을 유지한다.
+- 팝업 표의 선택 지속시간 열은 로딩 완료 후 표 내부 가로 스크롤 영역 중앙에 자동으로 나타난다.
+- 선택 초과빈도년도의 재현기간 행은 년도와 강수량을 굵게 표시한다.
+- 분석시각·강우시작시각 열은 고정 폭을 지정하지 않고 말줄임만 해제하여, 전체 시각을 표시하면서 주변 열과 함께 자동으로 너비를 계산한다.
- `pnpm --filter sn run typecheck`, `git diff --check`가 통과했다. 3002 포트 개발 서버 실행 중이므로 build는 실행하지 않았다.
diff --git a/apps/sn/docs/작업내용/SFR-011-008-침수예측/005-fe-SFR-011-008-침수예측현황-확률강우량-구현기록.md b/apps/sn/docs/작업내용/SFR-011-008-침수예측/005-fe-SFR-011-008-침수예측현황-확률강우량-구현기록.md
index a7071cccf..d80976eb2 100644
--- a/apps/sn/docs/작업내용/SFR-011-008-침수예측/005-fe-SFR-011-008-침수예측현황-확률강우량-구현기록.md
+++ b/apps/sn/docs/작업내용/SFR-011-008-침수예측/005-fe-SFR-011-008-침수예측현황-확률강우량-구현기록.md
@@ -5,10 +5,13 @@
- 침수위험도 오른쪽에 확률강우량 열을 추가하고 대표 누적강우 지속시간 `predcRainHr`를 `N시간` 링크로 표시했다.
- 링크는 `GET /api/v1/fldng/predc/prst/prbbt-rnfl?sggCd=...`를 호출한다.
- 팝업 본문에 재현기간 행×지속시간 열의 확률강우량표를 표시한다.
-- 선택 지속시간 열의 값 셀만 강조하고 표 헤더는 기존 스타일을 유지한다.
+- 선택 지속시간·초과빈도년도의 교차값 한 셀만 색으로 강조하고 표 헤더는 기존 스타일을 유지한다.
+- 팝업 표가 로딩되면 목록에서 선택한 지속시간 열이 보이도록 표 내부 가로 스크롤을 자동으로 맞춘다.
+- 선택 행의 초과빈도년도에 해당하는 재현기간 행은 년도와 강수량을 굵게 표시한다.
- 팝업 하단에 확률관측소코드·관측소명·면적비율 및 API 합계를 표시한다.
- 팝업 맨 아래에 `시간은 강우가 지속된 시간입니다` 안내를 표시한다.
- 링크 클릭 이벤트는 기존 행 선택의 지도 이동으로 전파하지 않는다.
+- 침수예측현황 목록의 분석시각·강우시작시각은 말줄임을 적용하지 않고, 고정 열 폭 없이 표의 자동 너비 계산과 가로 스크롤로 분까지 표시한다.
## 검증
- `pnpm --filter sn run typecheck` 성공
diff --git a/apps/sn/src/features/ctyFldngPrvtnBiz/api/cty-fldng-prvtn-biz-api.ts b/apps/sn/src/features/ctyFldngPrvtnBiz/api/cty-fldng-prvtn-biz-api.ts
index bdbd5d405..4e79e0b11 100644
--- a/apps/sn/src/features/ctyFldngPrvtnBiz/api/cty-fldng-prvtn-biz-api.ts
+++ b/apps/sn/src/features/ctyFldngPrvtnBiz/api/cty-fldng-prvtn-biz-api.ts
@@ -45,6 +45,12 @@ export const getAllCtyFldngPrvtnBusinesses = async (
return result.items;
};
+/** 등록 화면에 자동으로 넣을 다음 사업순번을 조회한다. */
+export const getNextCtyFldngPrvtnSeqNo = async () => {
+ const body = await getData(`${API_PATH}/nextSeqNo`);
+ return Number(unwrapData(body));
+};
+
/** 수정 화면의 사업 한 건을 조회한다. */
export const getCtyFldngPrvtnBusiness = (id: string) =>
getData(`${API_PATH}/${encodeURIComponent(id)}`) as Promise;
diff --git a/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizFormPage.tsx b/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizFormPage.tsx
index f0507195b..26b2ab7e6 100644
--- a/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizFormPage.tsx
+++ b/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizFormPage.tsx
@@ -22,6 +22,7 @@ import {
createCtyFldngPrvtnBusiness,
deleteCtyFldngPrvtnBusiness,
getCtyFldngPrvtnBusiness,
+ getNextCtyFldngPrvtnSeqNo,
updateCtyFldngPrvtnBusiness,
} from "../api/cty-fldng-prvtn-biz-api";
import type {
@@ -91,6 +92,35 @@ const DETAIL_FIELDS = [
["rmrkSrcCn", "비고출처", "text"],
] as const;
type BasicKey = (typeof BASIC_FIELDS)[number][0];
+/** 저장 전에 반드시 입력해야 하는 항목이다. 시도는 별도 선택이라 region 키로 관리한다. */
+const REQUIRED_BASIC_KEYS = new Set(["seqNo", "dsgnYmd"]);
+type FieldErrorKey = BasicKey | "region" | `soa-${string}`;
+
+/** 금액·면적 입력의 DB 자릿수 규칙이다. intDigits 는 정수부, scale 은 소수부 한계다. */
+const NUMBER_RULES: Record = {
+ dsgnArea: { label: "지정면적", intDigits: 10, scale: 9 },
+ tpco: { label: "총사업비", intDigits: 20, scale: 2 },
+ gvexp: { label: "국비", intDigits: 20, scale: 2 },
+ lcexp: { label: "지방비", intDigits: 20, scale: 2 },
+};
+const SETTLEMENT_RULE = { label: "결산금액", intDigits: 20, scale: 2 };
+
+/** 숫자 입력이 비었는지, 숫자인지, 0 이상인지, 자릿수를 넘지 않는지 확인한다. */
+const numberError = (
+ value: unknown,
+ rule: { label: string; intDigits: number; scale: number },
+) => {
+ const text = String(value ?? "").trim();
+ if (!text) return undefined;
+ if (!/^\d*(\.\d+)?$/.test(text))
+ return `${rule.label}은(는) 0 이상 숫자로 입력해주세요.`;
+ const [integerPart = "", decimalPart = ""] = text.split(".");
+ if (decimalPart.length > rule.scale)
+ return `${rule.label}은(는) 소수점 ${rule.scale}자리까지 입력할 수 있습니다.`;
+ if (integerPart.replace(/^0+/, "").length > rule.intDigits)
+ return `${rule.label}은(는) 정수 ${rule.intDigits}자리를 넘을 수 없습니다.`;
+ return undefined;
+};
type DetailKey = (typeof DETAIL_FIELDS)[number][0];
/** 등록 화면의 빈 기본·결산·4단계 상세 값을 생성한다. */
@@ -294,6 +324,18 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) {
const [notice, setNotice] = useState("");
const [ctpvCd, setCtpvCd] = useState("0");
const [sggCd, setSggCd] = useState("0");
+ const [fieldErrors, setFieldErrors] = useState<
+ Partial>
+ >({});
+
+ /** 입력을 고치면 해당 항목의 오류 문구를 지운다. */
+ const clearFieldError = (key: FieldErrorKey) =>
+ setFieldErrors((current) => {
+ if (!current[key]) return current;
+ const next = { ...current };
+ delete next[key];
+ return next;
+ });
const listHref = `/oper/ctyFldngPrvtnBiz${searchParams.toString() ? `?${searchParams}` : ""}`;
const availableSgg = useMemo(
() =>
@@ -308,6 +350,13 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) {
if (!ctpv.length) void fetchCtpv();
if (!sgg.length) void fetchSgg();
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
+ useEffect(() => {
+ if (id) return;
+ // 등록 화면은 다음 사업순번을 자동으로 채우고 사용자가 바꾸지 못하게 한다.
+ getNextCtyFldngPrvtnSeqNo()
+ .then((seqNo) => setForm((current) => ({ ...current, seqNo })))
+ .catch(() => setNotice("다음 사업순번을 조회하지 못했습니다."));
+ }, [id]);
useEffect(() => {
if (!id) return;
setLoading(true);
@@ -337,14 +386,42 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) {
/** 필수값·날짜·숫자를 검사한 뒤 등록 또는 수정한다. */
const handleSave = async () => {
- const dates = [
- form.dsgnYmd,
- ...form.details.flatMap((d) => [d.bgngYmd, d.cmcnYmd]),
- ];
+ const nextErrors: Partial> = {};
+ if (!String(form.ctpvNm ?? "").trim()) nextErrors.region = "시도는 필수입니다.";
+ if (form.seqNo === null || form.seqNo === undefined || String(form.seqNo) === "")
+ nextErrors.seqNo = "사업순번은 필수입니다.";
+ if (!String(form.dsgnYmd ?? "").trim())
+ nextErrors.dsgnYmd = "지정일은 필수입니다.";
+ else if (!validDate(form.dsgnYmd))
+ nextErrors.dsgnYmd = "YYYY-MM-DD 형식의 유효한 날짜로 입력해주세요.";
+ const requiredMissing = Object.keys(nextErrors).length > 0;
+ Object.entries(NUMBER_RULES).forEach(([key, rule]) => {
+ const message = numberError(form[key as BasicKey], rule);
+ if (message) nextErrors[key as BasicKey] = message;
+ });
+ form.settlements.forEach((settlement) => {
+ const message = numberError(settlement.soaAmt, {
+ ...SETTLEMENT_RULE,
+ label: `${settlement.soaYr}년 결산금액`,
+ });
+ if (message) nextErrors[`soa-${settlement.soaYr}`] = message;
+ });
+ if (Object.keys(nextErrors).length > 0) {
+ setFieldErrors(nextErrors);
+ setNotice(
+ requiredMissing
+ ? "필수 항목을 입력해주세요."
+ : "입력값을 확인해주세요.",
+ );
+ return;
+ }
+ const dates = form.details.flatMap((d) => [d.bgngYmd, d.cmcnYmd]);
if (dates.some((value) => !validDate(value))) {
setNotice("일자는 YYYY-MM-DD 형식의 유효한 날짜여야 합니다.");
return;
}
+ setFieldErrors({});
+ setNotice("");
setLoading(true);
try {
if (id) await updateCtyFldngPrvtnBusiness(id, form);
@@ -393,14 +470,16 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) {
suggestionOptions.length > 0 ? `${fieldId}-suggestions` : undefined;
const span: 1 | 2 | 3 =
key === "excptnMttrCn" ? 3 : key === "bizNm" || key === "pstn" ? 2 : 1;
- // 수정 화면에서는 중복 판정 업무키인 사업순번과 단계를 변경할 수 없다.
- const isBusinessKeyLocked = Boolean(id) && (key === "seqNo" || key === "stp");
+ // 사업순번은 등록 시 자동 번호를 보여주고, 수정 시에는 중복 판정 업무키라 바꿀 수 없다.
+ const isBusinessKeyLocked =
+ key === "seqNo" || (Boolean(id) && key === "stp");
return (
<>
@@ -408,19 +487,21 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) {
id={fieldId}
className="w-full !text-[1.3rem]"
maxLength={standardMaxLengths[key]}
+ min={NUMBER_RULES[key] ? 0 : undefined}
type={type === "date" ? "date" : type}
list={suggestionListId}
disabled={isBusinessKeyLocked}
value={String(form[key] ?? "")}
- onChange={(event) =>
+ onChange={(event) => {
+ clearFieldError(key);
setForm((current) => ({
...current,
[key]:
type === "date"
? formatDateInput(event.target.value)
: inputValue(event.target.value, type),
- }))
- }
+ }));
+ }}
/>
{suggestionListId ? (