[as] feat: 하수도기본계획 등록 폼 레이아웃 구현

This commit is contained in:
2026-07-27 17:29:23 +09:00
parent cae9f94388
commit fe76864a05
25 changed files with 2130 additions and 488 deletions
@@ -0,0 +1,10 @@
import { SewerBscPlanEditPage } from "@/pages/sewer-bsc-plan";
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function Page({ params }: PageProps) {
const { id } = await params;
return <SewerBscPlanEditPage id={id} />;
}
@@ -0,0 +1,17 @@
import Link from "next/link";
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function Page({ params }: PageProps) {
const { id } = await params;
return (
<div className="mt-6 px-4 text-[1.4rem]">
하수도기본계획 상세({id})는 후속 구현 예정입니다.{" "}
<Link className="underline" href={`/sewer-bsc-plan/${id}/edit`}>
수정 화면으로 이동
</Link>
</div>
);
}
@@ -0,0 +1,12 @@
import Link from "next/link";
export default function Page() {
return (
<div className="mt-6 px-4 text-[1.4rem]">
하수도기본계획 목록은 후속 구현 예정입니다.{" "}
<Link className="underline" href="/sewer-bsc-plan/regist">
등록 화면으로 이동
</Link>
</div>
);
}
@@ -0,0 +1,5 @@
import { SewerBscPlanRegistPage } from "@/pages/sewer-bsc-plan";
export default function Page() {
return <SewerBscPlanRegistPage />;
}
@@ -1,367 +0,0 @@
# 재원협의(fnrs-cnsltn) vs 설치인가(instl-sncn) 차이 분석
| 항목 | 내용 |
| --------- | ----------------------------------------------------------------------------- |
| 작성일 | 2026-07-09 |
| 기준 구현 | `fnrs-cnsltn` (추가 요구사항 반영 완료) |
| 비교 대상 | `instl-sncn` |
| 목적 | 두 페이지의 동작·구조 차이를 정리하고, 설치인가 정렬 시 이식 대상을 명확히 함 |
---
## 1. 요약
두 모듈은 **동일한 PBADMS(공공행정 지원) 업무 패턴**을 따르는 형제 페이지다.
공통 흐름:
```
목록 → TaskSe 선택 후 신청 → 저장(SV) → 신청완료(ST)
→ 환경청제출(LE) → 기술검토 → 검토결과 회신(EP)
→ 인가승인(TA) → 고시(TB)
```
| 구분 | fnrs-cnsltn | instl-sncn |
| ------------- | ----------------- | ---------------- |
| 업무명 | 재원협의 | 설치인가 |
| 문서번호 | `fnrsCnsltnDocNo` | `instlSncnDocNo` |
| docSeComCd | `FNRS` | `INSTL` |
| 라우트 | `/fnrs-cnsltn/*` | `/instl-sncn/*` |
| pages 파일 수 | 39 | 32 |
**결론:** `fnrs-cnsltn`이 추가 요구사항이 반영된 기준(reference) 구현이고, `instl-sncn`은 동일 골격이지만 보완요청·결재선 편집·등록/수정 폼 완성도 등에서 미반영·TODO·단순화 상태다.
---
## 2. 파일/폴더 구조
### 2-1. fnrs-cnsltn에만 존재
| 경로 | 역할 |
| -------------------------------------------- | ------------------------- |
| `ui/sections/fnrs-shar-section.tsx` | 재원분담 조회 섹션 |
| `ui/sections/supplement-demand-section.tsx` | 보완요청 이력 섹션 |
| `ui/components/sortable-prgrs-stts-row.tsx` | 결재선 DnD 편집 행 |
| `ui/components/sortable-apln-picker-row.tsx` | 결재선 템플릿 피커 DnD 행 |
| `ui/components/aprvr-picker-dialog.tsx` | 결재자 선택 다이얼로그 |
| `ui/components/elapr-apln-picker-grid.tsx` | 결재선 편집 그리드 |
| `ui/components/elapr-tmplt-select-table.tsx` | 결재서식 선택 테이블 |
| `model/reviewer-option.ts` | 결재자 옵션 타입 |
### 2-2. instl-sncn에만 존재
| 경로 | 역할 | 비고 |
| -------------------------------------- | ------------------- | ------------------------------------------ |
| `ui/sections/history-info-section.tsx` | 등록/수정 이력 표시 | **어디에서도 import되지 않음 (dead code)** |
### 2-3. 공통 구조
```
index.ts
ui/
{list,view,form,regist,edit}-page.tsx
sections/ (basic-info, contact, scale, cost, checklist, attachments, tech-review)
components/ (floating-action-buttons, elapr-approval-dialog, elapr-tmplt-picker-dialog, checklist-task-panel)
lib/ button-config, save-payload-utils, form-data-mapper, regist-schema, checklist/review utils
model/ columns.tsx, view-types.ts
```
App Router도 동일: `page.tsx`, `regist/page.tsx`, `[id]/page.tsx`, `[id]/edit/page.tsx`
---
## 3. 목록 페이지
### 3-1. 검색 필터
| 항목 | fnrs-cnsltn | instl-sncn |
| ------------------ | ----------------------------------------- | --------------------------------- |
| 시도/시군구 API | `@/entities/adstdstr` | 동일 |
| 지역 검색 파라미터 | **`admdstCd`** (시군구 우선, 없으면 시도) | **`sidoComCd` + `sggComCd`** 분리 |
| "전체" 옵션 | `ALL_SELECT_VALUE = "__ALL__"` SelectItem | 없음 (placeholder만) |
| 구분 CodeSelect | `upCd="INSTL"` ※ | `upCd="INSTL"` |
| 사업명 | 동일 | 동일 |
> ※ fnrs 목록의 구분 필터가 `INSTL` 코드를 사용 중이며, TaskSePickerDialog는 `codeUpCd="FNRS"`를 쓴다. **불일치 가능성**이 있어 확인이 필요하다.
### 3-2. 정렬·페이지네이션
| 항목 | fnrs-cnsltn | instl-sncn |
| ------------------------ | ---------------------- | ------------------------------------- |
| 기본 sort (list-page) | `fnrsCnsltnDocNo,desc` | `instlSncnDocNo,desc` |
| fallback sort (list.tsx) | `fnrsCnsltnDocNo,desc` | **`regDt,desc`** (list-page와 불일치) |
| 페이지 크기 | 10 | 10 |
| URL 동기화 | 없음 (`useState` 로컬) | 없음 |
### 3-3. 테이블 컬럼
`model/columns.tsx` 로직은 사실상 동일하다. DTO 타입명만 다르다.
컬럼: 시도, 시군구, 사업명, 업무구분, 담당자, 해당기관, 진행상황, 신청일
### 3-4. 행 클릭·신청
| 항목 | fnrs-cnsltn | instl-sncn |
| ------------ | ------------------------------------------------------------------ | ------------------ |
| 행 클릭 | **조건부 라우팅**: `SV`/`SL` + 등록자 본인 → `/edit`, 그 외 → view | **항상 view** |
| useUserStore | 사용 | 미사용 |
| 신청 버튼 | TaskSePickerDialog `codeUpCd="FNRS"` | `codeUpCd="INSTL"` |
```46:54:apps/as/src/pages/fnrs-cnsltn/ui/fnrs-list.tsx
const handleRowClick = (row: FnrsCnsltnMngResponseDto) => {
const canEdit =
(row.prgrsSttsComCd === "SV" || row.prgrsSttsComCd === "SL") &&
row.rgtr === userInfo?.userUnqId;
router.push(
canEdit ? `/fnrs-cnsltn/${row.fnrsCnsltnDocNo}/edit` : `/fnrs-cnsltn/${row.fnrsCnsltnDocNo}`,
);
};
```
---
## 4. 상세(View) 페이지
### 4-1. 섹션 구성
| 섹션 | fnrs-cnsltn | instl-sncn |
| ------------------------------- | ------------------------------------------ | -------------------------- |
| BasicInfo | 환경청구분, 확정예산사업번호, 예산신청연도 | **사업구분(`sncnSeCdNm`)** |
| ContactInfo | 동일 패턴 | 동일 |
| BusinessScale / Cost | 동일 | 동일 |
| **FnrsShar (재원분담)** | **있음** | **없음** (도메인 고유) |
| Checklist | 동일 | 동일 |
| Attachments | 동일 | 동일 |
| **SupplementDemand (보완요청)** | **있음** | **없음** |
| TechReviewOpinion | 동일 | 동일 |
| HistoryInfo | 없음 | 파일만 존재, view 미연결 |
| FloatingActionButtons | `docSeComCd="FNRS"` | `docSeComCd="INSTL"` |
### 4-2. 데이터 훅
| 항목 | fnrs-cnsltn | instl-sncn |
| --------- | ---------------------------- | -------------------- |
| 상세 훅 | `useFnrsCnsltnMng` | `useInstlSncnDetail` |
| 에러 판정 | `isError \|\| !data?.detDto` | `isError \|\| !data` |
| 로딩 | Skeleton | Skeleton |
---
## 5. 등록/수정(Form) 페이지
### 5-1. 폼 완성도
| 항목 | fnrs-cnsltn | instl-sncn |
| ------------- | --------------------------------- | --------------- |
| 저장(SV) | O | O (SV 고정) |
| 신청완료(ST) | O (edit 모드) | **없음** |
| 삭제 | O (edit 모드, `useConfirm`) | **없음** |
| 재원분담 섹션 | `FnrsSharFormSection` + 잔액 검증 | 없음 |
| 저장 후 이동 | **`/edit` 유지** | **view로 이동** |
| 목록 버튼 | `/fnrs-cnsltn` 고정 | `router.back()` |
### 5-2. 기본정보 Form 필드
| 필드 | fnrs | instl |
| ----------------- | ---------------- | --------------------------------------- |
| taskSe 코드 | `FNRS` | `INSTL` |
| 사업구분 | 없음 | **`sncnSeComCd` (SNCNSE 라디오, 필수)** |
| BnsPicker 연동 | bizNm ~ bizCn | + **`bgtBizCd`, `bgtAplyYr`** |
| 재이용(RE) 조건부 | rtlztnFclt / Usg | 동일 |
### 5-3. Schema
| 항목 | fnrs | instl |
| ------------------- | ------------------- | -------------- |
| 관리 객체 | `fnrsCnsltnMng` | `instlSncnMng` |
| 예산 필드 | `cfmtnBgtBizNo` | `bgtBizCd` |
| 재원분담 | `fnrsSharList` 배열 | 없음 |
| sharAtchFileGroupId | 있음 | 없음 |
| privacyConsent | 있음 | 있음 |
### 5-4. Edit 페이지 래퍼
| 항목 | fnrs | instl |
| ------------------ | ------------------------------- | ------------------------------- |
| 로딩 Skeleton | 있음 | **없음** |
| 데이터 없음 메시지 | "상세 정보를 찾을 수 없습니다." | "수정 대상을 찾을 수 없습니다." |
---
## 6. FloatingActionButtons / 결재 / 보완
### 6-1. FAB 동작 차이
공통: 역할×상태 기반 버튼(`button-config.ts`), checklist 진행률 검증, elapr pending approval 체크
| 기능 | fnrs-cnsltn | instl-sncn |
| ------------------------------- | ------------------------------------------------------- | ------------------------------------------------------- |
| 보완완료 (`splmntCmptn`) | **`submitSupplement` API 연동** | **`// TODO` stub** |
| 보완요청 (`dmndSplmnt`) | **`buildSupplementReqPayload` + `submitReqSupplement`** | **`// TODO` stub** (문자열만 전달) |
| 검토완료 (`rvwCmptn`) | `techReViewSaveCd: "SV"` 후 **결재 다이얼로그 오픈** | `techReViewSaveCd: "CP"`, **다이얼로그 없이 즉시 완료** |
| 검토자지정 | `buildElaprPrgrsSttsListFromAplnRows(aplnRows)` | `buildElaprPrgrsSttsListFromApln(aplnPage.content)` |
| atrzAprv / atrzRply / grndsChck | TODO handler | TODO handler |
```576:617:apps/as/src/pages/instl-sncn/ui/components/floating-action-buttons.tsx
async function handleSplmntCmptn(context: ActionHandlerContext): Promise<void> {
// TODO: 보완완료 API 호출
context.onSuccess?.();
}
// ...
async function handleDmndSplmnt(context: ActionHandlerContext): Promise<void> {
// TODO: 보완요청 API 호출
context.onSuccess?.();
}
```
### 6-2. ElaprApprovalDialog (가장 큰 UX 격차)
| 항목 | fnrs (419줄) | instl (231줄) |
| ---------------- | ------------------------------------------------------ | -------------------------------- |
| 결재선 표시 | DnD sortable 편집 테이블 | **읽기 전용** 테이블 |
| 결재선 추가/저장 | **지원** (`usePrgrsSttsRows`, `useSaveElaprPrgrsStts`) | 없음 |
| 결재자 변경 | **AprvrPickerDialog** | 없음 |
| 전결 체크박스 | **있음** (`finalApprovalYn`) | 없음 |
| 반려 사유 | API에 `rjctRsn` 전달 | UI는 있으나 **payload에 미전달** |
| 저장 전 검증 | `hasUnsavedPrgrsSttsChanges` | 없음 |
### 6-3. ElaprTmpltPickerDialog
| 항목 | fnrs | instl |
| --------------- | -------------------------------------------------------------------- | ----------------------- |
| UI | 2-pane: 서식 테이블 + **결재선 편집 그리드** | 단일 테이블 + 선택 버튼 |
| docSeComCd 필터 | `FNRS` | `INSTL` |
| onConfirm | `{ tmplt, aplnRows }` 편집 결과 | `onSelect(tmplt)`만 |
| 검증 | `validateAllRowsForSave`, `validateAtrzOrder`, `validateUniqueSeqNo` | 없음 |
---
## 7. Entity / API / Hook
### 7-1. Service 메서드
| 메서드 | fnrs | instl |
| ----------------------- | --------------------------- | --------- |
| 목록 | `findPage` | `getPage` |
| 상세 | `findById` | `getById` |
| saveApply | O | O |
| techReview | O | O |
| changePrgrsSttsComCd | O | O |
| remove | O | O |
| **submitSupplement** | **O** (`/submitsupplement`) | **X** |
| **submitReqSupplement** | **O** (`/reqsupplement`) | **X** |
### 7-2. 상세 DTO 확장
**fnrs 추가 필드**
- `fnrsSharList`, `sharAtchFileList`
- `splmntDmndList` (typed)
**instl 추가 필드**
- `sncnSeComCd` / `sncnSeCdNm` (인가구분)
- `bgtBizCd` (예산사업코드)
- `rtlztnFcltSeComNm`, `rtlztnUsgSeComNm`
- `splmntDmndList` 타입은 base에 존재하나 view/API 미연결
- `instlSncnRegionService` (시도/시군구 — 목록은 adstdstr 사용, 미활용)
### 7-3. save-payload-utils
| 함수 | fnrs | instl |
| -------------------------------- | ---------------- | --------- |
| buildSavePayload | + `fnrsSharList` | shar 없음 |
| buildTechReviewReplyPayload | O | O |
| **buildSupplementReqPayload** | **O** | **X** |
| **buildSupplementSubmitPayload** | **O** | **X** |
---
## 8. 비즈니스 도메인 차이
| 영역 | fnrs-cnsltn | instl-sncn |
| -------------- | ----------------------------------------- | ------------------------ |
| 업무 코드 | `FNRS` | `INSTL` |
| TaskSe | FNRS 하위 | INSTL 하위 |
| 고시 버튼 라벨 | "재원협의 고시" | "설치인가 고시" |
| 재원분담 | **핵심 도메인** (`fnrs-shar-se`) | 해당 없음 |
| 인가구분 | 없음 | **`SNCNSE` 코드 (필수)** |
| 예산 식별 | `cfmtnBgtBizNo` | `bgtBizCd` |
| 진행상태 코드 | SV, ST, LE, ER, TE, EP, TA, TB, SL, TP 등 | **동일 세트 공유** |
| 역할 매핑 | ROLE_USER05/04/03/01 | 동일 |
---
## 9. fnrs-cnsltn에만 있는 기능 (추가 요구사항 후보)
instl-sncn 정렬 시 **우선 이식 대상**:
1. 목록 행 클릭 조건부 edit 라우팅 (`SV`/`SL` + 등록자)
2. 보완요청 전체 플로우 — view 섹션, payload builder, API, FAB handler
3. 보완완료 API 연동 (`submitSupplement`)
4. 등록/수정 폼 완성 — 신청완료, 삭제, 저장 후 edit 유지
5. 결재선 DnD 편집형 `ElaprApprovalDialog` (+ aprvr-picker, sortable rows)
6. 결재선 편집형 `ElaprTmpltPickerDialog` (+ apln grid, validation)
7. 검토완료 → 결재 다이얼로그 연계 흐름
8. 검색 필터 "전체" 옵션 및 `admdstCd` 통합 방식 (백엔드 API에 맞게)
9. Edit 페이지 로딩 Skeleton
10. entity에 supplement API/hooks 추가
---
## 10. instl-sncn에만 있는 기능 (유지 필요)
1. **`sncnSeComCd` (사업구분/인가구분)** — 도메인 고유
2. **`bgtBizCd` / `bgtAplyYr`** BnsPicker 연동
3. **`history-info-section.tsx`** — 구현만 있고 view 미연결 (활성화 또는 삭제 결정 필요)
4. entity **`instlSncnRegionService`** — 목록은 adstdstr 사용 중이라 중복/레거시 가능
5. **`INSTL_SNCN_REGISTER_SELECTION_KEY`** sessionStorage 키
---
## 11. 동일·거의 동일 패턴
| 영역 | 동일도 |
| ----------------------------------------------------- | --------------------- |
| App Router 4-route 구조 | 100% |
| `button-config.ts` 역할×상태 매트릭스 | ~99% (액션명만 다름) |
| `columns.tsx` 진행상황 Badge | ~100% |
| checklist / contact / scale / cost / attachments 섹션 | ~95% (import 경로만) |
| review-step-utils 로직 | ~100% (상수명만 다름) |
| TaskSePickerDialog 신청 플로우 | 100% |
| FAB 전체 구조·대부분 handler | ~85% |
| checklist-form-state, business-cost-utils | 거의 동일 |
| shared widgets (`@/widgets/pbadms-sprt`) | 동일 |
---
## 12. instl-sncn 정렬 권장 우선순위
| 우선순위 | 항목 | 근거 |
| -------- | ------------------------------------------------------- | ---------------------------- |
| P0 | 보완요청/보완완료 API + UI | fnrs 완전 구현 vs instl TODO |
| P0 | ElaprApprovalDialog 고도화 | 결재선 편집·전결·반려사유 |
| P0 | ElaprTmpltPickerDialog 2-pane | 검토자지정 품질 |
| P1 | Form 신청완료/삭제 + 저장 후 edit 유지 | 등록 UX parity |
| P1 | 목록 행 클릭 edit 라우팅 | fnrs 추가 요구사항 |
| P1 | 검토완료 → 결재 다이얼로그 연계 | 흐름 통일 |
| P2 | sort fallback 불일치 수정 (`regDt` vs `instlSncnDocNo`) | instl 내부 버그성 |
| P2 | history-info-section 연결 또는 삭제 | dead code 정리 |
| P3 | URL search params 동기화 | 양쪽 모두 미구현 (공통 개선) |
---
## 13. 참고 코드 위치
| 주제 | 경로 |
| ---------------------------- | ------------------------------------------------------------ |
| 목록 edit 라우팅 (fnrs) | `pages/fnrs-cnsltn/ui/fnrs-list.tsx` |
| 목록 항상 view (instl) | `pages/instl-sncn/ui/instl-list.tsx` |
| view 추가 섹션 (fnrs) | `pages/fnrs-cnsltn/ui/fnrs-view-page.tsx` |
| FAB 보완 TODO (instl) | `pages/instl-sncn/ui/components/floating-action-buttons.tsx` |
| 결재 다이얼로그 (fnrs) | `pages/fnrs-cnsltn/ui/components/elapr-approval-dialog.tsx` |
| 결재 다이얼로그 (instl) | `pages/instl-sncn/ui/components/elapr-approval-dialog.tsx` |
| 폼 완성도 (fnrs) | `pages/fnrs-cnsltn/ui/fnrs-form-page.tsx` |
| 폼 단순화 (instl) | `pages/instl-sncn/ui/instl-form-page.tsx` |
| supplement API (fnrs entity) | `entities/fnrs-cnsltn/api/fnrs-cnsltn-service.ts` |
@@ -8,7 +8,7 @@
| API 엔티티 | `@/entities/sewer-bsc-plan` (`SewerBscPlanController`) |
| 참조 패턴 | `@/pages/fnrs-cnsltn` + `@/widgets/pbadms-sprt` |
| 문서 목적 | contents 영역 **섹션별** UI·상태·API 매핑 및 구현 방향 |
| 작성 기준일 | 2026-07-23 |
| 작성 기준일 | 2026-07-27 |
---
@@ -25,11 +25,11 @@ JSP `568~1274`행: `<div class="contents">` ~ `<!-- //contents -->`
| C | `slide_15` | 신청자·설계자 정보 |
| D | `slide_19` | 개인정보 동의 |
| E | `slide_11` | 사업개요(단계별 인구·보급률) |
| F | `slide_12` | 총사업비(단계×원가 계층) |
| F | `slide_12` | 총사업비 — `BizCstMatrixFormSection` 전용 |
| G | `slide_13` | 재원분담 |
| H | `slide_14` | 처리시설별 하수도정비기본계획 |
| H | `slide_14` | 처리시설별 하수도정비기본계획 (Checkbox + Tabs) |
| I | `slide_16` | 체크리스트 + 필수첨부 + 기타첨부 |
| J | JSP 상단 `saveBtn` / `presentnBtn` | React: sticky FAB (`floating-action-buttons` − 환경청제출·반려) |
| J | JSP 상단 `saveBtn` / `presentnBtn` | React: 폼 하단 액션바 (`fnrs-form-page` 279~323과 동일 구성, API만 sewer) |
### 1-2. 제외
@@ -65,7 +65,7 @@ entities/sewer-bsc-plan/ # 이미 구현됨
**Vercel React Best Practices (적용 요약)**
- `client-swr-dedup`: React Query로 체크리스트/필수첨부/코드 조회 중복 제거
- `bundle-dynamic-imports`: 처리시설 동적 표·이전계획 Dialog는 `next/dynamic` 후보
- `bundle-dynamic-imports`: 처리시설 Tabs 패널·이전계획 Dialog는 `next/dynamic` 후보
- `rerender-derived-state`: 단계 연도·합계·진행률은 render 중 파생, 불필요 effect 금지
- `async-parallel`: 등록 진입 시 코드/체크리스트트리/필수첨부트리를 병렬 조회
@@ -74,8 +74,9 @@ entities/sewer-bsc-plan/ # 이미 구현됨
## 3. 전체 레이아웃
`fnrs-form-page.tsx`와 동일한 **FormProvider + 세로 섹션 스택** 구조를 따르고,
하단 액션바는 **`floating-action-buttons.tsx`와 동일 구성에서「환경청제출」「반려」만 제외**한다.
타이틀·브레드크럼은 폼 밖(`PageLayout` / regist·edit·view 셸)에서 처리한다.
하단 액션바는 **[`fnrs-form-page.tsx` 279~323](../../src/pages/fnrs-cnsltn/ui/fnrs-form-page.tsx)** 과 동일 구성(목록·저장·신청완료·삭제·보완완료)이며 **API/라우트만 sewer** 로 바꾼다.
타이틀·브레드크럼은 폼 밖(`PageLayout` / regist·edit·view 셸)에서 처리한다.
(상세 View의 sticky FAB·검토/결재 버튼은 본 등록 문서 범위 밖 — `fnrs-view-page` 대응 후속.)
### 3-1. 컴포넌트 트리 (fnrs 대응)
@@ -89,56 +90,42 @@ PageLayout (title / breadcrumb)
├─ ContactInfoFormSection // JSP slide_15 (pbadms-sprt)
├─ PrivacyConsentSection // JSP slide_19 (pbadms-sprt, 등록·수정)
├─ OutlineFormSection // JSP slide_11 (사업개요, 신규)
├─ BusinessCostFormSection // JSP slide_12 (pbadms-sprt, 1차)
├─ BizCstMatrixFormSection // JSP slide_12 (총사업비 전용, 신규)
├─ FnrsCstFormSection // JSP slide_13 (재원분담, 신규 ※ FnrsShar 아님)
├─ TmplntFormSection // JSP slide_14 (처리시설, 신규)
├─ TmplntFormSection // JSP slide_14 (처리시설, Tabs로 시설별 패널)
├─ ChecklistFormSection // JSP slide_16 (pbadms-sprt)
├─ AttachmentsFormSection // JSP 필수·기타첨부 (pbadms-sprt)
├─ SplmntDmndFormSection // edit + SL 상태만 (pbadms-sprt)
└─ sticky bottom action bar // FloatingActionButtons 계열
// = fnrs floating-action-buttons.tsx 와 동일 구성
// 단, 「재원협의 고시」「환경청제출」「반려」 제외
└─ form footer action bar // fnrs-form-page 279~323 동일
// [목록] / [저장] / edit시 [신청완료][삭제] / SL시 [보완완료]
```
**sticky bottom action bar 버튼 구성**
기준: [`floating-action-buttons.tsx`](../../src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx) 렌더 순서.
표시 여부는 역할·`prgrsSttsComCd`·결재단계에 따른 `getButtonConfigs` (sewer용 button-config로 이관).
**폼 하단 액션바 버튼 구성**
기준: [`fnrs-form-page.tsx` 279~323](../../src/pages/fnrs-cnsltn/ui/fnrs-form-page.tsx). UI·분기만 동일하고 훅/라우트는 sewer.
| 순서 | 버튼 | 조건/액션 키 (fnrs) | sewer 비고 |
| ---- | ----------------- | -------------------------------- | ------------------------------------------- |
| 1 | 목록 | 항상 | `/sewer-bsc-plan` |
| 2 | 수정 | `canEdit` (등록자 + `strg` 가능) | edit 라우트 |
| 3 | 저장 | `strg` | `saveApply` / SV |
| 4 | 신청완료 | `aply` | 체크리스트+필수첨부 검증 → `saveApply` / LE |
| 5 | 삭제 | `del` | `delete` |
| — | ~~재원협의 고시~~ | `fnrsCnsltnAncmnt` | **제외** |
| — | ~~환경청제출~~ | `envoSbmsn` | **제외** |
| — | ~~반려~~ | `rjct` | **제외** |
| — | ~~인가승인~~ | `sncnAprv` | **제외** |
| 6 | 검토결과 회신 | `rvwRply` | |
| 7 | 기술검토 의뢰 | `rqstTechRvw` | |
| 8 | 검토자지정 | `dsgntRvwr` | ElaprTmpltPickerDialog |
| 9 | 저장 (검토) | `rvwStrg` | `techReview` 저장 분기 |
| 10 | 검토완료 | `rvwCmptn` | `techReview` 완료 분기 |
| 11 | 보완요청 | `dmndSplmnt` | SplmntDmndDialog + `submitReqSupplement` |
| 12 | 결재(승인) | `atrzAprv` | ElaprApprovalDialog |
| 13 | 결재(회신) | `atrzRply` | ElaprApprovalDialog |
| 순서 | 버튼 | 조건 | sewer 비고 |
| ---- | -------- | ----------------------------------------- | ---------------------------------------------------- |
| 1 | 목록 | 항상 | `router.push("/sewer-bsc-plan")` |
| 2 | 저장 | `!isSplmntPrgrsStts` | `type="submit"` → `saveApply` / 임시저장(SV) |
| 3 | 신청완료 | `!isSplmntPrgrsStts && isEditMode` | 체크리스트+필수첨부 검증 → `saveApply` / LE |
| 4 | 삭제 | `!isSplmntPrgrsStts && isEditMode` | confirm → `delete` → 목록 |
| 5 | 보완완료 | `isSplmntPrgrsStts` (`prgrsSttsComCd=SL`) | `submitSupplement` (fnrs `onSplmntCmptn` 대응) |
**fnrs와의 차이 (섹션 치환만, 골격 동일)**
| fnrs-form-page / FAB | sewer-bsc-plan |
| ------------------------------------------ | ------------------------------------------------ |
| `BasicInfoFormSection` | 동일 역할 (마스터·연도·개요 필드는 sewer DTO) |
| `ContactInfoFormSection` | 동일 (`fieldPrefix="sewerBscPlanDtl"`) |
| `PrivacyConsentSection` | 동일 |
| `BusinessScaleFormSection` | → `OutlineFormSection` (단계별 인구·보급률) |
| `BusinessCostFormSection` | 동일 재사용 (회계트리) |
| `FnrsSharFormSection` | → `FnrsCstFormSection` (국고·지방비·원인자·기타) |
| _(없음)_ | `TmplntFormSection` (처리시설 매트릭스) |
| `ChecklistFormSection` `docSeComCd="FNRS"` | `docSeComCd="SEWER"` (TBD) |
| `AttachmentsFormSection` | 동일 패턴 |
| `SplmntDmndFormSection` | 동일 (`readOnly={!isSplmntPrgrsStts}`) |
| sticky FAB (전체) | **동일, 단 `환경청제출`·`반려` 제외** |
| fnrs-form-page | sewer-bsc-plan |
| ---------------------------------------------- | ------------------------------------------------------ |
| `BasicInfoFormSection` | 동일 역할 (마스터·연도·개요 필드는 sewer DTO) |
| `ContactInfoFormSection` | 동일 (`fieldPrefix="sewerBscPlanDtl"`) |
| `PrivacyConsentSection` | 동일 |
| `BusinessScaleFormSection` | → `OutlineFormSection` (단계별 인구·보급률) |
| `BusinessCostFormSection` | → `BizCstMatrixFormSection` (단계×원가 계층 전용) |
| `FnrsSharFormSection` | → `FnrsCstFormSection` (국고·지방비·원인자·기타) |
| _(없음)_ | `TmplntFormSection` (체크박스 + Tabs 시설별 매트릭스) |
| `ChecklistFormSection` `docSeComCd="FNRS"` | `docSeComCd="SEWER"` (TBD) |
| `AttachmentsFormSection` | 동일 패턴 |
| `SplmntDmndFormSection` | 동일 (`readOnly={!isSplmntPrgrsStts}`) |
| 폼 하단 액션바 (목록·저장·신청완료·삭제·보완완료) | **동일 구성, API·라우트만 sewer** |
### 3-2. 본문 ASCII (폼 페이지)
@@ -162,13 +149,15 @@ PageLayout (title / breadcrumb)
│ │ OutlineFormSection │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ┌─ 총사업비 (백만원) ──────────────────────────────────────┐ │
│ │ BusinessCostFormSection │ │
│ │ BizCstMatrixFormSection │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ┌─ 재원분담 (백만원) ──────────────────────────────────────┐ │
│ │ FnrsCstFormSection │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ┌─ 처리시설별 하수도정비기본계획 ──────────────────────────┐ │
│ │ TmplntFormSection │ │
│ │ ├─ BscFctList Checkbox │ │
│ │ └─ Tabs (선택 시설별 TabsTrigger / TabsContent) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ┌─ 체크리스트 ─────────────────────────────────────────────┐ │
│ │ ChecklistFormSection │ │
@@ -180,50 +169,38 @@ PageLayout (title / breadcrumb)
│ │ SplmntDmndFormSection │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ sticky bottom (FloatingActionButtons, 환경청제출·반려 제외) ──┐ │
│ │ [목록] [수정] [저장] [신청완료] [삭제] │ │
│ │ [검토결과 회신] [기술검토 의뢰] [검토자지정] │ │
│ │ [저장(검토)] [검토완료] [보완요청] [결재(승인)] [결재(회신)] │ │
│ │ * 라벨/핸들러는 sewer 도메인 명칭 TBD │ │
│ │ ※ 역할·진행상태별 표시 — 한 화면에 전부 노출되지 않음 │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ ┌─ form footer (fnrs-form-page 279~323 동일) ──────────────┐ │
│ │ [목록] [저장] / edit: [신청완료][삭제] / SL: [보완완료] │ │
│ │ ※ API·라우트만 sewer — UI 분기는 fnrs와 동일 │ │
│ └──────────────────────────────────────────────────────────┘ │
│ </form> │
└─────────────────────────────────────────────────────────────────┘
```
### 3-3. 액션바 동작 (`floating-action-buttons` 동일, 환경청제출·반려 제외)
### 3-3. 액션바 동작 (`fnrs-form-page` 279~323 동일, API만 sewer)
구현체: `pages/sewer-bsc-plan/ui/components/floating-action-buttons.tsx`
(fnrs FAB를 복제하되 `envoSbmsn`·`rjct` 렌더·핸들러 제거)
구현 위치: `sewer-bsc-plan-form-page.tsx` 하단
(`fnrs-form-page.tsx`의 `mx-4 flex flex-wrap justify-center gap-3` 블록을 그대로 옮기고 훅·path만 교체)
| 버튼 | 액션 키 | 대표 동작 |
| ------------- | ------------- | -------------------------------------------------------------- |
| 목록 | — | `router.push("/sewer-bsc-plan")` |
| 수정 | — (`canEdit`) | `/sewer-bsc-plan/[id]/edit` |
| 저장 | `strg` | `saveApply` + `SV` |
| 신청완료 | `aply` | 체크리스트 완료 + **필수 첨부 완료** 검증 → `saveApply` + `LE` |
| 삭제 | `del` | confirm → `delete` → 목록 |
| 검토결과 회신 | `rvwRply` | 진행상태 갱신 |
| 기술검토 의뢰 | `rqstTechRvw` | 진행상태 갱신 |
| 검토자지정 | `dsgntRvwr` | ElaprTmpltPickerDialog |
| 저장 (검토) | `rvwStrg` | `techReview` (저장) |
| 검토완료 | `rvwCmptn` | `techReview` (검토완료) |
| 보완요청 | `dmndSplmnt` | SplmntDmndDialog → `submitReqSupplement` |
| 결재(승인) | `atrzAprv` | ElaprApprovalDialog |
| 결재(회신) | `atrzRply` | ElaprApprovalDialog |
| 버튼 | 조건 | 대표 동작 |
| -------- | ---------------------------- | -------------------------------------------------------------------------- |
| 목록 | 항상 | `router.push("/sewer-bsc-plan")` |
| 저장 | `!isSplmntPrgrsStts` | `type="submit"` → `saveApply` + 임시저장(`SV`) |
| 신청완료 | edit && `!isSplmntPrgrsStts` | 체크리스트 완료 + **필수 첨부 완료** 검증 → `saveApply` + `LE` |
| 삭제 | edit && `!isSplmntPrgrsStts` | confirm → `delete` → 목록 |
| 보완완료 | `isSplmntPrgrsStts` (SL) | `form.handleSubmit` → `useSubmitSupplementSewerBscPlan` |
**신청완료(`aply`) 사전 검증**
**신청완료 사전 검증** (fnrs `handleApplyClick`과 동일 정책)
1. **체크리스트**: `isFormChecklistComplete(cklstResultList)` — 미완료 시 `"모든 체크리스트 항목을 체크해야 신청이 가능합니다."`
2. **필수 첨부**: `esntlAtchYn === "Y"`인 필수첨부 슬롯마다 `esntlAtchFileList`에 `atchFileGroupId`가 있어야 함
- Form: `AttachmentsFormSection`의 트리 + 폼 `esntlAtchFileList`
- View(FAB): `data.esntlFileList`에서 필수 항목의 `atflCd`(또는 동등 그룹 ID) 존재 여부
- 미충족 시 예: `"필수 첨부파일을 모두 등록해야 신청이 가능합니다."`
- 헬퍼 후보: `isFormEsntlAtchComplete(requiredTree, esntlAtchFileList)` (`pbadms-sprt`에 추가, Form/FAB 공용)
- 헬퍼 후보: `isFormEsntlAtchComplete(requiredTree, esntlAtchFileList)` (`pbadms-sprt`에 추가)
저장(`strg`)에는 위 검증을 적용하지 않는다 (임시저장 허용).
저장에는 위 검증을 적용하지 않는다 (임시저장 허용).
저장/제출 버튼을 섹션 헤더(`gTitle2 gRt`)에 두지 않는다 — **sticky FAB 단일 진입점**.
저장/제출 버튼을 섹션 헤더(`gTitle2 gRt`)에 두지 않는다 — **폼 하단 액션바 단일 진입점** (§4-J).
### 3-4. 라우트
@@ -285,7 +262,7 @@ JSP hidden → React 폼 기본값·세션 스토어로 흡수.
**동작**
- `goalYrStep1` onChange → 2·3·4단계 기본값 세팅 + E/F/G 헤더 연도 span 갱신 (`watch` 파생)
- JSP 상단 `gRt` 저장/제출은 사용하지 않음 → FormPage sticky 하단 액션바 (§3-3)
- JSP 상단 `gRt` 저장/제출은 사용하지 않음 → FormPage 하단 액션바 (§3-3, §4-J)
---
@@ -340,19 +317,21 @@ JSP hidden → React 폼 기본값·세션 스토어로 흡수.
### 4-F. 총사업비 (`slide_12`)
**파일**: `ui/sections/biz-cst-matrix-form-section.tsx` (**전용 컴포넌트** — fnrs `BusinessCostFormSection` 재사용 금지)
**래퍼**: `SectionCollapsible` title=`총사업비` (단위 `백만원`은 헤더 우측)
**JSP 현실**: 단계(1~4) × 원가 계층(총사업비, 공사비, 처리시설, 하수관로 신설/보수, 펌프장, 배수설비, 기타, 부대경비, 설계/감리/부대비). 일부 readonly·자동합산 JS.
**신규 API 현실**: `bizCstList: SewerBscPlanBizCstItem[]` = `{ planStpNo, acntgSeCd, cstrnAmt }`
→ **회계구분 트리(`acntg-se`) 기반 행**으로 모델링 (fnrs `BusinessCostFormSection`과 동일 철학).
**신규 API**: `bizCstList: SewerBscPlanBizCstItem[]` = `{ planStpNo, acntgSeCd, cstrnAmt }`
**구현 방향**
**구현 방향 (`BizCstMatrixFormSection` 전용)**
1. **1차(권장)**: `BusinessCostFormSection` 재사용 + `useAcntgSeMngTree`
- JSP 계층 라벨은 회계코드 트리 depth로 표현
- 합계 행은 `buildAcntgCostItems` / 파생 합계
2. **JSP 픽셀 단위 복제**가 필요하면: `BizCstMatrixFormSection` 전용 컴포넌트 + 저장 시 `acntgSeCd` 매핑 테이블 — 비용↑, 백엔드 코드 정의 선행 필요
단위 표시: `백만원` (섹션 헤더 우측).
- 행 = `useAcntgSeMngTree` → `buildAcntgCostItems` (fnrs `BusinessCostFormSection`과 동일 출처)
- `costItems` 로드 시 `useEffect`로 `bizCstList`를 `mergeBizCstList(existing, costItems)`로 동기화
- 열 = `planStpNo` 1~4 (헤더에 목표연도 표시)
- readonly 행(`CostItemDef.readonly=true`)은 children 합산으로 자동계산; Input에 `readOnly` + `bg-muted`
- 변경 시 `setAmt` → `recalculateBizCstMatrix(list, costItems)` → `setValue("bizCstList")`
- 저장 시 섹션 onChange 합산을 신뢰하여 `bizCstList` 그대로 전송 (`toBizCstSaveList` identity)
**표준사업비 조회** 버튼은 JSP에서 주석 처리됨 → **미구현**.
@@ -382,7 +361,8 @@ JSP hidden → React 폼 기본값·세션 스토어로 흡수.
**JSP 동작**
1. `BscFctList` 체크박스로 처리시설 다중 선택 (`arrBscFctCd`)
2. 선택 시 `createFormSection(code, name)`로 **당초 4단계 | 현재 | 변경 4단계** 매트릭스 동적 삽입
2. 선택 시 `createFormSection(code, name)`로 **당초 4단계 | 현재 | 변경 4단계** 매트릭스 동적 삽입
→ **React**: 세로 삽입 대신 `@common/components` `Tabs`로 시설별 패널 관리 (§아래 Tabs 규칙)
3. 활성 `formFields`(축소판): 처리구역면적, 하수처리인구, 계획하수량, 시설용량, 분류식 오수/우수, 합류식, 차집, 배수설비, 오수중계/일반/우수 펌프
4. 시설별 필수첨부 `createFileAttachmentSection`
5. `fetchBasicPlanData`로 당초(이전) 데이터 바인딩
@@ -416,18 +396,30 @@ JSP hidden → React 폼 기본값·세션 스토어로 흡수.
```
ui/sections/tmplnt-form-section.tsx
├─ TmplntFacilityPicker # Checkbox 목록 (시설 마스터 API TBD)
├─ TmplntMatrixTable # 시설 1개당 표 (useFieldArray)
└─ TmplntFacilityAttachments # 시설별 필수첨부 (선택)
├─ TmplntFacilityPicker # BscFctList Checkbox 목록 (시설 마스터 API TBD)
└─ Tabs (@common/components Tabs) # 선택 시설별 동적 패널
├─ TabsList / TabsTrigger # value=swtrmtFcltCd, 라벨=시설명
└─ TabsContent (시설 1개당)
├─ TmplntMatrixTable # 당초|현재|변경 매트릭스 (useFieldArray)
└─ TmplntFacilityAttachments # 시설별 필수첨부 (선택)
```
- 체크 추가 → `useFieldArray` append; 해제 → remove + confirm
**Tabs 관리 규칙** (`packages/common/components/src/ui/tabs`)
- `BscFctList` 체크 **선택** → `useFieldArray` append + 해당 시설 `TabsTrigger`/`TabsContent` 추가 → `Tabs` `value`를 신규 시설로 전환
- 체크 **해제** → confirm 후 remove + 해당 탭 제거 → 남은 탭 중 하나로 `value` 이동 (없으면 Tabs 영역 비표시)
- 선택 시설이 0개면 Tabs UI 숨김; 1개 이상이면 Tabs로 시설별 패널 전환
- 매트릭스·첨부는 각 `TabsContent` 안에만 렌더 (JSP의 세로 동적 삽입을 탭 패널로 대체)
**매트릭스 열**
- **당초 열**: 이전계획 API 조회 결과 읽기 전용 (없으면 빈 칸)
- **현재 열**: 기준년도 시점 값 (API 제공 시)
- **변경 열**: 편집 가능 → 저장 payload의 dtl만 전송
- 시설 마스터 목록 API: 레거시 `BscFctList` 대체 — **TBD** (`swtrmtFclt` 계열)
`bundle-dynamic-imports`: 매트릭스 섹션을 dynamic import 권장.
시설 마스터 목록 API: 레거시 `BscFctList` 대체 — **TBD** (`swtrmtFclt` 계열).
`bundle-dynamic-imports`: `TmplntFormSection`(또는 TabsContent 내부 매트릭스)을 `next/dynamic` 후보.
---
@@ -464,10 +456,48 @@ JSP `uploadFileETC` multiple → Zip 안내 문구 유지.
### 4-J. 저장 / 제출
| JSP | 진행값 | URL | React |
| ---- | ---------- | ------------------------- | -------------------------------------------------------------------- |
| 저장 | `SAVE` | `insertFnrscDmbp.do` | `useSaveApplySewerBscPlan` + `prgrsSttsComCd` = 임시저장 코드 |
| 제출 | `PRESENTN` | `insertFnrscDscss.do` (!) | 동일 `saveApply` 또는 제출용 상태코드 — **백엔드 상태코드 확인 TBD** |
**UI 구성**: [`fnrs-form-page.tsx` 279~323](../../src/pages/fnrs-cnsltn/ui/fnrs-form-page.tsx) 과 **동일**.
마크업·분기(`isSplmntPrgrsStts` / `isEditMode`)·버튼 색상·`disabled={isSubmitting}` 패턴을 그대로 두고, **호출 API·목록 path만 sewer** 로 교체한다.
```tsx
<div className="mx-4 flex flex-wrap justify-center gap-3 sm:gap-4">
<Button color="white" type="button" onClick={() => router.push("/sewer-bsc-plan")} disabled={isSubmitting}>
목록
</Button>
{!isSplmntPrgrsStts ? (
<>
<Button type="submit" disabled={isSubmitting}>저장</Button>
{isEditMode && (
<>
<Button color="green" type="button" onClick={handleApplyClick} disabled={isSubmitting}>
신청완료
</Button>
<Button color="red" type="button" onClick={handleDelClick} disabled={isSubmitting}>
삭제
</Button>
</>
)}
</>
) : (
<Button type="button" onClick={form.handleSubmit(onSplmntCmptn)} disabled={isSubmitting}>
보완완료
</Button>
)}
</div>
```
| 버튼 | fnrs 대응 | sewer API / 동작 |
| -------- | ----------------------------- | ------------------------------------------------------------ |
| 목록 | `router.push("/fnrs-cnsltn")` | `router.push("/sewer-bsc-plan")` |
| 저장 | `saveApply` + 임시저장 | `useSaveApplySewerBscPlan` + `prgrsSttsComCd`=임시저장(`SV`) |
| 신청완료 | `handleApplyClick` → 제출 | 체크리스트+필수첨부 검증 → `saveApply` + `LE` (상태코드 TBD) |
| 삭제 | `handleDelClick` | `useDeleteSewerBscPlan`(또는 동등) → 목록 |
| 보완완료 | `onSplmntCmptn` | `useSubmitSupplementSewerBscPlan` |
| JSP | 진행값 | URL | React |
| ---- | ---------- | ------------------------- | ---------------------------------------------------------- |
| 저장 | `SAVE` | `insertFnrscDmbp.do` | `saveApply` + 임시저장 코드 |
| 제출 | `PRESENTN` | `insertFnrscDscss.do` (!) | `saveApply` + 제출 상태코드 — **백엔드 상태코드 확인 TBD** |
페이로드 조립 (`buildApplyPayload`):
@@ -481,7 +511,7 @@ SewerBscPlanApplySaveRequestDto {
}
```
성공 후: `invalidateQueries(sewerBscPlanBaseKey)` → `router.push('/sewer-bsc-plan')`.
성공 후: `invalidateQueries(sewerBscPlanBaseKey)` → `router.push('/sewer-bsc-plan')` (신청완료는 상세로 보낼 수 있음 — fnrs와 동일 정책).
권한: JSP `AUTH3` → 기존 AS 권한 훅/역할로 버튼 `disabled`/`hidden`.
@@ -497,7 +527,7 @@ Base: `/as-service/api/v1/sewer-bsc-plans`
| 등록 저장·제출 | `useSaveApplySewerBscPlan` → `saveApply` | 본 문서 핵심 |
| 수정 로드 | `useSewerBscPlanDetails(docNo, chgCycl)` | 편집 시 |
| 마스터만 | `useSewerBscPlan` | 목록 행 클릭 등 |
| 보완 제출 | `useSubmitSupplementSewerBscPlan` | 등록 JSP 범위 밖 |
| 보완 제출 | `useSubmitSupplementSewerBscPlan` | edit + `prgrsSttsComCd=SL` 시 폼 하단 [보완완료] |
| 기술검토 | `useTechReviewSewerBscPlan` | 등록 JSP 범위 밖 |
등록 JSP에 없는 섹션(보완요구·검토의견)은 **상세/검토 화면**에서 `SplmntDmndFormSection` / 검토의견 섹션으로 후속 구현.
@@ -520,13 +550,13 @@ apps/as/
│ ├── sewer-bsc-plan-regist-page.tsx
│ ├── sewer-bsc-plan-form-page.tsx
│ ├── components/
│ │ ├── floating-action-buttons.tsx
│ │ └── previous-plan-lookup-dialog.tsx
│ └── sections/
│ ├── basic-info-form-section.tsx
│ ├── outline-form-section.tsx # 사업개요
│ ├── fnrs-cst-form-section.tsx # 재원분담
│ └── tmplnt-form-section.tsx # 처리시설
│ ├── outline-form-section.tsx # 사업개요
│ ├── biz-cst-matrix-form-section.tsx # 총사업비 (전용)
│ ├── fnrs-cst-form-section.tsx # 재원분담
│ └── tmplnt-form-section.tsx # 처리시설 (Checkbox + Tabs)
└── docs/sewer-bsc-plan/
└── dmbp-regist-migration.md # 본 문서
```
@@ -535,8 +565,13 @@ apps/as/
- `ContactInfoFormSection`, `PrivacyConsentSection`
- `ChecklistFormSection`, `AttachmentsFormSection`
- `BusinessCostFormSection` (총사업비 1차안)
- `SectionCollapsible`
- `Tabs` / `TabsList` / `TabsTrigger` / `TabsContent` (`@common/components` — 처리시설 패널)
도메인 전용 (재사용 금지):
- `BizCstMatrixFormSection` — 총사업비 (fnrs `BusinessCostFormSection` 미사용)
- 폼 하단 액션바 — `fnrs-form-page` 279~323 복제, API만 sewer
---
@@ -550,8 +585,7 @@ BEGIN
CALL 코드조회(SEWERBSCPLANSE, SEWER, …) // 병렬
CALL 체크리스트트리(docSeComCd) // 병렬
CALL 필수첨부트리(docSeComCd) // 병렬
CALL 회계구분트리 // 총사업비용
CALL 처리시설마스터목록 // TBD
CALL 처리시설마스터목록 // TBD (BscFctList)
DISPLAY 폼 defaultValues
ON 1단계목표연도 CHANGE DO
@@ -560,10 +594,18 @@ BEGIN
END ON
ON 처리시설 체크 DO
ADD tmplntList 항목 + 매트릭스 UI
ADD tmplntList 항목
ADD TabsTrigger / TabsContent (시설 패널)
SET Tabs value = 신규 시설
CALL 이전기본계획수치(시설코드) // 당초열, TBD
END ON
ON 처리시설 체크해제 DO
CONFIRM 제거
REMOVE tmplntList + 해당 Tabs 패널
SET Tabs value = 남은 시설 (없으면 Tabs 숨김)
END ON
ON [이전기본계획조회] CLICK DO
OPEN PreviousPlanLookupDialog
END ON
@@ -591,9 +633,9 @@ END
| # | 이슈 | 대응 |
| --- | ---------------------------------------------------- | ------------------------------------------------- |
| 1 | JSP 제출 URL이 `dscss`로 분기 (오타/구프로세스 가능) | 신규는 `saveApply` 단일 진입으로 통일 |
| 2 | 총사업비 UI 계층 ≠ `BizCstItem` | 회계트리 매핑 우선; 픽셀 복제는 2차 |
| 2 | 총사업비 UI 계층 ≠ `BizCstItem` | `BizCstMatrixFormSection` 전용 + `acntgSeCd` 매핑 테이블 |
| 3 | 재원분담 ≠ fnrs shar | 전용 `fnrs-cst-form-section` |
| 4 | 처리시설 매트릭스(당초/현재/변경) | 저장은 변경 dtl 중심; 당초는 조회 전용 |
| 4 | 처리시설 매트릭스(당초/현재/변경) | Checkbox 선택 → Tabs 패널; 저장은 변경 dtl 중심; 당초는 조회 전용 |
| 5 | 시설·이전계획 API | TBD — 미정 시 체크박스 목록 mock(`faker`)만 |
| 6 | `docSeComCd` 값 | CMS/체크리스트 마스터와 합의 |
| 7 | 레거시 multipart vs TUS | fnrs 첨부 패턴 준수 |
@@ -603,13 +645,13 @@ END
## 9. 구현 순서 (권장)
1. 라우트 + `FormPage` 골격 + zod 스키마(마스터·dtl·동의)
1. 라우트 + `FormPage` 골격 + zod 스키마(마스터·dtl·동의) + 폼 하단 액션바(§4-J)
2. B·C·D 섹션 (공통 위젯 최대 활용)
3. E 사업개요 + G 재원분담 (4열 매트릭스 패턴 공유)
4. F 총사업비 (`BusinessCostFormSection` 연동)
4. F 총사업비 (`BizCstMatrixFormSection` 전용)
5. I 체크리스트·첨부
6. H 처리시설 (가장 복잡 — 마지막)
7. 저장/제출 + 권한
6. H 처리시설 (`BscFctList` Checkbox + `@common/components` Tabs — 가장 복잡)
7. 저장/신청완료/삭제/보완완료 API 연동 (§4-J)
8. 이전계획 Dialog (API 확정 후)
---
@@ -0,0 +1,3 @@
export { SewerBscPlanEditPage } from "./ui/sewer-bsc-plan-edit-page";
export { SewerBscPlanFormPage } from "./ui/sewer-bsc-plan-form-page";
export { SewerBscPlanRegistPage } from "./ui/sewer-bsc-plan-regist-page";
@@ -0,0 +1,102 @@
import type { CostItemDef } from "@/widgets/pbadms-sprt/lib/business-cost-form-utils";
export interface BizCstMatrixItem {
planStpNo: number;
acntgSeCd: string;
cstrnAmt: number;
}
const PLAN_STEPS = [1, 2, 3, 4] as const;
function getAmt(list: BizCstMatrixItem[], acntgSeCd: string, planStpNo: number): number {
return (
list.find((item) => item.acntgSeCd === acntgSeCd && item.planStpNo === planStpNo)
?.cstrnAmt ?? 0
);
}
function setAmt(
list: BizCstMatrixItem[],
acntgSeCd: string,
planStpNo: number,
cstrnAmt: number,
): BizCstMatrixItem[] {
const index = list.findIndex(
(item) => item.acntgSeCd === acntgSeCd && item.planStpNo === planStpNo,
);
if (index >= 0) {
const next = [...list];
next[index] = { ...next[index], cstrnAmt };
return next;
}
return [...list, { planStpNo, acntgSeCd, cstrnAmt }];
}
/** CostItemDef 트리의 children 기반 bottom-up 부모합산 (각 단계별 cstrnAmt) */
function recalculateBizCstMatrix(
list: BizCstMatrixItem[],
costItems: CostItemDef[],
): BizCstMatrixItem[] {
let next = [...list];
let hasChanges = false;
for (let i = costItems.length - 1; i >= 0; i--) {
const item = costItems[i];
if (!item.readonly || !item.children) continue;
for (const planStpNo of PLAN_STEPS) {
const childSum = item.children.reduce(
(sum, childCode) => sum + getAmt(next, childCode, planStpNo),
0,
);
const current = getAmt(next, item.code, planStpNo);
if (current !== childSum) {
next = setAmt(next, item.code, planStpNo, childSum);
hasChanges = true;
}
}
}
return hasChanges ? next : list;
}
function createEmptyBizCstList(costItems: CostItemDef[]): BizCstMatrixItem[] {
const list: BizCstMatrixItem[] = [];
for (const item of costItems) {
for (const planStpNo of PLAN_STEPS) {
list.push({ planStpNo, acntgSeCd: item.code, cstrnAmt: 0 });
}
}
return list;
}
function mergeBizCstList(
existing: BizCstMatrixItem[],
costItems: CostItemDef[],
): BizCstMatrixItem[] {
const existingMap = new Map(
existing.map((item) => [`${item.acntgSeCd}:${item.planStpNo}`, item.cstrnAmt]),
);
const base = createEmptyBizCstList(costItems).map((item) => ({
...item,
cstrnAmt: existingMap.get(`${item.acntgSeCd}:${item.planStpNo}`) ?? 0,
}));
return recalculateBizCstMatrix(base, costItems);
}
function toBizCstSaveList(
list: BizCstMatrixItem[],
costItems: CostItemDef[],
): BizCstMatrixItem[] {
return recalculateBizCstMatrix(list, costItems);
}
export {
createEmptyBizCstList,
getAmt,
mergeBizCstList,
PLAN_STEPS,
recalculateBizCstMatrix,
setAmt,
toBizCstSaveList,
};
@@ -0,0 +1,82 @@
import type { CostItemDef } from "@/widgets/pbadms-sprt/lib/business-cost-form-utils";
import { type BizCstMatrixItem,getAmt, PLAN_STEPS } from "./biz-cst-matrix-utils";
export interface FnrsCstItem {
planStpNo: number;
gvexpAmt?: number;
lcexpAmt?: number;
orgprBdchgAmt?: number;
etcAmt?: number;
sumAmt?: number;
}
export interface FnrsCstStepRemaining {
planStpNo: number;
remaining: number;
}
function getBizCstRootCode(costItems: CostItemDef[]): string | undefined {
return costItems.find((item) => item.level === 1 && item.readonly)?.code;
}
function getBizCstStepTotal(
bizCstList: BizCstMatrixItem[],
costItems: CostItemDef[],
planStpNo: number,
): number {
const rootCode = getBizCstRootCode(costItems);
if (!rootCode) return 0;
return getAmt(bizCstList, rootCode, planStpNo);
}
function sumFnrsCstLeaf(item: FnrsCstItem): number {
return (
(item.gvexpAmt ?? 0) +
(item.lcexpAmt ?? 0) +
(item.orgprBdchgAmt ?? 0) +
(item.etcAmt ?? 0)
);
}
function recalcFnrsCstSumAmt(item: FnrsCstItem): FnrsCstItem {
return {
...item,
sumAmt: sumFnrsCstLeaf(item),
};
}
function calcFnrsCstRemaining(
bizCstList: BizCstMatrixItem[],
fnrsCstList: FnrsCstItem[],
costItems: CostItemDef[],
): FnrsCstStepRemaining[] {
return PLAN_STEPS.map((planStpNo) => {
const bizTotal = getBizCstStepTotal(bizCstList, costItems, planStpNo);
const fnrsItem = fnrsCstList.find((item) => item.planStpNo === planStpNo);
const sharTotal = fnrsItem ? sumFnrsCstLeaf(fnrsItem) : 0;
return { planStpNo, remaining: bizTotal - sharTotal };
});
}
function validateFnrsCstBalance(
bizCstList: BizCstMatrixItem[],
fnrsCstList: FnrsCstItem[],
costItems: CostItemDef[],
): string | null {
if (costItems.length === 0) return null;
const remainingList = calcFnrsCstRemaining(bizCstList, fnrsCstList, costItems);
const hasMismatch = remainingList.some((item) => item.remaining !== 0);
if (hasMismatch) return "재원분담 총사업비를 확인해주세요.";
return null;
}
export {
calcFnrsCstRemaining,
getBizCstRootCode,
getBizCstStepTotal,
recalcFnrsCstSumAmt,
sumFnrsCstLeaf,
validateFnrsCstBalance,
};
@@ -0,0 +1,284 @@
import type { IUserInfo } from "@/shared/store";
import type {
SewerBscPlanApplySaveRequestDto,
SewerBscPlanDetResponseDto,
} from "@/entities/sewer-bsc-plan";
import type { BizCstMatrixItem } from "./biz-cst-matrix-utils";
import { recalcFnrsCstSumAmt } from "./fnrs-cst-form-utils";
import {
formatTaskSeComCdForApi,
parseTaskSeComCdFromApi,
type SewerBscPlanRegistFormData,
} from "./sewer-bsc-plan-regist-schema";
const PLAN_STEPS = [1, 2, 3, 4] as const;
function createEmptyOtlnList() {
return PLAN_STEPS.map((planStpNo) => ({
planStpNo,
sewerPlanPpltnCnt: 0,
sewerPrcsPpltnCnt: 0,
sewerPsplyRt: 0,
}));
}
function createEmptyFnrsCstList() {
return PLAN_STEPS.map((planStpNo) => ({
planStpNo,
gvexpAmt: 0,
lcexpAmt: 0,
orgprBdchgAmt: 0,
etcAmt: 0,
sumAmt: 0,
}));
}
function mapSelectedValue(value?: string | null): "Y" | "N" | null {
if (value === "Y" || value === "N") return value;
return null;
}
function mapEsntlAtchFileList(esntlFileList: SewerBscPlanDetResponseDto["esntlFileList"]) {
const esntlMap = new Map<string, string>();
for (const item of esntlFileList ?? []) {
if (!item.esntlAtchFileId || !item.atflCd) continue;
esntlMap.set(item.esntlAtchFileId, item.atflCd);
}
return Array.from(esntlMap.entries()).map(([esntlAtchFileId, atchFileGroupId]) => ({
esntlAtchFileId,
atchFileGroupId,
}));
}
function createEmptyTmplntDtlList(swtrmtFcltCd: string) {
return PLAN_STEPS.map((planStpNo) => ({
planStpNo,
swtrmtFcltCd,
prcsZoneArea: 0,
swtrmtPpltnCnt: 0,
planSwqty: 0,
fcltCpct: 0,
sprsysDrtwPplLen: 0,
sprsysExlnPplLen: 0,
cbsysPplLen: 0,
icptgPplLen: 0,
drgCnt: 0,
drtwRlyPumpCnt: 0,
drtwPumpCnt: 0,
exlnPumpCnt: 0,
}));
}
export function getCreateDefaultValues(userInfo: IUserInfo | null): SewerBscPlanRegistFormData {
return {
sewerBscPlan: {
sewerBscPlanNm: "",
sewerBscPlanSeComCd: "",
sewerBscPlanSeCdNm: "",
taskSeComCd: [],
admdstCd: userInfo?.instSeCd ?? "",
prgrsSttsComCd: "SV",
sido: "",
gugun: userInfo?.instSeNm ?? "",
},
sewerBscPlanDtl: {
planYr: "",
crtrYr: "",
frlvlGoalYr: "",
sclvlGoalYr: "",
thlvlGoalYr: "",
folvlGoalYr: "",
bizOtlnCn: "",
aplcntNm: userInfo?.userName ?? "",
aplcntMblTelno: userInfo?.mphn ?? "",
aplcntOgdpNm: userInfo?.ogdpSeNm ?? "",
aplcntEmlAddr: userInfo?.email ?? "",
dsgnrNm: "",
dsgnrTelno: "",
dsgnrOgdpNm: "",
dsgnrEmlAddr: "",
atchFileGroupId: "",
},
sewerBscPlanOtlnList: createEmptyOtlnList(),
bizCstList: [] as BizCstMatrixItem[],
fnrsCstList: createEmptyFnrsCstList(),
sewerBscPlanTmplntList: [],
cklstResultList: [],
esntlAtchFileList: [],
splmntDmndList: [],
privacyConsent: "N",
};
}
export function mapDetailToFormData(data: SewerBscPlanDetResponseDto): SewerBscPlanRegistFormData {
const detDto = data.detDto;
const dtl = data.activeDtlDto;
if (!detDto) throw new Error("상세 정보(detDto)가 없습니다.");
const otlnMap = new Map((data.otlnList ?? []).map((item) => [item.planStpNo, item]));
const fnrsMap = new Map((data.fnrsCstList ?? []).map((item) => [item.planStpNo, item]));
return {
sewerBscPlan: {
sewerBscPlanDocNo: detDto.sewerBscPlanDocNo,
sewerBscPlanNm: detDto.sewerBscPlanNm ?? "",
sewerBscPlanSeComCd: detDto.sewerBscPlanSeComCd ?? "",
sewerBscPlanSeCdNm: detDto.sewerBscPlanSeCdNm ?? "",
taskSeComCd: parseTaskSeComCdFromApi(detDto.taskSeComCd),
admdstCd: detDto.admdstCd,
envoSeComCd: detDto.envoSeComCd,
prgrsSttsComCd: detDto.prgrsSttsComCd,
sido: detDto.sido,
gugun: detDto.gugun,
},
sewerBscPlanDtl: {
chgCycl: dtl?.chgCycl ?? detDto.chgCycl,
planYr: dtl?.planYr ?? "",
crtrYr: dtl?.crtrYr ?? "",
frlvlGoalYr: dtl?.frlvlGoalYr ?? "",
sclvlGoalYr: dtl?.sclvlGoalYr ?? "",
thlvlGoalYr: dtl?.thlvlGoalYr ?? "",
folvlGoalYr: dtl?.folvlGoalYr ?? "",
bizOtlnCn: dtl?.bizOtlnCn ?? "",
aplcntNm: dtl?.aplcntNm ?? "",
aplcntMblTelno: dtl?.aplcntMblTelno ?? "",
aplcntOgdpNm: dtl?.aplcntOgdpNm ?? "",
aplcntEmlAddr: dtl?.aplcntEmlAddr ?? "",
dsgnrNm: dtl?.dsgnrNm ?? "",
dsgnrTelno: dtl?.dsgnrTelno ?? "",
dsgnrOgdpNm: dtl?.dsgnrOgdpNm ?? "",
dsgnrEmlAddr: dtl?.dsgnrEmlAddr ?? "",
atchFileGroupId: dtl?.atchFileGroupId ?? "",
},
sewerBscPlanOtlnList: PLAN_STEPS.map((planStpNo) => {
const item = otlnMap.get(planStpNo);
return {
planStpNo,
sewerPlanPpltnCnt: item?.sewerPlanPpltnCnt ?? 0,
sewerPrcsPpltnCnt: item?.sewerPrcsPpltnCnt ?? 0,
sewerPsplyRt: item?.sewerPsplyRt ?? 0,
};
}),
bizCstList: (data.bizCstList ?? []).map((item) => ({
planStpNo: item.planStpNo ?? 1,
acntgSeCd: item.acntgSeCd ?? "",
cstrnAmt: item.cstrnAmt ?? 0,
})),
fnrsCstList: PLAN_STEPS.map((planStpNo) => {
const item = fnrsMap.get(planStpNo);
return {
planStpNo,
gvexpAmt: item?.gvexpAmt ?? 0,
lcexpAmt: item?.lcexpAmt ?? 0,
orgprBdchgAmt: item?.orgprBdchgAmt ?? 0,
etcAmt: item?.etcAmt ?? 0,
sumAmt: item?.sumAmt ?? 0,
};
}),
sewerBscPlanTmplntList: (data.tmplntList ?? []).map((item) => {
const code = item.swtrmtFcltCd ?? "";
const dtlMap = new Map(
(item.sewerBscPlanTmplntDtlList ?? []).map((dtlItem) => [dtlItem.planStpNo, dtlItem]),
);
return {
swtrmtFcltCd: code,
swtrmtFcltNm: item.swtrmtFcltNm,
atchFileGroupId: item.atchFileGroupId,
sewerBscPlanTmplntDtlList: createEmptyTmplntDtlList(code).map((empty) => {
const dtlItem = dtlMap.get(empty.planStpNo);
if (!dtlItem) return empty;
return {
planStpNo: empty.planStpNo,
swtrmtFcltCd: code,
prcsZoneArea: dtlItem.prcsZoneArea ?? 0,
swtrmtPpltnCnt: dtlItem.swtrmtPpltnCnt ?? 0,
planSwqty: dtlItem.planSwqty ?? 0,
fcltCpct: dtlItem.fcltCpct ?? 0,
sprsysDrtwPplLen: dtlItem.sprsysDrtwPplLen ?? 0,
sprsysExlnPplLen: dtlItem.sprsysExlnPplLen ?? 0,
cbsysPplLen: dtlItem.cbsysPplLen ?? 0,
icptgPplLen: dtlItem.icptgPplLen ?? 0,
drgCnt: dtlItem.drgCnt ?? 0,
drtwRlyPumpCnt: dtlItem.drtwRlyPumpCnt ?? 0,
drtwPumpCnt: dtlItem.drtwPumpCnt ?? 0,
exlnPumpCnt: dtlItem.exlnPumpCnt ?? 0,
};
}),
};
}),
cklstResultList:
data.cklstResultRows?.map((item) => ({
cklstSn: item.cklstSn,
cklstArtclSn: item.cklstArtclSn,
cklstRvwSn: item.cklstRvwSn,
rvwStpComCd: item.rvwStpComCd,
selected: mapSelectedValue(item.rvwRsltAnmlyYn),
picRvwCn: item.picRvwCn,
})) ?? [],
esntlAtchFileList: mapEsntlAtchFileList(data.esntlFileList),
splmntDmndList:
data.splmntDmndList?.map((item) => ({
splmntDmndSn: item.splmntDmndSn,
splmntArtclComCd: item.splmntArtclComCd,
splmntArtclCdNm: item.splmntArtclCdNm,
splmntDmndCn: item.splmntDmndCn,
splmntDmndDt: item.splmntDmndDt,
splmntDmndAtchFileGroupId: item.splmntDmndAtchFileGroupId ?? "",
splmntDmndAtchFileNm: item.splmntDmndAtchFileInfo?.orgnlFileNm ?? "",
splmntCn: item.splmntCn ?? "",
splmntPicNm: item.splmntPicNm ?? "",
splmntCmptnDt: item.splmntCmptnDt ?? "",
splmntAtchFileGroupId: item.splmntAtchFileGroupId ?? "",
splmntAtchFileNm: item.splmntAtchFileInfo?.orgnlFileNm ?? "",
splmntAtchFileAtflId: item.splmntAtchFileInfo?.atflId ?? "",
cmptnYn: item.cmptnYn,
})) ?? [],
privacyConsent: "Y",
};
}
export function buildApplyPayload(
data: SewerBscPlanRegistFormData,
prgrsSttsComCd: "SV" | "LE" | "TE",
options?: { isEditMode?: boolean; docNo?: string },
): SewerBscPlanApplySaveRequestDto {
const { taskSeComCd, ...sewerBscPlanRest } = data.sewerBscPlan;
return {
sewerBscPlan: {
...sewerBscPlanRest,
taskSeComCd: formatTaskSeComCdForApi(taskSeComCd),
sewerBscPlanDocNo: options?.isEditMode ? options.docNo : undefined,
prgrsSttsComCd,
},
sewerBscPlanDtl: {
...data.sewerBscPlanDtl,
sewerBscPlanDocNo: options?.isEditMode ? options.docNo : undefined,
},
sewerBscPlanOtlnList: data.sewerBscPlanOtlnList,
bizCstList: data.bizCstList,
fnrsCstList: data.fnrsCstList.map((item) => recalcFnrsCstSumAmt(item)),
sewerBscPlanTmplntList: data.sewerBscPlanTmplntList.map((item) => ({
swtrmtFcltCd: item.swtrmtFcltCd,
atchFileGroupId: item.atchFileGroupId,
sewerBscPlanTmplntDtlList: item.sewerBscPlanTmplntDtlList,
})),
cklstResultList: data.cklstResultList.map((item) => ({
...item,
selected: item.selected ?? undefined,
})),
esntlAtchFileList: data.esntlAtchFileList,
splmntDmndList: data.splmntDmndList.map(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
({ splmntAtchFileNm, splmntAtchFileAtflId, splmntDmndAtchFileNm, ...item }) => ({
...item,
...(prgrsSttsComCd === "TE" ? { cmptnYn: "Y" } : null),
}),
),
};
}
export { createEmptyTmplntDtlList };
@@ -0,0 +1,143 @@
import { z } from "zod";
const tmplntDtlSchema = z.object({
planStpNo: z.number(),
swtrmtFcltCd: z.string(),
prcsZoneArea: z.number().optional(),
swtrmtPpltnCnt: z.number().optional(),
planSwqty: z.number().optional(),
fcltCpct: z.number().optional(),
sprsysDrtwPplLen: z.number().optional(),
sprsysExlnPplLen: z.number().optional(),
cbsysPplLen: z.number().optional(),
icptgPplLen: z.number().optional(),
drgCnt: z.number().optional(),
drtwRlyPumpCnt: z.number().optional(),
drtwPumpCnt: z.number().optional(),
exlnPumpCnt: z.number().optional(),
});
export const sewerBscPlanRegistSchema = z.object({
sewerBscPlan: z.object({
sewerBscPlanDocNo: z.string().optional(),
sewerBscPlanNm: z.string().min(1, "기본계획명은 필수입니다"),
sewerBscPlanSeComCd: z.string().optional(),
sewerBscPlanSeCdNm: z.string().optional(),
taskSeComCd: z.array(z.string()).optional(),
admdstCd: z.string().optional(),
envoSeComCd: z.string().optional(),
prgrsSttsComCd: z.string().optional(),
sido: z.string().optional(),
gugun: z.string().optional(),
}),
sewerBscPlanDtl: z.object({
chgCycl: z.number().optional(),
planYr: z.string().min(1, "계획년도는 필수입니다"),
crtrYr: z.string().min(1, "기준년도는 필수입니다"),
frlvlGoalYr: z.string().min(1, "1단계 목표연도는 필수입니다"),
sclvlGoalYr: z.string().min(1, "2단계 목표연도는 필수입니다"),
thlvlGoalYr: z.string().min(1, "3단계 목표연도는 필수입니다"),
folvlGoalYr: z.string().min(1, "4단계 목표연도는 필수입니다"),
bizOtlnCn: z.string().min(1, "계획개요는 필수입니다"),
aplcntNm: z.string().optional(),
aplcntMblTelno: z.string().optional(),
aplcntOgdpNm: z.string().optional(),
aplcntEmlAddr: z.string().optional(),
dsgnrNm: z.string().min(1, "설계자 이름은 필수입니다"),
dsgnrTelno: z
.string()
.min(1, "설계자 전화번호는 필수입니다")
.regex(/^[0-9-]+$/, "숫자와 하이픈(-)만 입력 가능합니다"),
dsgnrOgdpNm: z.string().min(1, "설계자 소속은 필수입니다"),
dsgnrEmlAddr: z
.string()
.min(1, "설계자 이메일은 필수입니다")
.email("올바른 이메일 형식이 아닙니다"),
atchFileGroupId: z.string().optional(),
}),
sewerBscPlanOtlnList: z.array(
z.object({
planStpNo: z.number(),
sewerPlanPpltnCnt: z.number().optional(),
sewerPrcsPpltnCnt: z.number().optional(),
sewerPsplyRt: z.number().optional(),
}),
),
bizCstList: z.array(
z.object({
planStpNo: z.number(),
acntgSeCd: z.string(),
cstrnAmt: z.number(),
}),
),
fnrsCstList: z.array(
z.object({
planStpNo: z.number(),
gvexpAmt: z.number().optional(),
lcexpAmt: z.number().optional(),
orgprBdchgAmt: z.number().optional(),
etcAmt: z.number().optional(),
sumAmt: z.number().optional(),
}),
),
sewerBscPlanTmplntList: z.array(
z.object({
swtrmtFcltCd: z.string(),
swtrmtFcltNm: z.string().optional(),
atchFileGroupId: z.string().optional(),
sewerBscPlanTmplntDtlList: z.array(tmplntDtlSchema).optional(),
}),
),
cklstResultList: z.array(
z.object({
cklstSn: z.number(),
cklstArtclSn: z.number(),
cklstRvwSn: z.number(),
rvwStpComCd: z.string(),
selected: z.enum(["Y", "N"]).nullable(),
picRvwCn: z.string().nullable().optional(),
}),
),
esntlAtchFileList: z.array(
z.object({
esntlAtchFileId: z.string(),
atchFileGroupId: z.string(),
}),
),
splmntDmndList: z.array(
z.object({
splmntDmndSn: z.number().optional(),
splmntArtclComCd: z.string().optional(),
splmntArtclCdNm: z.string().nullish(),
splmntDmndCn: z.string().optional(),
splmntDmndDt: z.string().optional(),
splmntDmndAtchFileGroupId: z.string().optional(),
splmntDmndAtchFileNm: z.string().optional(),
splmntCn: z.string().optional(),
splmntPicNm: z.string().optional(),
splmntCmptnDt: z.string().optional(),
splmntAtchFileGroupId: z.string().optional(),
splmntAtchFileNm: z.string().optional(),
splmntAtchFileAtflId: z.string().optional(),
cmptnYn: z.string().optional(),
}),
),
privacyConsent: z.enum(["Y", "N"], {
required_error: "개인정보 수집/이용 동의는 필수입니다",
}),
});
export type SewerBscPlanRegistFormData = z.infer<typeof sewerBscPlanRegistSchema>;
export function formatTaskSeComCdForApi(taskSeComCd?: string[]): string | undefined {
if (!taskSeComCd?.length) return undefined;
return taskSeComCd.join(",");
}
export function parseTaskSeComCdFromApi(taskSeComCd?: string): string[] {
if (!taskSeComCd) return [];
return taskSeComCd.split(",").filter(Boolean);
}
/** 체크리스트·필수첨부 docSeComCd — CMS 합의 전 임시값 */
export const SEWER_BSC_PLAN_DOC_SE_COM_CD = "SEWER";
@@ -0,0 +1,33 @@
function buildYearOptions(from: number, to: number): { value: string; label: string }[] {
const years: { value: string; label: string }[] = [];
const step = from <= to ? 1 : -1;
for (let year = from; step > 0 ? year <= to : year >= to; year += step) {
const value = String(year);
years.push({ value, label: value });
}
return years;
}
/** 계획년도·기준년도: 현재 ~ 2005 */
function getPlanYearOptions(now = new Date().getFullYear()) {
return buildYearOptions(now, 2005);
}
/** 단계 목표연도: 현재 ~ 현재+30 */
function getGoalYearOptions(now = new Date().getFullYear()) {
return buildYearOptions(now, now + 30);
}
function getLinkedGoalYears(frlvlGoalYr: string) {
const base = Number(frlvlGoalYr);
if (!Number.isFinite(base) || base <= 0) {
return { sclvlGoalYr: "", thlvlGoalYr: "", folvlGoalYr: "" };
}
return {
sclvlGoalYr: String(base + 5),
thlvlGoalYr: String(base + 10),
folvlGoalYr: String(base + 15),
};
}
export { buildYearOptions, getGoalYearOptions, getLinkedGoalYears, getPlanYearOptions };
@@ -0,0 +1,274 @@
"use client";
import { Controller, useFormContext } from "react-hook-form";
import { useUserStore } from "@/shared/lib";
import { Field, FieldError } from "@/shared/ui/field";
import { SectionCollapsible } from "@/widgets/pbadms-sprt";
import {
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Table,
TableCell,
TableHead,
TableHeader,
TableRow,
Textarea,
} from "@common/components";
import type { SewerBscPlanRegistFormData } from "../../lib/sewer-bsc-plan-regist-schema";
import { getGoalYearOptions, getLinkedGoalYears, getPlanYearOptions } from "../../lib/year-options";
function BasicInfoFormSection() {
const userInfo = useUserStore((state) => state.userInfo);
const { control, setValue, watch } = useFormContext<SewerBscPlanRegistFormData>();
const planYearOptions = getPlanYearOptions();
const goalYearOptions = getGoalYearOptions();
const seCdNm = watch("sewerBscPlan.sewerBscPlanSeCdNm");
const localGovLabel =
[watch("sewerBscPlan.sido"), watch("sewerBscPlan.gugun")].filter(Boolean).join(" ") ||
userInfo?.instSeNm ||
"-";
function handleFrlvlGoalYrChange(value: string) {
setValue("sewerBscPlanDtl.frlvlGoalYr", value, { shouldDirty: true, shouldValidate: true });
const linked = getLinkedGoalYears(value);
setValue("sewerBscPlanDtl.sclvlGoalYr", linked.sclvlGoalYr, {
shouldDirty: true,
shouldValidate: true,
});
setValue("sewerBscPlanDtl.thlvlGoalYr", linked.thlvlGoalYr, {
shouldDirty: true,
shouldValidate: true,
});
setValue("sewerBscPlanDtl.folvlGoalYr", linked.folvlGoalYr, {
shouldDirty: true,
shouldValidate: true,
});
}
return (
<SectionCollapsible title="기본계획 신청">
<Table variant="secondary">
<colgroup>
<col width="15%" />
<col width="35%" />
<col width="15%" />
<col width="35%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead className="w-[20rem]" data-required>
기본계획명
</TableHead>
<TableCell colSpan={3}>
<Controller
name="sewerBscPlan.sewerBscPlanNm"
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Input
{...field}
id={field.name}
placeholder="기본계획명 입력"
aria-invalid={fieldState.invalid}
className="w-full max-w-[48rem]"
/>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead data-required>계획년도</TableHead>
<TableCell>
<Controller
name="sewerBscPlanDtl.planYr"
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Select value={field.value || undefined} onValueChange={field.onChange}>
<SelectTrigger aria-invalid={fieldState.invalid} className="w-[16rem]">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{planYearOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
</TableCell>
<TableHead data-required>기준년도</TableHead>
<TableCell>
<Controller
name="sewerBscPlanDtl.crtrYr"
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Select value={field.value || undefined} onValueChange={field.onChange}>
<SelectTrigger aria-invalid={fieldState.invalid} className="w-[16rem]">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{planYearOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead data-required>1단계 목표연도</TableHead>
<TableCell>
<Controller
name="sewerBscPlanDtl.frlvlGoalYr"
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Select
value={field.value || undefined}
onValueChange={handleFrlvlGoalYrChange}
>
<SelectTrigger aria-invalid={fieldState.invalid} className="w-[16rem]">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{goalYearOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
</TableCell>
<TableHead data-required>2단계 목표연도</TableHead>
<TableCell>
<Controller
name="sewerBscPlanDtl.sclvlGoalYr"
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Select value={field.value || undefined} onValueChange={field.onChange}>
<SelectTrigger aria-invalid={fieldState.invalid} className="w-[16rem]">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{goalYearOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead data-required>3단계 목표연도</TableHead>
<TableCell>
<Controller
name="sewerBscPlanDtl.thlvlGoalYr"
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Select value={field.value || undefined} onValueChange={field.onChange}>
<SelectTrigger aria-invalid={fieldState.invalid} className="w-[16rem]">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{goalYearOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
</TableCell>
<TableHead data-required>4단계 목표연도</TableHead>
<TableCell>
<Controller
name="sewerBscPlanDtl.folvlGoalYr"
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Select value={field.value || undefined} onValueChange={field.onChange}>
<SelectTrigger aria-invalid={fieldState.invalid} className="w-[16rem]">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{goalYearOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead data-required>계획개요</TableHead>
<TableCell colSpan={3}>
<Controller
name="sewerBscPlanDtl.bizOtlnCn"
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Textarea
{...field}
id={field.name}
placeholder="계획개요 입력"
className="!text-xl"
aria-invalid={fieldState.invalid}
rows={4}
/>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>구분</TableHead>
<TableCell>{seCdNm || "-"}</TableCell>
<TableHead>신청 지자체</TableHead>
<TableCell>{localGovLabel}</TableCell>
</TableRow>
</TableHeader>
</Table>
</SectionCollapsible>
);
}
export { BasicInfoFormSection };
@@ -0,0 +1,133 @@
"use client";
import { useEffect, useMemo } from "react";
import { useFormContext, useWatch } from "react-hook-form";
import { Field } from "@/shared/ui/field";
import { useAcntgSeMngTree } from "@/entities/acntg";
import { buildAcntgCostItems, SectionCollapsible } from "@/widgets/pbadms-sprt";
import {
Input,
Skeleton,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@common/components";
import {
getAmt,
mergeBizCstList,
recalculateBizCstMatrix,
setAmt,
} from "../../lib/biz-cst-matrix-utils";
import type { SewerBscPlanRegistFormData } from "../../lib/sewer-bsc-plan-regist-schema";
function BizCstMatrixFormSection() {
const { control, setValue, getValues } = useFormContext<SewerBscPlanRegistFormData>();
const bizCstList = useWatch({ control, name: "bizCstList" }) ?? [];
const frlvlGoalYr = useWatch({ control, name: "sewerBscPlanDtl.frlvlGoalYr" });
const sclvlGoalYr = useWatch({ control, name: "sewerBscPlanDtl.sclvlGoalYr" });
const thlvlGoalYr = useWatch({ control, name: "sewerBscPlanDtl.thlvlGoalYr" });
const folvlGoalYr = useWatch({ control, name: "sewerBscPlanDtl.folvlGoalYr" });
const { data: acntgTree = [] } = useAcntgSeMngTree();
const costItems = useMemo(() => buildAcntgCostItems(acntgTree), [acntgTree]);
useEffect(() => {
if (costItems.length === 0) return;
setValue("bizCstList", mergeBizCstList(getValues("bizCstList"), costItems));
}, [costItems, getValues, setValue]);
const stepHeaders = [
{ planStpNo: 1, label: "1단계", year: frlvlGoalYr },
{ planStpNo: 2, label: "2단계", year: sclvlGoalYr },
{ planStpNo: 3, label: "3단계", year: thlvlGoalYr },
{ planStpNo: 4, label: "4단계", year: folvlGoalYr },
];
function handleChange(acntgSeCd: string, planStpNo: number, value: string) {
const numValue = Number(value) || 0;
const next = recalculateBizCstMatrix(
setAmt(getValues("bizCstList"), acntgSeCd, planStpNo, numValue),
costItems,
);
setValue("bizCstList", next, { shouldDirty: true });
}
if (costItems.length === 0) {
return (
<SectionCollapsible title="총사업비">
<div className="space-y-2 p-2">
<Skeleton className="h-[8rem] rounded-xl" />
<Skeleton className="h-[32rem] rounded-xl" />
</div>
</SectionCollapsible>
);
}
return (
<SectionCollapsible title="총사업비">
<div className="overflow-x-auto">
<p className="mb-2 text-right font-normal text-muted-foreground">(단위: 백만원)</p>
<Table variant="secondary" className="table-fixed">
<colgroup>
<col width="12%" />
<col width="22%" />
<col width="22%" />
<col width="22%" />
<col width="22%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead className="w-[28rem]">구분</TableHead>
{stepHeaders.map((step) => (
<TableHead key={step.planStpNo} className="text-center">
{step.label}
<br />
<span className="font-normal text-muted-foreground">({step.year || "-"})</span>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{costItems.map((item) => {
const paddingLeft = `${item.level * 1.6}rem`;
const showSubIcon = item.level >= 2;
return (
<TableRow key={item.code}>
<TableHead className="text-left" style={{ paddingLeft }}>
<div className="relative">
{showSubIcon && (
<span className="absolute -left-6 top-0 text-gray-400">└</span>
)}
{item.label}
</div>
</TableHead>
{stepHeaders.map((step) => (
<TableCell key={step.planStpNo}>
<Field>
<Input
type="number"
value={getAmt(bizCstList, item.code, step.planStpNo) || ""}
onChange={(e) => handleChange(item.code, step.planStpNo, e.target.value)}
readOnly={item.readonly}
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
/>
</Field>
</TableCell>
))}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</SectionCollapsible>
);
}
export { BizCstMatrixFormSection };
@@ -0,0 +1,180 @@
"use client";
import { useMemo } from "react";
import { useFormContext, useWatch } from "react-hook-form";
import { Field } from "@/shared/ui/field";
import { useAcntgSeMngTree } from "@/entities/acntg";
import { buildAcntgCostItems, SectionCollapsible } from "@/widgets/pbadms-sprt";
import {
cn,
Input,
Skeleton,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@common/components";
import { PLAN_STEPS } from "../../lib/biz-cst-matrix-utils";
import { calcFnrsCstRemaining, recalcFnrsCstSumAmt } from "../../lib/fnrs-cst-form-utils";
import type { SewerBscPlanRegistFormData } from "../../lib/sewer-bsc-plan-regist-schema";
const FNRS_CST_ROWS = [
{ key: "sumAmt" as const, label: "총사업비", readonly: true },
{ key: "gvexpAmt" as const, label: "국고", readonly: false },
{ key: "lcexpAmt" as const, label: "지방비", readonly: false },
{ key: "orgprBdchgAmt" as const, label: "원인자부담금", readonly: false },
{ key: "etcAmt" as const, label: "기타", readonly: false },
];
type EditableFnrsKey = "gvexpAmt" | "lcexpAmt" | "orgprBdchgAmt" | "etcAmt";
function FnrsCstFormSection() {
const { control, setValue, getValues } = useFormContext<SewerBscPlanRegistFormData>();
const fnrsCstList = useWatch({ control, name: "fnrsCstList" }) ?? [];
const bizCstList = useWatch({ control, name: "bizCstList" }) ?? [];
const frlvlGoalYr = useWatch({ control, name: "sewerBscPlanDtl.frlvlGoalYr" });
const sclvlGoalYr = useWatch({ control, name: "sewerBscPlanDtl.sclvlGoalYr" });
const thlvlGoalYr = useWatch({ control, name: "sewerBscPlanDtl.thlvlGoalYr" });
const folvlGoalYr = useWatch({ control, name: "sewerBscPlanDtl.folvlGoalYr" });
const { data: acntgTree = [] } = useAcntgSeMngTree();
const costItems = useMemo(() => buildAcntgCostItems(acntgTree), [acntgTree]);
const stepHeaders = [
{ planStpNo: 1, label: "1단계", year: frlvlGoalYr },
{ planStpNo: 2, label: "2단계", year: sclvlGoalYr },
{ planStpNo: 3, label: "3단계", year: thlvlGoalYr },
{ planStpNo: 4, label: "4단계", year: folvlGoalYr },
];
const remainingList = useMemo(
() => calcFnrsCstRemaining(bizCstList, fnrsCstList, costItems),
[bizCstList, fnrsCstList, costItems],
);
function handleChange(planStpNo: number, fieldKey: EditableFnrsKey, value: string) {
const numValue = Number(value.replace(/,/g, "")) || 0;
const draft = [...getValues("fnrsCstList")];
const index = draft.findIndex((item) => item.planStpNo === planStpNo);
if (index < 0) return;
draft[index] = recalcFnrsCstSumAmt({ ...draft[index], [fieldKey]: numValue });
setValue("fnrsCstList", draft, { shouldDirty: true });
}
if (costItems.length === 0) {
return (
<SectionCollapsible title="재원분담">
<div className="space-y-2 p-2">
<Skeleton className="h-[8rem] rounded-xl" />
<Skeleton className="h-[32rem] rounded-xl" />
</div>
</SectionCollapsible>
);
}
return (
<SectionCollapsible title="재원분담">
<div className="overflow-x-auto">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-xl font-semibold">세부별 총사업비(①) - 재원분담 총사업비(②)</h3>
<span className="font-normal text-muted-foreground">(단위: 백만원)</span>
</div>
<Table variant="secondary" className="table-fixed">
<colgroup>
<col width="12%" />
<col width="22%" />
<col width="22%" />
<col width="22%" />
<col width="22%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>구분</TableHead>
{stepHeaders.map((step) => (
<TableHead key={step.planStpNo} className="text-center">
{step.label}
<br />
<span className="font-normal text-muted-foreground">({step.year || "-"})</span>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableHead>총사업비</TableHead>
{remainingList.map((item) => (
<TableCell key={item.planStpNo}>
<div className={cn("text-right", item.remaining !== 0 ? "text-destructive" : "")}>
{item.remaining.toLocaleString()}
</div>
</TableCell>
))}
</TableRow>
</TableBody>
</Table>
</div>
<div className="mt-6 overflow-x-auto">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-xl font-semibold">재원분담</h3>
<span className="font-normal text-muted-foreground">(단위: 백만원)</span>
</div>
<Table variant="secondary" className="table-fixed">
<colgroup>
<col width="12%" />
<col width="22%" />
<col width="22%" />
<col width="22%" />
<col width="22%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead className="w-[20rem]">구분</TableHead>
{stepHeaders.map((step) => (
<TableHead key={step.planStpNo} className="text-center">
{step.label}
<br />
<span className="font-normal text-muted-foreground">({step.year || "-"})</span>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{FNRS_CST_ROWS.map((row) => (
<TableRow key={row.key}>
<TableHead>{row.label}</TableHead>
{PLAN_STEPS.map((planStpNo) => {
const item = fnrsCstList.find((cst) => cst.planStpNo === planStpNo);
return (
<TableCell key={planStpNo}>
<Field>
<Input
type="text"
inputMode="decimal"
className={cn("text-right", row.readonly && "bg-muted")}
readOnly={row.readonly}
disabled={row.readonly}
value={item?.[row.key] ?? 0}
onChange={(e) => {
if (row.readonly) return;
handleChange(planStpNo, row.key as EditableFnrsKey, e.target.value);
}}
/>
</Field>
</TableCell>
);
})}
</TableRow>
))}
</TableBody>
</Table>
</div>
</SectionCollapsible>
);
}
export { FnrsCstFormSection };
@@ -0,0 +1,109 @@
"use client";
import { useFormContext, useWatch } from "react-hook-form";
import { Field } from "@/shared/ui/field";
import { SectionCollapsible } from "@/widgets/pbadms-sprt";
import {
Button,
Input,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@common/components";
import type { SewerBscPlanRegistFormData } from "../../lib/sewer-bsc-plan-regist-schema";
const OTLN_ROWS = [
{ key: "sewerPlanPpltnCnt" as const, label: "계획인구" },
{ key: "sewerPrcsPpltnCnt" as const, label: "처리인구" },
{ key: "sewerPsplyRt" as const, label: "하수도보급율" },
];
function OutlineFormSection() {
const { control, setValue, getValues } = useFormContext<SewerBscPlanRegistFormData>();
const otlnList = useWatch({ control, name: "sewerBscPlanOtlnList" }) ?? [];
const frlvlGoalYr = useWatch({ control, name: "sewerBscPlanDtl.frlvlGoalYr" });
const sclvlGoalYr = useWatch({ control, name: "sewerBscPlanDtl.sclvlGoalYr" });
const thlvlGoalYr = useWatch({ control, name: "sewerBscPlanDtl.thlvlGoalYr" });
const folvlGoalYr = useWatch({ control, name: "sewerBscPlanDtl.folvlGoalYr" });
const stepHeaders = [
{ label: "1단계", year: frlvlGoalYr },
{ label: "2단계", year: sclvlGoalYr },
{ label: "3단계", year: thlvlGoalYr },
{ label: "4단계", year: folvlGoalYr },
];
function handleChange(
planStpNo: number,
fieldKey: (typeof OTLN_ROWS)[number]["key"],
value: string,
) {
const numValue = Number(value.replace(/,/g, "")) || 0;
const draft = [...getValues("sewerBscPlanOtlnList")];
const index = draft.findIndex((item) => item.planStpNo === planStpNo);
if (index < 0) return;
draft[index] = { ...draft[index], [fieldKey]: numValue };
setValue("sewerBscPlanOtlnList", draft, { shouldDirty: true });
}
return (
<SectionCollapsible title="사업개요">
<div className="mb-2 flex justify-end px-2">
<Button type="button" color="white" disabled>
이전기본계획조회
</Button>
</div>
<Table variant="secondary">
<colgroup>
<col width="12%" />
<col width="22%" />
<col width="22%" />
<col width="22%" />
<col width="22%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead className="w-[20rem]">구분</TableHead>
{stepHeaders.map((step) => (
<TableHead key={step.label} className="text-center">
{step.label}
<br />
<span className="font-normal text-muted-foreground">({step.year || "-"})</span>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{OTLN_ROWS.map((row) => (
<TableRow key={row.key}>
<TableHead>{row.label}</TableHead>
{[1, 2, 3, 4].map((planStpNo) => {
const item = otlnList.find((otln) => otln.planStpNo === planStpNo);
return (
<TableCell key={planStpNo}>
<Field>
<Input
type="text"
inputMode="decimal"
className="text-right"
value={item?.[row.key] ?? 0}
onChange={(e) => handleChange(planStpNo, row.key, e.target.value)}
/>
</Field>
</TableCell>
);
})}
</TableRow>
))}
</TableBody>
</Table>
</SectionCollapsible>
);
}
export { OutlineFormSection };
@@ -0,0 +1,228 @@
"use client";
import { useMemo, useState } from "react";
import { useFieldArray, useFormContext, useWatch } from "react-hook-form";
import { faker } from "@faker-js/faker";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { Field } from "@/shared/ui/field";
import { SectionCollapsible } from "@/widgets/pbadms-sprt";
import {
Checkbox,
Input,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@common/components";
import { createEmptyTmplntDtlList } from "../../lib/form-data-mapper";
import type { SewerBscPlanRegistFormData } from "../../lib/sewer-bsc-plan-regist-schema";
interface FacilityOption {
code: string;
name: string;
}
const TMPLNT_FIELD_ROWS = [
{ key: "prcsZoneArea" as const, label: "처리구역면적(km²)" },
{ key: "swtrmtPpltnCnt" as const, label: "하수처리인구(명)" },
{ key: "planSwqty" as const, label: "계획하수량(㎥/일)" },
{ key: "fcltCpct" as const, label: "시설용량(㎥/일)" },
{ key: "sprsysDrtwPplLen" as const, label: "분류식 오수(km)" },
{ key: "sprsysExlnPplLen" as const, label: "분류식 우수(km)" },
{ key: "cbsysPplLen" as const, label: "합류식(km)" },
{ key: "icptgPplLen" as const, label: "차집관로(km)" },
{ key: "drgCnt" as const, label: "배수설비(개소)" },
{ key: "drtwRlyPumpCnt" as const, label: "오수 중계 펌프(개소)" },
{ key: "drtwPumpCnt" as const, label: "오수 일반 펌프(개소)" },
{ key: "exlnPumpCnt" as const, label: "우수 펌프(개소)" },
];
/** 시설 마스터 API TBD — faker mock */
function createMockFacilities(): FacilityOption[] {
faker.seed(20260727);
return Array.from({ length: 6 }, (_, index) => ({
code: `FCLT${String(index + 1).padStart(3, "0")}`,
name: `${faker.location.city()} 공공하수처리시설`,
}));
}
function TmplntFormSection() {
const { confirm } = useConfirm();
const { control, setValue, getValues } = useFormContext<SewerBscPlanRegistFormData>();
const { fields, append, remove } = useFieldArray({
control,
name: "sewerBscPlanTmplntList",
});
const tmplntList = useWatch({ control, name: "sewerBscPlanTmplntList" }) ?? [];
const frlvlGoalYr = useWatch({ control, name: "sewerBscPlanDtl.frlvlGoalYr" });
const sclvlGoalYr = useWatch({ control, name: "sewerBscPlanDtl.sclvlGoalYr" });
const thlvlGoalYr = useWatch({ control, name: "sewerBscPlanDtl.thlvlGoalYr" });
const folvlGoalYr = useWatch({ control, name: "sewerBscPlanDtl.folvlGoalYr" });
const facilityOptions = useMemo(() => createMockFacilities(), []);
const [activeTab, setActiveTab] = useState("");
const selectedCodes = useMemo(
() => new Set(tmplntList.map((item) => item.swtrmtFcltCd)),
[tmplntList],
);
const stepHeaders = [
{ planStpNo: 1, label: "변경 1단계", year: frlvlGoalYr },
{ planStpNo: 2, label: "변경 2단계", year: sclvlGoalYr },
{ planStpNo: 3, label: "변경 3단계", year: thlvlGoalYr },
{ planStpNo: 4, label: "변경 4단계", year: folvlGoalYr },
];
async function handleFacilityToggle(option: FacilityOption, checked: boolean) {
if (checked) {
append({
swtrmtFcltCd: option.code,
swtrmtFcltNm: option.name,
atchFileGroupId: "",
sewerBscPlanTmplntDtlList: createEmptyTmplntDtlList(option.code),
});
setActiveTab(option.code);
return;
}
if (!(await confirm(`「${option.name}」을(를) 제거하시겠습니까?`))) return;
const index = getValues("sewerBscPlanTmplntList").findIndex(
(item) => item.swtrmtFcltCd === option.code,
);
if (index < 0) return;
remove(index);
const remaining = getValues("sewerBscPlanTmplntList");
setActiveTab(remaining[0]?.swtrmtFcltCd ?? "");
}
function handleDtlChange(
facilityIndex: number,
planStpNo: number,
fieldKey: (typeof TMPLNT_FIELD_ROWS)[number]["key"],
value: string,
) {
const numValue = Number(value.replace(/,/g, "")) || 0;
const list = [...getValues("sewerBscPlanTmplntList")];
const facility = list[facilityIndex];
if (!facility) return;
const dtlList = [...(facility.sewerBscPlanTmplntDtlList ?? [])];
const dtlIndex = dtlList.findIndex((item) => item.planStpNo === planStpNo);
if (dtlIndex < 0) return;
dtlList[dtlIndex] = { ...dtlList[dtlIndex], [fieldKey]: numValue };
list[facilityIndex] = { ...facility, sewerBscPlanTmplntDtlList: dtlList };
setValue("sewerBscPlanTmplntList", list, { shouldDirty: true });
}
const resolvedActiveTab =
activeTab && selectedCodes.has(activeTab)
? activeTab
: (tmplntList[0]?.swtrmtFcltCd ?? "");
return (
<SectionCollapsible title="처리시설별 하수도정비기본계획">
<div className="space-y-4 p-2">
<div className="flex flex-wrap gap-4">
{facilityOptions.map((option) => (
<label key={option.code} className="flex items-center gap-2 text-xl">
<Checkbox
checked={selectedCodes.has(option.code)}
onCheckedChange={(checked) => handleFacilityToggle(option, checked === true)}
/>
<span>{option.name}</span>
</label>
))}
</div>
{fields.length > 0 ? (
<Tabs
value={resolvedActiveTab}
onValueChange={setActiveTab}
variant="underline"
>
<TabsList>
{fields.map((field) => (
<TabsTrigger key={field.id} value={field.swtrmtFcltCd}>
{field.swtrmtFcltNm || field.swtrmtFcltCd}
</TabsTrigger>
))}
</TabsList>
{fields.map((field, facilityIndex) => {
const dtlList = tmplntList[facilityIndex]?.sewerBscPlanTmplntDtlList ?? [];
return (
<TabsContent key={field.id} value={field.swtrmtFcltCd}>
<Table variant="secondary">
<TableHeader>
<TableRow>
<TableHead className="w-[22rem]">구분</TableHead>
{stepHeaders.map((step) => (
<TableHead key={step.planStpNo} className="text-center">
{step.label}
<br />
<span className="font-normal text-muted-foreground">
({step.year || "-"})
</span>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{TMPLNT_FIELD_ROWS.map((row) => (
<TableRow key={row.key}>
<TableHead>{row.label}</TableHead>
{stepHeaders.map((step) => {
const dtl = dtlList.find((item) => item.planStpNo === step.planStpNo);
return (
<TableCell key={step.planStpNo}>
<Field>
<Input
type="text"
inputMode="decimal"
className="text-right"
value={dtl?.[row.key] ?? 0}
onChange={(e) =>
handleDtlChange(
facilityIndex,
step.planStpNo,
row.key,
e.target.value,
)
}
/>
</Field>
</TableCell>
);
})}
</TableRow>
))}
</TableBody>
</Table>
</TabsContent>
);
})}
</Tabs>
) : (
<p className="px-2 text-xl text-muted-foreground">
처리시설을 선택하면 시설별 입력 탭이 표시됩니다.
</p>
)}
</div>
</SectionCollapsible>
);
}
export { TmplntFormSection };
@@ -0,0 +1,43 @@
"use client";
import { useSewerBscPlan, useSewerBscPlanDetails } from "@/entities/sewer-bsc-plan";
import { Skeleton } from "@common/components";
import { SewerBscPlanFormPage } from "./sewer-bsc-plan-form-page";
interface SewerBscPlanEditPageProps {
id: string;
}
function SewerBscPlanEditPage({ id }: SewerBscPlanEditPageProps) {
const { data: master, isLoading: isMasterLoading } = useSewerBscPlan(id);
const chgCycl = master?.chgCycl ?? 0;
const { data, isLoading: isDetailsLoading } = useSewerBscPlanDetails(id, chgCycl);
if (isMasterLoading || isDetailsLoading) {
return (
<div className="mt-6 space-y-6 px-4">
<div className="space-y-2">
<Skeleton className="h-[4rem] w-full" />
<Skeleton className="h-[20rem] w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-[4rem] w-full" />
<Skeleton className="h-[20rem] w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-[4rem] w-full" />
<Skeleton className="h-[20rem] w-full" />
</div>
</div>
);
}
if (!data?.detDto) {
return <div className="mt-6 text-[1.4rem]">상세 정보를 찾을 수 없습니다.</div>;
}
return <SewerBscPlanFormPage id={id} mode="edit" initialData={data} />;
}
export { SewerBscPlanEditPage };
@@ -0,0 +1,287 @@
"use client";
import { useMemo, useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { useRouter } from "next/navigation";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQueryClient } from "@tanstack/react-query";
import { useAlertStore, useUserStore } from "@/shared/lib";
import { useAcntgSeMngTree } from "@/entities/acntg";
import {
sewerBscPlanBaseKey,
type SewerBscPlanDetResponseDto,
sewerBscPlanService,
} from "@/entities/sewer-bsc-plan";
import {
AttachmentsFormSection,
buildAcntgCostItems,
ChecklistFormSection,
ContactInfoFormSection,
isFormChecklistComplete,
PrivacyConsentSection,
SplmntDmndFormSection,
} from "@/widgets/pbadms-sprt";
import { Button } from "@common/components";
import { validateFnrsCstBalance } from "../lib/fnrs-cst-form-utils";
import { buildApplyPayload, getCreateDefaultValues, mapDetailToFormData } from "../lib/form-data-mapper";
import {
SEWER_BSC_PLAN_DOC_SE_COM_CD,
type SewerBscPlanRegistFormData,
sewerBscPlanRegistSchema,
} from "../lib/sewer-bsc-plan-regist-schema";
import { BasicInfoFormSection } from "./sections/basic-info-form-section";
import { BizCstMatrixFormSection } from "./sections/biz-cst-matrix-form-section";
import { FnrsCstFormSection } from "./sections/fnrs-cst-form-section";
import { OutlineFormSection } from "./sections/outline-form-section";
import { TmplntFormSection } from "./sections/tmplnt-form-section";
interface SewerBscPlanFormPageProps {
id?: string;
mode: "create" | "edit";
initialData?: SewerBscPlanDetResponseDto;
}
function SewerBscPlanFormPage({ id, mode, initialData }: SewerBscPlanFormPageProps) {
const queryClient = useQueryClient();
const router = useRouter();
const { confirm } = useConfirm();
const userInfo = useUserStore((state) => state.userInfo);
const alert = useAlertStore((state) => state.setMessage);
const [isSubmitting, setIsSubmitting] = useState(false);
const isEditMode = mode === "edit";
const isSplmntPrgrsStts = initialData?.detDto?.prgrsSttsComCd === "SL";
const { data: acntgTree = [] } = useAcntgSeMngTree();
const costItems = useMemo(() => buildAcntgCostItems(acntgTree), [acntgTree]);
const form = useForm<SewerBscPlanRegistFormData>({
resolver: zodResolver(sewerBscPlanRegistSchema),
mode: "onChange",
defaultValues:
isEditMode && initialData
? mapDetailToFormData(initialData)
: getCreateDefaultValues(userInfo),
});
function assertFnrsCstBalance(data: SewerBscPlanRegistFormData): boolean {
const validationMessage = validateFnrsCstBalance(
data.bizCstList,
data.fnrsCstList,
costItems,
);
if (validationMessage) {
alert(validationMessage);
return false;
}
return true;
}
async function submitForm(
data: SewerBscPlanRegistFormData,
prgrsSttsComCd: "SV" | "LE",
): Promise<string | undefined> {
if (!assertFnrsCstBalance(data)) return undefined;
const payload = buildApplyPayload(data, prgrsSttsComCd, {
isEditMode,
docNo: initialData?.detDto?.sewerBscPlanDocNo ?? id,
});
return sewerBscPlanService.saveApply(payload);
}
async function onSave(data: SewerBscPlanRegistFormData) {
if (isSubmitting) return;
try {
setIsSubmitting(true);
const docNo = await submitForm(data, "SV");
if (!docNo) return;
alert("저장되었습니다.");
router.push(`/sewer-bsc-plan/${docNo}/edit`);
if (isEditMode && id) {
queryClient.invalidateQueries({
queryKey: [...sewerBscPlanBaseKey, "findDetails", id],
});
}
} catch (error) {
console.error("저장 실패:", error);
alert("저장 중 오류가 발생했습니다.");
} finally {
setIsSubmitting(false);
}
}
async function onApply(data: SewerBscPlanRegistFormData) {
if (isSubmitting) return;
if (data.privacyConsent !== "Y") {
alert("개인정보 수집/이용에 동의해야 신청이 가능합니다.");
return;
}
if (!isFormChecklistComplete(data.cklstResultList)) {
alert("모든 체크리스트 항목을 체크해야 신청이 가능합니다.");
return;
}
try {
setIsSubmitting(true);
const docNo = await submitForm(data, "LE");
if (!docNo) return;
alert("신청 완료되었습니다.");
router.push(`/sewer-bsc-plan/${docNo}`);
queryClient.invalidateQueries({ queryKey: sewerBscPlanBaseKey });
} catch (error) {
console.error("신청 실패:", error);
alert("신청 중 오류가 발생했습니다.");
} finally {
setIsSubmitting(false);
}
}
async function onSplmntCmptn(data: SewerBscPlanRegistFormData) {
if (isSubmitting) return;
const hasEmptySplmntCn = data.splmntDmndList?.some(
(item) => !item.splmntCn || !item.splmntCn.trim(),
);
if (hasEmptySplmntCn) {
alert("보완요청내용이 있습니다. 보완완료내용을 입력해 주세요.");
return;
}
const docNo = initialData?.detDto?.sewerBscPlanDocNo ?? id;
if (!docNo) {
alert("문서번호가 없습니다.");
return;
}
if (!assertFnrsCstBalance(data)) return;
try {
setIsSubmitting(true);
const payload = buildApplyPayload(data, "TE", { isEditMode: true, docNo });
const result = await sewerBscPlanService.submitSupplement(docNo, payload);
if (!result) return;
alert("보완완료 처리되었습니다.");
router.push(`/sewer-bsc-plan/${docNo}`);
queryClient.invalidateQueries({ queryKey: sewerBscPlanBaseKey });
} catch (error) {
console.error("보완완료 실패:", error);
alert("보완완료 처리 중 오류가 발생했습니다.");
} finally {
setIsSubmitting(false);
}
}
function handleApplyClick() {
form.handleSubmit(onApply)();
}
async function handleDelClick() {
if (isSubmitting) return;
const docNo = initialData?.detDto?.sewerBscPlanDocNo ?? id;
if (!docNo) {
alert("문서번호가 없습니다.");
return;
}
if (!(await confirm("삭제하시겠습니까?"))) return;
try {
setIsSubmitting(true);
await sewerBscPlanService.delete(docNo);
alert("삭제되었습니다.");
router.push("/sewer-bsc-plan");
} catch (error) {
console.error("삭제 실패:", error);
alert("삭제 중 오류가 발생했습니다.");
} finally {
setIsSubmitting(false);
}
}
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSave)} className="mt-6 space-y-4">
<BasicInfoFormSection />
<ContactInfoFormSection fieldPrefix="sewerBscPlanDtl" />
<PrivacyConsentSection />
<OutlineFormSection />
<BizCstMatrixFormSection />
<FnrsCstFormSection />
<TmplntFormSection />
<ChecklistFormSection
docSeComCd={SEWER_BSC_PLAN_DOC_SE_COM_CD}
fieldPrefix="sewerBscPlan"
/>
<AttachmentsFormSection
docSeComCd={SEWER_BSC_PLAN_DOC_SE_COM_CD}
fieldPrefix="sewerBscPlanDtl"
initialEsntlFiles={isEditMode ? initialData?.esntlFileList : undefined}
initialEtcFiles={isEditMode ? initialData?.etcFileList : undefined}
/>
{isEditMode && <SplmntDmndFormSection readOnly={!isSplmntPrgrsStts} />}
<div className="sticky bottom-0 z-40 !-mb-2 border-t bg-white pb-8 pt-6 shadow-[0_-4px_12px_rgba(0,0,0,0.06)]">
<div className="mx-4 flex flex-wrap justify-center gap-3 sm:gap-4">
<Button
color="white"
type="button"
onClick={() => router.push("/sewer-bsc-plan")}
disabled={isSubmitting}
>
목록
</Button>
{!isSplmntPrgrsStts ? (
<>
<Button type="submit" disabled={isSubmitting}>
저장
</Button>
{isEditMode && (
<>
<Button
color="green"
type="button"
onClick={handleApplyClick}
disabled={isSubmitting}
>
신청완료
</Button>
<Button
color="red"
type="button"
onClick={handleDelClick}
disabled={isSubmitting}
>
삭제
</Button>
</>
)}
</>
) : (
<Button
type="button"
onClick={form.handleSubmit(onSplmntCmptn)}
disabled={isSubmitting}
>
보완완료
</Button>
)}
</div>
</div>
</form>
</FormProvider>
);
}
export { SewerBscPlanFormPage };
@@ -0,0 +1,7 @@
import { SewerBscPlanFormPage } from "./sewer-bsc-plan-form-page";
function SewerBscPlanRegistPage() {
return <SewerBscPlanFormPage mode="create" />;
}
export { SewerBscPlanRegistPage };
+5 -1
View File
@@ -4,7 +4,11 @@ export {
createUploadKey,
isEtcFileKey,
} from "./lib/attachments-form-utils";
export { buildAcntgCostItems, recalculateBizCstTotals } from "./lib/business-cost-form-utils";
export {
buildAcntgCostItems,
type CostItemDef,
recalculateBizCstTotals,
} from "./lib/business-cost-form-utils";
export { buildCnstpScaleItems, recalculateBzScaleTotals } from "./lib/business-scale-form-utils";
export {
buildChecklistTabs,
@@ -1,6 +1,11 @@
import { getDefaultFileKey } from "@/shared/lib";
export type FieldPrefix = "fnrsCnsltnMng" | "instlSncnMng" | "designCnstnMng";
export type FieldPrefix =
| "fnrsCnsltnMng"
| "instlSncnMng"
| "designCnstnMng"
| "sewerBscPlan"
| "sewerBscPlanDtl";
export const ETC_FILE_KEY_PREFIX = "etc::";
@@ -54,19 +54,21 @@ function BizCstTotalDisplayTable({ costItems, sharItems }: BizCstTotalDisplayTab
return (
<div className="mt-2 overflow-x-auto px-2">
<div className="mb-2 flex w-1/2 items-center justify-between">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-xl font-semibold">세부별 총사업비(①) - 재원분담 총사업비(②)</h3>
<span className="font-normal text-muted-foreground">(단위: 백만원)</span>
</div>
<Table variant="secondary" className="w-1/2 table-fixed">
<Table variant="secondary">
<TableHeader>
<TableRow>
<TableHead>구분</TableHead>
<TableHead>① 예산반영(A) - ② 현행(A)</TableHead>
<TableHead>① 금회설계시(C) - ② 변경요구(B)</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableHead>총사업비</TableHead>
<TableCell>
<div className={cn("text-right", hasExstnMismatch ? "text-destructive" : "")}>
{formatRemaining(remaining.exstnRemaining)}
@@ -29,7 +29,7 @@ import { buildChecklistTabs, syncCklstResultListFromTree } from "../lib/checklis
import { SectionCollapsible } from "./section-collapsible";
type FieldPrefix = "fnrsCnsltnMng" | "instlSncnMng" | "designCnstnMng";
type FieldPrefix = "fnrsCnsltnMng" | "instlSncnMng" | "designCnstnMng" | "sewerBscPlan";
interface CklstResultListItem {
cklstSn: number;
@@ -7,7 +7,11 @@ import { Field, FieldError } from "@/shared/ui/field";
import { SectionCollapsible } from "@/widgets/pbadms-sprt";
import { Input, Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
type FieldPrefix = "fnrsCnsltnMng" | "instlSncnMng" | "designCnstnMng";
type FieldPrefix =
| "fnrsCnsltnMng"
| "instlSncnMng"
| "designCnstnMng"
| "sewerBscPlanDtl";
interface ContactInfoFormSectionProps {
fieldPrefix: FieldPrefix;