sn: 확률강우량 모달 추가

This commit is contained in:
2026-10-02 18:12:03 +09:00
parent e5f7778861
commit 8ed4f959cb
12 changed files with 347 additions and 45 deletions
+13
View File
@@ -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
+13
View File
@@ -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
+35 -3
View File
@@ -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 밖에서 에이전트가 기동한 프로세스도 종료 대상이다.
@@ -20,9 +20,12 @@
- 팝업 하단에 확률관측소코드·관측소명·면적비율(%) 비율표를 표시한다.
- 팝업 맨 아래에 `시간은 강우가 지속된 시간입니다` 안내를 표시한다.
- 지속시간 헤더는 기존 팝업 표 헤더 스타일을 유지한다.
- 선택 행의 대표 지속시간에 해당하는 열의 확률강우량 기준값 셀들을 강조 색상으로 표시한다. 표 헤더는 기존 스타일을 유지한다.
- 선택 행의 대표 지속시간과 초과빈도년도가 교차하는 확률강우량 기준값 한 셀만 강조 색상으로 표시한다. 표 헤더는 기존 스타일을 유지한다.
- 팝업 표가 표시되면 선택한 지속시간 열이 보이도록 표 내부 가로 스크롤을 자동으로 맞춘다.
- 선택 행의 초과빈도년도에 해당하는 재현기간 행은 년도와 강수량 글씨를 굵게 표시한다.
- 강수량은 mm 단위로 표시하고 로딩·빈 결과·오류 상태를 제공한다.
- 기존 행 클릭의 도시침수지도 이동과 별도 액션으로 동작시킨다.
- 현황 목록의 분석시각·강우시작시각은 분까지 말줄임 없이 표시하며, 좁은 팝업에서는 표의 가로 스크롤을 사용한다.
## 검증 계획
@@ -37,5 +40,8 @@
- 현황 목록에 `확률강우량` 열을 추가하고 대표 지속시간 `predcRainHr`를 `N시간` 링크로 표시한다.
- 링크 클릭은 행 선택 지도 이동을 실행하지 않으며, 시군구별 기준표 팝업을 연다.
- 팝업은 재현기간 행×지속시간 열의 확률강우량, 하단 관측소 면적비율과 API 합계를 표시한다.
- 선택 지속시간 열의 값 셀만 강조하고 헤더는 기존 스타일을 유지한다.
- 선택 지속시간·초과빈도년도의 교차값 한 셀만 색으로 강조하고 헤더는 기존 스타일을 유지한다.
- 팝업 표의 선택 지속시간 열은 로딩 완료 후 표 내부 가로 스크롤 영역 중앙에 자동으로 나타난다.
- 선택 초과빈도년도의 재현기간 행은 년도와 강수량을 굵게 표시한다.
- 분석시각·강우시작시각 열은 고정 폭을 지정하지 않고 말줄임만 해제하여, 전체 시각을 표시하면서 주변 열과 함께 자동으로 너비를 계산한다.
- `pnpm --filter sn run typecheck`, `git diff --check`가 통과했다. 3002 포트 개발 서버 실행 중이므로 build는 실행하지 않았다.
@@ -5,10 +5,13 @@
- 침수위험도 오른쪽에 확률강우량 열을 추가하고 대표 누적강우 지속시간 `predcRainHr`를 `N시간` 링크로 표시했다.
- 링크는 `GET /api/v1/fldng/predc/prst/prbbt-rnfl?sggCd=...`를 호출한다.
- 팝업 본문에 재현기간 행×지속시간 열의 확률강우량표를 표시한다.
- 선택 지속시간 열의 값 셀만 강조하고 표 헤더는 기존 스타일을 유지한다.
- 선택 지속시간·초과빈도년도의 교차값 한 셀만 색으로 강조하고 표 헤더는 기존 스타일을 유지한다.
- 팝업 표가 로딩되면 목록에서 선택한 지속시간 열이 보이도록 표 내부 가로 스크롤을 자동으로 맞춘다.
- 선택 행의 초과빈도년도에 해당하는 재현기간 행은 년도와 강수량을 굵게 표시한다.
- 팝업 하단에 확률관측소코드·관측소명·면적비율 및 API 합계를 표시한다.
- 팝업 맨 아래에 `시간은 강우가 지속된 시간입니다` 안내를 표시한다.
- 링크 클릭 이벤트는 기존 행 선택의 지도 이동으로 전파하지 않는다.
- 침수예측현황 목록의 분석시각·강우시작시각은 말줄임을 적용하지 않고, 고정 열 폭 없이 표의 자동 너비 계산과 가로 스크롤로 분까지 표시한다.
## 검증
- `pnpm --filter sn run typecheck` 성공
@@ -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<number>(body));
};
/** 수정 화면의 사업 한 건을 조회한다. */
export const getCtyFldngPrvtnBusiness = (id: string) =>
getData(`${API_PATH}/${encodeURIComponent(id)}`) as Promise<CtyFldngPrvtnBiz>;
@@ -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<string>(["seqNo", "dsgnYmd"]);
type FieldErrorKey = BasicKey | "region" | `soa-${string}`;
/** 금액·면적 입력의 DB 자릿수 규칙이다. intDigits 는 정수부, scale 은 소수부 한계다. */
const NUMBER_RULES: Record<string, { label: string; intDigits: number; scale: number }> = {
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<Record<FieldErrorKey, string>>
>({});
/** 입력을 고치면 해당 항목의 오류 문구를 지운다. */
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<Record<FieldErrorKey, string>> = {};
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 (
<StandardFormField
key={key}
id={fieldId}
label={label}
required={key === "bizNm"}
required={key === "bizNm" || REQUIRED_BASIC_KEYS.has(key)}
error={fieldErrors[key]}
span={span}
>
<>
@@ -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 ? (
<datalist id={suggestionListId}>
@@ -455,11 +536,17 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) {
)}
<StandardFormSection title="기본 정보">
<StandardFormField id="cty-fldng-ctpv" label="시도">
<StandardFormField
id="cty-fldng-ctpv"
label="시도"
required
error={fieldErrors.region}
>
<Select
value={ctpvCd}
onValueChange={(value) => {
const code = String(value);
clearFieldError("region");
setCtpvCd(code);
setSggCd("0");
setForm((current) => ({
@@ -533,13 +620,16 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) {
key={year}
id={`cty-fldng-settlement-${year}`}
label={`${year}년`}
error={fieldErrors[`soa-${year}`]}
>
<Input
id={`cty-fldng-settlement-${year}`}
className="w-full !text-[1.3rem]"
type="number"
min={0}
value={item?.soaAmt ?? ""}
onChange={(event) =>
onChange={(event) => {
clearFieldError(`soa-${year}`);
setForm((current) => ({
...current,
settlements: [
@@ -554,8 +644,8 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) {
: Number(event.target.value),
},
],
}))
}
}));
}}
/>
</StandardFormField>
);
@@ -157,21 +157,11 @@ type Search = {
};
/** 브라우저 로컬 날짜를 검색 input이 사용하는 YYYY-MM-DD 형식으로 변환한다. */
const formatLocalDateInputValue = (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}`;
};
/** 지정일 기본 검색기간을 최초 사업자료 기준일인 2013-01-01부터 오늘까지로 생성한다. */
const createDefaultDesignationDateRange = () => {
const to = new Date();
return {
dsgnYmdFrom: "2013-01-01",
dsgnYmdTo: formatLocalDateInputValue(to),
};
};
/** 지정일 검색조건은 처음부터 비워 둔다. 지정일이 없는 사업도 목록에서 보이도록 하기 위함이다. */
const createDefaultDesignationDateRange = () => ({
dsgnYmdFrom: "",
dsgnYmdTo: "",
});
type TableRow = Record<string, string | number | null | undefined> & {
ctyFldngPrvtnBizSn: number;
@@ -20,6 +20,9 @@ import useStore from "@/store/useStore";
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
import { downloadFloodPredictionStatusExcel } from "../lib/fldng-predc-prst-excel";
import { getFloodRiskGradeClassName } from "../lib/flood-risk-grade";
import PrbbtRnflDialog, {
type PrbbtRnflResponse,
} from "./PrbbtRnflDialog";
type ApiRow = {
sggCd: string;
@@ -30,6 +33,7 @@ type ApiRow = {
analysisDateTime: string;
observedAccumulatedRainfall: number | null;
predictionHour: number | null;
predcRainHr: number | null;
predictedAccumulatedRainfall: number | null;
exceededFrequencyYear: number | null;
floodRiskGrade: string;
@@ -44,6 +48,7 @@ type TableRow = ApiRow &
predictionHourDisplay: string;
predictedAccumulatedRainfallDisplay: string;
exceededFrequencyYearDisplay: string;
probabilityRainfallDisplay: string;
};
type Filters = {
@@ -109,6 +114,13 @@ const HEADS: UiHeadProps[] = [
align: "right",
},
{ id: "floodRiskGrade", title: "침수위험도", widthPercent: 10 },
{ id: "probabilityRainfallDisplay", title: "확률강우량", widthPercent: 10 },
];
/** 날짜·시각의 뒷부분이 잘리지 않도록 전체 표시할 목록 열이다. */
const FULL_TEXT_TIME_HEAD_IDS = [
"analysisDateTimeDisplay",
"rainBeginningDateTimeDisplay",
];
/** 최초·초기화 시 시각 범위를 제한하지 않고 최신 침수예측 데이터부터 조회한다. */
@@ -135,6 +147,12 @@ const predictionHourText = (value: number | null) => {
return hour > 0 ? `${hour}시간 후` : "-";
};
/** 분석 결과의 대표 누적강우시간을 확률강우량 기준표 링크 문구로 표시한다. */
const probabilityDurationText = (value: number | null) => {
const hour = Number(value);
return Number.isInteger(hour) && hour > 0 ? `${hour}시간` : "-";
};
const formatNumber = (value: number | null, digits: number) =>
value == null || !Number.isFinite(Number(value))
? "-"
@@ -202,6 +220,14 @@ export default function FldngPredcPrstPopupContent() {
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [selectedRowId, setSelectedRowId] = useState("");
// 확률강우량 기준표는 목록의 선택 행과 별도로 열고 닫는다.
const [probabilityRow, setProbabilityRow] = useState<TableRow | null>(null);
const [probabilityData, setProbabilityData] =
useState<PrbbtRnflResponse | null>(null);
const [probabilityLoading, setProbabilityLoading] = useState(false);
const [probabilityError, setProbabilityError] = useState<string | null>(null);
// 연속 클릭 또는 닫기 이후 도착한 이전 응답이 현재 팝업을 덮지 않도록 요청 순서를 보관한다.
const probabilityRequestIdRef = useRef(0);
// 개발 StrictMode에서도 최초 목록 API를 한 번만 호출하기 위한 ref다.
const didInitialLoadRef = useRef(false);
@@ -268,6 +294,9 @@ export default function FldngPredcPrstPopupContent() {
item.exceededFrequencyYear == null
? "-"
: String(item.exceededFrequencyYear),
probabilityRainfallDisplay: probabilityDurationText(
item.predcRainHr,
),
})),
);
setTotal(Number(payload?.totalElements ?? 0));
@@ -373,6 +402,43 @@ export default function FldngPredcPrstPopupContent() {
}
};
/** 선택 시군구의 확률강우량과 관측소 비율을 조회한다. 지도 행 클릭은 버튼에서 차단한다. */
const handleProbabilityClick = async (row: TableRow) => {
if (!row.sggCd || !Number.isInteger(Number(row.predcRainHr)) || Number(row.predcRainHr) <= 0) {
return;
}
const requestId = ++probabilityRequestIdRef.current;
setProbabilityRow(row);
setProbabilityData(null);
setProbabilityError(null);
setProbabilityLoading(true);
try {
const params = new URLSearchParams({ sggCd: row.sggCd });
const response = await getRawJson(
`/sewerage-service/api/v1/fldng/predc/prst/prbbt-rnfl?${params.toString()}`,
);
if (requestId !== probabilityRequestIdRef.current) return;
const payload: PrbbtRnflResponse = response?.data ?? response;
setProbabilityData(payload);
} catch (error) {
if (requestId !== probabilityRequestIdRef.current) return;
console.error("시군구 확률강우량 조회 중 오류:", error);
setProbabilityError("확률강우량 기준표를 조회하지 못했습니다.");
} finally {
if (requestId === probabilityRequestIdRef.current) {
setProbabilityLoading(false);
}
}
};
/** 닫힌 팝업으로 늦게 도착하는 API 응답을 무시한다. */
const handleProbabilityOpenChange = (open: boolean) => {
if (open) return;
probabilityRequestIdRef.current += 1;
setProbabilityRow(null);
setProbabilityLoading(false);
};
const handleDownload = async () => {
if (!total) {
alert("다운로드할 데이터가 없습니다.");
@@ -562,10 +628,27 @@ export default function FldngPredcPrstPopupContent() {
>
{row.floodRiskGrade || "-"}
</span>
) : headId === "probabilityRainfallDisplay" ? (
row.probabilityRainfallDisplay === "-" ? (
"-"
) : (
<button
type="button"
className="font-medium text-primary underline underline-offset-2 hover:opacity-75 focus-visible:rounded-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
aria-label={`${row.sggNm} ${row.probabilityRainfallDisplay} 확률강우량 기준표 보기`}
onClick={(event) => {
event.stopPropagation();
void handleProbabilityClick(row);
}}
>
{row.probabilityRainfallDisplay}
</button>
)
) : undefined
}
scrollMode="horizontal"
minTableWidth="100rem"
minTableWidth="112rem"
fullTextHeadIds={FULL_TEXT_TIME_HEAD_IDS}
fillAvailableHeight={rows.length === 0}
emptyMessage="조회 조건에 해당하는 침수예측현황이 없습니다."
actions={
@@ -591,6 +674,16 @@ export default function FldngPredcPrstPopupContent() {
</p>
<p>행을 선택하면 해당 시군구·빈도의 도시침수지도로 이동합니다.</p>
</div>
<PrbbtRnflDialog
open={probabilityRow !== null}
onOpenChange={handleProbabilityOpenChange}
sggName={probabilityRow?.sggNm ?? ""}
selectedDurationHour={probabilityRow?.predcRainHr ?? null}
selectedFrequencyYear={probabilityRow?.exceededFrequencyYear ?? null}
data={probabilityData}
loading={probabilityLoading}
error={probabilityError}
/>
</section>
</StandardPopupLayout>
);
@@ -1,6 +1,6 @@
"use client";
import { useMemo } from "react";
import { useLayoutEffect, useMemo, useRef } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@common/components";
@@ -31,6 +31,7 @@ type PrbbtRnflDialogProps = {
onOpenChange: (open: boolean) => void;
sggName: string;
selectedDurationHour: number | null;
selectedFrequencyYear: number | null;
data: PrbbtRnflResponse | null;
loading: boolean;
error: string | null;
@@ -42,16 +43,22 @@ const formatRainfall = (value: number) =>
const formatAreaRatio = (value: number) =>
Number.isFinite(Number(value)) ? Number(value).toFixed(3) : "-";
/** 재현기간을 행, 강우지속시간을 열로 구성하고 목록에서 선택한 지속시간의 기준값을 강조한다. */
/** 재현기간을 행, 강우지속시간을 열로 구성하고 선택한 두 기준이 교차하는 값만 색으로 강조한다. */
export default function PrbbtRnflDialog({
open,
onOpenChange,
sggName,
selectedDurationHour,
selectedFrequencyYear,
data,
loading,
error,
}: PrbbtRnflDialogProps) {
/** 선택한 지속시간 열을 모달 안에서 찾기 위한 표 스크롤 영역이다. */
const tableScrollRef = useRef<HTMLDivElement>(null);
/** 목록에서 클릭한 지속시간에 대응하는 표 헤더이다. */
const selectedDurationHeaderRef = useRef<HTMLTableCellElement>(null);
const { frequencies, durations, rainfallByKey } = useMemo(() => {
const rainfall = data?.rainfall ?? [];
return {
@@ -70,6 +77,22 @@ export default function PrbbtRnflDialog({
};
}, [data]);
/** 표가 표시되면 선택 지속시간 열을 가로 스크롤 영역의 가운데로 맞춘다. */
useLayoutEffect(() => {
if (!open || loading || error || selectedDurationHour == null) return;
const scrollArea = tableScrollRef.current;
const selectedHeader = selectedDurationHeaderRef.current;
if (!scrollArea || !selectedHeader) return;
const scrollAreaRect = scrollArea.getBoundingClientRect();
const selectedHeaderRect = selectedHeader.getBoundingClientRect();
scrollArea.scrollLeft +=
selectedHeaderRect.left -
scrollAreaRect.left -
(scrollArea.clientWidth - selectedHeaderRect.width) / 2;
}, [open, loading, error, selectedDurationHour, durations]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
@@ -94,7 +117,7 @@ export default function PrbbtRnflDialog({
</p>
) : (
<>
<div className="min-h-0 overflow-auto rounded-md border border-border">
<div ref={tableScrollRef} className="min-h-0 overflow-auto rounded-md border border-border">
<table className="w-max min-w-full border-collapse text-right text-lg">
<caption className="sr-only">
{sggName} 재현기간 및 강우지속시간별 확률강우량(mm)
@@ -110,6 +133,7 @@ export default function PrbbtRnflDialog({
{durations.map((durationHour) => (
<th
key={durationHour}
ref={durationHour === selectedDurationHour ? selectedDurationHeaderRef : undefined}
scope="col"
className="min-w-[9rem] border border-border px-3 py-2 text-center"
>
@@ -120,10 +144,15 @@ export default function PrbbtRnflDialog({
</thead>
<tbody>
{frequencies.map((frequencyYear) => (
<tr key={frequencyYear}>
<tr
key={frequencyYear}
className={frequencyYear === selectedFrequencyYear ? "font-bold" : ""}
>
<th
scope="row"
className="sticky left-0 z-[1] border border-border bg-background px-3 py-2 text-right font-medium"
className={`sticky left-0 z-[1] border border-border bg-background px-3 py-2 text-right ${
frequencyYear === selectedFrequencyYear ? "font-bold" : "font-medium"
}`}
>
{frequencyYear}
</th>
@@ -135,8 +164,10 @@ export default function PrbbtRnflDialog({
<td
key={durationHour}
className={`border border-border px-3 py-2 tabular-nums ${
rainfall != null && durationHour === selectedDurationHour
? "bg-amber-100 font-semibold text-amber-950"
rainfall != null &&
durationHour === selectedDurationHour &&
frequencyYear === selectedFrequencyYear
? "bg-amber-100 font-bold text-amber-950"
: ""
}`}
>
@@ -43,6 +43,7 @@ type MapPopupDataTableProps<T extends Record<string, unknown>> = {
headId: string,
rowIndex: number,
) => ReactNode | undefined;
fullTextHeadIds?: string[]; /** 시각처럼 뒤쪽이 잘리면 의미가 달라지는 열은 고정 너비 없이 말줄임만 해제한다. */
sort?: UiTableSort;
onSortChange?: (sort: UiTableSort | undefined) => void;
};
@@ -77,6 +78,7 @@ export default function MapPopupDataTable<T extends Record<string, unknown>>({
onCellClick,
clickableHeadIds,
renderCell,
fullTextHeadIds,
sort,
onSortChange,
}: MapPopupDataTableProps<T>) {
@@ -143,7 +145,15 @@ export default function MapPopupDataTable<T extends Record<string, unknown>>({
? head.align
: "center",
width: head.widthPercent,
},
...(fullTextHeadIds?.includes(head.id)
? {
"data-sn-cell-wrap": "",
style: {
whiteSpace: "nowrap",
},
}
: {}),
} as ColumnDef<T>["meta"],
})),
[
clickableHeadIds,
@@ -151,6 +161,7 @@ export default function MapPopupDataTable<T extends Record<string, unknown>>({
page,
pageSize,
renderCell,
fullTextHeadIds,
sort,
total,
visibleHeads,
+15 -1
View File
@@ -1,6 +1,20 @@
## Project
This repository contains a Next.js web application.
## Portable paths
Resolve paths from this `apps/wr/AGENTS.md` file, not from a developer-specific drive or the current shell directory.
- `FE_APP_ROOT`: directory containing this file (`apps/wr`)
- `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`
- `WR_BACKEND_ROOT`: `WORKSPACE_ROOT/wr_back_out` when that checkout exists; otherwise use a path explicitly supplied by the developer.
Verify a calculated path exists before using it. If the repositories are installed under different parent directories, use the explicitly supplied path.
Main technologies:
- Next.js
- TypeScript
@@ -41,4 +55,4 @@ apps/
wr
## local backend src 경로
D:\iipbs\wr_back_out - http://localhost:8883/reuse-service
`WR_BACKEND_ROOT` - http://localhost:8883/reuse-service