Merge branch 'refs/heads/main' into feature/0612_sentry_fr_merge

This commit is contained in:
2026-08-05 10:48:22 +09:00
196 changed files with 7961 additions and 1600 deletions
+9 -5
View File
@@ -19,11 +19,15 @@
}
],
"paths": {
"@/*": ["./src/*"],
"@common/components": ["../../packages/common/components/dist/"],
"@common/network": ["../../packages/common/network/"]
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"../../packages/common/types/**/*.d.ts"
],
"exclude": ["node_modules", ".next", "dist", "coverage", "storybook-static", "public"]
}
+13
View File
@@ -0,0 +1,13 @@
import "@/app/styles/globals.css";
export const metadata = {
title: "디지털 행정지원 시스템",
};
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<body>{children}</body>
</html>
);
}
@@ -0,0 +1,27 @@
import { DesignRegistPage } from "@/pages/design-cnstn";
import { parseTaskSeComCdFromApi } from "@/pages/design-cnstn/lib/design-cnstn-regist-schema";
interface PageProps {
searchParams: Promise<{
taskSeComCd?: string;
admdstCd?: string;
sido?: string;
gugun?: string;
}>;
}
export default async function Page({ searchParams }: PageProps) {
const params = await searchParams;
const taskSeComCd = parseTaskSeComCdFromApi(params.taskSeComCd);
return (
<DesignRegistPage
defaults={{
taskSeComCd: taskSeComCd.length ? taskSeComCd : undefined,
admdstCd: params.admdstCd,
sido: params.sido,
gugun: params.gugun,
}}
/>
);
}
@@ -0,0 +1,27 @@
import { FnrsRegistPage } from "@/pages/fnrs-cnsltn";
import { parseTaskSeComCdFromApi } from "@/pages/fnrs-cnsltn/lib/fnrs-cnsltn-regist-schema";
interface PageProps {
searchParams: Promise<{
taskSeComCd?: string;
admdstCd?: string;
sido?: string;
gugun?: string;
}>;
}
export default async function Page({ searchParams }: PageProps) {
const params = await searchParams;
const taskSeComCd = parseTaskSeComCdFromApi(params.taskSeComCd);
return (
<FnrsRegistPage
defaults={{
taskSeComCd: taskSeComCd.length ? taskSeComCd : undefined,
admdstCd: params.admdstCd,
sido: params.sido,
gugun: params.gugun,
}}
/>
);
}
@@ -0,0 +1,28 @@
import { InstlRegistPage } from "@/pages/instl-sncn";
interface InstlSncnRegistRouteProps {
searchParams: Promise<{
taskSeComCd?: string;
admdstCd?: string;
sido?: string;
gugun?: string;
}>;
}
export default async function Page({ searchParams }: InstlSncnRegistRouteProps) {
const params = await searchParams;
const taskSeComCd = params.taskSeComCd
? params.taskSeComCd.split(",").filter(Boolean)
: undefined;
return (
<InstlRegistPage
defaults={{
taskSeComCd,
admdstCd: params.admdstCd,
sido: params.sido,
gugun: params.gugun,
}}
/>
);
}
@@ -0,0 +1,13 @@
import { SewerBscPlanEditPage } from "@/pages/sewer-bsc-plan";
interface PageProps {
params: Promise<{ id: string }>;
searchParams: Promise<{ chgCycl?: string }>;
}
export default async function Page({ params, searchParams }: PageProps) {
const { id } = await params;
const { chgCycl } = await searchParams;
const propChgCycl = chgCycl ? Number(chgCycl) : undefined;
return <SewerBscPlanEditPage id={id} propChgCycl={propChgCycl} />;
}
@@ -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,3 @@
import { SewerBscPlanListPage } from "@/pages/sewer-bsc-plan";
export default SewerBscPlanListPage;
@@ -0,0 +1,19 @@
import { SewerBscPlanRegistPage } from "@/pages/sewer-bsc-plan";
interface PageProps {
searchParams: Promise<{
taskSeComCd?: string;
taskSeCdNm?: string;
sewerBscPlanSeComCd?: string;
sewerBscPlanSeCdNm?: string;
admdstCd?: string;
sido?: string;
gugun?: string;
}>;
}
export default async function Page({ searchParams }: PageProps) {
const params = await searchParams;
return <SewerBscPlanRegistPage defaults={params} />;
}
@@ -6,8 +6,8 @@ import { Providers } from "@/app/providers";
import "@/app/styles/globals.css";
export const metadata: Metadata = {
title: "행정지원시스템",
description: "행정지원시스템",
title: "디지털 행정지원 시스템",
description: "디지털 행정지원 시스템",
};
interface RootLayoutProps {
@@ -1,12 +0,0 @@
import { DesignRegistPage } from "@/pages/design-cnstn";
import { parseTaskSeComCdFromApi } from "@/pages/design-cnstn/lib/design-cnstn-regist-schema";
interface PageProps {
searchParams: Promise<{ taskSeComCd?: string }>;
}
export default async function Page({ searchParams }: PageProps) {
const params = await searchParams;
const taskSeComCd = parseTaskSeComCdFromApi(params.taskSeComCd);
return <DesignRegistPage taskSeComCd={taskSeComCd.length ? taskSeComCd : undefined} />;
}
@@ -1,12 +0,0 @@
import { FnrsRegistPage } from "@/pages/fnrs-cnsltn";
import { parseTaskSeComCdFromApi } from "@/pages/fnrs-cnsltn/lib/fnrs-cnsltn-regist-schema";
interface PageProps {
searchParams: Promise<{ taskSeComCd?: string }>;
}
export default async function Page({ searchParams }: PageProps) {
const params = await searchParams;
const taskSeComCd = parseTaskSeComCdFromApi(params.taskSeComCd);
return <FnrsRegistPage taskSeComCd={taskSeComCd.length ? taskSeComCd : undefined} />;
}
@@ -1,14 +0,0 @@
import { InstlRegistPage } from "@/pages/instl-sncn";
interface InstlSncnRegistRouteProps {
searchParams: Promise<{ taskSeComCd?: string }>;
}
export default async function Page({ searchParams }: InstlSncnRegistRouteProps) {
const params = await searchParams;
const taskSeComCd = params.taskSeComCd
? params.taskSeComCd.split(",").filter(Boolean)
: undefined;
return <InstlRegistPage taskSeComCd={taskSeComCd} />;
}
+10
View File
@@ -0,0 +1,10 @@
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { COOKIE_DOMAIN, REFRESH_TOKEN } from "@/shared/config";
export async function POST() {
const cookieStore = await cookies();
cookieStore.delete({ name: REFRESH_TOKEN, domain: COOKIE_DOMAIN });
return NextResponse.json({ message: "Logged out" }, { status: 200 });
}
@@ -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` |
@@ -0,0 +1,674 @@
# dmbpRegist.jsp → React 마이그레이션 분석·구현 가이드
| 항목 | 내용 |
| ----------- | ------------------------------------------------------ |
| 원본 JSP | `adssNewOld/.../fnrsc/dmbp/dmbpRegist.jsp` |
| 대상 앱 | `frontend/apps/as` |
| 도메인 | 하수도기본계획 (구: 기본계획검토 / dmbp) |
| API 엔티티 | `@/entities/sewer-bsc-plan` (`SewerBscPlanController`) |
| 참조 패턴 | `@/pages/fnrs-cnsltn` + `@/widgets/pbadms-sprt` |
| 문서 목적 | contents 영역 **섹션별** UI·상태·API 매핑 및 구현 방향 |
| 작성 기준일 | 2026-07-27 |
---
## 1. 마이그레이션 범위
### 1-1. 포함 (contents만)
JSP `568~1274`행: `<div class="contents">` ~ `<!-- //contents -->`
| 영역 | JSP 식별 | 개요 |
| ---- | ---------------------------------- | --------------------------------------------------------------- |
| A | `gTitle1` | 페이지 타이틀·브레드크럼 |
| B | `slide_10` | 기본계획변경 신청(마스터·연도·개요) |
| C | `slide_15` | 신청자·설계자 정보 |
| D | `slide_19` | 개인정보 동의 |
| E | `slide_11` | 사업개요(단계별 인구·보급률) |
| F | `slide_12` | 총사업비 — `BizCstMatrixFormSection` 전용 |
| G | `slide_13` | 재원분담 |
| H | `slide_14` | 처리시설별 하수도정비기본계획 (Checkbox + Tabs) |
| I | `slide_16` | 체크리스트 + 필수첨부 + 기타첨부 |
| J | JSP 상단 `saveBtn` / `presentnBtn` | React: 폼 하단 액션바 (`fnrs-form-page` 279~323과 동일 구성, API만 sewer) |
### 1-2. 제외
- `<%@ include %>`: top / sideMenu / footer / `bscPlnPopup.jsp` / `sndrdBzAmnt.jsp`
- 레이아웃·메뉴·권한 셸 → 기존 `BaseLayout` / `PageLayout` / AUTH 체계
- `mStep1` 진행 단계 표시 (미구현 · 마이그레이션 범위 밖)
팝업(`이전기본계획조회`)은 **별도 Dialog 컴포넌트**로 재구현하며, include JSP 자체는 포팅하지 않는다.
---
## 2. 목표 아키텍처
```
app/(with-base-layout)/sewer-bsc-plan/regist/page.tsx
└─ pages/sewer-bsc-plan/ui/sewer-bsc-plan-regist-page.tsx
└─ sewer-bsc-plan-form-page.tsx # FormProvider + 섹션 조립
├─ sections/*-form-section.tsx # 도메인 전용
└─ widgets/pbadms-sprt/* # 공통 재사용
entities/sewer-bsc-plan/ # 이미 구현됨
saveApply / findDetails / submitSupplement …
```
**폼 스택 (fnrs-cnsltn과 동일)**
- `react-hook-form` + `zod` (`zodResolver`, `mode: "onChange"`)
- 섹션 래퍼: `SectionCollapsible` (Accordion 아님)
- UI: `@common/components` (Input, Select, Textarea, Button, Radio, Tabs, Table 등)
- 그리드가 필요한 곳만 `@tanstack/react-table` + `DataTable`
- 데이터: React Query (`useSaveApplySewerBscPlan` 등) — 목데이터는 API 미연동 구간만 `@faker-js/faker`
**Vercel React Best Practices (적용 요약)**
- `client-swr-dedup`: React Query로 체크리스트/필수첨부/코드 조회 중복 제거
- `bundle-dynamic-imports`: 처리시설 Tabs 패널·이전계획 Dialog는 `next/dynamic` 후보
- `rerender-derived-state`: 단계 연도·합계·진행률은 render 중 파생, 불필요 effect 금지
- `async-parallel`: 등록 진입 시 코드/체크리스트트리/필수첨부트리를 병렬 조회
---
## 3. 전체 레이아웃
`fnrs-form-page.tsx`와 동일한 **FormProvider + 세로 섹션 스택** 구조를 따르고,
하단 액션바는 **[`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 대응)
```
PageLayout (title / breadcrumb)
└─ SewerBscPlanRegistPage | SewerBscPlanEditPage | SewerBscPlanViewPage
└─ SewerBscPlanFormPage | SewerBscPlanViewPage 본문
FormProvider (등록/수정) / 읽기 섹션 (상세)
└─ <form|div className="mt-6 space-y-4">
├─ BasicInfoFormSection // JSP slide_10
├─ ContactInfoFormSection // JSP slide_15 (pbadms-sprt)
├─ PrivacyConsentSection // JSP slide_19 (pbadms-sprt, 등록·수정)
├─ OutlineFormSection // JSP slide_11 (사업개요, 신규)
├─ BizCstMatrixFormSection // JSP slide_12 (총사업비 전용, 신규)
├─ FnrsCstFormSection // JSP slide_13 (재원분담, 신규 ※ FnrsShar 아님)
├─ TmplntFormSection // JSP slide_14 (처리시설, Tabs로 시설별 패널)
├─ ChecklistFormSection // JSP slide_16 (pbadms-sprt)
├─ AttachmentsFormSection // JSP 필수·기타첨부 (pbadms-sprt)
├─ SplmntDmndFormSection // edit + SL 상태만 (pbadms-sprt)
└─ form footer action bar // fnrs-form-page 279~323 동일
// [목록] / [저장] / edit시 [신청완료][삭제] / SL시 [보완완료]
```
**폼 하단 액션바 버튼 구성**
기준: [`fnrs-form-page.tsx` 279~323](../../src/pages/fnrs-cnsltn/ui/fnrs-form-page.tsx). UI·분기만 동일하고 훅/라우트는 sewer.
| 순서 | 버튼 | 조건 | 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 | 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 (폼 페이지)
```
┌─────────────────────────────────────────────────────────────────┐
│ PageLayout: 하수도기본계획 신청/수정 breadcrumb │
├─────────────────────────────────────────────────────────────────┤
│ FormProvider │
│ <form className="mt-6 space-y-4"> │
│ │
│ ┌─ SectionCollapsible: 기본계획 신청 ─────────────────────┐ │
│ │ BasicInfoFormSection │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ┌─ 신청자 정보 ────────────────────────────────────────────┐ │
│ │ ContactInfoFormSection │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ┌─ 개인정보 동의 ──────────────────────────────────────────┐ │
│ │ PrivacyConsentSection │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ┌─ 사업개요 ────────────── [이전기본계획조회] ─────────────┐ │
│ │ OutlineFormSection │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ┌─ 총사업비 (백만원) ──────────────────────────────────────┐ │
│ │ BizCstMatrixFormSection │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ┌─ 재원분담 (백만원) ──────────────────────────────────────┐ │
│ │ FnrsCstFormSection │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ┌─ 처리시설별 하수도정비기본계획 ──────────────────────────┐ │
│ │ TmplntFormSection │ │
│ │ ├─ BscFctList Checkbox │ │
│ │ └─ Tabs (선택 시설별 TabsTrigger / TabsContent) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ┌─ 체크리스트 ─────────────────────────────────────────────┐ │
│ │ ChecklistFormSection │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ┌─ 첨부파일 ───────────────────────────────────────────────┐ │
│ │ AttachmentsFormSection │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ┌─ 보완요구 (edit + SL) ───────────────────────────────────┐ │
│ │ SplmntDmndFormSection │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ form footer (fnrs-form-page 279~323 동일) ──────────────┐ │
│ │ [목록] [저장] / edit: [신청완료][삭제] / SL: [보완완료] │ │
│ │ ※ API·라우트만 sewer — UI 분기는 fnrs와 동일 │ │
│ └──────────────────────────────────────────────────────────┘ │
│ </form> │
└─────────────────────────────────────────────────────────────────┘
```
### 3-3. 액션바 동작 (`fnrs-form-page` 279~323 동일, API만 sewer)
구현 위치: `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")` |
| 저장 | `!isSplmntPrgrsStts` | `type="submit"` → `saveApply` + 임시저장(`SV`) |
| 신청완료 | edit && `!isSplmntPrgrsStts` | 체크리스트 완료 + **필수 첨부 완료** 검증 → `saveApply` + `LE` |
| 삭제 | edit && `!isSplmntPrgrsStts` | confirm → `delete` → 목록 |
| 보완완료 | `isSplmntPrgrsStts` (SL) | `form.handleSubmit` → `useSubmitSupplementSewerBscPlan` |
**신청완료 사전 검증** (fnrs `handleApplyClick`과 동일 정책)
1. **체크리스트**: `isFormChecklistComplete(cklstResultList)` — 미완료 시 `"모든 체크리스트 항목을 체크해야 신청이 가능합니다."`
2. **필수 첨부**: `esntlAtchYn === "Y"`인 필수첨부 슬롯마다 `esntlAtchFileList`에 `atchFileGroupId`가 있어야 함
- Form: `AttachmentsFormSection`의 트리 + 폼 `esntlAtchFileList`
- 미충족 시 예: `"필수 첨부파일을 모두 등록해야 신청이 가능합니다."`
- 헬퍼 후보: `isFormEsntlAtchComplete(requiredTree, esntlAtchFileList)` (`pbadms-sprt`에 추가)
저장에는 위 검증을 적용하지 않는다 (임시저장 허용).
저장/제출 버튼을 섹션 헤더(`gTitle2 gRt`)에 두지 않는다 — **폼 하단 액션바 단일 진입점** (§4-J).
### 3-4. 라우트
| 화면 | path | 셸 → FormPage |
| ---- | --------------------------- | ------------------------------------------ |
| 등록 | `/sewer-bsc-plan/regist` | `mode="create"` |
| 수정 | `/sewer-bsc-plan/[id]/edit` | `mode="edit"` + `useSewerBscPlanDetails` |
| 상세 | `/sewer-bsc-plan/[id]` | ViewPage (본 문서 범위 밖, fnrs-view 대응) |
`docSeComCd` 후보: 백엔드 체크리스트·필수첨부 등록값 확인 후 **`SEWER`** (또는 레거시 `DMBP`) — **TBD, CMS 코드와 맞출 것**.
---
## 4. 섹션별 상세 구현
### 4-0. Hidden / 세션 컨텍스트 (폼 루트)
JSP hidden → React 폼 기본값·세션 스토어로 흡수.
| JSP | 의미 | React |
| ------------------------------- | --------------- | ------------------------------------------------------- |
| `chgSe` / `chgSeSp` / `chgSeCd` | 변경구분 | `sewerBscPlan.sewerBscPlanSeComCd` + 표시용 코드명 |
| `progrsSttus` | SAVE / PRESENTN | 저장·제출 시 `prgrsSttsComCd`로 매핑 (임시저장 vs 제출) |
| `planSeCd` | 고정 `A` | 필요 시 상수 또는 API 필드 확인 |
| `adstDstrCd` / `rgnCd` | 행정구역 | `sewerBscPlan.admdstCd` |
| `sidoNm` / `gugunNm` | 표시용 | 읽기 전용 표시 (`detDto.sido`/`gugun`) |
구현: `getCreateDefaultValues(userInfo)`에서 로그인 사용자·소속 지자체 주입.
---
### 4-A. 타이틀·브레드크럼
| JSP | React |
| ------------------------ | ------------------------------------------------------ |
| `h3` 기본계획검토 | PageLayout title: **하수도기본계획 신청** (IIPBS 명명) |
| 설치인가 및 제원협의 > … | 메뉴 메타 기반 breadcrumb |
별도 섹션 컴포넌트 불필요. 라우트 `page.tsx` / layout metadata로 처리.
---
### 4-B. 기본계획변경 신청 (`slide_10`)
**파일**: `ui/sections/basic-info-form-section.tsx`
**래퍼**: `SectionCollapsible` title=`기본계획 신청`
**필드 접두사**: `sewerBscPlan.*` + `sewerBscPlanDtl.*`
| 표시명 | JSP | API 매핑 | UI | 필수 | 비고 |
| ---------------- | ----------------- | ------------------------------- | ------------------ | ---- | ---------------------------------- |
| 기본계획명 | `drngMtncPlanNm` | `sewerBscPlan.sewerBscPlanNm` | Input | Y | |
| 계획년도 | `planYr` | `sewerBscPlanDtl.planYr` | Select (현재~2005) | Y | |
| 기준년도 | `crtrYr` | `sewerBscPlanDtl.crtrYr` | Select | Y | |
| 1~4단계 목표연도 | `goalYrStep1~4` | `frlvlGoalYr`~`folvlGoalYr` | Select (현재~+30) | Y | 1단계 변경 시 +5/+10/+15 자동 연동 |
| 계획개요 | `bzCn` | `sewerBscPlanDtl.bizOtlnCn` | Textarea | Y | |
| 구분 | `#chgSeNm` | `sewerBscPlanSeComCd` (+코드명) | 읽기 또는 Select | | 신규/변경 등 |
| 신청 지자체 | `${lclgvrnmntNm}` | `admdstCd` | 읽기 전용 | | |
**동작**
- `goalYrStep1` onChange → 2·3·4단계 기본값 세팅 + E/F/G 헤더 연도 span 갱신 (`watch` 파생)
- JSP 상단 `gRt` 저장/제출은 사용하지 않음 → FormPage 하단 액션바 (§3-3, §4-J)
---
### 4-C. 신청자 정보 (`slide_15`)
**재사용**: `@/widgets/pbadms-sprt`의 `ContactInfoFormSection`
**props**: `fieldPrefix="sewerBscPlanDtl"` (또는 dtl 필드 구조에 맞게)
| 표시 | JSP | API |
| ---------------------------- | --------------------------- | --------------------------------------------------------------------------------------- |
| 신청자 이름/전화/부서/이메일 | `${UsrNm}` 등 서버렌더 | 로그인 사용자 읽기 전용 → `aplcntNm`, `aplcntMblTelno`, `aplcntOgdpNm`, `aplcntEmlAddr` |
| 설계자 | `dsgnrNm/Telno/Psitn/Email` | `dsgnrNm`, `dsgnrTelno`, `dsgnrOgdpNm`, `dsgnrEmlAddr` |
신청자 4필드는 세션에서 defaultValues에 채우고 Input `disabled`.
---
### 4-D. 개인정보 동의 (`slide_19`)
**재사용**: `PrivacyConsentSection`
| JSP | React |
| ----------------------------- | ------------------------------------------------------------------ |
| 고지 textarea + 보유기간 문구 | 동일 문구 상수/마크다운 |
| `privacyAgree` Y/N | 폼 필드 `privacyAgree: "Y" \| "N"` (API 미전송, 클라이언트 검증만) |
**검증**: 제출 시 `Y` 필수 (JSP `presentnBtn`과 동일). 저장(임시)은 동의 없이 허용할지 정책 TBD — 기본은 JSP와 같이 **제출만 강제**.
---
### 4-E. 사업개요 (`slide_11`)
**파일**: `ui/sections/outline-form-section.tsx` (신규, 도메인 전용)
**API**: `sewerBscPlanOtlnList: SewerBscPlanOtlnItem[]` (planStpNo 1~4)
| 행(구분) | JSP name | Item 필드 |
| ------------ | --------------- | ------------------- |
| 계획인구 | `arrPlanPpltn` | `sewerPlanPpltnCnt` |
| 처리인구 | `arrPrcsPpltn` | `sewerPrcsPpltnCnt` |
| 하수도보급율 | `arrDrngSplyRt` | `sewerPsplyRt` |
열: 1~4단계 (헤더에 `frlvlGoalYr` 등 표시).
**버튼**: `이전기본계획조회` → Dialog (`PreviousPlanLookupDialog`)
- 레거시: `fnbscPlnPopupToggle` + `bscPlnPopup.jsp`
- React: Modal + 조회 API (**TBD**: 이전 계획 조회 엔드포인트; 없으면 1차는 Dialog UI만 + mock)
숫자 입력: 콤마 제거 후 number 전송 (제출 시 JSP `replaceComma` 대체).
---
### 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 }`
**구현 방향 (`BizCstMatrixFormSection` 전용)**
- 행 = `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에서 주석 처리됨 → **미구현**.
---
### 4-G. 재원분담 (`slide_13`)
**주의**: fnrs의 `FnrsSharFormSection`(재원협의 공유구분 트리)과 **다름**.
| 행 | JSP | `SewerBscPlanFnrsCstItem` |
| ------------ | -------------- | ------------------------- |
| 총사업비 | `arrTotAmnt` | `sumAmt` (또는 파생) |
| 국고 | `arrNlepAmt` | `gvexpAmt` |
| 지방비 | `arrLcepAmt` | `lcexpAmt` |
| 원인자부담금 | `arrCusrAlotm` | `orgprBdchgAmt` |
| 기타 | `arrEtcAmt` | `etcAmt` |
**파일**: `ui/sections/fnrs-cst-form-section.tsx` (신규)
데이터: `fnrsCstList` 길이 4 (`planStpNo` 1~4).
검증(선택): 단계별 `gvexp+lcexp+orgpr+etc === sumAmt` (JSP `calculateTotalFunding` 참고).
---
### 4-H. 처리시설별 하수도정비기본계획 (`slide_14`) — 핵심 복잡도
**JSP 동작**
1. `BscFctList` 체크박스로 처리시설 다중 선택 (`arrBscFctCd`)
2. 선택 시 `createFormSection(code, name)`로 **당초 4단계 | 현재 | 변경 4단계** 매트릭스 동적 삽입
→ **React**: 세로 삽입 대신 `@common/components` `Tabs`로 시설별 패널 관리 (§아래 Tabs 규칙)
3. 활성 `formFields`(축소판): 처리구역면적, 하수처리인구, 계획하수량, 시설용량, 분류식 오수/우수, 합류식, 차집, 배수설비, 오수중계/일반/우수 펌프
4. 시설별 필수첨부 `createFileAttachmentSection`
5. `fetchBasicPlanData`로 당초(이전) 데이터 바인딩
**API 매핑**
| UI | DTO |
| -------------------- | ------------------------------------------------------------- |
| 선택 시설 목록 | `sewerBscPlanTmplntList[].swtrmtFcltCd` (+ `atchFileGroupId`) |
| 단계별 수치 (변경측) | `sewerBscPlanTmplntDtlList[]` → `SewerBscPlanTmplntDtlItem` |
| 임시저장 | `tmplntTmprStrgList` (편집 중 임시, 필요 시) |
필드 대응 (변경 단계 기준):
| UI 라벨 | `SewerBscPlanTmplntDtlItem` |
| -------------- | --------------------------- |
| 처리구역면적 | `prcsZoneArea` |
| 하수처리인구 | `swtrmtPpltnCnt` |
| 계획하수량 | `planSwqty` |
| 시설용량 | `fcltCpct` |
| 분류식 오수 | `sprsysDrtwPplLen` |
| 분류식 우수 | `sprsysExlnPplLen` |
| 합류식 | `cbsysPplLen` |
| 차집관로 | `icptgPplLen` |
| 배수설비 | `drgCnt` |
| 오수 중계 펌프 | `drtwRlyPumpCnt` |
| 오수 일반 펌프 | `drtwPumpCnt` |
| 우수 펌프 | `exlnPumpCnt` |
**구현 구성**
```
ui/sections/tmplnt-form-section.tsx
├─ TmplntFacilityPicker # BscFctList Checkbox 목록 (시설 마스터 API TBD)
└─ Tabs (@common/components Tabs) # 선택 시설별 동적 패널
├─ TabsList / TabsTrigger # value=swtrmtFcltCd, 라벨=시설명
└─ TabsContent (시설 1개당)
├─ TmplntMatrixTable # 당초|현재|변경 매트릭스 (useFieldArray)
└─ TmplntFacilityAttachments # 시설별 필수첨부 (선택)
```
**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`: `TmplntFormSection`(또는 TabsContent 내부 매트릭스)을 `next/dynamic` 후보.
---
### 4-I. 체크리스트 + 첨부 (`slide_16`)
#### I-1. 체크리스트
**재사용**: `ChecklistFormSection`
`docSeComCd="<SEWER|DMBP TBD>"`
`fieldPrefix`: 마스터에서 `taskSeComCd`를 읽는 경로 (예: `sewerBscPlan`)
| JSP | React |
| ----------------------- | ------------------------------------------- |
| 업무탭 `ceckEssntlArr` | 코드 `taskSeComCd` 다중 → Tabs |
| 진행률 / 전체 예·아니오 | 위젯 내장 로직 |
| `exmntResult` Y/N | `cklstResultList[].selected` (+ `picRvwCn`) |
저장: `cklstResultList: CklstResultItem[]`
#### I-2. 필수 첨부 (탭별)
**재사용**: `AttachmentsFormSection`
`docSeComCd` 동일, `esntlAtchFileList`
레거시: 체크리스트 탭마다 `essntlList` + `uploadFile` multipart.
신규: TUS/`attach-service` + `esntlAtchFileId` / `atchFileGroupId` (fnrs와 동일).
#### I-3. 기타 첨부
`AttachmentsFormSection`의 etc 영역 또는 `atchFileGroupId` on `sewerBscPlanDtl`.
JSP `uploadFileETC` multiple → Zip 안내 문구 유지.
---
### 4-J. 저장 / 제출
**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`):
```ts
SewerBscPlanApplySaveRequestDto {
sewerBscPlan, sewerBscPlanDtl,
sewerBscPlanOtlnList, bizCstList, fnrsCstList,
sewerBscPlanTmplntList, tmplntTmprStrgList?,
cklstResultList, esntlAtchFileList,
splmntDmndList?, rvwOpnnList? // 등록 화면에서는 보통 빈 배열
}
```
성공 후: `invalidateQueries(sewerBscPlanBaseKey)` → `router.push('/sewer-bsc-plan')` (신청완료는 상세로 보낼 수 있음 — fnrs와 동일 정책).
권한: JSP `AUTH3` → 기존 AS 권한 훅/역할로 버튼 `disabled`/`hidden`.
---
## 5. API / DTO 연결
Base: `/as-service/api/v1/sewer-bsc-plans`
엔티티: `@/entities/sewer-bsc-plan`
| 화면 동작 | Hook / Service | 비고 |
| -------------- | ---------------------------------------- | ---------------- |
| 등록 저장·제출 | `useSaveApplySewerBscPlan` → `saveApply` | 본 문서 핵심 |
| 수정 로드 | `useSewerBscPlanDetails(docNo, chgCycl)` | 편집 시 |
| 마스터만 | `useSewerBscPlan` | 목록 행 클릭 등 |
| 보완 제출 | `useSubmitSupplementSewerBscPlan` | edit + `prgrsSttsComCd=SL` 시 폼 하단 [보완완료] |
| 기술검토 | `useTechReviewSewerBscPlan` | 등록 JSP 범위 밖 |
등록 JSP에 없는 섹션(보완요구·검토의견)은 **상세/검토 화면**에서 `SplmntDmndFormSection` / 검토의견 섹션으로 후속 구현.
---
## 6. 권장 파일 배치
```
apps/as/
├── app/(with-base-layout)/sewer-bsc-plan/
│ └── regist/page.tsx
├── src/pages/sewer-bsc-plan/
│ ├── lib/
│ │ ├── sewer-bsc-plan-regist-schema.ts # zod
│ │ └── form-data-mapper.ts # Det ↔ Form, Apply payload
│ ├── model/
│ │ └── (필요 시 table columns)
│ └── ui/
│ ├── sewer-bsc-plan-regist-page.tsx
│ ├── sewer-bsc-plan-form-page.tsx
│ ├── components/
│ │ └── previous-plan-lookup-dialog.tsx
│ └── sections/
│ ├── basic-info-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 # 본 문서
```
공통 재사용 (신규 작성 최소화):
- `ContactInfoFormSection`, `PrivacyConsentSection`
- `ChecklistFormSection`, `AttachmentsFormSection`
- `SectionCollapsible`
- `Tabs` / `TabsList` / `TabsTrigger` / `TabsContent` (`@common/components` — 처리시설 패널)
도메인 전용 (재사용 금지):
- `BizCstMatrixFormSection` — 총사업비 (fnrs `BusinessCostFormSection` 미사용)
- 폼 하단 액션바 — `fnrs-form-page` 279~323 복제, API만 sewer
---
## 7. 화면 데이터 흐름
```
BEGIN
// 최초 진입 (등록)
CALL 로그인사용자조회
CALL 코드조회(SEWERBSCPLANSE, SEWER, …) // 병렬
CALL 체크리스트트리(docSeComCd) // 병렬
CALL 필수첨부트리(docSeComCd) // 병렬
CALL 처리시설마스터목록 // TBD (BscFctList)
DISPLAY 폼 defaultValues
ON 1단계목표연도 CHANGE DO
SET 2·3·4단계 연도 (+5/+10/+15)
DISPLAY E/F/G/H 헤더 연도
END ON
ON 처리시설 체크 DO
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
ON [저장] CLICK DO
VALIDATE zod (동의 제외 가능)
CALL saveApply(payload, 임시저장 상태)
NAVIGATE 목록
END ON
ON [신청완료] CLICK DO
VALIDATE zod + privacyAgree=Y + 체크리스트 완료 + 필수첨부(esntlAtchYn=Y) 완료
CONFIRM 신청 확인
CALL saveApply(payload, 제출 상태 LE)
NAVIGATE 상세
END ON
END
```
---
## 8. JSP ↔ 신규 갭·리스크
| # | 이슈 | 대응 |
| --- | ---------------------------------------------------- | ------------------------------------------------- |
| 1 | JSP 제출 URL이 `dscss`로 분기 (오타/구프로세스 가능) | 신규는 `saveApply` 단일 진입으로 통일 |
| 2 | 총사업비 UI 계층 ≠ `BizCstItem` | `BizCstMatrixFormSection` 전용 + `acntgSeCd` 매핑 테이블 |
| 3 | 재원분담 ≠ fnrs shar | 전용 `fnrs-cst-form-section` |
| 4 | 처리시설 매트릭스(당초/현재/변경) | Checkbox 선택 → Tabs 패널; 저장은 변경 dtl 중심; 당초는 조회 전용 |
| 5 | 시설·이전계획 API | TBD — 미정 시 체크박스 목록 mock(`faker`)만 |
| 6 | `docSeComCd` 값 | CMS/체크리스트 마스터와 합의 |
| 7 | 레거시 multipart vs TUS | fnrs 첨부 패턴 준수 |
| 8 | 주석 처리된 상세 `formFields` (자연발생인구 등) | **축소된 formFields만** 이관; 주석 블록 복원 금지 |
---
## 9. 구현 순서 (권장)
1. 라우트 + `FormPage` 골격 + zod 스키마(마스터·dtl·동의) + 폼 하단 액션바(§4-J)
2. B·C·D 섹션 (공통 위젯 최대 활용)
3. E 사업개요 + G 재원분담 (4열 매트릭스 패턴 공유)
4. F 총사업비 (`BizCstMatrixFormSection` 전용)
5. I 체크리스트·첨부
6. H 처리시설 (`BscFctList` Checkbox + `@common/components` Tabs — 가장 복잡)
7. 저장/신청완료/삭제/보완완료 API 연동 (§4-J)
8. 이전계획 Dialog (API 확정 후)
---
## 10. 체크리스트 (jsp-migration-as)
- [ ] contents만 이관 (include 제외)
- [ ] vercel-react-best-practices 적용 (Query 병렬·파생상태·dynamic)
- [ ] `@common/components` + 필요 시 DataTable
- [ ] API·모델은 `@/entities/sewer-bsc-plan` 우선
- [ ] 목데이터는 `@faker-js/faker`만 (시설 마스터 등 TBD 구간)
- [ ] 로딩·빈·에러 상태
- [ ] `router.push`로 목록 복귀
---
## 11. 후속 문서
- 목록(`dmbpList.jsp`) / 상세(`dmbpView.jsp`) / 수정(`dmbpModify.jsp`)은 본 문서와 동일 섹션 체계로 별도 확장
- 화면설계서 SPEC 형식이 필요하면 `docs/sewer-bsc-plan/SPEC.md`로 분리 (영역 A~J 표 + API 표)
@@ -0,0 +1,441 @@
# 화면설계서: 이전기본계획조회 (기본 계획 찾기)
## 1. 기본 정보
| 항목 | 내용 |
| ---------- | --------------------------------------------------------------- |
| 버전 | 0.2 |
| 프로세스명 | UI설계 |
| 화면명 | 이전기본계획조회 (기본 계획 찾기) |
| 화면 유형 | [v] 조회 [ ] 입력 [ ] 수정 [ ] 삭제 [ ] 출력 |
| 메뉴 경로 | 하수도기본계획 등록·수정 > 사업개요 > [이전기본계획조회] Dialog |
---
## 2. 화면 목적
하수도기본계획 등록·수정 화면에서 **이전(기수립) 기본계획** 수치와 관련 보고서를 **조회·다운로드**한다. 시도·구군·연도·처리시설을 선택한 뒤 총설·처리구역별 현황을 표시하고, 분류 트리에서 기본계획 파일을 내려받는다. **부모 사업개요 폼에는 수치를 반영하지 않는다**(레거시 `bscPlnPopup.jsp`와 동일).
---
## 3. 화면 구성
### 3-1. 전체 레이아웃
부모: 하수도기본계획 등록/수정 폼 → 사업개요 섹션의 `[이전기본계획조회]` 버튼으로 Dialog 오픈.
```
┌─────────────────────────────────────────────────────────────────────────────────┐
│ Dialog 타이틀: 기본 계획 찾기 [닫기] │
├─────────────────────────────────────────────────────────────────────────────────┤
│ [영역 A] 검색 필터 │
│ 시도 ▼ 구군 ▼ 연도 ▼ 처리시설 ▼ [조회] │
├─────────────────────────────────────────────────────────────────────────────────┤
│ [영역 B] 기본계획파일 │
│ ┌──────────────────────┬────────────────────────────────────────────────────┐ │
│ │ [영역 B-1] 분류 트리 │ [영역 B-2] 파일 목록 │ │
│ │ (약 40%) │ (약 60%) │ │
│ │ └ 노드… │ 파일명 / 다운로드 │ │
│ └──────────────────────┴────────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────────────────────────┤
│ [영역 C] 총설 │
│ ┌────────┬──────────────당초──────────────┬──기준──┬──────────────변경──────────────┐ │
│ │ 구분 │ 1단계 │ 2단계 │ 3단계 │ 4단계 │ 기준 │ 1단계 │ 2단계 │ 3단계 │ 4단계 │ │
│ ├────────┼───────┼───────┼───────┼───────┼────────┼───────┼───────┼───────┼───────┤ │
│ │계획목표│ 년 │ 년 │ 년 │ 년 │ 년 │ 년 │ 년 │ 년 │ 년 │ │
│ │년도 │ │ │ │ │ │ │ │ │ │ │
│ └────────┴───────┴───────┴───────┴───────┴────────┴───────┴───────┴───────┴───────┘ │
├─────────────────────────────────────────────────────────────────────────────────┤
│ [영역 D] 처리구역별 현황 │
│ ┌────────┬──────────────당초──────────────┬──기준──┬──────────────변경──────────────┐ │
│ │ 구분 │ 1~4단계(+연도헤더) │ 기준 │ 1~4단계(+연도헤더) │ │
│ ├────────┼────────────────────────────────┼────────┼────────────────────────────────┤ │
│ │ 하수처리인구 … 배제방식 (읽기 전용 행) │ │
│ └────────┴────────────────────────────────┴────────┴────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────────┘
```
### 3-2. 화면 데이터 흐름
```
BEGIN
// 부모 폼 — 사업개요
ON [이전기본계획조회] 버튼 CLICK DO
OPEN PreviousPlanLookupDialog
// 프리필: 부모 폼 sewerBscPlan.sido / gugun / admdstCd
CALL findSidoCombo()
DISPLAY 시도 옵션 (영역 A)
IF 시도 프리필 THEN
CALL findGugunCombo(sidoCd)
DISPLAY 구군 옵션 (영역 A)
CALL getLvNowDmgMtncGnsvy({ level: "1", ctpvNm })
// TBD: useLvNowDmgMtncGnsvy enabled — level별 필수 파라미터만 검사하도록 조정
IF 구군 프리필 THEN
CALL getLvNowDmgMtncGnsvy({ level: "2", ctpvNm, sggNm })
DISPLAY 연도 옵션 (영역 A) // children 중 level=3 노드의 gnsvyYr
CALL getClsfSystm({ clsfSystmNm: gugunNm })
DISPLAY 분류 트리 (영역 B-1)
END IF
END IF
END ON
// 영역 A: 시도 변경
ON 영역 A 시도 CHANGE DO
RESET 구군 / 연도 / 처리시설
CALL findGugunCombo(sidoCd)
CALL getLvNowDmgMtncGnsvy({ level: "1", ctpvNm })
// 구군 미선택 → 연도·처리시설 옵션 비움, 트리 빈 안내
END ON
// 영역 A: 구군 변경
ON 영역 A 구군 CHANGE DO
RESET 연도 / 처리시설
CALL getLvNowDmgMtncGnsvy({ level: "2", ctpvNm, sggNm })
DISPLAY 연도 옵션 (영역 A) // level=3 노드 → gnsvyYr
CALL getClsfSystm({ clsfSystmNm: sggNm })
DISPLAY 분류 트리 (영역 B-1)
END ON
// 영역 A: 연도 변경
ON 영역 A 연도 CHANGE DO
RESET 처리시설
CALL getLvNowDmgMtncGnsvy({ level: "3", ctpvNm, sggNm, gnsvyYr })
DISPLAY 처리시설 옵션 (영역 A) // level=3 노드 children → swtrmtFcltCd / swtrmtFcltNm
END ON
// 영역 A: 조회
ON [조회] 버튼 CLICK DO
VALIDATE 시도, 구군, 연도(targetYear), 처리시설(swtrmtFcltCd)
CALL getNowDmgMtncGnsvy({ swtrmtFcltCd, ctpvNm, sggNm, targetYear })
DISPLAY 총설 (영역 C) // 응답[0]의 planYr / planYrDt* / planYrDc*
DISPLAY 처리구역별 현황 (영역 D) // 응답[0]의 지표 Dt1~4 / base / Dc1~4
END ON
// 영역 B: 트리 노드 선택
ON 영역 B-1 노드 CLICK DO
CALL getFile({ clsfSystmSn, sort, vetPrcsYn }) // Response DTO TBD
DISPLAY 파일 목록 (영역 B-2)
END ON
// 영역 B: 파일 다운로드
ON 영역 B-2 파일 CLICK DO
CALL getFilePrcs(fileSn) // 파일 메타(경로·제목) — Response TBD
CALL fileDownload({ filePath, fileName })
// 브라우저 다운로드 트리거
END ON
ON [닫기] DO
CLOSE PreviousPlanLookupDialog
// 부모 폼 값 변경 없음
END ON
END
```
---
## 4. 영역 A — 검색 필터
구성: **시도 · 구군 · 연도 · 처리시설** (+ 조회).
- 시도·구군: `adstdstr` 콤보 (기존 스펙 유지)
- 연도·처리시설: 각각 `useLvNowDmgMtncGnsvy`를 **별도 호출**. `level`에 따라 반환 계층이 달라진다.
### 4-1. 필드 정의
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
| -------- | --------- | ---- | ------------------------- | ------------------------------------------------ | -------------------------------------------------------------- |
| 시도 | Select | Y | 부모 `sewerBscPlan.sido` | `ctpvNm`(표시명), 코드는 `adstDstrCd` | `useFindSidoCombo` |
| 구군 | Select | Y | 부모 `sewerBscPlan.gugun` | `sggNm`(표시명), 코드는 `adstDstrCd` | `useFindGugunCombo(sidoCd)` |
| 연도 | Select | Y | 없음 | `gnsvyYr` / 조회 시 `targetYear` | `useLvNowDmgMtncGnsvy({ level: "2", ctpvNm, sggNm })` |
| 처리시설 | Select | Y | 없음 | `swtrmtFcltCd` ← 옵션 value, 라벨 `swtrmtFcltNm` | `useLvNowDmgMtncGnsvy({ level: "3", ctpvNm, sggNm, gnsvyYr })` |
> 처리시설은 `bns-if` `useFacilityList`를 **사용하지 않는다**.
### 4-2. 버튼
| 버튼명 | 동작 |
| ------ | -------------------------------------------------------- |
| 조회 | 필수값 검증 후 `getNowDmgMtncGnsvy` 호출 → 영역 C·D 갱신 |
| 닫기 | Dialog 종료 (부모 미반영) |
### 4-3. `lvNowDmgMtncGnsvy` level 캐스케이드
| 트리거 | Request `level` | Request 파라미터 | UI에 쓰는 응답 | 옵션 추출 |
| --------- | --------------- | ---------------------------- | --------------- | ------------------------------------------------------------------------------ |
| 시도 선택 | `"1"` | `ctpvNm` | (하위 초기화용) | 연도·처리시설 Select 리셋. 구군은 `findGugunCombo` |
| 구군 선택 | `"2"` | `ctpvNm`, `sggNm` | 연도 Select | 트리에서 `level === 3` 노드의 `gnsvyYr` |
| 연도 선택 | `"3"` | `ctpvNm`, `sggNm`, `gnsvyYr` | 처리시설 Select | 선택 연도(`level === 3`) 노드의 `children[]` → `swtrmtFcltCd` / `swtrmtFcltNm` |
상위 변경 시 하위 선택값·옵션을 RESET한다 (시도→구군·연도·시설 / 구군→연도·시설 / 연도→시설).
---
## 5. 영역 B — 기본계획파일
### 5-1. 트리 (B-1)
| 표시명 | Field | 비고 |
| ------ | --------------- | ------------------------ |
| 분류명 | `clsfSystmNm` | `ClsfSystmResponseDto` |
| 노드키 | `clsfSystmSn` | 선택 시 파일 목록 조회 |
| 상위키 | `upClsfSystmSn` | 클라이언트에서 트리 조립 |
| 정렬 | `sortSeq` | |
| 경로 | `path` | |
- 조회 파라미터: `clsfSystmNm` = 선택 구군명(레거시와 동일). `upClsfSystmSn`은 하위 전개 시 TBD.
- 시도·구군 미선택 시: 「시도와 구군을 선택하세요.」 안내.
### 5-2. 파일 목록 (B-2)
| 표시명 | Field | 비고 |
| ------ | ----- | ------------------------------------------------------------------- |
| 파일명 | TBD | `getFile` Response DTO가 `unknown` — 백엔드 스키마 확정 후 매핑 |
| (기타) | TBD | 레거시: 트리 선택 후 목록 표시, 클릭 시 `fileprcs` → `fileDownload` |
### 5-3. 버튼·상호작용
| 동작 | 설명 |
| -------------- | -------------------------------------------------------------- |
| 트리 노드 클릭 | `getFile({ clsfSystmSn, … })` → B-2 목록 |
| 파일 클릭 | `getFilePrcs(fileSn)` → `fileDownload({ filePath, fileName })` |
| 빈 목록 | 「트리에서 항목을 선택하세요.」 / 「파일이 없습니다.」 |
---
## 6. 영역 C — 총설
### 6-1. 테이블 구조
| 행(구분) | 열 매핑 (당초1~~4 / 기준 / 변경1~~4) |
| ------------ | ------------------------------------------------------------------------------ |
| 계획목표년도 | `planYrDt1planYrDt4` ~~/~~ `planYr` ~~/~~ `planYrDc1planYrDc4` (+ `년` 접미사) |
- 헤더: 당초(1~~4단계) · 기준 · 변경(1~~4단계)
- 데이터 소스: `getNowDmgMtncGnsvy` 응답 배열의 **첫 번째** 요소
- 읽기 전용
---
## 7. 영역 D — 처리구역별 현황
### 7-1. 행 정의 (표시명 → Field 접두사)
각 행은 `Dt1Dt4` ~~/ (접미사 없음=기준) /~~ `Dc1Dc4` 셀로 표시한다. 숫자는 천단위 콤마, 없으면 `-`.
| 표시명 | Field 접두사 | 비고 |
| ------------------------ | -------------------- | ---------------------------------------------- |
| 하수처리인구(인) | `swtrmtPpltnCnt` | |
| 자연적하수처리인구수(인) | `natrSwtrmtPpltnCnt` | |
| 사회적하수처리인구수(인) | `scilSwtrmtPpltnCnt` | |
| 하수처리구역면적 | `swtrmtZoneArea` | |
| 물사용량원단위 | `wtrUsqtyOrgnUnit` | |
| 오수전환율(%) | `drtwSwtcRt` | |
| 변동부하율(%) | `flctnLodRt` | |
| 오수원단위 | `drtwOrgnUnit` | |
| 계획하수량합계(㎥/일) | `planSwageQntSum` | |
| 생활오수(㎥/일) | `lvngDrtwSum` | |
| 지하수(㎥/일) | `ugwtSum` | |
| 공장폐수(㎥/일) | `fctryWwtSum` | |
| 관광오수(㎥/일) | `toursmDrtwSum` | |
| 군부대(㎥/일) | `mtbsSum` | |
| 연계처리수(㎥/일) | `linkPrcsCntSum` | |
| 기타오수(㎥/일) | `etcDrtwSum` | |
| 지하수사용량(㎥/일) | `ugwtUseSum` | |
| 시설용량(㎥/일) | `fcltCpctSum` | |
| 배제방식 | `exclsMethExpln` | 문자열; 단계별 `*Dt*`/`*Dc*` 존재 시 동일 규칙 |
헤더 3행째 연도: 영역 C와 동일하게 `planYrDt*` / `planYr` / `planYrDc*` 표시.
---
## 8. API/DTO 명세
### 8-1. 공통
| 항목 | 값 |
| --------------- | ---------------------------------------------------- |
| DC Base | `/dc-service/api/v1` (`ENDPOINT.DC_SERVICE`) |
| AS adstdstr | `/as-service/api/v1/adstdstr` |
| 클라이언트 모듈 | `@/entities/dc` (`dcService`), `@/entities/adstdstr` |
> 영역 A 처리시설은 `bns-if`를 사용하지 않음.
### 8-2. 시도·구군 콤보 (non-DC)
| 기능 | Method | URL | Request DTO | Response DTO | API |
| -------- | ------ | ----------------------------------- | ------------------------ | -------------------------------- | ---------------------------------------------------------- |
| 시도목록 | GET | `/as-service/api/v1/adstdstr/sido` | — | `CmCdAdstDstrComboResponseDto[]` | `cmCdAdstDstrService.findSidoCombo` / `useFindSidoCombo` |
| 구군목록 | GET | `/as-service/api/v1/adstdstr/gugun` | Query: `sido` (시도코드) | `CmCdAdstDstrComboResponseDto[]` | `cmCdAdstDstrService.findGugunCombo` / `useFindGugunCombo` |
### 8-3. 연도·처리시설 계층 조회 — DC
| 기능 | Method | URL | Request DTO | Response DTO | API |
| --------------- | ------ | -------------------------------------- | ---------------------------- | -------------------------------- | --------------------------------------------------------- |
| 계층형 계획목록 | GET | `/dc-service/api/v1/lvNowDmgMtncGnsvy` | `LvNowDmgMtncGnsvySearchDto` | `LvNowDmgMtncGnsvyResponseDto[]` | `dcService.getLvNowDmgMtncGnsvy` / `useLvNowDmgMtncGnsvy` |
연도 Select·처리시설 Select는 **각각** `useLvNowDmgMtncGnsvy`를 호출한다 (`level`만 다르게).
**Request (**`LvNowDmgMtncGnsvySearchDto`**)**
| 필드 | 시도(`level=1`) | 구군→연도(`level=2`) | 연도→시설(`level=3`) |
| --------- | --------------- | -------------------- | -------------------- |
| `level` | `"1"` | `"2"` | `"3"` |
| `ctpvNm` | 필수 | 필수 | 필수 |
| `sggNm` | — | 필수 | 필수 |
| `gnsvyYr` | — | — | 필수 |
**Response 노드 필드 (**`LvNowDmgMtncGnsvyResponseDto`**, 부분 optional)**
| 필드 | 등장 level | 설명 |
| -------------- | ---------- | ------------------------ |
| `level` | 1~3 | 계층 깊이 |
| `ctpvNm` | 1 | 시도명 |
| `sggNm` | 2 | 구군명 |
| `gnsvyYr` | 3 | 연도 → 연도 Select value |
| `swtrmtFcltCd` | 시설(리프) | 처리시설 코드 |
| `swtrmtFcltNm` | 시설(리프) | 처리시설명 |
| `children` | 1~3 | 하위 노드 배열 |
> **TBD**: 현재 `useLvNowDmgMtncGnsvy` `enabled`가 `level && ctpvNm && sggNm && gnsvyYr`로 고정되어 `level=1`/`2` 호출이 막힘. level별 필수 파라미터만 검사하도록 수정 필요.
>
> 예: `level=1` → `ctpvNm` / `level=2` → `ctpvNm && sggNm` / `level=3` → `ctpvNm && sggNm && gnsvyYr`
### 8-4. 기본계획 수치 조회 — DC
| 기능 | Method | URL | Request DTO | Response DTO | API |
| ---------------- | ------ | ------------------------------------ | -------------------------- | ------------------------------ | ----------------------------------------------------- |
| 현재·단계별 수치 | GET | `/dc-service/api/v1/nowDmgMtncGnsvy` | `NowDmgMtncGnsvySearchDto` | `NowDmgMtncGnsvyResponseDto[]` | `dcService.getNowDmgMtncGnsvy` / `useNowDmgMtncGnsvy` |
**Request (**`NowDmgMtncGnsvySearchDto`**)**
| 필드 | 매핑 |
| -------------- | ----------------------- |
| `swtrmtFcltCd` | 영역 A 처리시설 |
| `ctpvNm` | 영역 A 시도 표시명 |
| `sggNm` | 영역 A 구군 표시명 |
| `targetYear` | 영역 A 연도 (`gnsvyYr`) |
**Response**: 지표 필드 + `Dt1Dt4` ~~/~~ `Dc1Dc4` 접미사 스테이지 필드 (`NowDmgMtncGnsvyResponseDto`). UI는 `data[0]` 사용.
### 8-5. 분류체계 — DC
| 기능 | Method | URL | Request DTO | Response DTO | API |
| ------------ | ------ | ------------------------------ | -------------------- | ------------------------ | ----------------------------------------- |
| 분류트리조회 | GET | `/dc-service/api/v1/clsfSystm` | `ClsfSystmParamsDto` | `ClsfSystmResponseDto[]` | `dcService.getClsfSystm` / `useClsfSystm` |
**Request**: `clsfSystmNm?`, `upClsfSystmSn?`
**Response**: `clsfSystmSn`, `upClsfSystmSn`, `clsfSystmNm`, `sortSeq`, `droppAble`, `path`
> 레거시 경로 `/clsf` → 현재 클라이언트는 `/clsfSystm`.
### 8-6. 파일 목록·처리·다운로드 — DC
| 기능 | Method | URL | Request DTO / Params | Response DTO | API |
| ------------ | ------ | --------------------------------- | ------------------------------------------------------- | ----------------- | -------------------------------------------- |
| 파일목록 | GET | `/dc-service/api/v1/file` | `FileParamsDto` (`sort?`, `clsfSystmSn?`, `vetPrcsYn?`) | `unknown` **TBD** | `dcService.getFile` / `useFile` |
| 파일처리정보 | GET | `/dc-service/api/v1/fileprcs` | Query: `fileSn` | `unknown` **TBD** | `dcService.getFilePrcs` / `useFilePrcs` |
| 파일다운로드 | POST | `/dc-service/api/v1/fileDownload` | `FileDownloadParamsDto` (`filePath?`, `fileName?`) | `unknown` **TBD** | `dcService.fileDownload` / `useFileDownload` |
> **TBD**: 파일 관련 Response 스키마·다운로드 blob/헤더 처리·`fileDownload` POST body vs query 전달 방식은 백엔드/게이트웨이 확인 후 확정.
---
## 9. UI 구현 가이드
### 9-1. 기술 스택·원칙
- UI: `@common/components` — `Dialog`, `Select`, `Button`, `Table`, `Skeleton`
- 상태: Dialog 로컬 state + React Query (`entities/dc`, `adstdstr`)
- 영역 A 연도·처리시설: `useLvNowDmgMtncGnsvy`를 level별 **두 번**(또는 시도 포함 시 세 번) 호출
- 테이블: 읽기 전용 고정 행 → `@tanstack/react-table` / `DataTable` **불필요**
- 트리: jsTree 미사용 — 재귀 들여쓰기 트리
- 번들: `PreviousPlanLookupDialog`는 outline에서 `next/dynamic` 로드 권장
- 부모 폼: **수치 미반영** (조회·다운로드 only)
- 1차 구현 시 DC 미연동이면 `@faker-js/faker` mock + TODO → 이후 `dcService` 훅으로 교체
### 9-2. 앱·파일 배치
| 구분 | 경로 |
| ------------ | ------------------------------------------------------------------------ |
| 진입 버튼 | `pages/sewer-bsc-plan/ui/sections/outline-form-section.tsx` |
| Dialog | `pages/sewer-bsc-plan/ui/previous-plan-lookup-dialog.tsx` |
| 필터 | `pages/sewer-bsc-plan/ui/previous-plan/previous-plan-search-filters.tsx` |
| 파일 | `pages/sewer-bsc-plan/ui/previous-plan/previous-plan-file-section.tsx` |
| 총설 | `pages/sewer-bsc-plan/ui/previous-plan/previous-plan-summary-table.tsx` |
| 처리구역 | `pages/sewer-bsc-plan/ui/previous-plan/previous-plan-area-table.tsx` |
| 셀 매핑 유틸 | `pages/sewer-bsc-plan/lib/previous-plan-table-utils.ts` |
| mock(선택) | `pages/sewer-bsc-plan/lib/previous-plan-mock.ts` |
| DC entity | `entities/dc/api/dc-service.ts`, `queries.ts`, `model/dc-interface.ts` |
| 레거시 참조 | `adssNewOld/.../cmmn/bscPlnPopup.jsp` |
### 9-3. 영역별 UI 매핑
| 영역 | 컴포넌트 | 데이터 |
| ---- | ------------------------------ | -------------------------------------------------------------- |
| A | `previous-plan-search-filters` | adstdstr + `useLvNowDmgMtncGnsvy`(level 1·2·3) |
| B | `previous-plan-file-section` | `useClsfSystm` + `useFile` + `useFilePrcs` + `useFileDownload` |
| C | `previous-plan-summary-table` | `useNowDmgMtncGnsvy` / 조회 결과 state |
| D | `previous-plan-area-table` | 동일 |
### 9-4. 다이얼로그·라우팅
| 항목 | 내용 |
| ------------ | ------------------------------------------------------------------------ |
| 다이얼로그명 | `PreviousPlanLookupDialog` / 타이틀 `기본 계획 찾기` |
| 오픈 | 사업개요 `[이전기본계획조회]` |
| 라우트 | 없음 (모달). 부모: `/sewer-bsc-plan/regist`, `/sewer-bsc-plan/[id]/edit` |
| 선택 반영 | 없음 |
### 9-5. 데이터 흐름 (구현 요약)
```
BEGIN
ON [이전기본계획조회] CLICK DO
OPEN PreviousPlanLookupDialog (sido/gugun 프리필)
END ON
ON 시도 CHANGE DO
CALL findGugunCombo
CALL getLvNowDmgMtncGnsvy(level=1)
RESET 연도·처리시설
END ON
ON 구군 CHANGE DO
CALL getLvNowDmgMtncGnsvy(level=2) // → 연도 옵션
CALL getClsfSystm
RESET 처리시설
END ON
ON 연도 CHANGE DO
CALL getLvNowDmgMtncGnsvy(level=3) // → 처리시설 옵션
END ON
ON [조회] CLICK DO
VALIDATE
CALL getNowDmgMtncGnsvy
DISPLAY 영역 C · 영역 D
END ON
ON 트리 노드 / 파일 CLICK DO
CALL getFile → getFilePrcs → fileDownload // Response TBD
END ON
END
```
---
## 10. 미확정 (TBD) 추적
| ID | 항목 | 비고 |
| --- | -------------------------------------------------------- | -------------------------------------------------------------------- |
| T1 | `useLvNowDmgMtncGnsvy` enabled을 level별 필수값으로 분기 | `1`: ctpvNm / `2`: +sggNm / `3`: +gnsvyYr |
| T2 | `getFile` / `getFilePrcs` / `fileDownload` Response DTO | 현재 `unknown` |
| T3 | 파일 다운로드 UX (blob, Content-Disposition, 게이트웨이) | 레거시 apigateway 직접 호출과 차이 |
| T4 | `clsfSystm`의 `upClsfSystmSn` 점진 로드 여부 | flat 목록 vs 계층 API |
| T5 | 1차 mock vs 즉시 `dcService` 실연동 | 마이그레이션 플랜은 mock 우선, entity는 준비됨 |
| T6 | 부모 폼 수치 반영 | 요구 없음(레거시 동일). 요청 시 별도 스펙 |
| T7 | `level=1` 응답 UI 활용 여부 | 시도 선택 시 호출하나 구군은 adstdstr — 응답은 캐시·검증용일 수 있음 |
+18 -3
View File
@@ -1,8 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
import { AUTH_USER_ID, CLAIM_NAME, PORT, PROXY_HOST, SERVER_API_URL, SITE_ID } from "@/shared/config";
import {
AUTH_USER_ID,
CLAIM_NAME,
PORT,
PROXY_HOST,
SERVER_API_URL,
SITE_ID,
} from "@/shared/config";
const serverApiUrl = process.env.SERVER_API_URL;
const dcServiceApiUrl = process.env.DC_SERVICE_API_URL;
// CSP 헤더 설정 함수 (img-src만)
function addSecurityHeaders(response: NextResponse): NextResponse {
@@ -22,7 +30,15 @@ export function middleware(req: NextRequest) {
if (pathname.startsWith("/server")) {
const queryParams = searchParams.toString();
const newUrl = `${serverApiUrl}${pathname.replace("/server", "")}${
let serverUrl: string | undefined;
if (pathname.includes("/dc-service")) {
serverUrl = dcServiceApiUrl;
} else {
serverUrl = serverApiUrl;
}
const newUrl = `${serverUrl}${pathname.replace("/server", "")}${
queryParams ? `?${queryParams}` : ""
}`;
@@ -34,7 +50,6 @@ export function middleware(req: NextRequest) {
return addSecurityHeaders(response);
}
// tus-js-client PATCH 요청: Location 헤더로 /server 없이 반환된 경로 직접 처리
// (예: /attach-service/api/v1/tus/upload/{id})
if (pathname.startsWith("/attach-service")) {
-1
View File
@@ -39,7 +39,6 @@
"next": "15.5.18",
"react-hook-form": "^7.54.2",
"tus-js-client": "^4.3.1",
"uuid": "^14.0.1",
"zod": "^3.24.2"
},
"devDependencies": {
+15 -6
View File
@@ -6,10 +6,19 @@
@tailwind components;
@tailwind utilities;
html,
body {
color: var(--foreground);
background: var(--background);
font-family: "Noto Sans KR", sans-serif;
font-size: 10px;
@layer base {
:root {
--secondary-background: rgba(0, 0, 0, 0.04);
}
[data-theme="black"] {
--secondary-background: rgba(255, 255, 255, 0.09);
}
html,
body {
color: var(--foreground);
background: var(--background);
font-family: "Noto Sans KR", sans-serif;
font-size: 10px;
}
}
@@ -13,6 +13,11 @@ async function findSidoCombo(): Promise<CmCdAdstDstrComboResponseDto[]> {
return response.data;
}
async function findUserSidoCombo(): Promise<CmCdAdstDstrComboResponseDto[]> {
const response = await client.get<CmCdAdstDstrComboResponseDto[]>(`${BASE}/user/sido`);
return response.data;
}
async function findGugunCombo(sido: string): Promise<CmCdAdstDstrComboResponseDto[]> {
const response = await client.get<CmCdAdstDstrComboResponseDto[]>(`${BASE}/gugun`, {
params: { sido },
@@ -20,7 +25,16 @@ async function findGugunCombo(sido: string): Promise<CmCdAdstDstrComboResponseDt
return response.data;
}
async function findUserGugunCombo(sido: string): Promise<CmCdAdstDstrComboResponseDto[]> {
const response = await client.get<CmCdAdstDstrComboResponseDto[]>(`${BASE}/user/gugun`, {
params: { sido },
});
return response.data;
}
export const cmCdAdstDstrService = {
findSidoCombo,
findUserSidoCombo,
findGugunCombo,
findUserGugunCombo,
} satisfies CmCdAdstDstrService;
@@ -1,6 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import { IUserInfo } from "@/shared/store";
import { cmCdAdstDstrService } from "./adstdstr-service";
@@ -13,6 +14,16 @@ export function useFindSidoCombo() {
});
}
export function useFindUserSidoCombo(userInfo?: IUserInfo | null) {
const adstDstrCdByUser = userInfo?.laeSeCd?.substring(2, 4);
return useQuery({
queryKey: [...cmCdAdstDstrBaseKey, "findUserSidoCombo", adstDstrCdByUser ?? "all"],
queryFn: () => cmCdAdstDstrService.findUserSidoCombo(),
select: (data) =>
adstDstrCdByUser ? data.filter((item) => item.adstDstrCd === adstDstrCdByUser) : data,
});
}
export function useFindGugunCombo(sido: string) {
return useQuery({
queryKey: [...cmCdAdstDstrBaseKey, "findGugunCombo", sido],
@@ -20,3 +31,11 @@ export function useFindGugunCombo(sido: string) {
enabled: Boolean(sido),
});
}
export function useFindUserGugunCombo(sido: string) {
return useQuery({
queryKey: [...cmCdAdstDstrBaseKey, "findUserGugunCombo", sido],
queryFn: () => cmCdAdstDstrService.findUserGugunCombo(sido),
enabled: Boolean(sido),
});
}
+6 -1
View File
@@ -1,5 +1,10 @@
export { cmCdAdstDstrService } from "./api/adstdstr-service";
export { useFindGugunCombo, useFindSidoCombo } from "./api/queries";
export {
useFindGugunCombo,
useFindSidoCombo,
useFindUserGugunCombo,
useFindUserSidoCombo,
} from "./api/queries";
export type {
CmCdAdstDstrComboResponseDto,
CmCdAdstDstrService,
@@ -7,5 +7,7 @@ export interface CmCdAdstDstrComboResponseDto {
/** Controller public 메서드명·시그니처와 동일 */
export interface CmCdAdstDstrService {
findSidoCombo(): Promise<CmCdAdstDstrComboResponseDto[]>;
findUserSidoCombo(): Promise<CmCdAdstDstrComboResponseDto[]>;
findGugunCombo(sido: string): Promise<CmCdAdstDstrComboResponseDto[]>;
findUserGugunCombo(sido: string): Promise<CmCdAdstDstrComboResponseDto[]>;
}
@@ -1,7 +1,11 @@
import { ACCESS_TOKEN, AUTH_USER_ID, CLAIM_NAME, ENDPOINT } from "@/shared/config";
import { type AuthPayload, type AuthService } from "@common/auth";
import {
type AuthPayload,
type AuthService,
clearDeviceId,
getOrCreateDeviceId,
} from "@common/auth";
import { clearDeviceId, getOrCreateDeviceId } from "../lib/device-id";
import { clearRefreshTimer, setToken, tokenManager } from "../lib/token-manager";
async function login(payload: AuthPayload): Promise<void> {
@@ -72,6 +76,7 @@ async function silentRefresh(): Promise<void> {
}
async function logout(): Promise<void> {
await fetch("/api/auth/logout", { method: "POST" });
tokenManager.clearAuthToken();
clearDeviceId();
@@ -1,17 +0,0 @@
import { v4 as uuidv4 } from "uuid";
const DEVICE_ID_KEY = "x-device-id";
export function getOrCreateDeviceId(): string {
if (typeof window === "undefined") return "";
const existing = localStorage.getItem(DEVICE_ID_KEY);
if (existing) return existing;
const id = uuidv4();
localStorage.setItem(DEVICE_ID_KEY, id);
return id;
}
export function clearDeviceId(): void {
if (typeof window === "undefined") return;
localStorage.removeItem(DEVICE_ID_KEY);
}
@@ -8,6 +8,8 @@ import type {
BnsListResponseWrapperDto,
BnsSearchDto,
EmCodeListResponseDto,
FacilityListResponseWrapperDto,
FacilitySearchDto,
} from "../model/bns-if-interface";
const BASE = ENDPOINT.AS_SERVICE.BNS_IF;
@@ -40,8 +42,18 @@ async function getEmCodeList(
return response.data;
}
async function getFacilityList(
params: FacilitySearchDto,
): Promise<FacilityListResponseWrapperDto> {
const response = await client.get<FacilityListResponseWrapperDto>(`${BASE}/facilityList`, {
params,
});
return response.data;
}
export const bnsIfService = {
getBnsList,
getBnsDetail,
getEmCodeList,
getFacilityList,
} satisfies BnsIfService;
+10 -1
View File
@@ -2,7 +2,7 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import type { BnsSearchDto } from "../model/bns-if-interface";
import type { BnsSearchDto, FacilitySearchDto } from "../model/bns-if-interface";
import { bnsIfService } from "./bns-if-service";
@@ -31,3 +31,12 @@ export function useEmCodeList(dpmCd?: string, lclgvrnmntCd?: string) {
queryFn: () => bnsIfService.getEmCodeList(dpmCd, lclgvrnmntCd),
});
}
export function useFacilityList(params: FacilitySearchDto, options?: { enabled?: boolean }) {
return useQuery({
queryKey: [...bnsIfBaseKey, "getFacilityList", params],
queryFn: () => bnsIfService.getFacilityList(params),
enabled: options?.enabled,
placeholderData: keepPreviousData,
});
}
+4 -1
View File
@@ -1,5 +1,5 @@
export { bnsIfService } from "./api/bns-if-service";
export { useBnsDetail, useBnsList, useEmCodeList } from "./api/queries";
export { useBnsDetail, useBnsList, useEmCodeList, useFacilityList } from "./api/queries";
export { buildBnsSearchDto } from "./lib/build-bns-search-dto";
export type {
BnsDetailResponseDto,
@@ -10,4 +10,7 @@ export type {
BnsSearchDto,
EmCodeItemDto,
EmCodeListResponseDto,
FacilityListResponseWrapperDto,
FacilityResponseDto,
FacilitySearchDto,
} from "./model/bns-if-interface";
@@ -117,9 +117,89 @@ export interface EmCodeListResponseDto {
ddtlList: EmCodeItemDto[];
}
/** 백엔드 FacilitySearchDto */
export interface FacilitySearchDto {
sido?: string;
gugun?: string;
}
/** 백엔드 FacilityResponseDto */
export interface FacilityResponseDto {
bscFctCd?: string;
bscFctNm?: string;
addr?: string;
fctCpc?: string;
fctCpc1?: string;
fctCpc2?: string;
fctCpc3?: string;
totInflAmntAve?: unknown;
pInflAmntAve?: unknown;
oInflAmntAve?: unknown;
aInflAmntAve?: unknown;
totTputAve?: unknown;
pTputAve?: unknown;
oTputAve?: unknown;
aTputAve?: unknown;
inflAssctBfrWqltBod?: unknown;
efflWqltBod?: unknown;
inflAssctBfrWqltCod?: unknown;
efflWqltCod?: unknown;
inflAssctBfrWqltSs?: unknown;
efflWqltSs?: unknown;
inflAssctBfrWqltTn?: unknown;
efflWqltTn?: unknown;
inflAssctBfrWqltTp?: unknown;
efflWqltTp?: unknown;
inflAssctBfrWqltClfgrpcnt?: unknown;
efflWqltClfgrpcnt?: unknown;
tmEfficiency?: unknown;
pcLoad?: unknown;
trtFrfHanNm?: string;
applctNtcheNm?: string;
excretionsTot?: unknown;
stkrsngWstwtrTot?: unknown;
lchtTot?: unknown;
etcTot?: unknown;
tel?: string;
rnDt?: string;
compDt?: string;
tot?: unknown;
oprClsNm?: string;
trstOprEtpNm?: string;
yrstpTrstCst?: string;
trstOprDt?: string;
trstAfr?: string;
dsfctNm?: string;
rvNm?: string;
mnstr?: string;
wbsnNm?: string;
efflWqltStndrdNm?: string;
elcpwAmnt?: unknown;
toeRt?: unknown;
toeSum?: unknown;
co2Emissions?: unknown;
toeQnty?: unknown;
toeRt2?: unknown;
useQnty?: unknown;
eqpPrmsDt?: string;
mdmbsn?: string;
lgbsn?: string;
lgbsnBod?: string;
cntrTrtFctNm?: string;
totWtrQrm?: unknown;
inflAssctBfrWqltToc?: unknown;
efflWqltToc?: unknown;
}
/** 백엔드 FacilityListResponseWrapperDto */
export interface FacilityListResponseWrapperDto {
fctList: FacilityResponseDto[];
}
/** Controller public 메서드명·시그니처와 동일 */
export interface BnsIfService {
getBnsList(params: BnsSearchDto): Promise<BnsListResponseWrapperDto>;
getBnsDetail(gbn: string, yr: string, seqNo: string): Promise<BnsDetailResponseWrapperDto>;
getEmCodeList(dpmCd?: string, lclgvrnmntCd?: string): Promise<EmCodeListResponseDto>;
getFacilityList(params: FacilitySearchDto): Promise<FacilityListResponseWrapperDto>;
}
+72
View File
@@ -0,0 +1,72 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
ClsfSystmParamsDto,
ClsfSystmResponseDto,
DcService,
FileDownloadParamsDto,
FileParamsDto,
LvNowDmgMtncGnsvyResponseDto,
LvNowDmgMtncGnsvySearchDto,
NowDmgMtncGnsvyResponseDto,
NowDmgMtncGnsvySearchDto,
} from "../model/dc-interface";
async function getNowDmgMtncGnsvy(
params: NowDmgMtncGnsvySearchDto,
): Promise<NowDmgMtncGnsvyResponseDto[]> {
const response = await client.get<NowDmgMtncGnsvyResponseDto[]>(
`${ENDPOINT.DC_SERVICE}/nowDmgMtncGnsvy`,
{ params },
);
return response.data;
}
async function getLvNowDmgMtncGnsvy(
params: LvNowDmgMtncGnsvySearchDto,
): Promise<LvNowDmgMtncGnsvyResponseDto[]> {
const response = await client.get<LvNowDmgMtncGnsvyResponseDto[]>(
`${ENDPOINT.DC_SERVICE}/lvNowDmgMtncGnsvy`,
{ params },
);
return response.data;
}
async function getClsfSystm(params: ClsfSystmParamsDto): Promise<ClsfSystmResponseDto[]> {
const response = await client.get<ClsfSystmResponseDto[]>(`${ENDPOINT.DC_SERVICE}/clsf`, {
params,
});
return response.data;
}
async function getFile(params: FileParamsDto): Promise<unknown> {
const response = await client.get<unknown>(`${ENDPOINT.DC_SERVICE}/file`, {
params,
});
return response.data;
}
async function getFilePrcs(fileSn: string): Promise<unknown> {
const response = await client.get<unknown>(`${ENDPOINT.DC_SERVICE}/fileprcs`, {
params: { fileSn },
});
return response.data;
}
async function fileDownload(params: FileDownloadParamsDto): Promise<Blob> {
const response = await client.get<Blob>(`${ENDPOINT.DC_SERVICE}/fileDownload`, {
params,
responseType: "blob",
});
return response.data;
}
export const dcService = {
getNowDmgMtncGnsvy,
getLvNowDmgMtncGnsvy,
getClsfSystm,
getFile,
getFilePrcs,
fileDownload,
} satisfies DcService;
+65
View File
@@ -0,0 +1,65 @@
import { useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import type {
ClsfSystmParamsDto,
FileParamsDto,
LvNowDmgMtncGnsvySearchDto,
NowDmgMtncGnsvySearchDto,
} from "../model/dc-interface";
import { dcService } from "./dc-service";
export const baseKey = [ENDPOINT.DC_SERVICE] as const;
export function useNowDmgMtncGnsvy(params: NowDmgMtncGnsvySearchDto, enabled?: boolean) {
return useQuery({
queryKey: [...baseKey, "nowDmgMtncGnsvy", params],
queryFn: () => dcService.getNowDmgMtncGnsvy(params),
enabled: enabled ?? Boolean(params.swtrmtFcltCd && params.ctpvNm),
});
}
function isLvNowDmgMtncGnsvyEnabled(params: LvNowDmgMtncGnsvySearchDto): boolean {
if (!params.level || !params.ctpvNm) return false;
if (params.level === "1") return true;
if (params.level === "2") return Boolean(params.sggNm);
if (params.level === "3") return Boolean(params.sggNm && params.gnsvyYr);
return false;
}
export function useLvNowDmgMtncGnsvy(params: LvNowDmgMtncGnsvySearchDto) {
return useQuery({
queryKey: [...baseKey, "lvNowDmgMtncGnsvy", params],
queryFn: () => dcService.getLvNowDmgMtncGnsvy(params),
enabled: isLvNowDmgMtncGnsvyEnabled(params),
});
}
export function useClsfSystm(params: ClsfSystmParamsDto) {
return useQuery({
queryKey: [...baseKey, "clsfSystm", params],
queryFn: () => dcService.getClsfSystm(params),
// TODO: T4 — upClsfSystmSn 점진 로드 여부 확정 후 enabled/파라미터 조정
enabled: Boolean(params.clsfSystmNm),
});
}
export function useFile(params: FileParamsDto) {
return useQuery({
queryKey: [...baseKey, "file", params],
queryFn: () => dcService.getFile(params),
// TODO: T2 — getFile Response DTO 확정 후 타입 교체
enabled: Boolean(params.clsfSystmSn),
});
}
export function useFilePrcs(fileSn: string) {
return useQuery({
queryKey: [...baseKey, "filePrcs", fileSn],
queryFn: () => dcService.getFilePrcs(fileSn),
// TODO: T2 — getFilePrcs Response DTO 확정 후 타입 교체
enabled: Boolean(fileSn),
});
}
+19
View File
@@ -0,0 +1,19 @@
export { dcService } from "./api/dc-service";
export {
baseKey as nowDmgMtncGnsvyBaseKey,
useClsfSystm,
useFile,
useFilePrcs,
useLvNowDmgMtncGnsvy,
useNowDmgMtncGnsvy,
} from "./api/queries";
export type {
ClsfSystmParamsDto,
ClsfSystmResponseDto,
FileDownloadParamsDto,
FileParamsDto,
LvNowDmgMtncGnsvyResponseDto,
LvNowDmgMtncGnsvySearchDto,
NowDmgMtncGnsvyResponseDto,
NowDmgMtncGnsvySearchDto,
} from "./model/dc-interface";
@@ -0,0 +1,115 @@
export interface NowDmgMtncGnsvySearchDto {
swtrmtFcltCd?: string;
ctpvNm?: string;
sggNm?: string;
targetYear?: string;
}
export interface LvNowDmgMtncGnsvySearchDto {
level?: string;
ctpvNm?: string;
sggNm?: string;
gnsvyYr?: string;
}
export interface ClsfSystmParamsDto {
clsfSystmNm?: string;
upClsfSystmSn?: string;
}
export interface FileParamsDto {
sort?: string;
clsfSystmSn?: string;
vetPrcsYn?: string;
}
export interface FileDownloadParamsDto {
filePath?: string;
fileName?: string;
}
type NullableNumber = number | null;
type NullableString = string | null;
/** 현재·단계별(Dt/Dc) 공통 지표 필드 */
interface NowDmgMtncGnsvyMetricFields {
planYr?: NullableString;
swtrmtPpltnCnt?: NullableNumber;
natrSwtrmtPpltnCnt?: NullableNumber;
scilSwtrmtPpltnCnt?: NullableNumber;
swtrmtZoneArea?: NullableNumber;
wtrUsqtyOrgnUnit?: NullableNumber;
drtwSwtcRt?: NullableNumber;
flctnLodRt?: NullableNumber;
drtwOrgnUnit?: NullableNumber;
planSwageQntSum?: NullableNumber;
lvngDrtwSum?: NullableNumber;
ugwtSum?: NullableNumber;
fctryWwtSum?: NullableNumber;
toursmDrtwSum?: NullableNumber;
mtbsSum?: NullableNumber;
linkPrcsCntSum?: NullableNumber;
etcDrtwSum?: NullableNumber;
ugwtUseSum?: NullableNumber;
fcltCpctSum?: NullableNumber;
exclsMethExpln?: NullableString;
brcPplSum?: NullableNumber;
icptgPplSum?: NullableNumber;
exclnPplSum?: NullableNumber;
stofcbrSum?: NullableNumber;
pmstnSum?: NullableNumber;
simplPubSwtrmtFcltSum?: NullableNumber;
swageImpdFcltSum?: NullableNumber;
mltvFclSum?: NullableNumber;
}
type StageSuffix = "Dt1" | "Dt2" | "Dt3" | "Dt4" | "Dc1" | "Dc2" | "Dc3" | "Dc4";
type StagedMetricFields = {
[K in keyof NowDmgMtncGnsvyMetricFields as `${K & string}${StageSuffix}`]?: NowDmgMtncGnsvyMetricFields[K];
};
export interface NowDmgMtncGnsvyResponseDto
extends NowDmgMtncGnsvyMetricFields, StagedMetricFields {
swtrmtFcltCd?: NullableString;
swtrmtFcltNm?: NullableString;
gnsvyYr?: NullableString;
ctpvNm?: NullableString;
sggNm?: NullableString;
}
/**
* lvNowDmgMtncGnsvy 계층 노드.
* - level 1: ctpvNm + children(구군)
* - level 2: sggNm + children(연도)
* - level 3: gnsvyYr + children(처리시설)
* - 시설 리프: swtrmtFcltCd / swtrmtFcltNm (level 없음)
*/
export interface LvNowDmgMtncGnsvyResponseDto {
level?: number;
ctpvNm?: string;
sggNm?: string;
gnsvyYr?: string;
swtrmtFcltCd?: string;
swtrmtFcltNm?: string;
children?: LvNowDmgMtncGnsvyResponseDto[];
}
export interface ClsfSystmResponseDto {
clsfSystmSn: string;
upClsfSystmSn: string;
clsfSystmNm: string;
sortSeq: string;
droppAble: boolean;
path: string | null;
children: ClsfSystmResponseDto[];
}
export interface DcService {
getNowDmgMtncGnsvy(params: NowDmgMtncGnsvySearchDto): Promise<NowDmgMtncGnsvyResponseDto[]>;
getLvNowDmgMtncGnsvy(params: LvNowDmgMtncGnsvySearchDto): Promise<LvNowDmgMtncGnsvyResponseDto[]>;
getClsfSystm(params: ClsfSystmParamsDto): Promise<ClsfSystmResponseDto[]>;
getFile(params: FileParamsDto): Promise<unknown>;
getFilePrcs(fileSn: string): Promise<unknown>;
fileDownload(params: FileDownloadParamsDto): Promise<Blob>;
}
@@ -39,11 +39,48 @@ export interface AdstZoneResponseDto {
export const INSTL_SNCN_REGISTER_SELECTION_KEY = "instlSncnRegisterSelection";
/** 백엔드 InstlSncnMngSearchDto + Pageable */
export interface InstlSncnMngSearchParams extends PageableParams {
bizNm?: string;
/** 설치인가문서번호 */
instlSncnDocNo?: string;
/** 행정구역코드 */
admdstCd?: string;
/** 환경청구분공통코드 */
envoSeComCd?: string;
/** 업무구분공통코드 */
taskSeComCd?: string;
sidoComCd?: string;
sggComCd?: string;
/** 예산사업코드 */
bgtBizCd?: string;
/** 예산신청연도 */
bgtAplyYr?: string;
/** 사업명 */
bizNm?: string;
/** 사업시작연도 */
bizBgngYr?: string;
/** 사업종료연도 */
bizEndYr?: string;
/** 사업내용 */
bizCn?: string;
/** 신청자명 */
aplcntNm?: string;
/** 신청자휴대전화번호 */
aplcntMblTelno?: string;
/** 신청자소속명 */
aplcntOgdpNm?: string;
/** 신청자이메일주소 */
aplcntEmlAddr?: string;
/** 설계자명 */
dsgnrNm?: string;
/** 설계자전화번호 */
dsgnrTelno?: string;
/** 설계자소속명 */
dsgnrOgdpNm?: string;
/** 설계자이메일주소 */
dsgnrEmlAddr?: string;
/** 첨부파일그룹아이디 */
atchFileGroupId?: string;
/** 진행상태공통코드 */
prgrsSttsComCd?: string;
}
/** 설치인가관리 저장 요청 DTO */
@@ -0,0 +1,138 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import type {
SewerBscPlanApplySaveRequestDto,
SewerBscPlanSaveRequestDto,
SewerBscPlanSearchRequestDto,
SewerBscPlanUpdateRequestDto,
} from "../model/sewer-bsc-plan-interface";
import { sewerBscPlanService } from "./sewer-bsc-plan-service";
export const sewerBscPlanBaseKey = [ENDPOINT.AS_SERVICE.SEWER_BSC_PLAN] as const;
export function useSewerBscPlanPage(params: SewerBscPlanSearchRequestDto) {
return useQuery({
queryKey: [...sewerBscPlanBaseKey, "findPage", params],
queryFn: () => sewerBscPlanService.findPage(params),
});
}
export function useSewerBscPlan(sewerBscPlanDocNo: string | null | undefined) {
return useQuery({
queryKey: [...sewerBscPlanBaseKey, "findById", sewerBscPlanDocNo],
queryFn: () => sewerBscPlanService.findById(sewerBscPlanDocNo!),
enabled: Boolean(sewerBscPlanDocNo),
});
}
export function useSewerBscPlanDetails(
sewerBscPlanDocNo: string | null | undefined,
chgCycl: number | null | undefined,
) {
return useQuery({
queryKey: [...sewerBscPlanBaseKey, "findDetails", sewerBscPlanDocNo, chgCycl],
queryFn: () => sewerBscPlanService.findDetails(sewerBscPlanDocNo!, chgCycl!),
enabled: Boolean(sewerBscPlanDocNo) && chgCycl != null,
});
}
export function useSaveSewerBscPlan() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: SewerBscPlanSaveRequestDto) => sewerBscPlanService.save(body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: sewerBscPlanBaseKey });
},
});
}
export function useUpdateSewerBscPlan() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
sewerBscPlanDocNo,
body,
}: {
sewerBscPlanDocNo: string;
body: SewerBscPlanUpdateRequestDto;
}) => sewerBscPlanService.update(sewerBscPlanDocNo, body),
onSuccess: (_data, { sewerBscPlanDocNo }) => {
queryClient.invalidateQueries({ queryKey: sewerBscPlanBaseKey });
queryClient.invalidateQueries({
queryKey: [...sewerBscPlanBaseKey, "findById", sewerBscPlanDocNo],
});
},
});
}
export function useDeleteSewerBscPlan() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (sewerBscPlanDocNo: string) => sewerBscPlanService.delete(sewerBscPlanDocNo),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: sewerBscPlanBaseKey });
},
});
}
export function useSaveApplySewerBscPlan() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: SewerBscPlanApplySaveRequestDto) => sewerBscPlanService.saveApply(body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: sewerBscPlanBaseKey });
},
});
}
export function useSubmitSupplementSewerBscPlan() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, body }: { id: string; body: SewerBscPlanApplySaveRequestDto }) =>
sewerBscPlanService.submitSupplement(id, body),
onSuccess: (_data, { id }) => {
queryClient.invalidateQueries({ queryKey: sewerBscPlanBaseKey });
queryClient.invalidateQueries({
queryKey: [...sewerBscPlanBaseKey, "findById", id],
});
},
});
}
export function useSubmitReqSupplementSewerBscPlan() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
chgCycl,
body,
}: {
id: string;
chgCycl: number;
body: SewerBscPlanApplySaveRequestDto;
}) => sewerBscPlanService.submitReqSupplement(id, chgCycl, body),
onSuccess: (_data, { id }) => {
queryClient.invalidateQueries({ queryKey: sewerBscPlanBaseKey });
queryClient.invalidateQueries({
queryKey: [...sewerBscPlanBaseKey, "findById", id],
});
},
});
}
export function useTechReviewSewerBscPlan() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, body }: { id: string; body: SewerBscPlanApplySaveRequestDto }) =>
sewerBscPlanService.techReview(id, body),
onSuccess: (_data, { id }) => {
queryClient.invalidateQueries({ queryKey: sewerBscPlanBaseKey });
queryClient.invalidateQueries({
queryKey: [...sewerBscPlanBaseKey, "findById", id],
});
},
});
}
@@ -0,0 +1,98 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { PageResponse } from "@/shared/model";
import type {
SewerBscPlanApplySaveRequestDto,
SewerBscPlanApplySaveResponseDto,
SewerBscPlanDetResponseDto,
SewerBscPlanResponseDto,
SewerBscPlanSaveRequestDto,
SewerBscPlanSearchRequestDto,
SewerBscPlanService,
SewerBscPlanUpdateRequestDto,
} from "../model/sewer-bsc-plan-interface";
const BASE = ENDPOINT.AS_SERVICE.SEWER_BSC_PLAN;
async function findPage(
params: SewerBscPlanSearchRequestDto,
): Promise<PageResponse<SewerBscPlanResponseDto>> {
const response = await client.get<PageResponse<SewerBscPlanResponseDto>>(BASE, {
params,
});
return response.data;
}
async function findById(sewerBscPlanDocNo: string): Promise<SewerBscPlanResponseDto> {
const response = await client.get<SewerBscPlanResponseDto>(`${BASE}/${sewerBscPlanDocNo}`);
return response.data;
}
async function save(body: SewerBscPlanSaveRequestDto): Promise<SewerBscPlanResponseDto> {
const response = await client.post<SewerBscPlanResponseDto>(BASE, body);
return response.data;
}
async function update(
sewerBscPlanDocNo: string,
body: SewerBscPlanUpdateRequestDto,
): Promise<SewerBscPlanResponseDto> {
const response = await client.put<SewerBscPlanResponseDto>(`${BASE}/${sewerBscPlanDocNo}`, body);
return response.data;
}
async function deleteFn(sewerBscPlanDocNo: string): Promise<void> {
await client.delete<void>(`${BASE}/${sewerBscPlanDocNo}`);
}
async function saveApply(
body: SewerBscPlanApplySaveRequestDto,
): Promise<SewerBscPlanApplySaveResponseDto> {
const response = await client.post<SewerBscPlanApplySaveResponseDto>(`${BASE}/save-apply`, body);
return response.data;
}
async function findDetails(
sewerBscPlanDocNo: string,
chgCycl: number,
): Promise<SewerBscPlanDetResponseDto> {
const response = await client.get<SewerBscPlanDetResponseDto>(
`${BASE}/${sewerBscPlanDocNo}/dtl/${chgCycl}`,
);
return response.data;
}
async function submitSupplement(
id: string,
body: SewerBscPlanApplySaveRequestDto,
): Promise<string> {
const response = await client.post<string>(`${BASE}/${id}/submitsupplement`, body);
return response.data;
}
async function submitReqSupplement(
id: string,
chgCycl: number,
body: SewerBscPlanApplySaveRequestDto,
): Promise<string> {
const response = await client.post<string>(`${BASE}/${id}/${chgCycl}/reqsupplement`, body);
return response.data;
}
async function techReview(id: string, body: SewerBscPlanApplySaveRequestDto): Promise<void> {
await client.post<void>(`${BASE}/${id}/tech-review`, body);
}
export const sewerBscPlanService = {
findPage,
findById,
save,
update,
delete: deleteFn,
saveApply,
findDetails,
submitSupplement,
submitReqSupplement,
techReview,
} satisfies SewerBscPlanService;
@@ -0,0 +1,47 @@
export { sewerBscPlanBaseKey } from "./api/queries";
export {
useDeleteSewerBscPlan,
useSaveApplySewerBscPlan,
useSaveSewerBscPlan,
useSewerBscPlan,
useSewerBscPlanDetails,
useSewerBscPlanPage,
useSubmitReqSupplementSewerBscPlan,
useSubmitSupplementSewerBscPlan,
useTechReviewSewerBscPlan,
useUpdateSewerBscPlan,
} from "./api/queries";
export { sewerBscPlanService } from "./api/sewer-bsc-plan-service";
export type {
AtflMngClientResponseDto,
CklstResultItem,
CklstRvwRsltTreeDto,
EsntlAtchFileItem,
EsntlAtchFileMngTreeResponseDto,
SewerBscPlanApplySaveRequestDto,
SewerBscPlanApplySaveResponseDto,
SewerBscPlanBizCstItem,
SewerBscPlanBizCstResponseDto,
SewerBscPlanDetResponseDto,
SewerBscPlanDtlResponseDto,
SewerBscPlanDtlSaveRequestDto,
SewerBscPlanFnrsCstItem,
SewerBscPlanFnrsCstResponseDto,
SewerBscPlanOtlnItem,
SewerBscPlanOtlnResponseDto,
SewerBscPlanResponseDto,
SewerBscPlanRvwOpnnItem,
SewerBscPlanRvwOpnnResponseDto,
SewerBscPlanSaveRequestDto,
SewerBscPlanSearchRequestDto,
SewerBscPlanService,
SewerBscPlanSplmntDmndItem,
SewerBscPlanSplmntDmndResponseDto,
SewerBscPlanTmplntDtlItem,
SewerBscPlanTmplntDtlResponseDto,
SewerBscPlanTmplntItem,
SewerBscPlanTmplntResponseDto,
SewerBscPlanTmplntTmprStrgItem,
SewerBscPlanTmplntTmprStrgResponseDto,
SewerBscPlanUpdateRequestDto,
} from "./model/sewer-bsc-plan-interface";
@@ -0,0 +1,351 @@
import type {
AtflMngClientResponseDto,
CklstResultSaveBase,
CklstRvwRsltTreeDto,
EsntlAtchFileMngTreeResponseDto,
EsntlAtchFileSaveBase,
PageableParams,
PageResponse,
PbadmsApplySaveHeader,
RvwOpnnResponseExtension,
RvwOpnnSaveBase,
SplmntDmndResponseExtension,
SplmntDmndSaveBase,
} from "@/shared/model";
export type {
AtflMngClientResponseDto,
CklstRvwRsltTreeDto,
EsntlAtchFileMngTreeResponseDto,
} from "@/shared/model";
/** 백엔드 SewerBscPlanSaveRequestDto */
export interface SewerBscPlanSaveRequestDto {
sewerBscPlanDocNo?: string;
admdstCd?: string;
envoSeComCd?: string;
sewerBscPlanSeComCd?: string;
sewerBscPlanNm?: string;
taskSeComCd?: string;
prgrsSttsComCd?: string;
}
/** 백엔드 SewerBscPlanUpdateRequestDto */
export interface SewerBscPlanUpdateRequestDto {
admdstCd?: string;
envoSeComCd?: string;
sewerBscPlanSeComCd?: string;
sewerBscPlanNm?: string;
taskSeComCd?: string;
prgrsSttsComCd?: string;
}
/** 백엔드 SewerBscPlanSearchRequestDto + Pageable */
export interface SewerBscPlanSearchRequestDto extends PageableParams {
sewerBscPlanDocNo?: string;
admdstCd?: string;
envoSeComCd?: string;
sewerBscPlanSeComCd?: string;
sewerBscPlanNm?: string;
taskSeComCd?: string;
}
/** 백엔드 SewerBscPlanResponseDto */
export interface SewerBscPlanResponseDto {
sewerBscPlanDocNo: string;
admdstCd?: string;
envoSeComCd?: string;
envoSeCdNm?: string;
sewerBscPlanSeComCd?: string;
sewerBscPlanSeCdNm?: string;
sewerBscPlanNm?: string;
taskSeComCd?: string;
taskSeCdNm?: string;
prgrsSttsComCd?: string;
prgrsSttsCdNm?: string;
sido?: string;
gugun?: string;
chgCycl?: number;
regDt?: string;
rgtr?: string;
rgtrNm?: string;
mdfcnDt?: string;
}
/** 백엔드 SewerBscPlanDtlSaveRequestDto */
export interface SewerBscPlanDtlSaveRequestDto {
sewerBscPlanDocNo?: string;
chgCycl?: number;
aplcntNm?: string;
aplcntMblTelno?: string;
aplcntOgdpNm?: string;
aplcntEmlAddr?: string;
dsgnrNm?: string;
dsgnrTelno?: string;
dsgnrOgdpNm?: string;
dsgnrEmlAddr?: string;
planYr?: string;
crtrYr?: string;
bizOtlnCn?: string;
frlvlGoalYr?: string;
sclvlGoalYr?: string;
thlvlGoalYr?: string;
folvlGoalYr?: string;
atchFileGroupId?: string;
}
/** 백엔드 SewerBscPlanDtlResponseDto */
export interface SewerBscPlanDtlResponseDto {
sewerBscPlanDocNo?: string;
chgCycl?: number;
aplcntNm?: string;
aplcntMblTelno?: string;
aplcntOgdpNm?: string;
aplcntEmlAddr?: string;
dsgnrNm?: string;
dsgnrTelno?: string;
dsgnrOgdpNm?: string;
dsgnrEmlAddr?: string;
planYr?: string;
crtrYr?: string;
bizOtlnCn?: string;
frlvlGoalYr?: string;
sclvlGoalYr?: string;
thlvlGoalYr?: string;
folvlGoalYr?: string;
atchFileGroupId?: string;
}
/** 백엔드 SewerBscPlanOtlnResponseDto */
export interface SewerBscPlanOtlnResponseDto {
sewerBscPlanDocNo?: string;
chgCycl?: number;
planStpNo?: number;
sewerPlanPpltnCnt?: number;
sewerPrcsPpltnCnt?: number;
sewerPsplyRt?: number;
}
/** 백엔드 SewerBscPlanFnrsCstResponseDto */
export interface SewerBscPlanFnrsCstResponseDto {
sewerBscPlanDocNo?: string;
chgCycl?: number;
planStpNo?: number;
gvexpAmt?: number;
lcexpAmt?: number;
orgprBdchgAmt?: number;
etcAmt?: number;
sumAmt?: number;
}
/** 백엔드 SewerBscPlanBizCstResponseDto */
export interface SewerBscPlanBizCstResponseDto {
sewerBscPlanDocNo?: string;
chgCycl?: number;
planStpNo?: number;
acntgSeCd?: string;
cstrnAmt?: number;
}
/** 백엔드 SewerBscPlanSplmntDmndResponseDto */
export interface SewerBscPlanSplmntDmndResponseDto
extends SplmntDmndSaveBase, SplmntDmndResponseExtension {
sewerBscPlanDocNo?: string;
chgCycl?: number;
}
/** 백엔드 SewerBscPlanRvwOpnnResponseDto */
export interface SewerBscPlanRvwOpnnResponseDto extends RvwOpnnSaveBase, RvwOpnnResponseExtension {
sewerBscPlanDocNo?: string;
chgCycl?: number;
}
/** 백엔드 SewerBscPlanTmplntDtlResponseDto */
export interface SewerBscPlanTmplntDtlResponseDto {
sewerBscPlanDocNo?: string;
chgCycl?: number;
planStpNo?: number;
swtrmtFcltCd?: string;
prcsZoneArea?: number;
swtrmtPpltnCnt?: number;
planSwqty?: number;
fcltCpct?: number;
sprsysDrtwPplLen?: number;
sprsysExlnPplLen?: number;
cbsysPplLen?: number;
icptgPplLen?: number;
drgCnt?: number;
drtwRlyPumpCnt?: number;
drtwPumpCnt?: number;
exlnPumpCnt?: number;
}
/** 백엔드 SewerBscPlanTmplntResponseDto */
export interface SewerBscPlanTmplntResponseDto {
sewerBscPlanDocNo?: string;
chgCycl?: number;
swtrmtFcltCd?: string;
swtrmtFcltNm?: string;
atchFileGroupId?: string;
atchFileInfo?: AtflMngClientResponseDto;
sewerBscPlanTmplntDtlList?: SewerBscPlanTmplntDtlResponseDto[];
}
/** 백엔드 SewerBscPlanTmplntTmprStrgResponseDto */
export interface SewerBscPlanTmplntTmprStrgResponseDto {
swtrmtFcltCd?: string;
rgtr?: string;
dtStrgDt?: string;
prcsZoneArea?: number;
swtrmtPpltnCnt?: number;
planSwqty?: number;
fcltCpct?: number;
sprsysDrtwPplLen?: number;
sprsysExlnPplLen?: number;
cbsysPplLen?: number;
icptgPplLen?: number;
drgCnt?: number;
drtwRlyPumpCnt?: number;
drtwPumpCnt?: number;
exlnPumpCnt?: number;
}
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanOtlnItem */
export interface SewerBscPlanOtlnItem {
planStpNo?: number;
sewerPlanPpltnCnt?: number;
sewerPrcsPpltnCnt?: number;
sewerPsplyRt?: number;
}
/** 백엔드 SewerBscPlanApplySaveRequestDto.CklstResultItem */
export interface CklstResultItem extends CklstResultSaveBase {
selected?: string;
}
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanFnrsCstItem */
export interface SewerBscPlanFnrsCstItem {
planStpNo?: number;
gvexpAmt?: number;
lcexpAmt?: number;
orgprBdchgAmt?: number;
etcAmt?: number;
sumAmt?: number;
}
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanSplmntDmndItem */
export type SewerBscPlanSplmntDmndItem = SplmntDmndSaveBase;
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanRvwOpnnItem */
export type SewerBscPlanRvwOpnnItem = RvwOpnnSaveBase;
/** 백엔드 SewerBscPlanApplySaveRequestDto.EsntlAtchFileItem */
export type EsntlAtchFileItem = EsntlAtchFileSaveBase;
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanBizCstItem */
export interface SewerBscPlanBizCstItem {
planStpNo?: number;
acntgSeCd?: string;
cstrnAmt?: number;
}
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanTmplntDtlItem */
export interface SewerBscPlanTmplntDtlItem {
planStpNo?: number;
swtrmtFcltCd?: string;
prcsZoneArea?: number;
swtrmtPpltnCnt?: number;
planSwqty?: number;
fcltCpct?: number;
sprsysDrtwPplLen?: number;
sprsysExlnPplLen?: number;
cbsysPplLen?: number;
icptgPplLen?: number;
drgCnt?: number;
drtwRlyPumpCnt?: number;
drtwPumpCnt?: number;
exlnPumpCnt?: number;
}
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanTmplntItem */
export interface SewerBscPlanTmplntItem {
swtrmtFcltCd?: string;
atchFileGroupId?: string;
sewerBscPlanTmplntDtlList?: SewerBscPlanTmplntDtlItem[];
}
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanTmplntTmprStrgItem */
export interface SewerBscPlanTmplntTmprStrgItem {
dtStrgDt?: string;
swtrmtFcltCd?: string;
prcsZoneArea?: number;
swtrmtPpltnCnt?: number;
planSwqty?: number;
fcltCpct?: number;
sprsysDrtwPplLen?: number;
sprsysExlnPplLen?: number;
cbsysPplLen?: number;
icptgPplLen?: number;
drgCnt?: number;
drtwRlyPumpCnt?: number;
drtwPumpCnt?: number;
exlnPumpCnt?: number;
}
/** 백엔드 SewerBscPlanApplySaveRequestDto */
export interface SewerBscPlanApplySaveRequestDto extends PbadmsApplySaveHeader {
sewerBscPlan: SewerBscPlanSaveRequestDto;
sewerBscPlanDtl: SewerBscPlanDtlSaveRequestDto;
sewerBscPlanOtlnList?: SewerBscPlanOtlnItem[];
cklstResultList?: CklstResultItem[];
fnrsCstList?: SewerBscPlanFnrsCstItem[];
splmntDmndList?: SewerBscPlanSplmntDmndItem[];
rvwOpnnList?: SewerBscPlanRvwOpnnItem[];
esntlAtchFileList?: EsntlAtchFileItem[];
bizCstList?: SewerBscPlanBizCstItem[];
sewerBscPlanTmplntList?: SewerBscPlanTmplntItem[];
tmplntTmprStrgList?: SewerBscPlanTmplntTmprStrgItem[];
}
/** 백엔드 SewerBscPlanApplySaveResponseDto */
export interface SewerBscPlanApplySaveResponseDto {
sewerBscPlanDocNo: string;
chgCycl: number;
}
/** 백엔드 SewerBscPlanDetResponseDto */
export interface SewerBscPlanDetResponseDto {
detDto?: SewerBscPlanResponseDto;
activeDtlDto?: SewerBscPlanDtlResponseDto;
otlnList?: SewerBscPlanOtlnResponseDto[];
cklstResultRows?: CklstRvwRsltTreeDto[];
fnrsCstList?: SewerBscPlanFnrsCstResponseDto[];
splmntDmndList?: SewerBscPlanSplmntDmndResponseDto[];
rvwOpnnList?: SewerBscPlanRvwOpnnResponseDto[];
esntlFileList?: EsntlAtchFileMngTreeResponseDto[];
etcFileList?: AtflMngClientResponseDto[];
bizCstList?: SewerBscPlanBizCstResponseDto[];
tmplntList?: SewerBscPlanTmplntResponseDto[];
tmplntTmprStrgList?: SewerBscPlanTmplntTmprStrgResponseDto[];
}
/** Controller public 메서드명·시그니처와 동일 */
export interface SewerBscPlanService {
findPage(params: SewerBscPlanSearchRequestDto): Promise<PageResponse<SewerBscPlanResponseDto>>;
findById(sewerBscPlanDocNo: string): Promise<SewerBscPlanResponseDto>;
save(body: SewerBscPlanSaveRequestDto): Promise<SewerBscPlanResponseDto>;
update(
sewerBscPlanDocNo: string,
body: SewerBscPlanUpdateRequestDto,
): Promise<SewerBscPlanResponseDto>;
delete(sewerBscPlanDocNo: string): Promise<void>;
saveApply(body: SewerBscPlanApplySaveRequestDto): Promise<SewerBscPlanApplySaveResponseDto>;
findDetails(sewerBscPlanDocNo: string, chgCycl: number): Promise<SewerBscPlanDetResponseDto>;
submitSupplement(id: string, body: SewerBscPlanApplySaveRequestDto): Promise<string>;
submitReqSupplement(
id: string,
chgCycl: number,
body: SewerBscPlanApplySaveRequestDto,
): Promise<string>;
techReview(id: string, body: SewerBscPlanApplySaveRequestDto): Promise<void>;
}
@@ -70,7 +70,12 @@ export function mapDetailToFormData(data: DesignCnstnMngDetResponseDto): DesignC
export function getCreateDefaultValues(
userInfo: IUserInfo | null,
taskSeComCd?: string[],
options?: {
taskSeComCd?: string[];
admdstCd?: string;
sido?: string;
gugun?: string;
},
): DesignCnstnRegistFormData {
return {
designCnstnMng: {
@@ -78,7 +83,7 @@ export function getCreateDefaultValues(
bizBgngYr: "",
bizEndYr: "",
bizCn: "",
taskSeComCd: taskSeComCd ?? [],
taskSeComCd: options?.taskSeComCd ?? [],
dsgnrNm: "",
dsgnrTelno: "",
dsgnrOgdpNm: "",
@@ -87,6 +92,9 @@ export function getCreateDefaultValues(
aplcntMblTelno: userInfo?.mphn ?? "",
aplcntOgdpNm: userInfo?.ogdpSeNm ?? "",
aplcntEmlAddr: userInfo?.email ?? "",
admdstCd: options?.admdstCd ?? "",
sido: options?.sido ?? "",
gugun: options?.gugun ?? "",
prgrsSttsComCd: "CV",
atchFileGroupId: "",
},
@@ -36,10 +36,15 @@ interface DesignFormPageProps {
id?: string;
mode: "create" | "edit";
initialData?: DesignCnstnMngDetResponseDto;
taskSeComCd?: string[];
createDefaults?: {
taskSeComCd?: string[];
admdstCd?: string;
sido?: string;
gugun?: string;
};
}
function DesignFormPage({ id, mode, initialData, taskSeComCd }: DesignFormPageProps) {
function DesignFormPage({ id, mode, initialData, createDefaults }: DesignFormPageProps) {
const queryClient = useQueryClient();
const router = useRouter();
const { confirm } = useConfirm();
@@ -54,7 +59,7 @@ function DesignFormPage({ id, mode, initialData, taskSeComCd }: DesignFormPagePr
defaultValues:
isEditMode && initialData
? mapDetailToFormData(initialData)
: getCreateDefaultValues(userInfo, taskSeComCd),
: getCreateDefaultValues(userInfo, createDefaults),
});
function buildFormPayload(
@@ -11,7 +11,7 @@ import {
type DesignCnstnMngSearchDto,
useDesignCnstnMngPage,
} from "@/entities/design-cnstn";
import { TaskSePickerDialog } from "@/widgets/pbadms-sprt";
import { PbadmsTaskApplyDialog, type PbadmsTaskApplySelection } from "@/widgets/pbadms-sprt";
import { Button } from "@common/components";
import { formatTaskSeComCdForApi } from "../lib/design-cnstn-regist-schema";
@@ -71,9 +71,15 @@ function DesignList({ searchParams, pagination, onPaginationChange }: DesignList
},
});
const handleConfirmTaskSe = (selectedCodes: string[]) => {
const handleConfirm = (selection: PbadmsTaskApplySelection) => {
setIsTaskSeDialogOpen(false);
router.push(`/design-cnstn/regist?taskSeComCd=${formatTaskSeComCdForApi(selectedCodes)}`);
const qs = new URLSearchParams({
taskSeComCd: formatTaskSeComCdForApi(selection.selectedCodes),
admdstCd: selection.admdstCd,
sido: selection.sido,
gugun: selection.gugun,
});
router.push(`/design-cnstn/regist?${qs}`);
};
return (
@@ -87,10 +93,10 @@ function DesignList({ searchParams, pagination, onPaginationChange }: DesignList
</div>
{tableComponent}
</section>
<TaskSePickerDialog
<PbadmsTaskApplyDialog
open={isTaskSeDialogOpen}
onOpenChange={setIsTaskSeDialogOpen}
onConfirm={handleConfirmTaskSe}
onConfirm={handleConfirm}
title="설계자문 신청"
codeUpCd="CNFM"
/>
@@ -1,11 +1,19 @@
import { DesignFormPage } from "./design-form-page";
interface DesignRegistPageProps {
interface DesignRegistDefaults {
taskSeComCd?: string[];
admdstCd?: string;
sido?: string;
gugun?: string;
}
function DesignRegistPage({ taskSeComCd }: DesignRegistPageProps) {
return <DesignFormPage mode="create" taskSeComCd={taskSeComCd} />;
interface DesignRegistPageProps {
defaults?: DesignRegistDefaults;
}
function DesignRegistPage({ defaults }: DesignRegistPageProps) {
return <DesignFormPage mode="create" createDefaults={defaults} />;
}
export { DesignRegistPage };
export type { DesignRegistDefaults };
@@ -3,7 +3,6 @@
import { useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { useUserStore } from "@/shared/lib";
import { Field, FieldError, FieldGroup } from "@/shared/ui/field";
import { findCodeLabel, useCodeList } from "@/entities/code";
import { type BnsApplyData, BnsPickerDialog } from "@/widgets/bns-picker";
@@ -21,10 +20,12 @@ import {
import type { DesignCnstnRegistFormData } from "../../lib/design-cnstn-regist-schema";
function BasicInfoFormSection() {
const userInfo = useUserStore((state) => state.userInfo);
const { control, watch, setValue } = useFormContext<DesignCnstnRegistFormData>();
const [isPickerOpen, setIsPickerOpen] = useState(false);
const taskSeComCd = watch("designCnstnMng.taskSeComCd");
const localGovLabel = [watch("designCnstnMng.sido"), watch("designCnstnMng.gugun")]
.filter(Boolean)
.join(" ");
const { data: taskOptions } = useCodeList("CNFM");
function handleBnsSelect(data: BnsApplyData) {
@@ -142,7 +143,7 @@ function BasicInfoFormSection() {
<TableHead data-required>구분</TableHead>
<TableCell>{taskSeComCdLabel}</TableCell>
<TableHead>신청 지자체</TableHead>
<TableCell>{userInfo?.instSeNm}</TableCell>
<TableCell>{localGovLabel}</TableCell>
</TableRow>
</TableHeader>
</Table>
@@ -118,7 +118,12 @@ export function mapDetailToFormData(data: FnrsCnsltnMngDetResponseDto): FnrsCnsl
export function getCreateDefaultValues(
userInfo: IUserInfo | null,
taskSeComCd?: string[],
options?: {
taskSeComCd?: string[];
admdstCd?: string;
sido?: string;
gugun?: string;
},
): FnrsCnsltnRegistFormData {
return {
fnrsCnsltnMng: {
@@ -126,7 +131,7 @@ export function getCreateDefaultValues(
bizBgngYr: "",
bizEndYr: "",
bizCn: "",
taskSeComCd: taskSeComCd ?? [],
taskSeComCd: options?.taskSeComCd ?? [],
cfmtnBgtBizNo: "",
bgtAplyYr: "",
dsgnrNm: "",
@@ -137,6 +142,9 @@ export function getCreateDefaultValues(
aplcntMblTelno: userInfo?.mphn ?? "",
aplcntOgdpNm: userInfo?.ogdpSeNm ?? "",
aplcntEmlAddr: userInfo?.email ?? "",
admdstCd: options?.admdstCd ?? "",
sido: options?.sido ?? "",
gugun: options?.gugun ?? "",
prgrsSttsComCd: "SV",
atchFileGroupId: "",
sharAtchFileGroupId: "",
@@ -47,10 +47,15 @@ interface FnrsFormPageProps {
id?: string;
mode: "create" | "edit";
initialData?: FnrsCnsltnMngDetResponseDto;
taskSeComCd?: string[];
createDefaults?: {
taskSeComCd?: string[];
admdstCd?: string;
sido?: string;
gugun?: string;
};
}
function FnrsFormPage({ id, mode, initialData, taskSeComCd }: FnrsFormPageProps) {
function FnrsFormPage({ id, mode, initialData, createDefaults }: FnrsFormPageProps) {
const queryClient = useQueryClient();
const router = useRouter();
const { confirm } = useConfirm();
@@ -73,7 +78,7 @@ function FnrsFormPage({ id, mode, initialData, taskSeComCd }: FnrsFormPageProps)
defaultValues:
isEditMode && initialData
? mapDetailToFormData(initialData)
: getCreateDefaultValues(userInfo, taskSeComCd),
: getCreateDefaultValues(userInfo, createDefaults),
});
function buildFormPayload(
+11 -5
View File
@@ -11,7 +11,7 @@ import {
type FnrsCnsltnMngSearchDto,
useFnrsCnsltnMngPage,
} from "@/entities/fnrs-cnsltn";
import { TaskSePickerDialog } from "@/widgets/pbadms-sprt";
import { PbadmsTaskApplyDialog, type PbadmsTaskApplySelection } from "@/widgets/pbadms-sprt";
import { Button } from "@common/components";
import { formatTaskSeComCdForApi } from "../lib/fnrs-cnsltn-regist-schema";
@@ -71,9 +71,15 @@ function FnrsList({ searchParams, pagination, onPaginationChange }: FnrsListProp
},
});
const handleConfirmTaskSe = (selectedCodes: string[]) => {
const handleConfirm = (selection: PbadmsTaskApplySelection) => {
setIsTaskSeDialogOpen(false);
router.push(`/fnrs-cnsltn/regist?taskSeComCd=${formatTaskSeComCdForApi(selectedCodes)}`);
const qs = new URLSearchParams({
taskSeComCd: formatTaskSeComCdForApi(selection.selectedCodes),
admdstCd: selection.admdstCd,
sido: selection.sido,
gugun: selection.gugun,
});
router.push(`/fnrs-cnsltn/regist?${qs}`);
};
return (
@@ -87,10 +93,10 @@ function FnrsList({ searchParams, pagination, onPaginationChange }: FnrsListProp
</div>
{tableComponent}
</section>
<TaskSePickerDialog
<PbadmsTaskApplyDialog
open={isTaskSeDialogOpen}
onOpenChange={setIsTaskSeDialogOpen}
onConfirm={handleConfirmTaskSe}
onConfirm={handleConfirm}
title="재원협의 신청"
codeUpCd="FNRS"
/>
@@ -1,11 +1,19 @@
import { FnrsFormPage } from "./fnrs-form-page";
interface FnrsRegistPageProps {
interface FnrsRegistDefaults {
taskSeComCd?: string[];
admdstCd?: string;
sido?: string;
gugun?: string;
}
function FnrsRegistPage({ taskSeComCd }: FnrsRegistPageProps) {
return <FnrsFormPage mode="create" taskSeComCd={taskSeComCd} />;
interface FnrsRegistPageProps {
defaults?: FnrsRegistDefaults;
}
function FnrsRegistPage({ defaults }: FnrsRegistPageProps) {
return <FnrsFormPage mode="create" createDefaults={defaults} />;
}
export { FnrsRegistPage };
export type { FnrsRegistDefaults };
@@ -3,7 +3,6 @@
import { useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { useUserStore } from "@/shared/lib";
import { Field, FieldError, FieldGroup, FieldLabel, FieldSet } from "@/shared/ui/field";
import { findCodeLabel, useCodeList } from "@/entities/code";
import { type BnsApplyData, BnsPickerDialog } from "@/widgets/bns-picker";
@@ -24,10 +23,12 @@ import {
import type { FnrsCnsltnRegistFormData } from "../../lib/fnrs-cnsltn-regist-schema";
function BasicInfoFormSection() {
const userInfo = useUserStore((state) => state.userInfo);
const { control, watch, setValue } = useFormContext<FnrsCnsltnRegistFormData>();
const [isPickerOpen, setIsPickerOpen] = useState(false);
const taskSeComCd = watch("fnrsCnsltnMng.taskSeComCd");
const localGovLabel = [watch("fnrsCnsltnMng.sido"), watch("fnrsCnsltnMng.gugun")]
.filter(Boolean)
.join(" ");
const { data: taskOptions } = useCodeList("FNRS");
const { data: rtlztnFcltOptions } = useCodeList("RTLZTNFCLTSE");
const { data: rtlztnUsgOptions } = useCodeList("RTLZTNUSGSE");
@@ -48,6 +49,12 @@ function BasicInfoFormSection() {
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>
@@ -141,7 +148,7 @@ function BasicInfoFormSection() {
<TableHead data-required>구분</TableHead>
<TableCell>{taskSeComCdLabel}</TableCell>
<TableHead>신청 지자체</TableHead>
<TableCell>{userInfo?.instSeNm}</TableCell>
<TableCell>{localGovLabel}</TableCell>
</TableRow>
{taskSeComCd.includes("RE") && (
<>
@@ -108,7 +108,12 @@ export function mapDetailToFormData(data: InstlSncnMngDetResponse): InstlSncnReg
export function getCreateDefaultValues(
userInfo: IUserInfo | null,
taskSeComCd?: string[],
options?: {
taskSeComCd?: string[];
admdstCd?: string;
sido?: string;
gugun?: string;
},
): InstlSncnRegistFormData {
return {
instlSncnMng: {
@@ -116,7 +121,7 @@ export function getCreateDefaultValues(
bizBgngYr: "",
bizEndYr: "",
bizCn: "",
taskSeComCd: taskSeComCd ?? [],
taskSeComCd: options?.taskSeComCd ?? [],
sncnSeComCd: "",
dsgnrNm: "",
dsgnrTelno: "",
@@ -126,6 +131,9 @@ export function getCreateDefaultValues(
aplcntMblTelno: userInfo?.mphn ?? "",
aplcntOgdpNm: userInfo?.ogdpSeNm ?? "",
aplcntEmlAddr: userInfo?.email ?? "",
admdstCd: options?.admdstCd ?? "",
sido: options?.sido ?? "",
gugun: options?.gugun ?? "",
prgrsSttsComCd: "SV",
atchFileGroupId: "",
},
@@ -39,10 +39,15 @@ interface InstlFormPageProps {
id?: string;
mode: "create" | "edit";
initialData?: InstlSncnMngDetResponse;
taskSeComCd?: string[];
createDefaults?: {
taskSeComCd?: string[];
admdstCd?: string;
sido?: string;
gugun?: string;
};
}
function InstlFormPage({ id, mode, initialData, taskSeComCd }: InstlFormPageProps) {
function InstlFormPage({ id, mode, initialData, createDefaults }: InstlFormPageProps) {
const queryClient = useQueryClient();
const router = useRouter();
const { confirm } = useConfirm();
@@ -58,7 +63,7 @@ function InstlFormPage({ id, mode, initialData, taskSeComCd }: InstlFormPageProp
defaultValues:
isEditMode && initialData
? mapDetailToFormData(initialData)
: getCreateDefaultValues(userInfo, taskSeComCd),
: getCreateDefaultValues(userInfo, createDefaults),
});
function buildFormPayload(
@@ -29,8 +29,7 @@ const EMPTY_ADST_LIST: CmCdAdstDstrComboResponseDto[] = [];
const DEFAULT_SEARCH_PARAMS: InstlSncnMngSearchParams = {
bizNm: "",
taskSeComCd: "",
sidoComCd: "",
sggComCd: "",
admdstCd: "",
page: DEFAULT_PAGE_INDEX,
size: DEFAULT_PAGE_SIZE,
sort: DEFAULT_SORT,
@@ -59,8 +58,9 @@ function InstlListPage() {
...DEFAULT_SEARCH_PARAMS,
bizNm,
taskSeComCd,
sidoComCd,
sggComCd,
admdstCd: sggComCd || sidoComCd || "",
page: DEFAULT_PAGE_INDEX,
size: pagination.pageSize,
});
};
+12 -7
View File
@@ -11,7 +11,7 @@ import {
type InstlSncnMngSearchParams,
useInstlSncnList,
} from "@/entities/instl-sncn";
import { TaskSePickerDialog } from "@/widgets/pbadms-sprt";
import { PbadmsTaskApplyDialog, type PbadmsTaskApplySelection } from "@/widgets/pbadms-sprt";
import { Button } from "@common/components";
import { formatTaskSeComCdForApi } from "../lib/instl-sncn-regist-schema";
@@ -32,8 +32,7 @@ function InstlList({ searchParams, pagination, onPaginationChange }: InstlListPr
() => ({
bizNm: searchParams.bizNm,
taskSeComCd: searchParams.taskSeComCd,
sidoComCd: searchParams.sidoComCd,
sggComCd: searchParams.sggComCd,
admdstCd: searchParams.admdstCd,
page: pagination.pageIndex,
size: pagination.pageSize,
sort: searchParams.sort ?? "instlSncnDocNo,desc",
@@ -76,9 +75,15 @@ function InstlList({ searchParams, pagination, onPaginationChange }: InstlListPr
setIsTaskSeDialogOpen(true);
};
const handleConfirmTaskSe = (selectedCodes: string[]) => {
const handleConfirm = (selection: PbadmsTaskApplySelection) => {
setIsTaskSeDialogOpen(false);
router.push(`/instl-sncn/regist?taskSeComCd=${formatTaskSeComCdForApi(selectedCodes)}`);
const qs = new URLSearchParams({
taskSeComCd: formatTaskSeComCdForApi(selection.selectedCodes),
admdstCd: selection.admdstCd,
sido: selection.sido,
gugun: selection.gugun,
});
router.push(`/instl-sncn/regist?${qs}`);
};
return (
@@ -92,10 +97,10 @@ function InstlList({ searchParams, pagination, onPaginationChange }: InstlListPr
</div>
{tableComponent}
</section>
<TaskSePickerDialog
<PbadmsTaskApplyDialog
open={isTaskSeDialogOpen}
onOpenChange={setIsTaskSeDialogOpen}
onConfirm={handleConfirmTaskSe}
onConfirm={handleConfirm}
title="설치인가 신청"
codeUpCd="INSTL"
/>
@@ -1,11 +1,19 @@
import { InstlFormPage } from "./instl-form-page";
interface InstlRegistPageProps {
interface InstlRegistDefaults {
taskSeComCd?: string[];
admdstCd?: string;
sido?: string;
gugun?: string;
}
function InstlRegistPage({ taskSeComCd }: InstlRegistPageProps) {
return <InstlFormPage mode="create" taskSeComCd={taskSeComCd} />;
interface InstlRegistPageProps {
defaults?: InstlRegistDefaults;
}
function InstlRegistPage({ defaults }: InstlRegistPageProps) {
return <InstlFormPage mode="create" createDefaults={defaults} />;
}
export { InstlRegistPage };
export type { InstlRegistDefaults };
@@ -3,7 +3,6 @@
import { useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { useUserStore } from "@/shared/lib";
import { Field, FieldError, FieldGroup, FieldLabel, FieldSet } from "@/shared/ui/field";
import { findCodeLabel, useCodeList } from "@/entities/code";
import { type BnsApplyData, BnsPickerDialog } from "@/widgets/bns-picker";
@@ -24,10 +23,12 @@ import {
import type { InstlSncnRegistFormData } from "../../lib/instl-sncn-regist-schema";
function BasicInfoFormSection() {
const userInfo = useUserStore((state) => state.userInfo);
const { control, watch, setValue } = useFormContext<InstlSncnRegistFormData>();
const [isPickerOpen, setIsPickerOpen] = useState(false);
const taskSeComCd = watch("instlSncnMng.taskSeComCd");
const localGovLabel = [watch("instlSncnMng.sido"), watch("instlSncnMng.gugun")]
.filter(Boolean)
.join(" ");
function handleBnsSelect(data: BnsApplyData) {
setValue("instlSncnMng.bizNm", data.bizNm);
@@ -52,6 +53,12 @@ function BasicInfoFormSection() {
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>
@@ -145,7 +152,7 @@ function BasicInfoFormSection() {
<TableHead data-required>구분</TableHead>
<TableCell>{taskSeComCdLabel}</TableCell>
<TableHead>신청 지자체</TableHead>
<TableCell>{userInfo?.instSeNm}</TableCell>
<TableCell>{localGovLabel}</TableCell>
</TableRow>
<TableRow>
<TableHead data-required>사업구분</TableHead>
@@ -0,0 +1,4 @@
export { SewerBscPlanEditPage } from "./ui/sewer-bsc-plan-edit-page";
export { SewerBscPlanFormPage } from "./ui/sewer-bsc-plan-form-page";
export { SewerBscPlanListPage } from "./ui/sewer-bsc-plan-list-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,286 @@
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 { type SewerBscPlanRegistFormData } from "./sewer-bsc-plan-regist-schema";
const PLAN_STEPS = [1, 2, 3, 4] as const;
/** 처리시설 상세: 0=현재, 1~4=변경 단계 */
const TMPLNT_DTL_STEPS = [0, 1, 2, 3, 4] as const;
function createEmptyOtlnList() {
return PLAN_STEPS.map((planStpNo) => ({ planStpNo }));
}
function createEmptyFnrsCstList() {
return PLAN_STEPS.map((planStpNo) => ({ planStpNo }));
}
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 TMPLNT_DTL_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,
options?: {
taskSeComCd?: string;
taskSeCdNm?: string;
sewerBscPlanSeComCd?: string;
sewerBscPlanSeCdNm?: string;
admdstCd?: string;
sido?: string;
gugun?: string;
},
): SewerBscPlanRegistFormData {
return {
sewerBscPlan: {
sewerBscPlanNm: "",
sewerBscPlanSeComCd: options?.sewerBscPlanSeComCd ?? "",
sewerBscPlanSeCdNm: options?.sewerBscPlanSeCdNm ?? "",
taskSeComCd: options?.taskSeComCd ?? "",
taskSeCdNm: options?.taskSeCdNm ?? "",
admdstCd: options?.admdstCd ?? userInfo?.instSeCd ?? "",
prgrsSttsComCd: "SV",
sido: options?.sido ?? "",
gugun: options?.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: 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,
atchFileNm: item.atchFileInfo?.orgnlFileNm,
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: 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(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
({ atchFileNm, swtrmtFcltNm, ...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,105 @@
import type { LvNowDmgMtncGnsvyResponseDto, NowDmgMtncGnsvyResponseDto } from "@/entities/dc";
type StageKey = "Dt1" | "Dt2" | "Dt3" | "Dt4" | "" | "Dc1" | "Dc2" | "Dc3" | "Dc4";
const STAGE_KEYS: StageKey[] = ["Dt1", "Dt2", "Dt3", "Dt4", "", "Dc1", "Dc2", "Dc3", "Dc4"];
function formatPlanCell(value: string | number | null | undefined, asYear = false): string {
if (value === null || value === undefined || value === "") return "-";
if (asYear) return `${value}년`;
if (typeof value === "number") return value.toLocaleString();
const num = Number(value);
if (!Number.isNaN(num) && value.trim() !== "" && /^-?\d+(\.\d+)?$/.test(value.trim()))
return num.toLocaleString();
return String(value);
}
function getStagedValue(
data: NowDmgMtncGnsvyResponseDto | null | undefined,
fieldPrefix: string,
stage: StageKey,
): string | number | null | undefined {
if (!data) return undefined;
const key = `${fieldPrefix}${stage}` as keyof NowDmgMtncGnsvyResponseDto;
return data[key] as string | number | null | undefined;
}
function getPlanYearCells(data: NowDmgMtncGnsvyResponseDto | null | undefined): string[] {
return STAGE_KEYS.map((stage) => formatPlanCell(getStagedValue(data, "planYr", stage), true));
}
function getMetricCells(
data: NowDmgMtncGnsvyResponseDto | null | undefined,
fieldPrefix: string,
): string[] {
return STAGE_KEYS.map((stage) => formatPlanCell(getStagedValue(data, fieldPrefix, stage)));
}
function collectNodesByLevel(
nodes: LvNowDmgMtncGnsvyResponseDto[] | undefined,
targetLevel: number,
acc: LvNowDmgMtncGnsvyResponseDto[] = [],
): LvNowDmgMtncGnsvyResponseDto[] {
if (!nodes) return acc;
for (const node of nodes) {
if (node.level === targetLevel) acc.push(node);
if (node.children?.length) collectNodesByLevel(node.children, targetLevel, acc);
}
return acc;
}
function extractYearOptions(tree: LvNowDmgMtncGnsvyResponseDto[] | undefined): string[] {
const years = collectNodesByLevel(tree, 3)
.map((node) => node.gnsvyYr)
.filter((year): year is string => Boolean(year));
return [...new Set(years)];
}
interface FacilityOption {
code: string;
name: string;
}
function extractFacilityOptions(
tree: LvNowDmgMtncGnsvyResponseDto[] | undefined,
gnsvyYr: string,
): FacilityOption[] {
const yearNodes = collectNodesByLevel(tree, 3).filter((node) => node.gnsvyYr === gnsvyYr);
const options: FacilityOption[] = [];
function pushFacility(node: LvNowDmgMtncGnsvyResponseDto) {
if (!node.swtrmtFcltCd) return;
options.push({
code: node.swtrmtFcltCd,
name: node.swtrmtFcltNm ?? node.swtrmtFcltCd,
});
}
if (yearNodes.length > 0) {
for (const yearNode of yearNodes) {
for (const child of yearNode.children ?? []) pushFacility(child);
}
return options;
}
// level=3 응답이 시설 리프만 내려주는 경우
function walk(nodes: LvNowDmgMtncGnsvyResponseDto[] | undefined) {
if (!nodes) return;
for (const node of nodes) {
pushFacility(node);
walk(node.children);
}
}
walk(tree);
return options;
}
export {
extractFacilityOptions,
extractYearOptions,
formatPlanCell,
getMetricCells,
getPlanYearCells,
STAGE_KEYS,
};
export type { FacilityOption };
@@ -0,0 +1,135 @@
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.string().optional(),
taskSeCdNm: 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().nullish(),
atchFileGroupId: z.string().optional(),
atchFileNm: 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>;
/** 체크리스트·필수첨부 docSeComCd — CMS 합의 전 임시값 */
export const SEWER_BSC_PLAN_DOC_SE_COM_CD = "SEWER";
@@ -0,0 +1,70 @@
import type { NowDmgMtncGnsvyResponseDto } from "@/entities/dc";
/**
* 폼 매트릭스 행 키 ↔ DC 당초(Dc) 필드 prefix.
* API에 대응 필드가 없으면 null (당초 셀 `-`).
*/
const TMPLNT_DC_FIELD_PREFIX: Record<string, string | null> = {
prcsZoneArea: "swtrmtZoneArea",
swtrmtPpltnCnt: "swtrmtPpltnCnt",
planSwqty: "planSwageQntSum",
fcltCpct: "fcltCpctSum",
sprsysDrtwPplLen: "brcPplSum",
sprsysExlnPplLen: "exclnPplSum",
cbsysPplLen: null,
icptgPplLen: "icptgPplSum",
drgCnt: "mltvFclSum",
drtwRlyPumpCnt: null,
drtwPumpCnt: "pmstnSum",
exlnPumpCnt: null,
};
export type TmplntFieldKey =
| "prcsZoneArea"
| "swtrmtPpltnCnt"
| "planSwqty"
| "fcltCpct"
| "sprsysDrtwPplLen"
| "sprsysExlnPplLen"
| "cbsysPplLen"
| "icptgPplLen"
| "drgCnt"
| "drtwRlyPumpCnt"
| "drtwPumpCnt"
| "exlnPumpCnt";
export const PLAN_STP_NOS = [1, 2, 3, 4] as const;
export type PlanStpNo = (typeof PLAN_STP_NOS)[number];
function formatOriginalValue(value: string | number | null | undefined): string {
if (value === undefined || value === null || value === "") return "-";
const num = Number(value);
if (!Number.isNaN(num)) return num.toLocaleString();
return String(value);
}
function getOriginalYear(
data: NowDmgMtncGnsvyResponseDto | null | undefined,
planStpNo: PlanStpNo,
): string {
if (!data) return "-";
const key = `planYrDc${planStpNo}` as keyof NowDmgMtncGnsvyResponseDto;
const value = data[key];
if (value === undefined || value === null || value === "") return "-";
return String(value);
}
function getOriginalValue(
data: NowDmgMtncGnsvyResponseDto | null | undefined,
fieldKey: TmplntFieldKey,
planStpNo: PlanStpNo,
): string {
if (!data) return "-";
const prefix = TMPLNT_DC_FIELD_PREFIX[fieldKey];
if (!prefix) return "-";
const key = `${prefix}Dc${planStpNo}` as keyof NowDmgMtncGnsvyResponseDto;
return formatOriginalValue(data[key] as string | number | null | undefined);
}
export { getOriginalValue, getOriginalYear };
@@ -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,202 @@
"use client";
import { Fragment } from "react";
import { createColumnHelper } from "@tanstack/react-table";
import { dayjs } from "@/shared/lib";
import type { SewerBscPlanResponseDto } from "@/entities/sewer-bsc-plan";
import { Badge } from "@common/components";
const columnHelper = createColumnHelper<SewerBscPlanResponseDto>();
const MAIN_STEP_LABELS = {
save: "저장",
apply: "신청완료",
envoReview: "환경청 검토",
techReview: "기술검토",
reply: "검토결과 회신",
supplement: "보완요청",
} as const;
const TECH_REVIEW_STATUS_CODES = new Set(["EE", "ER", "TE", "TS", "TR", "TP"]);
type BadgeState = "completed" | "current" | "pending" | "action";
interface ProgressBadge {
label: string;
state: BadgeState;
}
function buildProgressBadges(prgrsSttsComCd: string, prgrsSttsCdNm: string): ProgressBadge[] {
const techReviewLabel = prgrsSttsCdNm || MAIN_STEP_LABELS.techReview;
const replyLabel = prgrsSttsCdNm || MAIN_STEP_LABELS.reply;
if (prgrsSttsComCd === "SV") {
return [
{ label: MAIN_STEP_LABELS.save, state: "current" },
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "ST") {
return [
{ label: MAIN_STEP_LABELS.save, state: "completed" },
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "action" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "LE") {
return [
{ label: MAIN_STEP_LABELS.save, state: "completed" },
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{ label: MAIN_STEP_LABELS.reply, state: "action" },
];
}
if (prgrsSttsComCd === "SL") {
return [
{ label: MAIN_STEP_LABELS.save, state: "completed" },
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.supplement, state: "action" },
];
}
if (TECH_REVIEW_STATUS_CODES.has(prgrsSttsComCd)) {
return [
{ label: MAIN_STEP_LABELS.save, state: "completed" },
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{ label: techReviewLabel, state: "action" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "EP") {
return [
{ label: MAIN_STEP_LABELS.save, state: "completed" },
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{ label: MAIN_STEP_LABELS.techReview, state: "completed" },
{ label: replyLabel, state: "action" },
];
}
if (prgrsSttsComCd === "TA" || prgrsSttsComCd === "TB") {
return [
{ label: MAIN_STEP_LABELS.save, state: "completed" },
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{ label: MAIN_STEP_LABELS.techReview, state: "completed" },
{ label: MAIN_STEP_LABELS.reply, state: "completed" },
];
}
return [{ label: prgrsSttsCdNm || prgrsSttsComCd || "-", state: "current" }];
}
function getBadgeColor(state: BadgeState) {
switch (state) {
case "completed":
return "green";
case "current":
return "blue";
case "pending":
return "lightGray";
case "action":
return "orange";
}
}
function formatOptional(value: string | undefined) {
return value?.trim() ? value : "-";
}
interface ProgressStepBadgeProps {
prgrsSttsComCd: string;
prgrsSttsCdNm: string;
}
function ProgressStepBadge({ prgrsSttsComCd, prgrsSttsCdNm }: ProgressStepBadgeProps) {
const badges = buildProgressBadges(prgrsSttsComCd, prgrsSttsCdNm);
return (
<div className="flex flex-wrap items-center justify-start">
{badges.map((badge, index) => (
<Fragment key={`${badge.label}-${index}`}>
{index > 0 && <span className="mx-2">&gt;</span>}
<Badge color={getBadgeColor(badge.state)}>{badge.label}</Badge>
</Fragment>
))}
</div>
);
}
export function createSewerBscPlanColumns() {
return [
columnHelper.accessor("sido", {
header: "시도",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("gugun", {
header: "시군구",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("sewerBscPlanNm", {
header: "기본계획명",
meta: { align: "left" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("sewerBscPlanSeCdNm", {
header: "계획구분",
meta: { align: "center" },
cell: ({ row }) =>
formatOptional(row.original.sewerBscPlanSeCdNm || row.original.sewerBscPlanSeComCd),
}),
columnHelper.accessor("taskSeCdNm", {
header: "업무구분",
meta: { align: "center" },
cell: ({ row }) => formatOptional(row.original.taskSeCdNm || row.original.taskSeComCd),
}),
columnHelper.accessor("rgtrNm", {
header: "담당자",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("envoSeCdNm", {
header: "해당기관",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.display({
id: "progress",
header: "진행상황",
meta: { align: "center" },
cell: ({ row }) => (
<ProgressStepBadge
prgrsSttsComCd={row.original.prgrsSttsComCd || ""}
prgrsSttsCdNm={row.original.prgrsSttsCdNm || ""}
/>
),
}),
columnHelper.accessor("regDt", {
header: "등록일",
meta: { align: "center" },
cell: ({ getValue }) => {
const value = getValue();
return value ? dayjs(value).format("YYYY-MM-DD") : "-";
},
}),
];
}
@@ -0,0 +1,233 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useAlertStore } from "@/shared/lib";
import {
type CmCdAdstDstrComboResponseDto,
useFindGugunCombo,
useFindSidoCombo,
} from "@/entities/adstdstr";
import { useLvNowDmgMtncGnsvy, useNowDmgMtncGnsvy } from "@/entities/dc";
import { SectionCollapsible } from "@/widgets/pbadms-sprt";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Skeleton,
} from "@common/components";
import { extractFacilityOptions, extractYearOptions } from "../lib/previous-plan-table-utils";
import { PreviousPlanAreaTable } from "./previous-plan/previous-plan-area-table";
import { PreviousPlanFileSection } from "./previous-plan/previous-plan-file-section";
import { PreviousPlanSearchFilters } from "./previous-plan/previous-plan-search-filters";
import { PreviousPlanSummaryTable } from "./previous-plan/previous-plan-summary-table";
interface PreviousPlanLookupDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** 부모 폼 `sewerBscPlan.sido` 표시명 */
initialSidoNm?: string;
/** 부모 폼 `sewerBscPlan.gugun` 표시명 */
initialGugunNm?: string;
}
const EMPTY_ADST_LIST: CmCdAdstDstrComboResponseDto[] = [];
function PreviousPlanLookupDialog({
open,
onOpenChange,
initialSidoNm = "",
initialGugunNm = "",
}: PreviousPlanLookupDialogProps) {
const alert = useAlertStore((state) => state.setMessage);
const [sidoComCd, setSidoComCd] = useState("");
const [sggComCd, setSggComCd] = useState("");
const [gnsvyYr, setGnsvyYr] = useState("");
const [swtrmtFcltCd, setSwtrmtFcltCd] = useState("");
const [hasPrefillApplied, setHasPrefillApplied] = useState(false);
const [searchedClsfSystmNm, setSearchedClsfSystmNm] = useState("");
const { data: sidoData, isPending: isSidoPending } = useFindSidoCombo();
const { data: gugunData, isPending: isGugunPending } = useFindGugunCombo(sidoComCd);
const sidoOptions = sidoData ?? EMPTY_ADST_LIST;
const gugunOptions = gugunData ?? EMPTY_ADST_LIST;
const ctpvNm = sidoOptions.find((option) => option.adstDstrCd === sidoComCd)?.adstDstrNm ?? "";
const sggNm = gugunOptions.find((option) => option.adstDstrCd === sggComCd)?.adstDstrNm ?? "";
const searchParams = {
swtrmtFcltCd,
ctpvNm,
sggNm,
targetYear: gnsvyYr,
};
const { data, isFetching, refetch } = useNowDmgMtncGnsvy(searchParams, false);
const planData = gnsvyYr && swtrmtFcltCd ? (data?.[0] ?? null) : null;
const { data: yearTree, isPending: isYearPending } = useLvNowDmgMtncGnsvy({
level: "2",
ctpvNm: ctpvNm || undefined,
sggNm: sggNm || undefined,
});
const { data: facilityTree, isPending: isFacilityPending } = useLvNowDmgMtncGnsvy({
level: "3",
ctpvNm: ctpvNm || undefined,
sggNm: sggNm || undefined,
gnsvyYr: gnsvyYr || undefined,
});
const yearOptions = useMemo(() => extractYearOptions(yearTree), [yearTree]);
const facilityOptions = useMemo(
() => extractFacilityOptions(facilityTree, gnsvyYr),
[facilityTree, gnsvyYr],
);
useEffect(() => {
if (!open) {
setSidoComCd("");
setSggComCd("");
setGnsvyYr("");
setSwtrmtFcltCd("");
setSearchedClsfSystmNm("");
setHasPrefillApplied(false);
return;
}
}, [open]);
useEffect(() => {
if (!open || hasPrefillApplied || isSidoPending || !sidoOptions.length) return;
if (!initialSidoNm) {
setHasPrefillApplied(true);
return;
}
const matchedSido = sidoOptions.find((option) => option.adstDstrNm === initialSidoNm);
if (matchedSido) setSidoComCd(matchedSido.adstDstrCd);
setHasPrefillApplied(true);
}, [open, hasPrefillApplied, isSidoPending, sidoOptions, initialSidoNm]);
useEffect(() => {
if (!open || !sidoComCd || isGugunPending || !initialGugunNm) return;
if (sggComCd) return;
const matchedGugun = gugunOptions.find((option) => option.adstDstrNm === initialGugunNm);
if (matchedGugun) setSggComCd(matchedGugun.adstDstrCd);
}, [open, sidoComCd, isGugunPending, gugunOptions, initialGugunNm, sggComCd]);
function handleSidoChange(value: string) {
setSidoComCd(value);
setSggComCd("");
setGnsvyYr("");
setSwtrmtFcltCd("");
}
function handleGugunChange(value: string) {
setSggComCd(value);
setGnsvyYr("");
setSwtrmtFcltCd("");
}
function handleYearChange(value: string) {
setGnsvyYr(value);
setSwtrmtFcltCd("");
}
function handleFacilityChange(value: string) {
setSwtrmtFcltCd(value);
}
async function handleSearch() {
const { data: list, error } = await refetch();
if (error) {
alert("데이터 조회 중 오류가 발생했습니다.");
setSearchedClsfSystmNm("");
return;
}
if (!list?.length) {
alert("조회된 데이터가 없습니다.");
setSearchedClsfSystmNm("");
return;
}
setSearchedClsfSystmNm(sggNm || ctpvNm);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="flex max-h-[90vh] flex-col overflow-hidden sm:max-w-[90rem]"
aria-describedby={undefined}
>
<DialogHeader>
<DialogTitle>기본 계획 찾기</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
{isSidoPending ? (
<div className="mx-4">
<Skeleton className="h-[6rem] w-full" />
</div>
) : (
<PreviousPlanSearchFilters
sidoComCd={sidoComCd}
sggComCd={sggComCd}
gnsvyYr={gnsvyYr}
swtrmtFcltCd={swtrmtFcltCd}
sidoOptions={sidoOptions}
gugunOptions={gugunOptions}
yearOptions={yearOptions}
facilityOptions={facilityOptions}
isGugunPending={isGugunPending}
isYearPending={Boolean(sggNm) && isYearPending}
isFacilityPending={Boolean(gnsvyYr) && isFacilityPending}
isSearching={isFetching}
onSidoChange={handleSidoChange}
onGugunChange={handleGugunChange}
onYearChange={handleYearChange}
onFacilityChange={handleFacilityChange}
onSearch={handleSearch}
/>
)}
<SectionCollapsible title="기본계획파일" defaultOpen>
<PreviousPlanFileSection
key={searchedClsfSystmNm || "empty"}
clsfSystmNm={searchedClsfSystmNm || undefined}
/>
</SectionCollapsible>
<SectionCollapsible title="총설" defaultOpen>
{isFetching ? (
<Skeleton className="h-[8rem] w-full" />
) : (
<PreviousPlanSummaryTable data={planData} />
)}
</SectionCollapsible>
<SectionCollapsible title="처리구역별 현황" defaultOpen>
{isFetching ? (
<Skeleton className="h-[20rem] w-full" />
) : (
<PreviousPlanAreaTable data={planData} />
)}
</SectionCollapsible>
</div>
<DialogFooter>
<Button type="button" color="white" onClick={() => onOpenChange(false)}>
닫기
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { PreviousPlanLookupDialog };
@@ -0,0 +1,103 @@
"use client";
import type { NowDmgMtncGnsvyResponseDto } from "@/entities/dc";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
import { getMetricCells, getPlanYearCells } from "../../lib/previous-plan-table-utils";
const AREA_ROWS = [
{ label: "하수처리인구(인)", field: "swtrmtPpltnCnt" },
{ label: "자연적하수처리인구수(인)", field: "natrSwtrmtPpltnCnt" },
{ label: "사회적하수처리인구수(인)", field: "scilSwtrmtPpltnCnt" },
{ label: "하수처리구역면적", field: "swtrmtZoneArea" },
{ label: "물사용량원단위", field: "wtrUsqtyOrgnUnit" },
{ label: "오수전환율(%)", field: "drtwSwtcRt" },
{ label: "변동부하율(%)", field: "flctnLodRt" },
{ label: "오수원단위", field: "drtwOrgnUnit" },
{ label: "계획하수량합계(㎥/일)", field: "planSwageQntSum" },
{ label: "생활오수(㎥/일)", field: "lvngDrtwSum" },
{ label: "지하수(㎥/일)", field: "ugwtSum" },
{ label: "공장폐수(㎥/일)", field: "fctryWwtSum" },
{ label: "관광오수(㎥/일)", field: "toursmDrtwSum" },
{ label: "군부대(㎥/일)", field: "mtbsSum" },
{ label: "연계처리수(㎥/일)", field: "linkPrcsCntSum" },
{ label: "기타오수(㎥/일)", field: "etcDrtwSum" },
{ label: "지하수사용량(㎥/일)", field: "ugwtUseSum" },
{ label: "시설용량(㎥/일)", field: "fcltCpctSum" },
{ label: "배제방식", field: "exclsMethExpln" },
] as const;
interface PreviousPlanAreaTableProps {
data: NowDmgMtncGnsvyResponseDto | null;
}
function PreviousPlanAreaTable({ data }: PreviousPlanAreaTableProps) {
const yearCells = getPlanYearCells(data);
return (
<Table variant="secondary">
<colgroup>
<col width="16%" />
<col width="9.3%" />
<col width="9.3%" />
<col width="9.3%" />
<col width="9.3%" />
<col width="9.3%" />
<col width="9.3%" />
<col width="9.3%" />
<col width="9.3%" />
<col width="9.3%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead rowSpan={3} className="text-center">
구분
</TableHead>
<TableHead colSpan={4} className="text-center">
당초
</TableHead>
<TableHead rowSpan={2} className="text-center">
기준
</TableHead>
<TableHead colSpan={4} className="text-center">
변경
</TableHead>
</TableRow>
<TableRow>
<TableHead className="text-center">1단계</TableHead>
<TableHead className="text-center">2단계</TableHead>
<TableHead className="text-center">3단계</TableHead>
<TableHead className="text-center">4단계</TableHead>
<TableHead className="text-center">1단계</TableHead>
<TableHead className="text-center">2단계</TableHead>
<TableHead className="text-center">3단계</TableHead>
<TableHead className="text-center">4단계</TableHead>
</TableRow>
<TableRow>
{yearCells.map((cell, index) => (
<TableHead key={index} className="text-center font-normal">
{cell}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{AREA_ROWS.map((row) => {
const cells = getMetricCells(data, row.field);
return (
<TableRow key={row.field}>
<TableHead>{row.label}</TableHead>
{cells.map((cell, index) => (
<TableCell key={index} className="text-right">
{cell}
</TableCell>
))}
</TableRow>
);
})}
</TableBody>
</Table>
);
}
export { PreviousPlanAreaTable };
@@ -0,0 +1,241 @@
"use client";
import { useMemo, useState } from "react";
import { ChevronRight, File, Folder } from "lucide-react";
import { useAlertStore } from "@/shared/lib";
import { type ClsfSystmResponseDto, dcService, useClsfSystm, useFile } from "@/entities/dc";
import {
Button,
cn,
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
Skeleton,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@common/components";
interface LegacyFileItem {
no?: number | string;
fileTitle?: string;
regDt?: string;
fileSn?: string;
}
interface PreviousPlanFileSectionProps {
clsfSystmNm?: string;
}
// TODO: T2 — getFile Response DTO 확정 후 정규화 로직 교체 (레거시: no, fileTitle, regDt, fileSn)
function normalizeFileList(data: unknown): LegacyFileItem[] {
if (!Array.isArray(data)) return [];
return data.map((item) => {
if (!item || typeof item !== "object") return {};
const row = item as Record<string, unknown>;
return {
no: row.no as number | string | undefined,
fileTitle: (row.fileTitle ?? row.fileName ?? row.fileNm) as string | undefined,
regDt: row.regDt as string | undefined,
fileSn: (row.fileSn ?? row.fileId) as string | undefined,
};
});
}
function ClsfTreeItem({
item,
depth,
selectedSn,
onSelect,
}: {
item: ClsfSystmResponseDto;
depth: number;
selectedSn: string;
onSelect: (sn: string) => void;
}) {
const children = item.children ?? [];
const isFolder = children.length > 0;
if (isFolder) {
return (
<li>
<Collapsible defaultOpen={depth < 1} className="group/collapsible">
<CollapsibleTrigger
className="flex w-full items-center gap-1.5 rounded px-3 py-1.5 text-left text-xl hover:bg-muted"
style={{ paddingLeft: `${0.5 + depth * 0.75}rem` }}
>
<ChevronRight className="size-4 shrink-0 transition-transform group-data-[state=open]/collapsible:rotate-90" />
<Folder className="size-6 shrink-0" />
<span className="truncate">{item.clsfSystmNm}</span>
</CollapsibleTrigger>
<CollapsibleContent>
<ul className="m-0 ml-3 list-none border-l border-border p-0 pl-1">
{children.map((child) => (
<ClsfTreeItem
key={child.clsfSystmSn}
item={child}
depth={depth + 1}
selectedSn={selectedSn}
onSelect={onSelect}
/>
))}
</ul>
</CollapsibleContent>
</Collapsible>
</li>
);
}
const isSelected = selectedSn === item.clsfSystmSn;
return (
<li>
<button
type="button"
className={cn(
"flex w-full items-center gap-1.5 rounded px-3 py-1.5 text-left text-xl hover:bg-muted",
isSelected && "bg-muted font-semibold",
)}
style={{ paddingLeft: `${0.5 + depth * 1}rem` }}
onClick={() => onSelect(item.clsfSystmSn)}
>
<File className="size-6 shrink-0" />
<span className="truncate">{item.clsfSystmNm}</span>
</button>
</li>
);
}
function PreviousPlanFileSection({ clsfSystmNm }: PreviousPlanFileSectionProps) {
const alert = useAlertStore((state) => state.setMessage);
const [selectedClsfSn, setSelectedClsfSn] = useState("");
const [isDownloading, setIsDownloading] = useState(false);
const { data: clsfTree, isPending: isClsfPending } = useClsfSystm({
clsfSystmNm,
});
const { data: fileData, isPending: isFilePending } = useFile({
clsfSystmSn: selectedClsfSn || undefined,
});
const tree = clsfTree ?? [];
const files = useMemo(() => normalizeFileList(fileData), [fileData]);
async function handleDownload(fileSn?: string, fallbackName?: string) {
if (!fileSn) {
alert("파일 식별자가 없습니다.");
return;
}
setIsDownloading(true);
try {
// TODO: T2 — getFilePrcs Response DTO 확정 후 타입 가드
const prcsRaw = await dcService.getFilePrcs(fileSn);
const fileInfo = (Array.isArray(prcsRaw) ? prcsRaw[0] : prcsRaw) as
| { filePath?: string; fileTitle?: string; fileName?: string }
| undefined;
const filePath = fileInfo?.filePath;
const fileName = fileInfo?.fileTitle ?? fileInfo?.fileName ?? fallbackName;
if (!filePath || !fileName) {
alert("파일 정보를 가져올 수 없습니다.");
return;
}
const blob = await dcService.fileDownload({ filePath, fileName });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch {
alert("파일 다운로드 중 오류가 발생했습니다.");
} finally {
setIsDownloading(false);
}
}
if (!clsfSystmNm) {
return (
<p className="py-8 text-center text-lg text-muted-foreground">
시도 및 구군을 선택 후 조회하세요.
</p>
);
}
return (
<div className="flex h-[40rem] gap-2">
<div className="w-[40%] overflow-y-auto rounded border p-2">
{isClsfPending ? (
<Skeleton className="h-full w-full" />
) : tree.length === 0 ? (
<p className="py-8 text-center text-lg text-muted-foreground">파일이 없습니다.</p>
) : (
<ul className="m-0 list-none p-0">
{tree.map((item) => (
<ClsfTreeItem
key={item.clsfSystmSn}
item={item}
depth={0}
selectedSn={selectedClsfSn}
onSelect={(sn) => setSelectedClsfSn(sn)}
/>
))}
</ul>
)}
</div>
<div className="w-[60%] overflow-y-auto rounded border p-2">
{!selectedClsfSn ? (
<p className="py-8 text-center text-lg text-muted-foreground">
트리에서 항목을 선택하세요.
</p>
) : isFilePending ? (
<Skeleton className="h-full w-full" />
) : files.length === 0 ? (
<p className="py-8 text-center text-lg text-muted-foreground">파일이 없습니다.</p>
) : (
<Table variant="secondary">
<TableHeader>
<TableRow>
<TableHead className="!py-2 !text-xl">번호</TableHead>
<TableHead className="!py-2 !text-xl">파일명</TableHead>
<TableHead className="!py-2 !text-xl">등록일</TableHead>
<TableHead className="!py-2 !text-xl">다운로드</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{files.map((file, index) => (
<TableRow key={file.fileSn ?? `${file.fileTitle}-${index}`}>
<TableCell className="!py-2 text-center">{file.no ?? index + 1}</TableCell>
<TableCell className="!py-2 text-center">{file.fileTitle ?? "-"}</TableCell>
<TableCell className="!py-2 text-center">{file.regDt ?? "-"}</TableCell>
<TableCell className="!py-2 text-center">
<Button
type="button"
color="white"
size="sm"
disabled={isDownloading}
onClick={() => handleDownload(file.fileSn, file.fileTitle)}
>
다운로드
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
);
}
export { PreviousPlanFileSection };
@@ -0,0 +1,149 @@
"use client";
import type { CmCdAdstDstrComboResponseDto } from "@/entities/adstdstr";
import {
Button,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
import type { FacilityOption } from "../../lib/previous-plan-table-utils";
interface PreviousPlanSearchFiltersProps {
sidoComCd: string;
sggComCd: string;
gnsvyYr: string;
swtrmtFcltCd: string;
sidoOptions: CmCdAdstDstrComboResponseDto[];
gugunOptions: CmCdAdstDstrComboResponseDto[];
yearOptions: string[];
facilityOptions: FacilityOption[];
isGugunPending: boolean;
isYearPending: boolean;
isFacilityPending: boolean;
isSearching: boolean;
onSidoChange: (value: string) => void;
onGugunChange: (value: string) => void;
onYearChange: (value: string) => void;
onFacilityChange: (value: string) => void;
onSearch: () => void;
}
function PreviousPlanSearchFilters({
sidoComCd,
sggComCd,
gnsvyYr,
swtrmtFcltCd,
sidoOptions,
gugunOptions,
yearOptions,
facilityOptions,
isGugunPending,
isYearPending,
isFacilityPending,
isSearching,
onSidoChange,
onGugunChange,
onYearChange,
onFacilityChange,
onSearch,
}: PreviousPlanSearchFiltersProps) {
return (
<section className="card card-border mx-4 bg-background">
<div className="flex flex-wrap items-center gap-4 p-2">
<div className="flex items-center gap-2">
<Label htmlFor="previous-plan-sidoComCd" className="whitespace-nowrap">
시도
</Label>
<Select value={sidoComCd || undefined} onValueChange={onSidoChange}>
<SelectTrigger id="previous-plan-sidoComCd" className="w-[12rem]">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{sidoOptions.map((option) => (
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
{option.adstDstrNm}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label htmlFor="previous-plan-sggComCd" className="whitespace-nowrap">
구군
</Label>
<Select
key={sidoComCd || "gugun"}
value={sggComCd || undefined}
onValueChange={onGugunChange}
disabled={!sidoComCd || isGugunPending}
>
<SelectTrigger id="previous-plan-sggComCd" className="w-[12rem]">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{gugunOptions.map((option) => (
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
{option.adstDstrNm}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label htmlFor="previous-plan-gnsvyYr" className="whitespace-nowrap">
연도
</Label>
<Select
key={sggComCd || "year"}
value={gnsvyYr || undefined}
onValueChange={onYearChange}
disabled={!sggComCd || isYearPending}
>
<SelectTrigger id="previous-plan-gnsvyYr" className="w-[12rem]">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{yearOptions.map((year) => (
<SelectItem key={year} value={year}>
{year}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label htmlFor="previous-plan-swtrmtFcltCd" className="whitespace-nowrap">
처리시설
</Label>
<Select
key={gnsvyYr || "facility"}
value={swtrmtFcltCd || undefined}
onValueChange={onFacilityChange}
disabled={!gnsvyYr || isFacilityPending}
>
<SelectTrigger id="previous-plan-swtrmtFcltCd" className="w-[12rem]">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{facilityOptions.map((option) => (
<SelectItem key={option.code} value={option.code}>
{option.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button type="button" onClick={onSearch} disabled={isSearching}>
조회
</Button>
</div>
</section>
);
}
export { PreviousPlanSearchFilters };
@@ -0,0 +1,69 @@
"use client";
import type { NowDmgMtncGnsvyResponseDto } from "@/entities/dc";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
import { getPlanYearCells } from "../../lib/previous-plan-table-utils";
interface PreviousPlanSummaryTableProps {
data: NowDmgMtncGnsvyResponseDto | null;
}
function PreviousPlanSummaryTable({ data }: PreviousPlanSummaryTableProps) {
const yearCells = getPlanYearCells(data);
return (
<Table variant="secondary">
<colgroup>
<col width="10%" />
<col width="10%" />
<col width="10%" />
<col width="10%" />
<col width="10%" />
<col width="10%" />
<col width="10%" />
<col width="10%" />
<col width="10%" />
<col width="10%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead rowSpan={2} className="text-center">
구분
</TableHead>
<TableHead colSpan={4} className="text-center">
당초
</TableHead>
<TableHead rowSpan={2} className="text-center">
기준
</TableHead>
<TableHead colSpan={4} className="text-center">
변경
</TableHead>
</TableRow>
<TableRow>
<TableHead className="text-center">1단계</TableHead>
<TableHead className="text-center">2단계</TableHead>
<TableHead className="text-center">3단계</TableHead>
<TableHead className="text-center">4단계</TableHead>
<TableHead className="text-center">1단계</TableHead>
<TableHead className="text-center">2단계</TableHead>
<TableHead className="text-center">3단계</TableHead>
<TableHead className="text-center">4단계</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableHead>계획목표년도</TableHead>
{yearCells.map((cell, index) => (
<TableCell key={index} className="text-center">
{cell}
</TableCell>
))}
</TableRow>
</TableBody>
</Table>
);
}
export { PreviousPlanSummaryTable };
@@ -0,0 +1,276 @@
"use client";
import { Controller, useFormContext } from "react-hook-form";
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 { control, setValue, watch } = useFormContext<SewerBscPlanRegistFormData>();
const planYearOptions = getPlanYearOptions();
const goalYearOptions = getGoalYearOptions();
const sewerBscPlanSeLabel = [
watch("sewerBscPlan.sewerBscPlanSeCdNm"),
watch("sewerBscPlan.taskSeCdNm"),
]
.filter(Boolean)
.join(" ");
const localGovLabel = [watch("sewerBscPlan.sido"), watch("sewerBscPlan.gugun")]
.filter(Boolean)
.join(" ");
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>{sewerBscPlanSeLabel}</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] ?? ""}
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,132 @@
"use client";
import { useState } from "react";
import { useFormContext, useWatch } from "react-hook-form";
import dynamic from "next/dynamic";
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 PreviousPlanLookupDialog = dynamic(
() =>
import("../previous-plan-lookup-dialog").then((mod) => ({
default: mod.PreviousPlanLookupDialog,
})),
{ ssr: false },
);
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 [isLookupOpen, setIsLookupOpen] = useState(false);
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 [sido, gugun] = useWatch({
control,
name: ["sewerBscPlan.sido", "sewerBscPlan.gugun"],
});
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" onClick={() => setIsLookupOpen(true)}>
이전기본계획조회
</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] ?? ""}
onChange={(e) => handleChange(planStpNo, row.key, e.target.value)}
/>
</Field>
</TableCell>
);
})}
</TableRow>
))}
</TableBody>
</Table>
<PreviousPlanLookupDialog
open={isLookupOpen}
onOpenChange={setIsLookupOpen}
initialSidoNm={sido}
initialGugunNm={gugun}
/>
</SectionCollapsible>
);
}
export { OutlineFormSection };
@@ -0,0 +1,519 @@
"use client";
import { useId, useMemo, useState } from "react";
import { Controller, useFieldArray, useFormContext, useWatch } from "react-hook-form";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { Paperclip } from "lucide-react";
import { getDefaultFileKey, useTus } from "@/shared/lib";
import { Field } from "@/shared/ui/field";
import { attachService, useDownloadAttachmentByAtflCd } from "@/entities/attach";
import { useFacilityList } from "@/entities/bns-if";
import { useNowDmgMtncGnsvy } from "@/entities/dc";
import { SectionCollapsible } from "@/widgets/pbadms-sprt";
import {
Button,
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";
import {
getOriginalValue,
getOriginalYear,
PLAN_STP_NOS,
type TmplntFieldKey,
} from "../../lib/tmplnt-original-mapper";
interface FacilityOption {
code: string;
name: string;
}
interface ChangeStepHeader {
planStpNo: (typeof PLAN_STP_NOS)[number];
year: string | undefined;
}
interface UploadedTmplntFile {
fileName: string;
fileKey: string;
atflCd?: string;
atflId?: string;
}
const TMPLNT_ATCH_ACCEPT = ".pdf,.png,.jpg,.jpeg";
const TMPLNT_ATCH_FORMAT_LABEL = ".pdf, .png, .jpg, .jpeg";
const TMPLNT_FIELD_ROWS: { key: TmplntFieldKey; label: string }[] = [
{ key: "prcsZoneArea", label: "처리구역면적(km²)" },
{ key: "swtrmtPpltnCnt", label: "하수처리인구(명)" },
{ key: "planSwqty", label: "계획하수량(㎥/일)" },
{ key: "fcltCpct", label: "시설용량(㎥/일)" },
{ key: "sprsysDrtwPplLen", label: "분류식 오수(km)" },
{ key: "sprsysExlnPplLen", label: "분류식 우수(km)" },
{ key: "cbsysPplLen", label: "합류식(km)" },
{ key: "icptgPplLen", label: "차집관로(km)" },
{ key: "drgCnt", label: "배수설비(개소)" },
{ key: "drtwRlyPumpCnt", label: "오수 중계 펌프(개소)" },
{ key: "drtwPumpCnt", label: "오수 일반 펌프(개소)" },
{ key: "exlnPumpCnt", label: "우수 펌프(개소)" },
];
interface TmplntDtlInputProps {
facilityIndex: number;
planStpNo: number;
fieldKey: TmplntFieldKey;
}
function TmplntDtlInput({ facilityIndex, planStpNo, fieldKey }: TmplntDtlInputProps) {
const { control } = useFormContext<SewerBscPlanRegistFormData>();
const fieldName =
`sewerBscPlanTmplntList.${facilityIndex}.sewerBscPlanTmplntDtlList.${planStpNo}.${fieldKey}` as const;
return (
<Controller
control={control}
name={fieldName}
render={({ field }) => (
<Field>
<Input
type="text"
inputMode="decimal"
className="text-right"
value={field.value ?? ""}
onChange={(e) => {
const numValue = Number(e.target.value.replace(/,/g, "")) || 0;
field.onChange(numValue);
}}
/>
</Field>
)}
/>
);
}
interface TmplntFacilityMatrixProps {
swtrmtFcltCd: string;
sido: string;
gugun: string;
facilityIndex: number;
crtrYr: string | undefined;
changeStepHeaders: ChangeStepHeader[];
}
function TmplntFacilityMatrix({
swtrmtFcltCd,
sido,
gugun,
facilityIndex,
crtrYr,
changeStepHeaders,
}: TmplntFacilityMatrixProps) {
const searchParams = useMemo(
() => ({
swtrmtFcltCd,
ctpvNm: sido,
sggNm: gugun,
}),
[swtrmtFcltCd, sido, gugun],
);
const { data } = useNowDmgMtncGnsvy(searchParams);
const originalData = Array.isArray(data) ? (data[0] ?? null) : null;
return (
<Table variant="secondary">
<colgroup>
<col width="12%" />
<col width="9.75%" />
<col width="9.75%" />
<col width="9.75%" />
<col width="9.75%" />
<col width="10%" />
<col width="9.75%" />
<col width="9.75%" />
<col width="9.75%" />
<col width="9.75%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead className="w-[22rem]" rowSpan={2}>
구분
</TableHead>
<TableHead className="text-center" colSpan={4}>
당초
</TableHead>
<TableHead className="text-center" rowSpan={2}>
현재
<br />
<span className="font-normal text-muted-foreground">({crtrYr || "-"}년)</span>
</TableHead>
<TableHead className="text-center" colSpan={4}>
변경
</TableHead>
</TableRow>
<TableRow>
{PLAN_STP_NOS.map((planStpNo) => (
<TableHead key={`original-${planStpNo}`} className="text-center">
{planStpNo}단계
<br />
<span className="font-normal text-muted-foreground">
({getOriginalYear(originalData, planStpNo)})
</span>
</TableHead>
))}
{changeStepHeaders.map((step) => (
<TableHead key={`change-${step.planStpNo}`} className="text-center">
{step.planStpNo}단계
<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>
{PLAN_STP_NOS.map((planStpNo) => (
<TableCell key={`original-${row.key}-${planStpNo}`} className="text-right">
{getOriginalValue(originalData, row.key, planStpNo)}
</TableCell>
))}
<TableCell key={`current-${row.key}`}>
<TmplntDtlInput facilityIndex={facilityIndex} planStpNo={0} fieldKey={row.key} />
</TableCell>
{changeStepHeaders.map((step) => (
<TableCell key={`change-${row.key}-${step.planStpNo}`}>
<TmplntDtlInput
facilityIndex={facilityIndex}
planStpNo={step.planStpNo}
fieldKey={row.key}
/>
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
);
}
interface TmplntFacilityAttachmentsProps {
facilityIndex: number;
facilityName?: string;
atchFileGroupId?: string;
atchFileNm?: string;
}
function TmplntFacilityAttachments({
facilityIndex,
facilityName,
atchFileGroupId,
atchFileNm,
}: TmplntFacilityAttachmentsProps) {
const formId = useId();
const { setValue } = useFormContext<SewerBscPlanRegistFormData>();
const { mutate: downloadAttachment } = useDownloadAttachmentByAtflCd();
const [uploadedFile, setUploadedFile] = useState<UploadedTmplntFile | null>(() =>
atchFileGroupId && atchFileNm
? {
fileName: atchFileNm,
fileKey: `server::${atchFileGroupId}`,
atflCd: atchFileGroupId,
}
: null,
);
const { uploadFileAsync, isUploading, uploads } = useTus({
accept: TMPLNT_ATCH_ACCEPT,
onSuccess: (atflCd, atflId, _file, fileKey) => {
if (!atflCd || !atflId) return;
setValue(`sewerBscPlanTmplntList.${facilityIndex}.atchFileGroupId`, atflCd, {
shouldDirty: true,
});
setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
},
onError: (_error, _file, fileKey) => {
setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
},
});
const fileInputId = `${formId}-tmplnt-atch-${facilityIndex}`;
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
const displayFileName = uploadedFile?.fileName ?? atchFileNm ?? "";
const downloadAtflCd = uploadedFile?.atflCd || atchFileGroupId;
const hasFile = Boolean(uploadedFile || atchFileNm);
async function handleFileUpload(files: FileList | null) {
if (!files || files.length === 0) return;
const file = files[0];
const fileKey = getDefaultFileKey(file);
const existingAtflCd = atchFileGroupId || uploadedFile?.atflCd;
setUploadedFile({ fileName: file.name, fileKey });
setValue(`sewerBscPlanTmplntList.${facilityIndex}.atchFileNm`, file.name, {
shouldDirty: true,
});
try {
await uploadFileAsync(file, {
fileKey,
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
});
} catch {
// onError callback handles cleanup
}
}
async function handleFileRemove() {
if (!uploadedFile) return;
uploads[uploadedFile.fileKey]?.abort();
if (uploadedFile.atflId) {
try {
await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
} catch (error) {
console.error("처리시설 첨부파일 삭제 실패:", error);
}
}
setUploadedFile(null);
setValue(`sewerBscPlanTmplntList.${facilityIndex}.atchFileGroupId`, "", { shouldDirty: true });
setValue(`sewerBscPlanTmplntList.${facilityIndex}.atchFileNm`, "", { shouldDirty: true });
}
return (
<div className="mt-6 space-y-2">
<h3 className="mb-4 text-xl font-semibold">처리구역 필수 첨부파일</h3>
<Table variant="secondary">
<colgroup>
<col width="40%" />
<col width="15%" />
<col width="45%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead align="left">파일명</TableHead>
<TableHead>파일 확장자</TableHead>
<TableHead>파일첨부</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>{facilityName} 계획평면도</TableCell>
<TableCell className="text-center">{TMPLNT_ATCH_FORMAT_LABEL}</TableCell>
<TableCell>
{hasFile ? (
<div className="flex items-center gap-2">
{downloadAtflCd ? (
<a
className="inline-flex cursor-pointer items-center gap-1 text-blue-600 hover:underline"
onClick={() => downloadAttachment(downloadAtflCd)}
>
<Paperclip className="size-5 shrink-0" />
{displayFileName}
</a>
) : (
<span className="text-xl">{displayFileName}</span>
)}
<Button
type="button"
size="icon"
className="size-8 shrink-0 !bg-destructive"
disabled={uploading}
onClick={() => void handleFileRemove()}
>
<i className="diveicon di-x text-xs" aria-label="삭제" />
</Button>
</div>
) : (
<label htmlFor={fileInputId}>
<input
id={fileInputId}
type="file"
className="hidden"
accept={TMPLNT_ATCH_ACCEPT}
onChange={(e) => {
void handleFileUpload(e.target.files);
e.target.value = "";
}}
/>
<Button type="button" size="sm" variant="outline" asChild>
<span>파일선택</span>
</Button>
</label>
)}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
);
}
function TmplntFormSection() {
const { confirm } = useConfirm();
const { control, getValues } = useFormContext<SewerBscPlanRegistFormData>();
const { fields, append, remove } = useFieldArray({
control,
name: "sewerBscPlanTmplntList",
});
const tmplntList = useWatch({ control, name: "sewerBscPlanTmplntList" }) ?? [];
const [sido, gugun] = useWatch({
control,
name: ["sewerBscPlan.sido", "sewerBscPlan.gugun"],
});
const [frlvlGoalYr, sclvlGoalYr, thlvlGoalYr, folvlGoalYr, crtrYr] = useWatch({
control,
name: [
"sewerBscPlanDtl.frlvlGoalYr",
"sewerBscPlanDtl.sclvlGoalYr",
"sewerBscPlanDtl.thlvlGoalYr",
"sewerBscPlanDtl.folvlGoalYr",
"sewerBscPlanDtl.crtrYr",
],
});
const { data: facilityListData } = useFacilityList(
{ sido: sido ?? "", gugun: gugun ?? "" },
{ enabled: Boolean(sido) },
);
const facilityOptions =
facilityListData?.fctList
?.filter((facility) => facility.bscFctCd)
.map((facility) => ({
code: facility.bscFctCd!,
name: facility.bscFctNm ?? facility.bscFctCd!,
})) ?? [];
const [activeTab, setActiveTab] = useState("");
const selectedCodes = new Set(tmplntList.map((item) => item.swtrmtFcltCd));
const changeStepHeaders: ChangeStepHeader[] = [
{ planStpNo: 1, year: frlvlGoalYr },
{ planStpNo: 2, year: sclvlGoalYr },
{ planStpNo: 3, year: thlvlGoalYr },
{ planStpNo: 4, year: folvlGoalYr },
];
async function handleFacilityToggle(option: FacilityOption, checked: boolean) {
if (checked) {
append({
swtrmtFcltCd: option.code,
swtrmtFcltNm: option.name,
atchFileGroupId: "",
atchFileNm: "",
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 ?? "");
}
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.length === 0 ? (
<p className="text-xl text-muted-foreground">조회된 처리시설이 없습니다.</p>
) : (
facilityOptions.map((option) => (
<label key={option.code} className="flex cursor-pointer 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 className="p-0">
{fields.map((field) => {
const facilityName =
field.swtrmtFcltNm ||
facilityOptions.find((option) => option.code === field.swtrmtFcltCd)?.name;
return (
<TabsTrigger
key={field.id}
value={field.swtrmtFcltCd}
className="px-6 py-3 text-2xl"
>
{facilityName}
</TabsTrigger>
);
})}
</TabsList>
{fields.map((field, facilityIndex) => {
const facilityName =
field.swtrmtFcltNm ||
facilityOptions.find((option) => option.code === field.swtrmtFcltCd)?.name;
return (
<TabsContent
key={field.id}
value={field.swtrmtFcltCd}
className="mt-0 border-0 p-0 shadow-none"
>
<TmplntFacilityMatrix
swtrmtFcltCd={field.swtrmtFcltCd}
sido={sido ?? ""}
gugun={gugun ?? ""}
facilityIndex={facilityIndex}
crtrYr={crtrYr}
changeStepHeaders={changeStepHeaders}
/>
<TmplntFacilityAttachments
facilityIndex={facilityIndex}
facilityName={facilityName}
atchFileGroupId={tmplntList[facilityIndex]?.atchFileGroupId}
atchFileNm={tmplntList[facilityIndex]?.atchFileNm}
/>
</TabsContent>
);
})}
</Tabs>
) : (
<p className="text-xl text-muted-foreground">
처리시설을 선택하면 시설별 입력 탭이 표시됩니다.
</p>
)}
</div>
</SectionCollapsible>
);
}
export { TmplntFormSection };
@@ -0,0 +1,236 @@
"use client";
import { useEffect, useState } from "react";
import { useUserStore } from "@/shared/lib";
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
import {
type CmCdAdstDstrComboResponseDto,
useFindUserGugunCombo,
useFindUserSidoCombo,
} from "@/entities/adstdstr";
import { useCodeList } from "@/entities/code";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
RadioGroup,
RadioGroupItem,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Skeleton,
Table,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@common/components";
interface SewerBscPlanApplySelection {
admdstCd: string;
sido: string;
gugun: string;
sewerBscPlanSeComCd: string;
sewerBscPlanSeCdNm: string;
taskSeComCd: string;
taskSeCdNm: string;
}
interface SewerBscPlanApplyDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (selection: SewerBscPlanApplySelection) => void;
}
const EMPTY_ADST_LIST: CmCdAdstDstrComboResponseDto[] = [];
function SewerBscPlanApplyDialog({ open, onOpenChange, onConfirm }: SewerBscPlanApplyDialogProps) {
const userInfo = useUserStore((state) => state.userInfo);
const [sidoComCd, setSidoComCd] = useState("");
const [sggComCd, setSggComCd] = useState("");
const [sewerBscPlanSeComCd, setSewerBscPlanSeComCd] = useState("");
const [taskSeComCd, setTaskSeComCd] = useState("");
const sidoFilterUserInfo =
userInfo?.dtlInstCd === "ROLE_USER04" || userInfo?.dtlInstCd === "ROLE_USER05"
? userInfo
: undefined;
const { data: sidoData, isPending: isSidoPending } = useFindUserSidoCombo(sidoFilterUserInfo);
const { data: gugunData, isPending: isGugunPending } = useFindUserGugunCombo(sidoComCd);
const { data: planSeOptions, isPending: isPlanSePending } = useCodeList("SEWERBSCPLANSE");
const { data: taskSeOptions, isPending: isTaskSePending } = useCodeList("SEWER");
const sidoOptions = sidoData ?? EMPTY_ADST_LIST;
const gugunOptions = gugunData ?? EMPTY_ADST_LIST;
const isPending = isSidoPending || isPlanSePending || isTaskSePending;
useEffect(() => {
if (!open) return;
setSidoComCd("");
setSggComCd("");
setSewerBscPlanSeComCd("");
setTaskSeComCd("");
}, [open]);
const canConfirm = Boolean(sidoComCd && sewerBscPlanSeComCd && taskSeComCd);
function handleConfirm() {
if (!canConfirm) return;
const sidoOption = sidoOptions.find((option) => option.adstDstrCd === sidoComCd);
const gugunOption = gugunOptions.find((option) => option.adstDstrCd === sggComCd);
const planSeOption = planSeOptions?.find((option) => option.lwpoCd === sewerBscPlanSeComCd);
const taskSeOption = taskSeOptions?.find((option) => option.lwpoCd === taskSeComCd);
onConfirm({
admdstCd: sggComCd || sidoComCd,
sido: sidoOption?.adstDstrNm ?? "",
gugun: gugunOption?.adstDstrNm ?? "",
sewerBscPlanSeComCd,
sewerBscPlanSeCdNm: planSeOption?.lwpoCdNm ?? "",
taskSeComCd,
taskSeCdNm: taskSeOption?.lwpoCdNm ?? "",
});
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[60rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>하수도기본계획 신청</DialogTitle>
</DialogHeader>
{isPending ? (
<Skeleton className="h-[16rem] w-full" />
) : (
<Table variant="secondary">
<colgroup>
<col width="15%" />
<col width="35%" />
<col width="15%" />
<col width="35%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead data-required>시도</TableHead>
<TableCell>
<Select
value={sidoComCd || undefined}
onValueChange={(value) => {
setSidoComCd(value);
setSggComCd("");
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{sidoOptions.map((option) => (
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
{option.adstDstrNm}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableHead>시군구</TableHead>
<TableCell>
<Select
value={sggComCd || undefined}
onValueChange={setSggComCd}
disabled={!sidoComCd || isGugunPending}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{gugunOptions.map((option) => (
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
{option.adstDstrNm}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
</TableRow>
<TableRow>
<TableHead data-required>계획구분</TableHead>
<TableCell colSpan={3}>
<FieldSet className="px-2">
<RadioGroup
value={sewerBscPlanSeComCd || undefined}
onValueChange={setSewerBscPlanSeComCd}
>
<div className="flex flex-wrap gap-4">
{planSeOptions?.map((option) => (
<FieldLabel
key={option.lwpoCd}
htmlFor={`sewer-plan-se-${option.lwpoCd}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem
value={option.lwpoCd}
id={`sewer-plan-se-${option.lwpoCd}`}
/>
<span className="font-normal">{option.lwpoCdNm}</span>
</Field>
</FieldLabel>
))}
</div>
</RadioGroup>
</FieldSet>
</TableCell>
</TableRow>
<TableRow>
<TableHead data-required>업무구분</TableHead>
<TableCell colSpan={3}>
<FieldSet className="px-2">
<RadioGroup value={taskSeComCd || undefined} onValueChange={setTaskSeComCd}>
<div className="flex flex-wrap gap-4">
{taskSeOptions?.map((option) => (
<FieldLabel
key={option.lwpoCd}
htmlFor={`sewer-task-se-${option.lwpoCd}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem
value={option.lwpoCd}
id={`sewer-task-se-${option.lwpoCd}`}
/>
<span className="font-normal">{option.lwpoCdNm}</span>
</Field>
</FieldLabel>
))}
</div>
</RadioGroup>
</FieldSet>
</TableCell>
</TableRow>
</TableHeader>
</Table>
)}
<DialogFooter>
<Button type="button" color="white" onClick={() => onOpenChange(false)}>
취소
</Button>
<Button type="button" onClick={handleConfirm} disabled={isPending || !canConfirm}>
확인
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { SewerBscPlanApplyDialog };
export type { SewerBscPlanApplySelection };
@@ -0,0 +1,43 @@
"use client";
import { useSewerBscPlanDetails } from "@/entities/sewer-bsc-plan";
import { Skeleton } from "@common/components";
import { SewerBscPlanFormPage } from "./sewer-bsc-plan-form-page";
interface SewerBscPlanEditPageProps {
id: string;
propChgCycl?: number;
}
function SewerBscPlanEditPage({ id, propChgCycl }: SewerBscPlanEditPageProps) {
const chgCycl = propChgCycl ?? 1;
const { data, isLoading: isDetailsLoading } = useSewerBscPlanDetails(id, chgCycl);
if (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,303 @@
"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 {
SewerBscPlanApplySaveResponseDto,
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;
createDefaults?: {
taskSeComCd?: string;
taskSeCdNm?: string;
sewerBscPlanSeComCd?: string;
sewerBscPlanSeCdNm?: string;
admdstCd?: string;
sido?: string;
gugun?: string;
};
}
function SewerBscPlanFormPage({
id,
mode,
initialData,
createDefaults,
}: 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, createDefaults),
});
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<SewerBscPlanApplySaveResponseDto | 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 result = await submitForm(data, "SV");
if (!result?.sewerBscPlanDocNo) return;
alert("저장되었습니다.");
router.push(`/sewer-bsc-plan/${result.sewerBscPlanDocNo}/edit?chgCycl=${result.chgCycl}`);
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");
queryClient.invalidateQueries({ queryKey: [...sewerBscPlanBaseKey, "findPage"] });
} 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,172 @@
"use client";
import { useState } from "react";
import type { PaginationState } from "@tanstack/react-table";
import {
type CmCdAdstDstrComboResponseDto,
useFindGugunCombo,
useFindSidoCombo,
} from "@/entities/adstdstr";
import { CodeSelect } from "@/entities/code";
import type { SewerBscPlanSearchRequestDto } from "@/entities/sewer-bsc-plan";
import {
Button,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
import { SewerBscPlanList } from "./sewer-bsc-plan-list";
const DEFAULT_PAGE_INDEX = 0;
const DEFAULT_PAGE_SIZE = 10;
const DEFAULT_SORT = "sewerBscPlanDocNo,desc";
const EMPTY_ADST_LIST: CmCdAdstDstrComboResponseDto[] = [];
const DEFAULT_SEARCH_PARAMS: SewerBscPlanSearchRequestDto = {
sewerBscPlanNm: "",
sewerBscPlanSeComCd: "",
taskSeComCd: "",
admdstCd: "",
page: DEFAULT_PAGE_INDEX,
size: DEFAULT_PAGE_SIZE,
sort: DEFAULT_SORT,
};
const ALL_SELECT_VALUE = "__ALL__";
function SewerBscPlanListPage() {
const [sewerBscPlanNm, setSewerBscPlanNm] = useState("");
const [sewerBscPlanSeComCd, setSewerBscPlanSeComCd] = useState("");
const [taskSeComCd, setTaskSeComCd] = useState("");
const [sidoComCd, setSidoComCd] = useState("");
const [sggComCd, setSggComCd] = useState("");
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: DEFAULT_PAGE_INDEX,
pageSize: DEFAULT_PAGE_SIZE,
});
const [searchParams, setSearchParams] =
useState<SewerBscPlanSearchRequestDto>(DEFAULT_SEARCH_PARAMS);
const { data: sidoData } = useFindSidoCombo();
const { data: gugunData } = useFindGugunCombo(sidoComCd);
const sidoOptions = sidoData ?? EMPTY_ADST_LIST;
const gugunOptions = gugunData ?? EMPTY_ADST_LIST;
const handleSearch = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setPagination((prev) => ({ ...prev, pageIndex: DEFAULT_PAGE_INDEX }));
setSearchParams({
...DEFAULT_SEARCH_PARAMS,
sewerBscPlanNm,
sewerBscPlanSeComCd,
taskSeComCd,
admdstCd: sggComCd || sidoComCd || "",
page: DEFAULT_PAGE_INDEX,
size: pagination.pageSize,
});
};
return (
<div className="space-y-4 px-[1.8rem] pb-6">
<section className="card card-border mx-0 bg-background">
<form className="flex flex-wrap items-center gap-4 p-2" onSubmit={handleSearch}>
<div className="flex items-center gap-2">
<Label htmlFor="sidoComCd" className="whitespace-nowrap">
시도
</Label>
<Select
value={sidoComCd}
onValueChange={(value) => {
setSidoComCd(value === ALL_SELECT_VALUE ? "" : value);
setSggComCd("");
}}
>
<SelectTrigger id="sidoComCd" className="w-[12rem]">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_SELECT_VALUE}>전체</SelectItem>
{sidoOptions.map((option) => (
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
{option.adstDstrNm}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label htmlFor="sggComCd" className="whitespace-nowrap">
시군구
</Label>
<Select
value={sggComCd}
onValueChange={(value) => setSggComCd(value === ALL_SELECT_VALUE ? "" : value)}
disabled={!sidoComCd}
>
<SelectTrigger id="sggComCd" className="w-[12rem]">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_SELECT_VALUE}>전체</SelectItem>
{gugunOptions.map((option) => (
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
{option.adstDstrNm}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label htmlFor="sewerBscPlanSeComCd" className="whitespace-nowrap">
계획구분
</Label>
<CodeSelect
upCd="SEWERBSCPLANSE"
value={sewerBscPlanSeComCd}
onValueChange={setSewerBscPlanSeComCd}
placeholder="전체"
/>
</div>
<div className="flex items-center gap-2">
<Label htmlFor="taskSeComCd" className="whitespace-nowrap">
업무구분
</Label>
<CodeSelect
upCd="SEWER"
value={taskSeComCd}
onValueChange={setTaskSeComCd}
placeholder="전체"
/>
</div>
<div className="flex w-[32rem] items-center gap-2">
<Label htmlFor="sewerBscPlanNm" className="whitespace-nowrap">
기본계획명
</Label>
<Input
id="sewerBscPlanNm"
value={sewerBscPlanNm}
onChange={(event) => setSewerBscPlanNm(event.target.value)}
placeholder="기본계획명"
/>
</div>
<Button type="submit">조회</Button>
</form>
</section>
<SewerBscPlanList
searchParams={searchParams}
pagination={pagination}
onPaginationChange={setPagination}
/>
</div>
);
}
export { SewerBscPlanListPage };
@@ -0,0 +1,114 @@
"use client";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import { useDataTable, useUserStore } from "@/shared/lib";
import {
type SewerBscPlanResponseDto,
type SewerBscPlanSearchRequestDto,
useSewerBscPlanPage,
} from "@/entities/sewer-bsc-plan";
import { Button } from "@common/components";
import { createSewerBscPlanColumns } from "../model/columns";
import {
SewerBscPlanApplyDialog,
type SewerBscPlanApplySelection,
} from "./sewer-bsc-plan-apply-dialog";
interface SewerBscPlanListProps {
searchParams: SewerBscPlanSearchRequestDto;
pagination: PaginationState;
onPaginationChange: (pagination: PaginationState) => void;
}
function SewerBscPlanList({ searchParams, pagination, onPaginationChange }: SewerBscPlanListProps) {
const userInfo = useUserStore((state) => state.userInfo);
const router = useRouter();
const [isApplyDialogOpen, setIsApplyDialogOpen] = useState(false);
const queryParams = useMemo(
() => ({
sewerBscPlanNm: searchParams.sewerBscPlanNm,
sewerBscPlanSeComCd: searchParams.sewerBscPlanSeComCd,
taskSeComCd: searchParams.taskSeComCd,
admdstCd: searchParams.admdstCd,
page: pagination.pageIndex,
size: pagination.pageSize,
sort: searchParams.sort ?? "sewerBscPlanDocNo,desc",
}),
[searchParams, pagination],
);
const pageQuery = useSewerBscPlanPage(queryParams);
const columns = useMemo(() => createSewerBscPlanColumns(), []);
const handleRowClick = (row: SewerBscPlanResponseDto) => {
const canEdit =
(row.prgrsSttsComCd === "SV" || row.prgrsSttsComCd === "SL") &&
row.rgtr === userInfo?.userUnqId;
router.push(
canEdit
? `/sewer-bsc-plan/${row.sewerBscPlanDocNo}/edit?chgCycl=${row.chgCycl}`
: `/sewer-bsc-plan/${row.sewerBscPlanDocNo}`,
);
};
const { tableComponent } = useDataTable({
query: pageQuery,
tableOptions: {
columns,
state: { pagination },
onPaginationChange: (updater) => {
const next = typeof updater === "function" ? updater(pagination) : updater;
onPaginationChange(next);
},
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
},
dataTableOptions: {
viewPagination: true,
onRowClick: handleRowClick,
},
});
function handleConfirmApply(selection: SewerBscPlanApplySelection) {
setIsApplyDialogOpen(false);
const qs = new URLSearchParams({
taskSeComCd: selection.taskSeComCd,
taskSeCdNm: selection.taskSeCdNm,
sewerBscPlanSeComCd: selection.sewerBscPlanSeComCd,
sewerBscPlanSeCdNm: selection.sewerBscPlanSeCdNm,
admdstCd: selection.admdstCd,
sido: selection.sido,
gugun: selection.gugun,
});
router.push(`/sewer-bsc-plan/regist?${qs}`);
}
return (
<>
<section className="card card-border mx-0 bg-background pb-8 pt-0">
<div className="card-header flex items-center justify-between">
<h3 className="text-2xl font-semibold">하수도기본계획 목록</h3>
<Button type="button" color="green" onClick={() => setIsApplyDialogOpen(true)}>
신청
</Button>
</div>
{tableComponent}
</section>
<SewerBscPlanApplyDialog
open={isApplyDialogOpen}
onOpenChange={setIsApplyDialogOpen}
onConfirm={handleConfirmApply}
/>
</>
);
}
export { SewerBscPlanList };
@@ -0,0 +1,22 @@
import { SewerBscPlanFormPage } from "./sewer-bsc-plan-form-page";
interface SewerBscPlanRegistDefaults {
taskSeComCd?: string;
taskSeCdNm?: string;
sewerBscPlanSeComCd?: string;
sewerBscPlanSeCdNm?: string;
admdstCd?: string;
sido?: string;
gugun?: string;
}
interface SewerBscPlanRegistPageProps {
defaults?: SewerBscPlanRegistDefaults;
}
function SewerBscPlanRegistPage({ defaults }: SewerBscPlanRegistPageProps) {
return <SewerBscPlanFormPage mode="create" createDefaults={defaults} />;
}
export { SewerBscPlanRegistPage };
export type { SewerBscPlanRegistDefaults };
+2
View File
@@ -22,6 +22,7 @@ export const ENDPOINT = {
DESIGN_CNSTN_BIZ_SCL: "/as-service/api/v1/design-cnstn-biz-scl",
DESIGN_CNSTN_ESNTL_ATCH_FILE: "/as-service/api/v1/design-cnstn-esntl-atch-file",
DESIGN_CNSTN_RVW_OPNN: "/as-service/api/v1/design-cnstn-rvw-opnn",
SEWER_BSC_PLAN: "/as-service/api/v1/sewer-bsc-plans",
},
USER_SERVICE: {
LOGIN: "/user-service/login",
@@ -43,6 +44,7 @@ export const ENDPOINT = {
STATVERIFY_SERVICE: {
ST_ANLS_MST: "/statverify-service/api/v1/st-anls-mst",
},
DC_SERVICE: "/dc-service/api/v1",
};
export const JWT_EXPIRATION_TIME = 1800000;
+10 -2
View File
@@ -4,11 +4,16 @@ 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,
isFormChecklistComplete,
normalizeTaskSeComCdArray,
syncCklstResultListFromTree,
} from "./lib/checklist-form-utils";
export type { FnrsSharItemDef, FnrsSharListItem } from "./lib/fnrs-shar-form-utils";
@@ -28,6 +33,10 @@ export { BusinessScaleFormSection } from "./ui/business-scale-form-section";
export { ChecklistFormSection } from "./ui/checklist-form-section";
export { ContactInfoFormSection } from "./ui/contact-info-form-section";
export { FnrsSharFormSection } from "./ui/fnrs-shar-form-section";
export {
PbadmsTaskApplyDialog,
type PbadmsTaskApplySelection,
} from "./ui/pbadms-task-apply-dialog";
export { PrivacyConsentSection } from "./ui/privacy-consent-section";
export { SectionCollapsible } from "./ui/section-collapsible";
export {
@@ -37,4 +46,3 @@ export {
} from "./ui/splmnt-dmnd-dialog";
export { type SplmntDmndFormItem, SplmntDmndFormSection } from "./ui/splmnt-dmnd-form-section";
export { SupplementDemandSection } from "./ui/supplement-demand-section";
export { TaskSePickerDialog } from "./ui/task-se-picker-dialog";
@@ -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::";

Some files were not shown because too many files have changed in this diff Show More