Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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 밖에서 에이전트가 기동한 프로세스도 종료 대상이다.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# 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는 실행하지 않았다.
|
||||
@@ -0,0 +1,22 @@
|
||||
# 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는 실행하지 않았다.
|
||||
|
||||
## 수동 확인 필요
|
||||
- 실제 로그인 화면에서 팝업 크기·가로 스크롤·강조 색상 및 지도 이동 회귀 확인
|
||||
@@ -40,6 +40,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;
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import { useLayoutEffect, useMemo, useRef } 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;
|
||||
selectedFrequencyYear: 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,
|
||||
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 {
|
||||
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]);
|
||||
|
||||
/** 표가 표시되면 선택 지속시간 열을 가로 스크롤 영역의 가운데로 맞춘다. */
|
||||
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
|
||||
className="flex max-h-[90vh] w-[94vw] !max-w-[150rem] flex-col gap-4 overflow-hidden"
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{sggName} 확률강우량</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<p className="py-8 text-center text-xl" role="status">
|
||||
확률강우량을 조회하고 있습니다.
|
||||
</p>
|
||||
) : error ? (
|
||||
<p className="py-8 text-center text-xl text-destructive" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : !frequencies.length || !durations.length ? (
|
||||
<p className="py-8 text-center text-xl" role="status">
|
||||
해당 시군구의 확률강우량 기준자료가 없습니다.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<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)
|
||||
</caption>
|
||||
<thead className="sticky top-0 z-10 bg-muted text-foreground">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
className="sticky left-0 z-20 min-w-[8rem] border border-border bg-muted px-3 py-2 text-center"
|
||||
>
|
||||
재현기간(년)
|
||||
</th>
|
||||
{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"
|
||||
>
|
||||
{durationHour}시간
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{frequencies.map((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 ${
|
||||
frequencyYear === selectedFrequencyYear ? "font-bold" : "font-medium"
|
||||
}`}
|
||||
>
|
||||
{frequencyYear}
|
||||
</th>
|
||||
{durations.map((durationHour) => {
|
||||
const rainfall = rainfallByKey.get(
|
||||
`${frequencyYear}:${durationHour}`,
|
||||
);
|
||||
return (
|
||||
<td
|
||||
key={durationHour}
|
||||
className={`border border-border px-3 py-2 tabular-nums ${
|
||||
rainfall != null &&
|
||||
durationHour === selectedDurationHour &&
|
||||
frequencyYear === selectedFrequencyYear
|
||||
? "bg-amber-100 font-bold text-amber-950"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{rainfall == null ? "-" : formatRainfall(rainfall)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 space-y-2">
|
||||
<h3 className="text-xl font-semibold">확률관측소 면적비율</h3>
|
||||
<div className="max-h-[16rem] overflow-auto rounded-md border border-border">
|
||||
<table className="w-full border-collapse text-lg">
|
||||
<thead className="bg-muted text-foreground">
|
||||
<tr>
|
||||
<th scope="col" className="border border-border px-3 py-2">
|
||||
확률관측소코드
|
||||
</th>
|
||||
<th scope="col" className="border border-border px-3 py-2">
|
||||
관측소명
|
||||
</th>
|
||||
<th scope="col" className="border border-border px-3 py-2 text-right">
|
||||
면적비율(%)
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(data?.stations ?? []).map((station) => (
|
||||
<tr key={station.prbbtObsvtrCd}>
|
||||
<td className="border border-border px-3 py-2 text-center">
|
||||
{station.prbbtObsvtrCd}
|
||||
</td>
|
||||
<td className="border border-border px-3 py-2 text-center">
|
||||
{station.prbbtObsvtrNm || "-"}
|
||||
</td>
|
||||
<td className="border border-border px-3 py-2 text-right tabular-nums">
|
||||
{formatAreaRatio(station.areaRt)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="font-semibold">
|
||||
<th colSpan={2} scope="row" className="border border-border px-3 py-2 text-center">
|
||||
합계
|
||||
</th>
|
||||
<td className="border border-border px-3 py-2 text-right tabular-nums">
|
||||
{formatAreaRatio(data?.areaRtTotal ?? Number.NaN)}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<p className="shrink-0 text-right text-sm text-muted-foreground">
|
||||
시간은 강우가 지속된 시간입니다
|
||||
</p>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,9 @@ import { createPredictionAnalysisTimeRange } from "../model/analysis-time-range"
|
||||
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;
|
||||
@@ -31,6 +34,7 @@ type ApiRow = {
|
||||
analysisDateTime: string;
|
||||
observedAccumulatedRainfall: number | null;
|
||||
predictionHour: number | null;
|
||||
predcRainHr: number | null;
|
||||
predictedAccumulatedRainfall: number | null;
|
||||
exceededFrequencyYear: number | null;
|
||||
floodRiskGrade: string;
|
||||
@@ -45,6 +49,7 @@ type TableRow = ApiRow &
|
||||
predictionHourDisplay: string;
|
||||
predictedAccumulatedRainfallDisplay: string;
|
||||
exceededFrequencyYearDisplay: string;
|
||||
probabilityRainfallDisplay: string;
|
||||
};
|
||||
|
||||
type Filters = {
|
||||
@@ -108,6 +113,13 @@ const HEADS: UiHeadProps[] = [
|
||||
align: "right",
|
||||
},
|
||||
{ id: "floodRiskGrade", title: "침수위험도", widthPercent: 10 },
|
||||
{ id: "probabilityRainfallDisplay", title: "확률강우량", widthPercent: 10 },
|
||||
];
|
||||
|
||||
/** 날짜·시각의 뒷부분이 잘리지 않도록 전체 표시할 목록 열이다. */
|
||||
const FULL_TEXT_TIME_HEAD_IDS = [
|
||||
"analysisDateTimeDisplay",
|
||||
"rainBeginningDateTimeDisplay",
|
||||
];
|
||||
|
||||
/** 최초·초기화 시 시각 범위를 제한하지 않고 최신 침수예측 데이터부터 조회한다. */
|
||||
@@ -134,6 +146,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))
|
||||
? "-"
|
||||
@@ -201,6 +219,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);
|
||||
|
||||
@@ -267,6 +293,9 @@ export default function FldngPredcPrstPopupContent() {
|
||||
item.exceededFrequencyYear == null
|
||||
? "-"
|
||||
: String(item.exceededFrequencyYear),
|
||||
probabilityRainfallDisplay: probabilityDurationText(
|
||||
item.predcRainHr,
|
||||
),
|
||||
})),
|
||||
);
|
||||
setTotal(Number(payload?.totalElements ?? 0));
|
||||
@@ -372,6 +401,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("다운로드할 데이터가 없습니다.");
|
||||
@@ -561,10 +627,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={
|
||||
@@ -590,6 +673,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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user