diff --git a/apps/_template/tsconfig.json b/apps/_template/tsconfig.json index dab9b0b8e..e6c49fbe4 100644 --- a/apps/_template/tsconfig.json +++ b/apps/_template/tsconfig.json @@ -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"] } diff --git a/apps/as/app/auth/login/page.tsx b/apps/as/app/(auth)/auth/login/page.tsx similarity index 100% rename from apps/as/app/auth/login/page.tsx rename to apps/as/app/(auth)/auth/login/page.tsx diff --git a/apps/as/app/(auth)/layout.tsx b/apps/as/app/(auth)/layout.tsx new file mode 100644 index 000000000..09d5b93ec --- /dev/null +++ b/apps/as/app/(auth)/layout.tsx @@ -0,0 +1,13 @@ +import "@/app/styles/globals.css"; + +export const metadata = { + title: "디지털 행정지원 시스템", +}; + +export default function AuthLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/apps/as/app/(with-base-layout)/approval-line-management/page.tsx b/apps/as/app/(main)/(with-base-layout)/approval-line-management/page.tsx similarity index 100% rename from apps/as/app/(with-base-layout)/approval-line-management/page.tsx rename to apps/as/app/(main)/(with-base-layout)/approval-line-management/page.tsx diff --git a/apps/as/app/(with-base-layout)/checklist-management/page.tsx b/apps/as/app/(main)/(with-base-layout)/checklist-management/page.tsx similarity index 100% rename from apps/as/app/(with-base-layout)/checklist-management/page.tsx rename to apps/as/app/(main)/(with-base-layout)/checklist-management/page.tsx diff --git a/apps/as/app/(with-base-layout)/code-management/page.tsx b/apps/as/app/(main)/(with-base-layout)/code-management/page.tsx similarity index 100% rename from apps/as/app/(with-base-layout)/code-management/page.tsx rename to apps/as/app/(main)/(with-base-layout)/code-management/page.tsx diff --git a/apps/as/app/(with-base-layout)/design-cnstn/[id]/edit/page.tsx b/apps/as/app/(main)/(with-base-layout)/design-cnstn/[id]/edit/page.tsx similarity index 100% rename from apps/as/app/(with-base-layout)/design-cnstn/[id]/edit/page.tsx rename to apps/as/app/(main)/(with-base-layout)/design-cnstn/[id]/edit/page.tsx diff --git a/apps/as/app/(with-base-layout)/design-cnstn/[id]/page.tsx b/apps/as/app/(main)/(with-base-layout)/design-cnstn/[id]/page.tsx similarity index 100% rename from apps/as/app/(with-base-layout)/design-cnstn/[id]/page.tsx rename to apps/as/app/(main)/(with-base-layout)/design-cnstn/[id]/page.tsx diff --git a/apps/as/app/(with-base-layout)/design-cnstn/page.tsx b/apps/as/app/(main)/(with-base-layout)/design-cnstn/page.tsx similarity index 100% rename from apps/as/app/(with-base-layout)/design-cnstn/page.tsx rename to apps/as/app/(main)/(with-base-layout)/design-cnstn/page.tsx diff --git a/apps/as/app/(main)/(with-base-layout)/design-cnstn/regist/page.tsx b/apps/as/app/(main)/(with-base-layout)/design-cnstn/regist/page.tsx new file mode 100644 index 000000000..4b57da09a --- /dev/null +++ b/apps/as/app/(main)/(with-base-layout)/design-cnstn/regist/page.tsx @@ -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 ( + + ); +} diff --git a/apps/as/app/(with-base-layout)/esntl-atch-file-mng/page.tsx b/apps/as/app/(main)/(with-base-layout)/esntl-atch-file-mng/page.tsx similarity index 100% rename from apps/as/app/(with-base-layout)/esntl-atch-file-mng/page.tsx rename to apps/as/app/(main)/(with-base-layout)/esntl-atch-file-mng/page.tsx diff --git a/apps/as/app/(with-base-layout)/fnrs-cnsltn/[id]/edit/page.tsx b/apps/as/app/(main)/(with-base-layout)/fnrs-cnsltn/[id]/edit/page.tsx similarity index 100% rename from apps/as/app/(with-base-layout)/fnrs-cnsltn/[id]/edit/page.tsx rename to apps/as/app/(main)/(with-base-layout)/fnrs-cnsltn/[id]/edit/page.tsx diff --git a/apps/as/app/(with-base-layout)/fnrs-cnsltn/[id]/page.tsx b/apps/as/app/(main)/(with-base-layout)/fnrs-cnsltn/[id]/page.tsx similarity index 100% rename from apps/as/app/(with-base-layout)/fnrs-cnsltn/[id]/page.tsx rename to apps/as/app/(main)/(with-base-layout)/fnrs-cnsltn/[id]/page.tsx diff --git a/apps/as/app/(with-base-layout)/fnrs-cnsltn/page.tsx b/apps/as/app/(main)/(with-base-layout)/fnrs-cnsltn/page.tsx similarity index 100% rename from apps/as/app/(with-base-layout)/fnrs-cnsltn/page.tsx rename to apps/as/app/(main)/(with-base-layout)/fnrs-cnsltn/page.tsx diff --git a/apps/as/app/(main)/(with-base-layout)/fnrs-cnsltn/regist/page.tsx b/apps/as/app/(main)/(with-base-layout)/fnrs-cnsltn/regist/page.tsx new file mode 100644 index 000000000..6af4150ed --- /dev/null +++ b/apps/as/app/(main)/(with-base-layout)/fnrs-cnsltn/regist/page.tsx @@ -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 ( + + ); +} diff --git a/apps/as/app/(with-base-layout)/instl-sncn/[id]/edit/page.tsx b/apps/as/app/(main)/(with-base-layout)/instl-sncn/[id]/edit/page.tsx similarity index 100% rename from apps/as/app/(with-base-layout)/instl-sncn/[id]/edit/page.tsx rename to apps/as/app/(main)/(with-base-layout)/instl-sncn/[id]/edit/page.tsx diff --git a/apps/as/app/(with-base-layout)/instl-sncn/[id]/page.tsx b/apps/as/app/(main)/(with-base-layout)/instl-sncn/[id]/page.tsx similarity index 100% rename from apps/as/app/(with-base-layout)/instl-sncn/[id]/page.tsx rename to apps/as/app/(main)/(with-base-layout)/instl-sncn/[id]/page.tsx diff --git a/apps/as/app/(with-base-layout)/instl-sncn/page.tsx b/apps/as/app/(main)/(with-base-layout)/instl-sncn/page.tsx similarity index 100% rename from apps/as/app/(with-base-layout)/instl-sncn/page.tsx rename to apps/as/app/(main)/(with-base-layout)/instl-sncn/page.tsx diff --git a/apps/as/app/(main)/(with-base-layout)/instl-sncn/regist/page.tsx b/apps/as/app/(main)/(with-base-layout)/instl-sncn/regist/page.tsx new file mode 100644 index 000000000..ff047fd96 --- /dev/null +++ b/apps/as/app/(main)/(with-base-layout)/instl-sncn/regist/page.tsx @@ -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 ( + + ); +} diff --git a/apps/as/app/(with-base-layout)/layout.tsx b/apps/as/app/(main)/(with-base-layout)/layout.tsx similarity index 100% rename from apps/as/app/(with-base-layout)/layout.tsx rename to apps/as/app/(main)/(with-base-layout)/layout.tsx diff --git a/apps/as/app/(with-base-layout)/page.tsx b/apps/as/app/(main)/(with-base-layout)/page.tsx similarity index 100% rename from apps/as/app/(with-base-layout)/page.tsx rename to apps/as/app/(main)/(with-base-layout)/page.tsx diff --git a/apps/as/app/(with-base-layout)/rvw-mnpw-mng/page.tsx b/apps/as/app/(main)/(with-base-layout)/rvw-mnpw-mng/page.tsx similarity index 100% rename from apps/as/app/(with-base-layout)/rvw-mnpw-mng/page.tsx rename to apps/as/app/(main)/(with-base-layout)/rvw-mnpw-mng/page.tsx diff --git a/apps/as/app/(main)/(with-base-layout)/sewer-bsc-plan/[id]/edit/page.tsx b/apps/as/app/(main)/(with-base-layout)/sewer-bsc-plan/[id]/edit/page.tsx new file mode 100644 index 000000000..72492a84c --- /dev/null +++ b/apps/as/app/(main)/(with-base-layout)/sewer-bsc-plan/[id]/edit/page.tsx @@ -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 ; +} diff --git a/apps/as/app/(main)/(with-base-layout)/sewer-bsc-plan/[id]/page.tsx b/apps/as/app/(main)/(with-base-layout)/sewer-bsc-plan/[id]/page.tsx new file mode 100644 index 000000000..17894197e --- /dev/null +++ b/apps/as/app/(main)/(with-base-layout)/sewer-bsc-plan/[id]/page.tsx @@ -0,0 +1,17 @@ +import Link from "next/link"; + +interface PageProps { + params: Promise<{ id: string }>; +} + +export default async function Page({ params }: PageProps) { + const { id } = await params; + return ( +
+ 하수도기본계획 상세({id})는 후속 구현 예정입니다.{" "} + + 수정 화면으로 이동 + +
+ ); +} diff --git a/apps/as/app/(main)/(with-base-layout)/sewer-bsc-plan/page.tsx b/apps/as/app/(main)/(with-base-layout)/sewer-bsc-plan/page.tsx new file mode 100644 index 000000000..d07ca4b43 --- /dev/null +++ b/apps/as/app/(main)/(with-base-layout)/sewer-bsc-plan/page.tsx @@ -0,0 +1,3 @@ +import { SewerBscPlanListPage } from "@/pages/sewer-bsc-plan"; + +export default SewerBscPlanListPage; diff --git a/apps/as/app/(main)/(with-base-layout)/sewer-bsc-plan/regist/page.tsx b/apps/as/app/(main)/(with-base-layout)/sewer-bsc-plan/regist/page.tsx new file mode 100644 index 000000000..276160546 --- /dev/null +++ b/apps/as/app/(main)/(with-base-layout)/sewer-bsc-plan/regist/page.tsx @@ -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 ; +} diff --git a/apps/as/app/layout.tsx b/apps/as/app/(main)/layout.tsx similarity index 84% rename from apps/as/app/layout.tsx rename to apps/as/app/(main)/layout.tsx index 3b64ab52f..dacee7f25 100644 --- a/apps/as/app/layout.tsx +++ b/apps/as/app/(main)/layout.tsx @@ -6,8 +6,8 @@ import { Providers } from "@/app/providers"; import "@/app/styles/globals.css"; export const metadata: Metadata = { - title: "행정지원시스템", - description: "행정지원시스템", + title: "디지털 행정지원 시스템", + description: "디지털 행정지원 시스템", }; interface RootLayoutProps { diff --git a/apps/as/app/(with-base-layout)/design-cnstn/regist/page.tsx b/apps/as/app/(with-base-layout)/design-cnstn/regist/page.tsx deleted file mode 100644 index 567b634d5..000000000 --- a/apps/as/app/(with-base-layout)/design-cnstn/regist/page.tsx +++ /dev/null @@ -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 ; -} diff --git a/apps/as/app/(with-base-layout)/fnrs-cnsltn/regist/page.tsx b/apps/as/app/(with-base-layout)/fnrs-cnsltn/regist/page.tsx deleted file mode 100644 index 4360a6979..000000000 --- a/apps/as/app/(with-base-layout)/fnrs-cnsltn/regist/page.tsx +++ /dev/null @@ -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 ; -} diff --git a/apps/as/app/(with-base-layout)/instl-sncn/regist/page.tsx b/apps/as/app/(with-base-layout)/instl-sncn/regist/page.tsx deleted file mode 100644 index 476d0b7c2..000000000 --- a/apps/as/app/(with-base-layout)/instl-sncn/regist/page.tsx +++ /dev/null @@ -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 ; -} diff --git a/apps/as/app/api/auth/logout/route.ts b/apps/as/app/api/auth/logout/route.ts new file mode 100644 index 000000000..3e53a0705 --- /dev/null +++ b/apps/as/app/api/auth/logout/route.ts @@ -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 }); +} diff --git a/apps/as/docs/fnrs-cnsltn-vs-instl-sncn-diff.md b/apps/as/docs/fnrs-cnsltn-vs-instl-sncn-diff.md deleted file mode 100644 index c90dec22f..000000000 --- a/apps/as/docs/fnrs-cnsltn-vs-instl-sncn-diff.md +++ /dev/null @@ -1,367 +0,0 @@ -# 재원협의(fnrs-cnsltn) vs 설치인가(instl-sncn) 차이 분석 - -| 항목 | 내용 | -| --------- | ----------------------------------------------------------------------------- | -| 작성일 | 2026-07-09 | -| 기준 구현 | `fnrs-cnsltn` (추가 요구사항 반영 완료) | -| 비교 대상 | `instl-sncn` | -| 목적 | 두 페이지의 동작·구조 차이를 정리하고, 설치인가 정렬 시 이식 대상을 명확히 함 | - ---- - -## 1. 요약 - -두 모듈은 **동일한 PBADMS(공공행정 지원) 업무 패턴**을 따르는 형제 페이지다. - -공통 흐름: - -``` -목록 → TaskSe 선택 후 신청 → 저장(SV) → 신청완료(ST) - → 환경청제출(LE) → 기술검토 → 검토결과 회신(EP) - → 인가승인(TA) → 고시(TB) -``` - -| 구분 | fnrs-cnsltn | instl-sncn | -| ------------- | ----------------- | ---------------- | -| 업무명 | 재원협의 | 설치인가 | -| 문서번호 | `fnrsCnsltnDocNo` | `instlSncnDocNo` | -| docSeComCd | `FNRS` | `INSTL` | -| 라우트 | `/fnrs-cnsltn/*` | `/instl-sncn/*` | -| pages 파일 수 | 39 | 32 | - -**결론:** `fnrs-cnsltn`이 추가 요구사항이 반영된 기준(reference) 구현이고, `instl-sncn`은 동일 골격이지만 보완요청·결재선 편집·등록/수정 폼 완성도 등에서 미반영·TODO·단순화 상태다. - ---- - -## 2. 파일/폴더 구조 - -### 2-1. fnrs-cnsltn에만 존재 - -| 경로 | 역할 | -| -------------------------------------------- | ------------------------- | -| `ui/sections/fnrs-shar-section.tsx` | 재원분담 조회 섹션 | -| `ui/sections/supplement-demand-section.tsx` | 보완요청 이력 섹션 | -| `ui/components/sortable-prgrs-stts-row.tsx` | 결재선 DnD 편집 행 | -| `ui/components/sortable-apln-picker-row.tsx` | 결재선 템플릿 피커 DnD 행 | -| `ui/components/aprvr-picker-dialog.tsx` | 결재자 선택 다이얼로그 | -| `ui/components/elapr-apln-picker-grid.tsx` | 결재선 편집 그리드 | -| `ui/components/elapr-tmplt-select-table.tsx` | 결재서식 선택 테이블 | -| `model/reviewer-option.ts` | 결재자 옵션 타입 | - -### 2-2. instl-sncn에만 존재 - -| 경로 | 역할 | 비고 | -| -------------------------------------- | ------------------- | ------------------------------------------ | -| `ui/sections/history-info-section.tsx` | 등록/수정 이력 표시 | **어디에서도 import되지 않음 (dead code)** | - -### 2-3. 공통 구조 - -``` -index.ts -ui/ - {list,view,form,regist,edit}-page.tsx - sections/ (basic-info, contact, scale, cost, checklist, attachments, tech-review) - components/ (floating-action-buttons, elapr-approval-dialog, elapr-tmplt-picker-dialog, checklist-task-panel) -lib/ button-config, save-payload-utils, form-data-mapper, regist-schema, checklist/review utils -model/ columns.tsx, view-types.ts -``` - -App Router도 동일: `page.tsx`, `regist/page.tsx`, `[id]/page.tsx`, `[id]/edit/page.tsx` - ---- - -## 3. 목록 페이지 - -### 3-1. 검색 필터 - -| 항목 | fnrs-cnsltn | instl-sncn | -| ------------------ | ----------------------------------------- | --------------------------------- | -| 시도/시군구 API | `@/entities/adstdstr` | 동일 | -| 지역 검색 파라미터 | **`admdstCd`** (시군구 우선, 없으면 시도) | **`sidoComCd` + `sggComCd`** 분리 | -| "전체" 옵션 | `ALL_SELECT_VALUE = "__ALL__"` SelectItem | 없음 (placeholder만) | -| 구분 CodeSelect | `upCd="INSTL"` ※ | `upCd="INSTL"` | -| 사업명 | 동일 | 동일 | - -> ※ fnrs 목록의 구분 필터가 `INSTL` 코드를 사용 중이며, TaskSePickerDialog는 `codeUpCd="FNRS"`를 쓴다. **불일치 가능성**이 있어 확인이 필요하다. - -### 3-2. 정렬·페이지네이션 - -| 항목 | fnrs-cnsltn | instl-sncn | -| ------------------------ | ---------------------- | ------------------------------------- | -| 기본 sort (list-page) | `fnrsCnsltnDocNo,desc` | `instlSncnDocNo,desc` | -| fallback sort (list.tsx) | `fnrsCnsltnDocNo,desc` | **`regDt,desc`** (list-page와 불일치) | -| 페이지 크기 | 10 | 10 | -| URL 동기화 | 없음 (`useState` 로컬) | 없음 | - -### 3-3. 테이블 컬럼 - -`model/columns.tsx` 로직은 사실상 동일하다. DTO 타입명만 다르다. - -컬럼: 시도, 시군구, 사업명, 업무구분, 담당자, 해당기관, 진행상황, 신청일 - -### 3-4. 행 클릭·신청 - -| 항목 | fnrs-cnsltn | instl-sncn | -| ------------ | ------------------------------------------------------------------ | ------------------ | -| 행 클릭 | **조건부 라우팅**: `SV`/`SL` + 등록자 본인 → `/edit`, 그 외 → view | **항상 view** | -| useUserStore | 사용 | 미사용 | -| 신청 버튼 | TaskSePickerDialog `codeUpCd="FNRS"` | `codeUpCd="INSTL"` | - -```46:54:apps/as/src/pages/fnrs-cnsltn/ui/fnrs-list.tsx - const handleRowClick = (row: FnrsCnsltnMngResponseDto) => { - const canEdit = - (row.prgrsSttsComCd === "SV" || row.prgrsSttsComCd === "SL") && - row.rgtr === userInfo?.userUnqId; - - router.push( - canEdit ? `/fnrs-cnsltn/${row.fnrsCnsltnDocNo}/edit` : `/fnrs-cnsltn/${row.fnrsCnsltnDocNo}`, - ); - }; -``` - ---- - -## 4. 상세(View) 페이지 - -### 4-1. 섹션 구성 - -| 섹션 | fnrs-cnsltn | instl-sncn | -| ------------------------------- | ------------------------------------------ | -------------------------- | -| BasicInfo | 환경청구분, 확정예산사업번호, 예산신청연도 | **사업구분(`sncnSeCdNm`)** | -| ContactInfo | 동일 패턴 | 동일 | -| BusinessScale / Cost | 동일 | 동일 | -| **FnrsShar (재원분담)** | **있음** | **없음** (도메인 고유) | -| Checklist | 동일 | 동일 | -| Attachments | 동일 | 동일 | -| **SupplementDemand (보완요청)** | **있음** | **없음** | -| TechReviewOpinion | 동일 | 동일 | -| HistoryInfo | 없음 | 파일만 존재, view 미연결 | -| FloatingActionButtons | `docSeComCd="FNRS"` | `docSeComCd="INSTL"` | - -### 4-2. 데이터 훅 - -| 항목 | fnrs-cnsltn | instl-sncn | -| --------- | ---------------------------- | -------------------- | -| 상세 훅 | `useFnrsCnsltnMng` | `useInstlSncnDetail` | -| 에러 판정 | `isError \|\| !data?.detDto` | `isError \|\| !data` | -| 로딩 | Skeleton | Skeleton | - ---- - -## 5. 등록/수정(Form) 페이지 - -### 5-1. 폼 완성도 - -| 항목 | fnrs-cnsltn | instl-sncn | -| ------------- | --------------------------------- | --------------- | -| 저장(SV) | O | O (SV 고정) | -| 신청완료(ST) | O (edit 모드) | **없음** | -| 삭제 | O (edit 모드, `useConfirm`) | **없음** | -| 재원분담 섹션 | `FnrsSharFormSection` + 잔액 검증 | 없음 | -| 저장 후 이동 | **`/edit` 유지** | **view로 이동** | -| 목록 버튼 | `/fnrs-cnsltn` 고정 | `router.back()` | - -### 5-2. 기본정보 Form 필드 - -| 필드 | fnrs | instl | -| ----------------- | ---------------- | --------------------------------------- | -| taskSe 코드 | `FNRS` | `INSTL` | -| 사업구분 | 없음 | **`sncnSeComCd` (SNCNSE 라디오, 필수)** | -| BnsPicker 연동 | bizNm ~ bizCn | + **`bgtBizCd`, `bgtAplyYr`** | -| 재이용(RE) 조건부 | rtlztnFclt / Usg | 동일 | - -### 5-3. Schema - -| 항목 | fnrs | instl | -| ------------------- | ------------------- | -------------- | -| 관리 객체 | `fnrsCnsltnMng` | `instlSncnMng` | -| 예산 필드 | `cfmtnBgtBizNo` | `bgtBizCd` | -| 재원분담 | `fnrsSharList` 배열 | 없음 | -| sharAtchFileGroupId | 있음 | 없음 | -| privacyConsent | 있음 | 있음 | - -### 5-4. Edit 페이지 래퍼 - -| 항목 | fnrs | instl | -| ------------------ | ------------------------------- | ------------------------------- | -| 로딩 Skeleton | 있음 | **없음** | -| 데이터 없음 메시지 | "상세 정보를 찾을 수 없습니다." | "수정 대상을 찾을 수 없습니다." | - ---- - -## 6. FloatingActionButtons / 결재 / 보완 - -### 6-1. FAB 동작 차이 - -공통: 역할×상태 기반 버튼(`button-config.ts`), checklist 진행률 검증, elapr pending approval 체크 - -| 기능 | fnrs-cnsltn | instl-sncn | -| ------------------------------- | ------------------------------------------------------- | ------------------------------------------------------- | -| 보완완료 (`splmntCmptn`) | **`submitSupplement` API 연동** | **`// TODO` stub** | -| 보완요청 (`dmndSplmnt`) | **`buildSupplementReqPayload` + `submitReqSupplement`** | **`// TODO` stub** (문자열만 전달) | -| 검토완료 (`rvwCmptn`) | `techReViewSaveCd: "SV"` 후 **결재 다이얼로그 오픈** | `techReViewSaveCd: "CP"`, **다이얼로그 없이 즉시 완료** | -| 검토자지정 | `buildElaprPrgrsSttsListFromAplnRows(aplnRows)` | `buildElaprPrgrsSttsListFromApln(aplnPage.content)` | -| atrzAprv / atrzRply / grndsChck | TODO handler | TODO handler | - -```576:617:apps/as/src/pages/instl-sncn/ui/components/floating-action-buttons.tsx -async function handleSplmntCmptn(context: ActionHandlerContext): Promise { - // TODO: 보완완료 API 호출 - context.onSuccess?.(); -} -// ... -async function handleDmndSplmnt(context: ActionHandlerContext): Promise { - // TODO: 보완요청 API 호출 - context.onSuccess?.(); -} -``` - -### 6-2. ElaprApprovalDialog (가장 큰 UX 격차) - -| 항목 | fnrs (419줄) | instl (231줄) | -| ---------------- | ------------------------------------------------------ | -------------------------------- | -| 결재선 표시 | DnD sortable 편집 테이블 | **읽기 전용** 테이블 | -| 결재선 추가/저장 | **지원** (`usePrgrsSttsRows`, `useSaveElaprPrgrsStts`) | 없음 | -| 결재자 변경 | **AprvrPickerDialog** | 없음 | -| 전결 체크박스 | **있음** (`finalApprovalYn`) | 없음 | -| 반려 사유 | API에 `rjctRsn` 전달 | UI는 있으나 **payload에 미전달** | -| 저장 전 검증 | `hasUnsavedPrgrsSttsChanges` | 없음 | - -### 6-3. ElaprTmpltPickerDialog - -| 항목 | fnrs | instl | -| --------------- | -------------------------------------------------------------------- | ----------------------- | -| UI | 2-pane: 서식 테이블 + **결재선 편집 그리드** | 단일 테이블 + 선택 버튼 | -| docSeComCd 필터 | `FNRS` | `INSTL` | -| onConfirm | `{ tmplt, aplnRows }` 편집 결과 | `onSelect(tmplt)`만 | -| 검증 | `validateAllRowsForSave`, `validateAtrzOrder`, `validateUniqueSeqNo` | 없음 | - ---- - -## 7. Entity / API / Hook - -### 7-1. Service 메서드 - -| 메서드 | fnrs | instl | -| ----------------------- | --------------------------- | --------- | -| 목록 | `findPage` | `getPage` | -| 상세 | `findById` | `getById` | -| saveApply | O | O | -| techReview | O | O | -| changePrgrsSttsComCd | O | O | -| remove | O | O | -| **submitSupplement** | **O** (`/submitsupplement`) | **X** | -| **submitReqSupplement** | **O** (`/reqsupplement`) | **X** | - -### 7-2. 상세 DTO 확장 - -**fnrs 추가 필드** - -- `fnrsSharList`, `sharAtchFileList` -- `splmntDmndList` (typed) - -**instl 추가 필드** - -- `sncnSeComCd` / `sncnSeCdNm` (인가구분) -- `bgtBizCd` (예산사업코드) -- `rtlztnFcltSeComNm`, `rtlztnUsgSeComNm` -- `splmntDmndList` 타입은 base에 존재하나 view/API 미연결 -- `instlSncnRegionService` (시도/시군구 — 목록은 adstdstr 사용, 미활용) - -### 7-3. save-payload-utils - -| 함수 | fnrs | instl | -| -------------------------------- | ---------------- | --------- | -| buildSavePayload | + `fnrsSharList` | shar 없음 | -| buildTechReviewReplyPayload | O | O | -| **buildSupplementReqPayload** | **O** | **X** | -| **buildSupplementSubmitPayload** | **O** | **X** | - ---- - -## 8. 비즈니스 도메인 차이 - -| 영역 | fnrs-cnsltn | instl-sncn | -| -------------- | ----------------------------------------- | ------------------------ | -| 업무 코드 | `FNRS` | `INSTL` | -| TaskSe | FNRS 하위 | INSTL 하위 | -| 고시 버튼 라벨 | "재원협의 고시" | "설치인가 고시" | -| 재원분담 | **핵심 도메인** (`fnrs-shar-se`) | 해당 없음 | -| 인가구분 | 없음 | **`SNCNSE` 코드 (필수)** | -| 예산 식별 | `cfmtnBgtBizNo` | `bgtBizCd` | -| 진행상태 코드 | SV, ST, LE, ER, TE, EP, TA, TB, SL, TP 등 | **동일 세트 공유** | -| 역할 매핑 | ROLE_USER05/04/03/01 | 동일 | - ---- - -## 9. fnrs-cnsltn에만 있는 기능 (추가 요구사항 후보) - -instl-sncn 정렬 시 **우선 이식 대상**: - -1. 목록 행 클릭 조건부 edit 라우팅 (`SV`/`SL` + 등록자) -2. 보완요청 전체 플로우 — view 섹션, payload builder, API, FAB handler -3. 보완완료 API 연동 (`submitSupplement`) -4. 등록/수정 폼 완성 — 신청완료, 삭제, 저장 후 edit 유지 -5. 결재선 DnD 편집형 `ElaprApprovalDialog` (+ aprvr-picker, sortable rows) -6. 결재선 편집형 `ElaprTmpltPickerDialog` (+ apln grid, validation) -7. 검토완료 → 결재 다이얼로그 연계 흐름 -8. 검색 필터 "전체" 옵션 및 `admdstCd` 통합 방식 (백엔드 API에 맞게) -9. Edit 페이지 로딩 Skeleton -10. entity에 supplement API/hooks 추가 - ---- - -## 10. instl-sncn에만 있는 기능 (유지 필요) - -1. **`sncnSeComCd` (사업구분/인가구분)** — 도메인 고유 -2. **`bgtBizCd` / `bgtAplyYr`** BnsPicker 연동 -3. **`history-info-section.tsx`** — 구현만 있고 view 미연결 (활성화 또는 삭제 결정 필요) -4. entity **`instlSncnRegionService`** — 목록은 adstdstr 사용 중이라 중복/레거시 가능 -5. **`INSTL_SNCN_REGISTER_SELECTION_KEY`** sessionStorage 키 - ---- - -## 11. 동일·거의 동일 패턴 - -| 영역 | 동일도 | -| ----------------------------------------------------- | --------------------- | -| App Router 4-route 구조 | 100% | -| `button-config.ts` 역할×상태 매트릭스 | ~99% (액션명만 다름) | -| `columns.tsx` 진행상황 Badge | ~100% | -| checklist / contact / scale / cost / attachments 섹션 | ~95% (import 경로만) | -| review-step-utils 로직 | ~100% (상수명만 다름) | -| TaskSePickerDialog 신청 플로우 | 100% | -| FAB 전체 구조·대부분 handler | ~85% | -| checklist-form-state, business-cost-utils | 거의 동일 | -| shared widgets (`@/widgets/pbadms-sprt`) | 동일 | - ---- - -## 12. instl-sncn 정렬 권장 우선순위 - -| 우선순위 | 항목 | 근거 | -| -------- | ------------------------------------------------------- | ---------------------------- | -| P0 | 보완요청/보완완료 API + UI | fnrs 완전 구현 vs instl TODO | -| P0 | ElaprApprovalDialog 고도화 | 결재선 편집·전결·반려사유 | -| P0 | ElaprTmpltPickerDialog 2-pane | 검토자지정 품질 | -| P1 | Form 신청완료/삭제 + 저장 후 edit 유지 | 등록 UX parity | -| P1 | 목록 행 클릭 edit 라우팅 | fnrs 추가 요구사항 | -| P1 | 검토완료 → 결재 다이얼로그 연계 | 흐름 통일 | -| P2 | sort fallback 불일치 수정 (`regDt` vs `instlSncnDocNo`) | instl 내부 버그성 | -| P2 | history-info-section 연결 또는 삭제 | dead code 정리 | -| P3 | URL search params 동기화 | 양쪽 모두 미구현 (공통 개선) | - ---- - -## 13. 참고 코드 위치 - -| 주제 | 경로 | -| ---------------------------- | ------------------------------------------------------------ | -| 목록 edit 라우팅 (fnrs) | `pages/fnrs-cnsltn/ui/fnrs-list.tsx` | -| 목록 항상 view (instl) | `pages/instl-sncn/ui/instl-list.tsx` | -| view 추가 섹션 (fnrs) | `pages/fnrs-cnsltn/ui/fnrs-view-page.tsx` | -| FAB 보완 TODO (instl) | `pages/instl-sncn/ui/components/floating-action-buttons.tsx` | -| 결재 다이얼로그 (fnrs) | `pages/fnrs-cnsltn/ui/components/elapr-approval-dialog.tsx` | -| 결재 다이얼로그 (instl) | `pages/instl-sncn/ui/components/elapr-approval-dialog.tsx` | -| 폼 완성도 (fnrs) | `pages/fnrs-cnsltn/ui/fnrs-form-page.tsx` | -| 폼 단순화 (instl) | `pages/instl-sncn/ui/instl-form-page.tsx` | -| supplement API (fnrs entity) | `entities/fnrs-cnsltn/api/fnrs-cnsltn-service.ts` | diff --git a/apps/as/docs/sewer-bsc-plan/dmbp-regist-migration.md b/apps/as/docs/sewer-bsc-plan/dmbp-regist-migration.md new file mode 100644 index 000000000..e8f36ef2e --- /dev/null +++ b/apps/as/docs/sewer-bsc-plan/dmbp-regist-migration.md @@ -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`행: `
` ~ `` + +| 영역 | 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 (등록/수정) / 읽기 섹션 (상세) + └─ + ├─ 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 │ +│
│ +│ │ +│ ┌─ 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와 동일 │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│
│ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 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=""` +`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 +
+ + {!isSplmntPrgrsStts ? ( + <> + + {isEditMode && ( + <> + + + + )} + + ) : ( + + )} +
+``` + +| 버튼 | fnrs 대응 | sewer API / 동작 | +| -------- | ----------------------------- | ------------------------------------------------------------ | +| 목록 | `router.push("/fnrs-cnsltn")` | `router.push("/sewer-bsc-plan")` | +| 저장 | `saveApply` + 임시저장 | `useSaveApplySewerBscPlan` + `prgrsSttsComCd`=임시저장(`SV`) | +| 신청완료 | `handleApplyClick` → 제출 | 체크리스트+필수첨부 검증 → `saveApply` + `LE` (상태코드 TBD) | +| 삭제 | `handleDelClick` | `useDeleteSewerBscPlan`(또는 동등) → 목록 | +| 보완완료 | `onSplmntCmptn` | `useSubmitSupplementSewerBscPlan` | + +| JSP | 진행값 | URL | React | +| ---- | ---------- | ------------------------- | ---------------------------------------------------------- | +| 저장 | `SAVE` | `insertFnrscDmbp.do` | `saveApply` + 임시저장 코드 | +| 제출 | `PRESENTN` | `insertFnrscDscss.do` (!) | `saveApply` + 제출 상태코드 — **백엔드 상태코드 확인 TBD** | + +페이로드 조립 (`buildApplyPayload`): + +```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 표) diff --git a/apps/as/docs/spec/previous-plan-lookup/SPEC.md b/apps/as/docs/spec/previous-plan-lookup/SPEC.md new file mode 100644 index 000000000..927121116 --- /dev/null +++ b/apps/as/docs/spec/previous-plan-lookup/SPEC.md @@ -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 — 응답은 캐시·검증용일 수 있음 | diff --git a/apps/as/middleware.ts b/apps/as/middleware.ts index 94a1d0381..f5bd866f3 100644 --- a/apps/as/middleware.ts +++ b/apps/as/middleware.ts @@ -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")) { diff --git a/apps/as/package.json b/apps/as/package.json index cb3887d08..075300898 100644 --- a/apps/as/package.json +++ b/apps/as/package.json @@ -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": { diff --git a/apps/as/src/app/styles/globals.css b/apps/as/src/app/styles/globals.css index e557f947b..bf1ecad16 100644 --- a/apps/as/src/app/styles/globals.css +++ b/apps/as/src/app/styles/globals.css @@ -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; + } } diff --git a/apps/as/src/entities/adstdstr/api/adstdstr-service.ts b/apps/as/src/entities/adstdstr/api/adstdstr-service.ts index b4bb11fae..f282ea67b 100644 --- a/apps/as/src/entities/adstdstr/api/adstdstr-service.ts +++ b/apps/as/src/entities/adstdstr/api/adstdstr-service.ts @@ -13,6 +13,11 @@ async function findSidoCombo(): Promise { return response.data; } +async function findUserSidoCombo(): Promise { + const response = await client.get(`${BASE}/user/sido`); + return response.data; +} + async function findGugunCombo(sido: string): Promise { const response = await client.get(`${BASE}/gugun`, { params: { sido }, @@ -20,7 +25,16 @@ async function findGugunCombo(sido: string): Promise { + const response = await client.get(`${BASE}/user/gugun`, { + params: { sido }, + }); + return response.data; +} + export const cmCdAdstDstrService = { findSidoCombo, + findUserSidoCombo, findGugunCombo, + findUserGugunCombo, } satisfies CmCdAdstDstrService; diff --git a/apps/as/src/entities/adstdstr/api/queries.ts b/apps/as/src/entities/adstdstr/api/queries.ts index 353e25cfe..2556ec6db 100644 --- a/apps/as/src/entities/adstdstr/api/queries.ts +++ b/apps/as/src/entities/adstdstr/api/queries.ts @@ -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), + }); +} diff --git a/apps/as/src/entities/adstdstr/index.ts b/apps/as/src/entities/adstdstr/index.ts index da1dbd132..2fffe5a79 100644 --- a/apps/as/src/entities/adstdstr/index.ts +++ b/apps/as/src/entities/adstdstr/index.ts @@ -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, diff --git a/apps/as/src/entities/adstdstr/model/adstdstr-interface.ts b/apps/as/src/entities/adstdstr/model/adstdstr-interface.ts index 72cb673dc..1eaa14bff 100644 --- a/apps/as/src/entities/adstdstr/model/adstdstr-interface.ts +++ b/apps/as/src/entities/adstdstr/model/adstdstr-interface.ts @@ -7,5 +7,7 @@ export interface CmCdAdstDstrComboResponseDto { /** Controller public 메서드명·시그니처와 동일 */ export interface CmCdAdstDstrService { findSidoCombo(): Promise; + findUserSidoCombo(): Promise; findGugunCombo(sido: string): Promise; + findUserGugunCombo(sido: string): Promise; } diff --git a/apps/as/src/entities/auth/api/auth-service.ts b/apps/as/src/entities/auth/api/auth-service.ts index b7158bf80..d84f84776 100644 --- a/apps/as/src/entities/auth/api/auth-service.ts +++ b/apps/as/src/entities/auth/api/auth-service.ts @@ -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 { @@ -72,6 +76,7 @@ async function silentRefresh(): Promise { } async function logout(): Promise { + await fetch("/api/auth/logout", { method: "POST" }); tokenManager.clearAuthToken(); clearDeviceId(); diff --git a/apps/as/src/entities/auth/lib/device-id.ts b/apps/as/src/entities/auth/lib/device-id.ts deleted file mode 100644 index ce173b141..000000000 --- a/apps/as/src/entities/auth/lib/device-id.ts +++ /dev/null @@ -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); -} diff --git a/apps/as/src/entities/bns-if/api/bns-if-service.ts b/apps/as/src/entities/bns-if/api/bns-if-service.ts index 320a01efc..316ea041c 100644 --- a/apps/as/src/entities/bns-if/api/bns-if-service.ts +++ b/apps/as/src/entities/bns-if/api/bns-if-service.ts @@ -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 { + const response = await client.get(`${BASE}/facilityList`, { + params, + }); + return response.data; +} + export const bnsIfService = { getBnsList, getBnsDetail, getEmCodeList, + getFacilityList, } satisfies BnsIfService; diff --git a/apps/as/src/entities/bns-if/api/queries.ts b/apps/as/src/entities/bns-if/api/queries.ts index 7ab0b46a3..8edf93b0c 100644 --- a/apps/as/src/entities/bns-if/api/queries.ts +++ b/apps/as/src/entities/bns-if/api/queries.ts @@ -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, + }); +} diff --git a/apps/as/src/entities/bns-if/index.ts b/apps/as/src/entities/bns-if/index.ts index 430f4a611..d01b27189 100644 --- a/apps/as/src/entities/bns-if/index.ts +++ b/apps/as/src/entities/bns-if/index.ts @@ -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"; diff --git a/apps/as/src/entities/bns-if/model/bns-if-interface.ts b/apps/as/src/entities/bns-if/model/bns-if-interface.ts index e13ab8500..833accca8 100644 --- a/apps/as/src/entities/bns-if/model/bns-if-interface.ts +++ b/apps/as/src/entities/bns-if/model/bns-if-interface.ts @@ -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; getBnsDetail(gbn: string, yr: string, seqNo: string): Promise; getEmCodeList(dpmCd?: string, lclgvrnmntCd?: string): Promise; + getFacilityList(params: FacilitySearchDto): Promise; } diff --git a/apps/as/src/entities/dc/api/dc-service.ts b/apps/as/src/entities/dc/api/dc-service.ts new file mode 100644 index 000000000..650e81b23 --- /dev/null +++ b/apps/as/src/entities/dc/api/dc-service.ts @@ -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 { + const response = await client.get( + `${ENDPOINT.DC_SERVICE}/nowDmgMtncGnsvy`, + { params }, + ); + return response.data; +} + +async function getLvNowDmgMtncGnsvy( + params: LvNowDmgMtncGnsvySearchDto, +): Promise { + const response = await client.get( + `${ENDPOINT.DC_SERVICE}/lvNowDmgMtncGnsvy`, + { params }, + ); + return response.data; +} + +async function getClsfSystm(params: ClsfSystmParamsDto): Promise { + const response = await client.get(`${ENDPOINT.DC_SERVICE}/clsf`, { + params, + }); + return response.data; +} + +async function getFile(params: FileParamsDto): Promise { + const response = await client.get(`${ENDPOINT.DC_SERVICE}/file`, { + params, + }); + return response.data; +} + +async function getFilePrcs(fileSn: string): Promise { + const response = await client.get(`${ENDPOINT.DC_SERVICE}/fileprcs`, { + params: { fileSn }, + }); + return response.data; +} + +async function fileDownload(params: FileDownloadParamsDto): Promise { + const response = await client.get(`${ENDPOINT.DC_SERVICE}/fileDownload`, { + params, + responseType: "blob", + }); + return response.data; +} + +export const dcService = { + getNowDmgMtncGnsvy, + getLvNowDmgMtncGnsvy, + getClsfSystm, + getFile, + getFilePrcs, + fileDownload, +} satisfies DcService; diff --git a/apps/as/src/entities/dc/api/queries.ts b/apps/as/src/entities/dc/api/queries.ts new file mode 100644 index 000000000..043006305 --- /dev/null +++ b/apps/as/src/entities/dc/api/queries.ts @@ -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), + }); +} diff --git a/apps/as/src/entities/dc/index.ts b/apps/as/src/entities/dc/index.ts new file mode 100644 index 000000000..b9df34de5 --- /dev/null +++ b/apps/as/src/entities/dc/index.ts @@ -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"; diff --git a/apps/as/src/entities/dc/model/dc-interface.ts b/apps/as/src/entities/dc/model/dc-interface.ts new file mode 100644 index 000000000..59f0a7be2 --- /dev/null +++ b/apps/as/src/entities/dc/model/dc-interface.ts @@ -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; + getLvNowDmgMtncGnsvy(params: LvNowDmgMtncGnsvySearchDto): Promise; + getClsfSystm(params: ClsfSystmParamsDto): Promise; + getFile(params: FileParamsDto): Promise; + getFilePrcs(fileSn: string): Promise; + fileDownload(params: FileDownloadParamsDto): Promise; +} diff --git a/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts b/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts index 6ead54c13..4bbd7ae24 100644 --- a/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts +++ b/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts @@ -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 */ diff --git a/apps/as/src/entities/sewer-bsc-plan/api/queries.ts b/apps/as/src/entities/sewer-bsc-plan/api/queries.ts new file mode 100644 index 000000000..063e1bd00 --- /dev/null +++ b/apps/as/src/entities/sewer-bsc-plan/api/queries.ts @@ -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], + }); + }, + }); +} diff --git a/apps/as/src/entities/sewer-bsc-plan/api/sewer-bsc-plan-service.ts b/apps/as/src/entities/sewer-bsc-plan/api/sewer-bsc-plan-service.ts new file mode 100644 index 000000000..013b1d384 --- /dev/null +++ b/apps/as/src/entities/sewer-bsc-plan/api/sewer-bsc-plan-service.ts @@ -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> { + const response = await client.get>(BASE, { + params, + }); + return response.data; +} + +async function findById(sewerBscPlanDocNo: string): Promise { + const response = await client.get(`${BASE}/${sewerBscPlanDocNo}`); + return response.data; +} + +async function save(body: SewerBscPlanSaveRequestDto): Promise { + const response = await client.post(BASE, body); + return response.data; +} + +async function update( + sewerBscPlanDocNo: string, + body: SewerBscPlanUpdateRequestDto, +): Promise { + const response = await client.put(`${BASE}/${sewerBscPlanDocNo}`, body); + return response.data; +} + +async function deleteFn(sewerBscPlanDocNo: string): Promise { + await client.delete(`${BASE}/${sewerBscPlanDocNo}`); +} + +async function saveApply( + body: SewerBscPlanApplySaveRequestDto, +): Promise { + const response = await client.post(`${BASE}/save-apply`, body); + return response.data; +} + +async function findDetails( + sewerBscPlanDocNo: string, + chgCycl: number, +): Promise { + const response = await client.get( + `${BASE}/${sewerBscPlanDocNo}/dtl/${chgCycl}`, + ); + return response.data; +} + +async function submitSupplement( + id: string, + body: SewerBscPlanApplySaveRequestDto, +): Promise { + const response = await client.post(`${BASE}/${id}/submitsupplement`, body); + return response.data; +} + +async function submitReqSupplement( + id: string, + chgCycl: number, + body: SewerBscPlanApplySaveRequestDto, +): Promise { + const response = await client.post(`${BASE}/${id}/${chgCycl}/reqsupplement`, body); + return response.data; +} + +async function techReview(id: string, body: SewerBscPlanApplySaveRequestDto): Promise { + await client.post(`${BASE}/${id}/tech-review`, body); +} + +export const sewerBscPlanService = { + findPage, + findById, + save, + update, + delete: deleteFn, + saveApply, + findDetails, + submitSupplement, + submitReqSupplement, + techReview, +} satisfies SewerBscPlanService; diff --git a/apps/as/src/entities/sewer-bsc-plan/index.ts b/apps/as/src/entities/sewer-bsc-plan/index.ts new file mode 100644 index 000000000..eecf354cd --- /dev/null +++ b/apps/as/src/entities/sewer-bsc-plan/index.ts @@ -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"; diff --git a/apps/as/src/entities/sewer-bsc-plan/model/sewer-bsc-plan-interface.ts b/apps/as/src/entities/sewer-bsc-plan/model/sewer-bsc-plan-interface.ts new file mode 100644 index 000000000..cc5f533d8 --- /dev/null +++ b/apps/as/src/entities/sewer-bsc-plan/model/sewer-bsc-plan-interface.ts @@ -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>; + findById(sewerBscPlanDocNo: string): Promise; + save(body: SewerBscPlanSaveRequestDto): Promise; + update( + sewerBscPlanDocNo: string, + body: SewerBscPlanUpdateRequestDto, + ): Promise; + delete(sewerBscPlanDocNo: string): Promise; + saveApply(body: SewerBscPlanApplySaveRequestDto): Promise; + findDetails(sewerBscPlanDocNo: string, chgCycl: number): Promise; + submitSupplement(id: string, body: SewerBscPlanApplySaveRequestDto): Promise; + submitReqSupplement( + id: string, + chgCycl: number, + body: SewerBscPlanApplySaveRequestDto, + ): Promise; + techReview(id: string, body: SewerBscPlanApplySaveRequestDto): Promise; +} diff --git a/apps/as/src/pages/design-cnstn/lib/form-data-mapper.ts b/apps/as/src/pages/design-cnstn/lib/form-data-mapper.ts index a34888317..9ba03d856 100644 --- a/apps/as/src/pages/design-cnstn/lib/form-data-mapper.ts +++ b/apps/as/src/pages/design-cnstn/lib/form-data-mapper.ts @@ -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: "", }, diff --git a/apps/as/src/pages/design-cnstn/ui/design-form-page.tsx b/apps/as/src/pages/design-cnstn/ui/design-form-page.tsx index 6c09a5320..63d4eda75 100644 --- a/apps/as/src/pages/design-cnstn/ui/design-form-page.tsx +++ b/apps/as/src/pages/design-cnstn/ui/design-form-page.tsx @@ -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( diff --git a/apps/as/src/pages/design-cnstn/ui/design-list.tsx b/apps/as/src/pages/design-cnstn/ui/design-list.tsx index 8a0986379..a23f2e9ea 100644 --- a/apps/as/src/pages/design-cnstn/ui/design-list.tsx +++ b/apps/as/src/pages/design-cnstn/ui/design-list.tsx @@ -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
{tableComponent} - diff --git a/apps/as/src/pages/design-cnstn/ui/design-regist-page.tsx b/apps/as/src/pages/design-cnstn/ui/design-regist-page.tsx index fa8130898..54f1738d1 100644 --- a/apps/as/src/pages/design-cnstn/ui/design-regist-page.tsx +++ b/apps/as/src/pages/design-cnstn/ui/design-regist-page.tsx @@ -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 ; +interface DesignRegistPageProps { + defaults?: DesignRegistDefaults; +} + +function DesignRegistPage({ defaults }: DesignRegistPageProps) { + return ; } export { DesignRegistPage }; +export type { DesignRegistDefaults }; diff --git a/apps/as/src/pages/design-cnstn/ui/sections/basic-info-form-section.tsx b/apps/as/src/pages/design-cnstn/ui/sections/basic-info-form-section.tsx index 124370ddc..ded247b41 100644 --- a/apps/as/src/pages/design-cnstn/ui/sections/basic-info-form-section.tsx +++ b/apps/as/src/pages/design-cnstn/ui/sections/basic-info-form-section.tsx @@ -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(); 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() { 구분 {taskSeComCdLabel} 신청 지자체 - {userInfo?.instSeNm} + {localGovLabel} diff --git a/apps/as/src/pages/fnrs-cnsltn/lib/form-data-mapper.ts b/apps/as/src/pages/fnrs-cnsltn/lib/form-data-mapper.ts index 82707757d..d30765961 100644 --- a/apps/as/src/pages/fnrs-cnsltn/lib/form-data-mapper.ts +++ b/apps/as/src/pages/fnrs-cnsltn/lib/form-data-mapper.ts @@ -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: "", diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-form-page.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-form-page.tsx index 76c42f38f..e85efd562 100644 --- a/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-form-page.tsx +++ b/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-form-page.tsx @@ -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( diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-list.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-list.tsx index dd9c2292e..6f584074d 100644 --- a/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-list.tsx +++ b/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-list.tsx @@ -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 {tableComponent} - diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-regist-page.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-regist-page.tsx index 20c5f48b7..c2ea5b819 100644 --- a/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-regist-page.tsx +++ b/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-regist-page.tsx @@ -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 ; +interface FnrsRegistPageProps { + defaults?: FnrsRegistDefaults; +} + +function FnrsRegistPage({ defaults }: FnrsRegistPageProps) { + return ; } export { FnrsRegistPage }; +export type { FnrsRegistDefaults }; diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/sections/basic-info-form-section.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/sections/basic-info-form-section.tsx index 4b85c4f93..751312ac6 100644 --- a/apps/as/src/pages/fnrs-cnsltn/ui/sections/basic-info-form-section.tsx +++ b/apps/as/src/pages/fnrs-cnsltn/ui/sections/basic-info-form-section.tsx @@ -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(); 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 ( + + + + + + @@ -141,7 +148,7 @@ function BasicInfoFormSection() { 구분 {taskSeComCdLabel} 신청 지자체 - {userInfo?.instSeNm} + {localGovLabel} {taskSeComCd.includes("RE") && ( <> diff --git a/apps/as/src/pages/instl-sncn/lib/form-data-mapper.ts b/apps/as/src/pages/instl-sncn/lib/form-data-mapper.ts index a0a36a298..a1e1242c5 100644 --- a/apps/as/src/pages/instl-sncn/lib/form-data-mapper.ts +++ b/apps/as/src/pages/instl-sncn/lib/form-data-mapper.ts @@ -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: "", }, diff --git a/apps/as/src/pages/instl-sncn/ui/instl-form-page.tsx b/apps/as/src/pages/instl-sncn/ui/instl-form-page.tsx index 6cfd0f488..766712e35 100644 --- a/apps/as/src/pages/instl-sncn/ui/instl-form-page.tsx +++ b/apps/as/src/pages/instl-sncn/ui/instl-form-page.tsx @@ -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( diff --git a/apps/as/src/pages/instl-sncn/ui/instl-list-page.tsx b/apps/as/src/pages/instl-sncn/ui/instl-list-page.tsx index fe3bb4417..920127a49 100644 --- a/apps/as/src/pages/instl-sncn/ui/instl-list-page.tsx +++ b/apps/as/src/pages/instl-sncn/ui/instl-list-page.tsx @@ -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, }); }; diff --git a/apps/as/src/pages/instl-sncn/ui/instl-list.tsx b/apps/as/src/pages/instl-sncn/ui/instl-list.tsx index 79ff4586e..e54b41578 100644 --- a/apps/as/src/pages/instl-sncn/ui/instl-list.tsx +++ b/apps/as/src/pages/instl-sncn/ui/instl-list.tsx @@ -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 {tableComponent} - diff --git a/apps/as/src/pages/instl-sncn/ui/instl-regist-page.tsx b/apps/as/src/pages/instl-sncn/ui/instl-regist-page.tsx index 0b9472a15..509e9a7c5 100644 --- a/apps/as/src/pages/instl-sncn/ui/instl-regist-page.tsx +++ b/apps/as/src/pages/instl-sncn/ui/instl-regist-page.tsx @@ -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 ; +interface InstlRegistPageProps { + defaults?: InstlRegistDefaults; +} + +function InstlRegistPage({ defaults }: InstlRegistPageProps) { + return ; } export { InstlRegistPage }; +export type { InstlRegistDefaults }; diff --git a/apps/as/src/pages/instl-sncn/ui/sections/basic-info-form-section.tsx b/apps/as/src/pages/instl-sncn/ui/sections/basic-info-form-section.tsx index 199ff6444..e9c8aa8b2 100644 --- a/apps/as/src/pages/instl-sncn/ui/sections/basic-info-form-section.tsx +++ b/apps/as/src/pages/instl-sncn/ui/sections/basic-info-form-section.tsx @@ -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(); 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 (
+ + + + + + @@ -145,7 +152,7 @@ function BasicInfoFormSection() { 구분 {taskSeComCdLabel} 신청 지자체 - {userInfo?.instSeNm} + {localGovLabel} 사업구분 diff --git a/apps/as/src/pages/sewer-bsc-plan/index.ts b/apps/as/src/pages/sewer-bsc-plan/index.ts new file mode 100644 index 000000000..39f824683 --- /dev/null +++ b/apps/as/src/pages/sewer-bsc-plan/index.ts @@ -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"; diff --git a/apps/as/src/pages/sewer-bsc-plan/lib/biz-cst-matrix-utils.ts b/apps/as/src/pages/sewer-bsc-plan/lib/biz-cst-matrix-utils.ts new file mode 100644 index 000000000..41ff8e9da --- /dev/null +++ b/apps/as/src/pages/sewer-bsc-plan/lib/biz-cst-matrix-utils.ts @@ -0,0 +1,102 @@ +import type { CostItemDef } from "@/widgets/pbadms-sprt/lib/business-cost-form-utils"; + +export interface BizCstMatrixItem { + planStpNo: number; + acntgSeCd: string; + cstrnAmt: number; +} + +const PLAN_STEPS = [1, 2, 3, 4] as const; + +function getAmt(list: BizCstMatrixItem[], acntgSeCd: string, planStpNo: number): number { + return ( + list.find((item) => item.acntgSeCd === acntgSeCd && item.planStpNo === planStpNo) + ?.cstrnAmt ?? 0 + ); +} + +function setAmt( + list: BizCstMatrixItem[], + acntgSeCd: string, + planStpNo: number, + cstrnAmt: number, +): BizCstMatrixItem[] { + const index = list.findIndex( + (item) => item.acntgSeCd === acntgSeCd && item.planStpNo === planStpNo, + ); + if (index >= 0) { + const next = [...list]; + next[index] = { ...next[index], cstrnAmt }; + return next; + } + return [...list, { planStpNo, acntgSeCd, cstrnAmt }]; +} + +/** CostItemDef 트리의 children 기반 bottom-up 부모합산 (각 단계별 cstrnAmt) */ +function recalculateBizCstMatrix( + list: BizCstMatrixItem[], + costItems: CostItemDef[], +): BizCstMatrixItem[] { + let next = [...list]; + let hasChanges = false; + + for (let i = costItems.length - 1; i >= 0; i--) { + const item = costItems[i]; + if (!item.readonly || !item.children) continue; + + for (const planStpNo of PLAN_STEPS) { + const childSum = item.children.reduce( + (sum, childCode) => sum + getAmt(next, childCode, planStpNo), + 0, + ); + const current = getAmt(next, item.code, planStpNo); + if (current !== childSum) { + next = setAmt(next, item.code, planStpNo, childSum); + hasChanges = true; + } + } + } + + return hasChanges ? next : list; +} + +function createEmptyBizCstList(costItems: CostItemDef[]): BizCstMatrixItem[] { + const list: BizCstMatrixItem[] = []; + for (const item of costItems) { + for (const planStpNo of PLAN_STEPS) { + list.push({ planStpNo, acntgSeCd: item.code, cstrnAmt: 0 }); + } + } + return list; +} + +function mergeBizCstList( + existing: BizCstMatrixItem[], + costItems: CostItemDef[], +): BizCstMatrixItem[] { + const existingMap = new Map( + existing.map((item) => [`${item.acntgSeCd}:${item.planStpNo}`, item.cstrnAmt]), + ); + const base = createEmptyBizCstList(costItems).map((item) => ({ + ...item, + cstrnAmt: existingMap.get(`${item.acntgSeCd}:${item.planStpNo}`) ?? 0, + })); + return recalculateBizCstMatrix(base, costItems); +} + +function toBizCstSaveList( + list: BizCstMatrixItem[], + costItems: CostItemDef[], +): BizCstMatrixItem[] { + return recalculateBizCstMatrix(list, costItems); +} + +export { + createEmptyBizCstList, + getAmt, + mergeBizCstList, + PLAN_STEPS, + recalculateBizCstMatrix, + setAmt, + toBizCstSaveList, +}; diff --git a/apps/as/src/pages/sewer-bsc-plan/lib/fnrs-cst-form-utils.ts b/apps/as/src/pages/sewer-bsc-plan/lib/fnrs-cst-form-utils.ts new file mode 100644 index 000000000..a6ed6b94b --- /dev/null +++ b/apps/as/src/pages/sewer-bsc-plan/lib/fnrs-cst-form-utils.ts @@ -0,0 +1,82 @@ +import type { CostItemDef } from "@/widgets/pbadms-sprt/lib/business-cost-form-utils"; + +import { type BizCstMatrixItem,getAmt, PLAN_STEPS } from "./biz-cst-matrix-utils"; + +export interface FnrsCstItem { + planStpNo: number; + gvexpAmt?: number; + lcexpAmt?: number; + orgprBdchgAmt?: number; + etcAmt?: number; + sumAmt?: number; +} + +export interface FnrsCstStepRemaining { + planStpNo: number; + remaining: number; +} + +function getBizCstRootCode(costItems: CostItemDef[]): string | undefined { + return costItems.find((item) => item.level === 1 && item.readonly)?.code; +} + +function getBizCstStepTotal( + bizCstList: BizCstMatrixItem[], + costItems: CostItemDef[], + planStpNo: number, +): number { + const rootCode = getBizCstRootCode(costItems); + if (!rootCode) return 0; + return getAmt(bizCstList, rootCode, planStpNo); +} + +function sumFnrsCstLeaf(item: FnrsCstItem): number { + return ( + (item.gvexpAmt ?? 0) + + (item.lcexpAmt ?? 0) + + (item.orgprBdchgAmt ?? 0) + + (item.etcAmt ?? 0) + ); +} + +function recalcFnrsCstSumAmt(item: FnrsCstItem): FnrsCstItem { + return { + ...item, + sumAmt: sumFnrsCstLeaf(item), + }; +} + +function calcFnrsCstRemaining( + bizCstList: BizCstMatrixItem[], + fnrsCstList: FnrsCstItem[], + costItems: CostItemDef[], +): FnrsCstStepRemaining[] { + return PLAN_STEPS.map((planStpNo) => { + const bizTotal = getBizCstStepTotal(bizCstList, costItems, planStpNo); + const fnrsItem = fnrsCstList.find((item) => item.planStpNo === planStpNo); + const sharTotal = fnrsItem ? sumFnrsCstLeaf(fnrsItem) : 0; + return { planStpNo, remaining: bizTotal - sharTotal }; + }); +} + +function validateFnrsCstBalance( + bizCstList: BizCstMatrixItem[], + fnrsCstList: FnrsCstItem[], + costItems: CostItemDef[], +): string | null { + if (costItems.length === 0) return null; + + const remainingList = calcFnrsCstRemaining(bizCstList, fnrsCstList, costItems); + const hasMismatch = remainingList.some((item) => item.remaining !== 0); + if (hasMismatch) return "재원분담 총사업비를 확인해주세요."; + return null; +} + +export { + calcFnrsCstRemaining, + getBizCstRootCode, + getBizCstStepTotal, + recalcFnrsCstSumAmt, + sumFnrsCstLeaf, + validateFnrsCstBalance, +}; diff --git a/apps/as/src/pages/sewer-bsc-plan/lib/form-data-mapper.ts b/apps/as/src/pages/sewer-bsc-plan/lib/form-data-mapper.ts new file mode 100644 index 000000000..7a6b1f0ac --- /dev/null +++ b/apps/as/src/pages/sewer-bsc-plan/lib/form-data-mapper.ts @@ -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(); + + 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 }; diff --git a/apps/as/src/pages/sewer-bsc-plan/lib/previous-plan-table-utils.ts b/apps/as/src/pages/sewer-bsc-plan/lib/previous-plan-table-utils.ts new file mode 100644 index 000000000..f448325a4 --- /dev/null +++ b/apps/as/src/pages/sewer-bsc-plan/lib/previous-plan-table-utils.ts @@ -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 }; diff --git a/apps/as/src/pages/sewer-bsc-plan/lib/sewer-bsc-plan-regist-schema.ts b/apps/as/src/pages/sewer-bsc-plan/lib/sewer-bsc-plan-regist-schema.ts new file mode 100644 index 000000000..19d0bdf90 --- /dev/null +++ b/apps/as/src/pages/sewer-bsc-plan/lib/sewer-bsc-plan-regist-schema.ts @@ -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; + +/** 체크리스트·필수첨부 docSeComCd — CMS 합의 전 임시값 */ +export const SEWER_BSC_PLAN_DOC_SE_COM_CD = "SEWER"; diff --git a/apps/as/src/pages/sewer-bsc-plan/lib/tmplnt-original-mapper.ts b/apps/as/src/pages/sewer-bsc-plan/lib/tmplnt-original-mapper.ts new file mode 100644 index 000000000..50deb704d --- /dev/null +++ b/apps/as/src/pages/sewer-bsc-plan/lib/tmplnt-original-mapper.ts @@ -0,0 +1,70 @@ +import type { NowDmgMtncGnsvyResponseDto } from "@/entities/dc"; + +/** + * 폼 매트릭스 행 키 ↔ DC 당초(Dc) 필드 prefix. + * API에 대응 필드가 없으면 null (당초 셀 `-`). + */ +const TMPLNT_DC_FIELD_PREFIX: Record = { + 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 }; diff --git a/apps/as/src/pages/sewer-bsc-plan/lib/year-options.ts b/apps/as/src/pages/sewer-bsc-plan/lib/year-options.ts new file mode 100644 index 000000000..2c53dece5 --- /dev/null +++ b/apps/as/src/pages/sewer-bsc-plan/lib/year-options.ts @@ -0,0 +1,33 @@ +function buildYearOptions(from: number, to: number): { value: string; label: string }[] { + const years: { value: string; label: string }[] = []; + const step = from <= to ? 1 : -1; + for (let year = from; step > 0 ? year <= to : year >= to; year += step) { + const value = String(year); + years.push({ value, label: value }); + } + return years; +} + +/** 계획년도·기준년도: 현재 ~ 2005 */ +function getPlanYearOptions(now = new Date().getFullYear()) { + return buildYearOptions(now, 2005); +} + +/** 단계 목표연도: 현재 ~ 현재+30 */ +function getGoalYearOptions(now = new Date().getFullYear()) { + return buildYearOptions(now, now + 30); +} + +function getLinkedGoalYears(frlvlGoalYr: string) { + const base = Number(frlvlGoalYr); + if (!Number.isFinite(base) || base <= 0) { + return { sclvlGoalYr: "", thlvlGoalYr: "", folvlGoalYr: "" }; + } + return { + sclvlGoalYr: String(base + 5), + thlvlGoalYr: String(base + 10), + folvlGoalYr: String(base + 15), + }; +} + +export { buildYearOptions, getGoalYearOptions, getLinkedGoalYears, getPlanYearOptions }; diff --git a/apps/as/src/pages/sewer-bsc-plan/model/columns.tsx b/apps/as/src/pages/sewer-bsc-plan/model/columns.tsx new file mode 100644 index 000000000..4a9b03a9a --- /dev/null +++ b/apps/as/src/pages/sewer-bsc-plan/model/columns.tsx @@ -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(); + +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 ( +
+ {badges.map((badge, index) => ( + + {index > 0 && >} + {badge.label} + + ))} +
+ ); +} + +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 }) => ( + + ), + }), + columnHelper.accessor("regDt", { + header: "등록일", + meta: { align: "center" }, + cell: ({ getValue }) => { + const value = getValue(); + return value ? dayjs(value).format("YYYY-MM-DD") : "-"; + }, + }), + ]; +} diff --git a/apps/as/src/pages/sewer-bsc-plan/ui/previous-plan-lookup-dialog.tsx b/apps/as/src/pages/sewer-bsc-plan/ui/previous-plan-lookup-dialog.tsx new file mode 100644 index 000000000..843f0251b --- /dev/null +++ b/apps/as/src/pages/sewer-bsc-plan/ui/previous-plan-lookup-dialog.tsx @@ -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 ( + + + + 기본 계획 찾기 + + +
+ {isSidoPending ? ( +
+ +
+ ) : ( + + )} + + + + + + + {isFetching ? ( + + ) : ( + + )} + + + + {isFetching ? ( + + ) : ( + + )} + +
+ + + + +
+
+ ); +} + +export { PreviousPlanLookupDialog }; diff --git a/apps/as/src/pages/sewer-bsc-plan/ui/previous-plan/previous-plan-area-table.tsx b/apps/as/src/pages/sewer-bsc-plan/ui/previous-plan/previous-plan-area-table.tsx new file mode 100644 index 000000000..c118dbfc2 --- /dev/null +++ b/apps/as/src/pages/sewer-bsc-plan/ui/previous-plan/previous-plan-area-table.tsx @@ -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 ( +
+ + + + + + + + + + + + + + + + 구분 + + + 당초 + + + 기준 + + + 변경 + + + + 1단계 + 2단계 + 3단계 + 4단계 + 1단계 + 2단계 + 3단계 + 4단계 + + + {yearCells.map((cell, index) => ( + + {cell} + + ))} + + + + {AREA_ROWS.map((row) => { + const cells = getMetricCells(data, row.field); + return ( + + {row.label} + {cells.map((cell, index) => ( + + {cell} + + ))} + + ); + })} + +
+ ); +} + +export { PreviousPlanAreaTable }; diff --git a/apps/as/src/pages/sewer-bsc-plan/ui/previous-plan/previous-plan-file-section.tsx b/apps/as/src/pages/sewer-bsc-plan/ui/previous-plan/previous-plan-file-section.tsx new file mode 100644 index 000000000..c2d6cc457 --- /dev/null +++ b/apps/as/src/pages/sewer-bsc-plan/ui/previous-plan/previous-plan-file-section.tsx @@ -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; + 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 ( +
  • + + + + + {item.clsfSystmNm} + + +
      + {children.map((child) => ( + + ))} +
    +
    +
    +
  • + ); + } + + const isSelected = selectedSn === item.clsfSystmSn; + + return ( +
  • + +
  • + ); +} + +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 ( +

    + 시도 및 구군을 선택 후 조회하세요. +

    + ); + } + + return ( +
    +
    + {isClsfPending ? ( + + ) : tree.length === 0 ? ( +

    파일이 없습니다.

    + ) : ( +
      + {tree.map((item) => ( + setSelectedClsfSn(sn)} + /> + ))} +
    + )} +
    +
    + {!selectedClsfSn ? ( +

    + 트리에서 항목을 선택하세요. +

    + ) : isFilePending ? ( + + ) : files.length === 0 ? ( +

    파일이 없습니다.

    + ) : ( + + + + 번호 + 파일명 + 등록일 + 다운로드 + + + + {files.map((file, index) => ( + + {file.no ?? index + 1} + {file.fileTitle ?? "-"} + {file.regDt ?? "-"} + + + + + ))} + +
    + )} +
    +
    + ); +} + +export { PreviousPlanFileSection }; diff --git a/apps/as/src/pages/sewer-bsc-plan/ui/previous-plan/previous-plan-search-filters.tsx b/apps/as/src/pages/sewer-bsc-plan/ui/previous-plan/previous-plan-search-filters.tsx new file mode 100644 index 000000000..5ff87d01e --- /dev/null +++ b/apps/as/src/pages/sewer-bsc-plan/ui/previous-plan/previous-plan-search-filters.tsx @@ -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 ( +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
    +
    + ); +} + +export { PreviousPlanSearchFilters }; diff --git a/apps/as/src/pages/sewer-bsc-plan/ui/previous-plan/previous-plan-summary-table.tsx b/apps/as/src/pages/sewer-bsc-plan/ui/previous-plan/previous-plan-summary-table.tsx new file mode 100644 index 000000000..0f9e907fb --- /dev/null +++ b/apps/as/src/pages/sewer-bsc-plan/ui/previous-plan/previous-plan-summary-table.tsx @@ -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 ( + + + + + + + + + + + + + + + + + 구분 + + + 당초 + + + 기준 + + + 변경 + + + + 1단계 + 2단계 + 3단계 + 4단계 + 1단계 + 2단계 + 3단계 + 4단계 + + + + + 계획목표년도 + {yearCells.map((cell, index) => ( + + {cell} + + ))} + + +
    + ); +} + +export { PreviousPlanSummaryTable }; diff --git a/apps/as/src/pages/sewer-bsc-plan/ui/sections/basic-info-form-section.tsx b/apps/as/src/pages/sewer-bsc-plan/ui/sections/basic-info-form-section.tsx new file mode 100644 index 000000000..c935b9185 --- /dev/null +++ b/apps/as/src/pages/sewer-bsc-plan/ui/sections/basic-info-form-section.tsx @@ -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(); + 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 ( + + + + + + + + + + + + 기본계획명 + + + ( + + + + + )} + /> + + + + 계획년도 + + ( + + + + + )} + /> + + 기준년도 + + ( + + + + + )} + /> + + + + 1단계 목표연도 + + ( + + + + + )} + /> + + 2단계 목표연도 + + ( + + + + + )} + /> + + + + 3단계 목표연도 + + ( + + + + + )} + /> + + 4단계 목표연도 + + ( + + + + + )} + /> + + + + 계획개요 + + ( + +