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