diff --git a/apps/as/app/(with-base-layout)/sewer-bsc-plan/[id]/edit/page.tsx b/apps/as/app/(with-base-layout)/sewer-bsc-plan/[id]/edit/page.tsx new file mode 100644 index 000000000..2ddd7ff21 --- /dev/null +++ b/apps/as/app/(with-base-layout)/sewer-bsc-plan/[id]/edit/page.tsx @@ -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 ; +} diff --git a/apps/as/app/(with-base-layout)/sewer-bsc-plan/[id]/page.tsx b/apps/as/app/(with-base-layout)/sewer-bsc-plan/[id]/page.tsx new file mode 100644 index 000000000..17894197e --- /dev/null +++ b/apps/as/app/(with-base-layout)/sewer-bsc-plan/[id]/page.tsx @@ -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 ( +
+ 하수도기본계획 상세({id})는 후속 구현 예정입니다.{" "} + + 수정 화면으로 이동 + +
+ ); +} diff --git a/apps/as/app/(with-base-layout)/sewer-bsc-plan/page.tsx b/apps/as/app/(with-base-layout)/sewer-bsc-plan/page.tsx new file mode 100644 index 000000000..47f03c736 --- /dev/null +++ b/apps/as/app/(with-base-layout)/sewer-bsc-plan/page.tsx @@ -0,0 +1,12 @@ +import Link from "next/link"; + +export default function Page() { + return ( +
+ 하수도기본계획 목록은 후속 구현 예정입니다.{" "} + + 등록 화면으로 이동 + +
+ ); +} diff --git a/apps/as/app/(with-base-layout)/sewer-bsc-plan/regist/page.tsx b/apps/as/app/(with-base-layout)/sewer-bsc-plan/regist/page.tsx new file mode 100644 index 000000000..c85bd720b --- /dev/null +++ b/apps/as/app/(with-base-layout)/sewer-bsc-plan/regist/page.tsx @@ -0,0 +1,5 @@ +import { SewerBscPlanRegistPage } from "@/pages/sewer-bsc-plan"; + +export default function Page() { + return ; +} diff --git a/apps/as/docs/fnrs-cnsltn-vs-instl-sncn-diff.md b/apps/as/docs/fnrs-cnsltn-vs-instl-sncn-diff.md deleted file mode 100644 index c90dec22f..000000000 --- a/apps/as/docs/fnrs-cnsltn-vs-instl-sncn-diff.md +++ /dev/null @@ -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 { - // TODO: 보완완료 API 호출 - context.onSuccess?.(); -} -// ... -async function handleDmndSplmnt(context: ActionHandlerContext): Promise { - // 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` | diff --git a/apps/as/docs/sewer-bsc-plan/dmbp-regist-migration.md b/apps/as/docs/sewer-bsc-plan/dmbp-regist-migration.md index 47f1047b1..e8f36ef2e 100644 --- a/apps/as/docs/sewer-bsc-plan/dmbp-regist-migration.md +++ b/apps/as/docs/sewer-bsc-plan/dmbp-regist-migration.md @@ -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`행: `
` ~ `` | 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 ( + + + + + + + + + + + + 기본계획명 + + + ( + + + + + )} + /> + + + + 계획년도 + + ( + + + + + )} + /> + + 기준년도 + + ( + + + + + )} + /> + + + + 1단계 목표연도 + + ( + + + + + )} + /> + + 2단계 목표연도 + + ( + + + + + )} + /> + + + + 3단계 목표연도 + + ( + + + + + )} + /> + + 4단계 목표연도 + + ( + + + + + )} + /> + + + + 계획개요 + + ( + +