` ~ ``
| 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와 동일 │ │
+│ └──────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
-### 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
+
+
+ {!isSplmntPrgrsStts ? (
+ <>
+
+ {isEditMode && (
+ <>
+
+
+ >
+ )}
+ >
+ ) : (
+
+ )}
+
+```
+
+| 버튼 | 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 확정 후)
---
diff --git a/apps/as/src/pages/sewer-bsc-plan/index.ts b/apps/as/src/pages/sewer-bsc-plan/index.ts
new file mode 100644
index 000000000..d5af2c203
--- /dev/null
+++ b/apps/as/src/pages/sewer-bsc-plan/index.ts
@@ -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";
diff --git a/apps/as/src/pages/sewer-bsc-plan/lib/biz-cst-matrix-utils.ts b/apps/as/src/pages/sewer-bsc-plan/lib/biz-cst-matrix-utils.ts
new file mode 100644
index 000000000..41ff8e9da
--- /dev/null
+++ b/apps/as/src/pages/sewer-bsc-plan/lib/biz-cst-matrix-utils.ts
@@ -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,
+};
diff --git a/apps/as/src/pages/sewer-bsc-plan/lib/fnrs-cst-form-utils.ts b/apps/as/src/pages/sewer-bsc-plan/lib/fnrs-cst-form-utils.ts
new file mode 100644
index 000000000..a6ed6b94b
--- /dev/null
+++ b/apps/as/src/pages/sewer-bsc-plan/lib/fnrs-cst-form-utils.ts
@@ -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,
+};
diff --git a/apps/as/src/pages/sewer-bsc-plan/lib/form-data-mapper.ts b/apps/as/src/pages/sewer-bsc-plan/lib/form-data-mapper.ts
new file mode 100644
index 000000000..b24acd348
--- /dev/null
+++ b/apps/as/src/pages/sewer-bsc-plan/lib/form-data-mapper.ts
@@ -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
();
+
+ 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 };
diff --git a/apps/as/src/pages/sewer-bsc-plan/lib/sewer-bsc-plan-regist-schema.ts b/apps/as/src/pages/sewer-bsc-plan/lib/sewer-bsc-plan-regist-schema.ts
new file mode 100644
index 000000000..168463a52
--- /dev/null
+++ b/apps/as/src/pages/sewer-bsc-plan/lib/sewer-bsc-plan-regist-schema.ts
@@ -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;
+
+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";
diff --git a/apps/as/src/pages/sewer-bsc-plan/lib/year-options.ts b/apps/as/src/pages/sewer-bsc-plan/lib/year-options.ts
new file mode 100644
index 000000000..2c53dece5
--- /dev/null
+++ b/apps/as/src/pages/sewer-bsc-plan/lib/year-options.ts
@@ -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 };
diff --git a/apps/as/src/pages/sewer-bsc-plan/ui/sections/basic-info-form-section.tsx b/apps/as/src/pages/sewer-bsc-plan/ui/sections/basic-info-form-section.tsx
new file mode 100644
index 000000000..20be550c8
--- /dev/null
+++ b/apps/as/src/pages/sewer-bsc-plan/ui/sections/basic-info-form-section.tsx
@@ -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();
+ 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 (
+
+
+
+ );
+}
+
+export { BasicInfoFormSection };
diff --git a/apps/as/src/pages/sewer-bsc-plan/ui/sections/biz-cst-matrix-form-section.tsx b/apps/as/src/pages/sewer-bsc-plan/ui/sections/biz-cst-matrix-form-section.tsx
new file mode 100644
index 000000000..f6cba8863
--- /dev/null
+++ b/apps/as/src/pages/sewer-bsc-plan/ui/sections/biz-cst-matrix-form-section.tsx
@@ -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();
+ 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 (
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
+
+export { BizCstMatrixFormSection };
diff --git a/apps/as/src/pages/sewer-bsc-plan/ui/sections/fnrs-cst-form-section.tsx b/apps/as/src/pages/sewer-bsc-plan/ui/sections/fnrs-cst-form-section.tsx
new file mode 100644
index 000000000..cfbf12fcf
--- /dev/null
+++ b/apps/as/src/pages/sewer-bsc-plan/ui/sections/fnrs-cst-form-section.tsx
@@ -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();
+ 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 (
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
세부별 총사업비(①) - 재원분담 총사업비(②)
+ (단위: 백만원)
+
+
+
+
+
+
+
+
+
+
+
+ 구분
+ {stepHeaders.map((step) => (
+
+ {step.label}
+
+ ({step.year || "-"})
+
+ ))}
+
+
+
+
+ 총사업비
+ {remainingList.map((item) => (
+
+
+ {item.remaining.toLocaleString()}
+
+
+ ))}
+
+
+
+
+
+
+
+
재원분담
+ (단위: 백만원)
+
+
+
+
+ );
+}
+
+export { FnrsCstFormSection };
diff --git a/apps/as/src/pages/sewer-bsc-plan/ui/sections/outline-form-section.tsx b/apps/as/src/pages/sewer-bsc-plan/ui/sections/outline-form-section.tsx
new file mode 100644
index 000000000..326561d75
--- /dev/null
+++ b/apps/as/src/pages/sewer-bsc-plan/ui/sections/outline-form-section.tsx
@@ -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();
+ 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 (
+
+
+
+
+
+
+ );
+}
+
+export { OutlineFormSection };
diff --git a/apps/as/src/pages/sewer-bsc-plan/ui/sections/tmplnt-form-section.tsx b/apps/as/src/pages/sewer-bsc-plan/ui/sections/tmplnt-form-section.tsx
new file mode 100644
index 000000000..ea92fb3ad
--- /dev/null
+++ b/apps/as/src/pages/sewer-bsc-plan/ui/sections/tmplnt-form-section.tsx
@@ -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();
+ 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 (
+
+
+
+ {facilityOptions.map((option) => (
+
+ ))}
+
+
+ {fields.length > 0 ? (
+
+
+ {fields.map((field) => (
+
+ {field.swtrmtFcltNm || field.swtrmtFcltCd}
+
+ ))}
+
+
+ {fields.map((field, facilityIndex) => {
+ const dtlList = tmplntList[facilityIndex]?.sewerBscPlanTmplntDtlList ?? [];
+ return (
+
+
+
+ );
+ })}
+
+ ) : (
+
+ 처리시설을 선택하면 시설별 입력 탭이 표시됩니다.
+
+ )}
+
+
+ );
+}
+
+export { TmplntFormSection };
diff --git a/apps/as/src/pages/sewer-bsc-plan/ui/sewer-bsc-plan-edit-page.tsx b/apps/as/src/pages/sewer-bsc-plan/ui/sewer-bsc-plan-edit-page.tsx
new file mode 100644
index 000000000..6b648571c
--- /dev/null
+++ b/apps/as/src/pages/sewer-bsc-plan/ui/sewer-bsc-plan-edit-page.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ if (!data?.detDto) {
+ return 상세 정보를 찾을 수 없습니다.
;
+ }
+
+ return ;
+}
+
+export { SewerBscPlanEditPage };
diff --git a/apps/as/src/pages/sewer-bsc-plan/ui/sewer-bsc-plan-form-page.tsx b/apps/as/src/pages/sewer-bsc-plan/ui/sewer-bsc-plan-form-page.tsx
new file mode 100644
index 000000000..7b1ed3412
--- /dev/null
+++ b/apps/as/src/pages/sewer-bsc-plan/ui/sewer-bsc-plan-form-page.tsx
@@ -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({
+ 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 {
+ 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 (
+
+
+
+ );
+}
+
+export { SewerBscPlanFormPage };
diff --git a/apps/as/src/pages/sewer-bsc-plan/ui/sewer-bsc-plan-regist-page.tsx b/apps/as/src/pages/sewer-bsc-plan/ui/sewer-bsc-plan-regist-page.tsx
new file mode 100644
index 000000000..c55fc1363
--- /dev/null
+++ b/apps/as/src/pages/sewer-bsc-plan/ui/sewer-bsc-plan-regist-page.tsx
@@ -0,0 +1,7 @@
+import { SewerBscPlanFormPage } from "./sewer-bsc-plan-form-page";
+
+function SewerBscPlanRegistPage() {
+ return ;
+}
+
+export { SewerBscPlanRegistPage };
diff --git a/apps/as/src/widgets/pbadms-sprt/index.ts b/apps/as/src/widgets/pbadms-sprt/index.ts
index caab6fba0..7cec9e8b0 100644
--- a/apps/as/src/widgets/pbadms-sprt/index.ts
+++ b/apps/as/src/widgets/pbadms-sprt/index.ts
@@ -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,
diff --git a/apps/as/src/widgets/pbadms-sprt/lib/attachments-form-utils.ts b/apps/as/src/widgets/pbadms-sprt/lib/attachments-form-utils.ts
index 4fdd62292..698ae70d3 100644
--- a/apps/as/src/widgets/pbadms-sprt/lib/attachments-form-utils.ts
+++ b/apps/as/src/widgets/pbadms-sprt/lib/attachments-form-utils.ts
@@ -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::";
diff --git a/apps/as/src/widgets/pbadms-sprt/ui/biz-cst-total-display-table.tsx b/apps/as/src/widgets/pbadms-sprt/ui/biz-cst-total-display-table.tsx
index 0e3cbebe5..ac6d9fa60 100644
--- a/apps/as/src/widgets/pbadms-sprt/ui/biz-cst-total-display-table.tsx
+++ b/apps/as/src/widgets/pbadms-sprt/ui/biz-cst-total-display-table.tsx
@@ -54,19 +54,21 @@ function BizCstTotalDisplayTable({ costItems, sharItems }: BizCstTotalDisplayTab
return (
-
+
세부별 총사업비(①) - 재원분담 총사업비(②)
(단위: 백만원)
-
+
+ 구분
① 예산반영(A) - ② 현행(A)
① 금회설계시(C) - ② 변경요구(B)
+ 총사업비
{formatRemaining(remaining.exstnRemaining)}
diff --git a/apps/as/src/widgets/pbadms-sprt/ui/checklist-form-section.tsx b/apps/as/src/widgets/pbadms-sprt/ui/checklist-form-section.tsx
index 56c01d76e..c4857f066 100644
--- a/apps/as/src/widgets/pbadms-sprt/ui/checklist-form-section.tsx
+++ b/apps/as/src/widgets/pbadms-sprt/ui/checklist-form-section.tsx
@@ -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;
diff --git a/apps/as/src/widgets/pbadms-sprt/ui/contact-info-form-section.tsx b/apps/as/src/widgets/pbadms-sprt/ui/contact-info-form-section.tsx
index 0ccf5c580..c3dc02fcc 100644
--- a/apps/as/src/widgets/pbadms-sprt/ui/contact-info-form-section.tsx
+++ b/apps/as/src/widgets/pbadms-sprt/ui/contact-info-form-section.tsx
@@ -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;