[as] WIP: 기본계획조회 다이얼로그 구현

This commit is contained in:
2026-07-31 09:30:09 +09:00
parent e56938f7ff
commit e67492853c
12 changed files with 1488 additions and 17 deletions
@@ -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 — 응답은 캐시·검증용일 수 있음 |
+28 -2
View File
@@ -2,9 +2,11 @@ import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
ClsfSystmRequestDto,
ClsfSystmParamsDto,
ClsfSystmResponseDto,
DcService,
FileDownloadParamsDto,
FileParamsDto,
LvNowDmgMtncGnsvyResponseDto,
LvNowDmgMtncGnsvySearchDto,
NowDmgMtncGnsvyResponseDto,
@@ -31,15 +33,39 @@ async function getLvNowDmgMtncGnsvy(
return response.data;
}
async function getClsfSystm(params: ClsfSystmRequestDto): Promise<ClsfSystmResponseDto[]> {
async function getClsfSystm(params: ClsfSystmParamsDto): Promise<ClsfSystmResponseDto[]> {
const response = await client.get<ClsfSystmResponseDto[]>(`${ENDPOINT.DC_SERVICE}/clsfSystm`, {
params,
});
return response.data;
}
async function getFile(params: FileParamsDto): Promise<unknown> {
const response = await client.get<unknown>(`${ENDPOINT.DC_SERVICE}/file`, {
params,
});
return response.data;
}
async function getFilePrcs(fileSn: string): Promise<unknown> {
const response = await client.get<unknown>(`${ENDPOINT.DC_SERVICE}/fileprcs`, {
params: { fileSn },
});
return response.data;
}
async function fileDownload(params: FileDownloadParamsDto): Promise<unknown> {
const response = await client.get<unknown>(`${ENDPOINT.DC_SERVICE}/fileDownload`, {
params,
});
return response.data;
}
export const dcService = {
getNowDmgMtncGnsvy,
getLvNowDmgMtncGnsvy,
getClsfSystm,
getFile,
getFilePrcs,
fileDownload,
} satisfies DcService;
+32 -3
View File
@@ -3,7 +3,8 @@ import { useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import type {
ClsfSystmRequestDto,
ClsfSystmParamsDto,
FileParamsDto,
LvNowDmgMtncGnsvySearchDto,
NowDmgMtncGnsvySearchDto,
} from "../model/dc-interface";
@@ -20,17 +21,45 @@ export function useNowDmgMtncGnsvy(params: NowDmgMtncGnsvySearchDto) {
});
}
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: Boolean(params.level && params.ctpvNm && params.sggNm && params.gnsvyYr),
enabled: isLvNowDmgMtncGnsvyEnabled(params),
});
}
export function useClsfSystm(params: ClsfSystmRequestDto) {
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),
});
}
+18 -2
View File
@@ -1,3 +1,19 @@
export { dcService } from "./api/dc-service";
export { baseKey as nowDmgMtncGnsvyBaseKey, useNowDmgMtncGnsvy } from "./api/queries";
export type { NowDmgMtncGnsvyResponseDto, NowDmgMtncGnsvySearchDto } from "./model/dc-interface";
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";
+30 -9
View File
@@ -12,11 +12,22 @@ export interface LvNowDmgMtncGnsvySearchDto {
gnsvyYr?: string;
}
export interface ClsfSystmRequestDto {
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;
@@ -67,14 +78,21 @@ export interface NowDmgMtncGnsvyResponseDto
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[];
level?: number;
ctpvNm?: string;
sggNm?: string;
gnsvyYr?: string;
swtrmtFcltCd?: string;
swtrmtFcltNm?: string;
children?: LvNowDmgMtncGnsvyResponseDto[];
}
export interface ClsfSystmResponseDto {
@@ -89,5 +107,8 @@ export interface ClsfSystmResponseDto {
export interface DcService {
getNowDmgMtncGnsvy(params: NowDmgMtncGnsvySearchDto): Promise<NowDmgMtncGnsvyResponseDto[]>;
getLvNowDmgMtncGnsvy(params: LvNowDmgMtncGnsvySearchDto): Promise<LvNowDmgMtncGnsvyResponseDto[]>;
getClsfSystm(params: ClsfSystmRequestDto): Promise<ClsfSystmResponseDto[]>;
getClsfSystm(params: ClsfSystmParamsDto): Promise<ClsfSystmResponseDto[]>;
getFile(params: FileParamsDto): Promise<unknown>;
getFilePrcs(fileSn: string): Promise<unknown>;
fileDownload(params: FileDownloadParamsDto): Promise<unknown>;
}
@@ -0,0 +1,105 @@
import type { LvNowDmgMtncGnsvyResponseDto, NowDmgMtncGnsvyResponseDto } from "@/entities/dc";
type StageKey = "Dt1" | "Dt2" | "Dt3" | "Dt4" | "" | "Dc1" | "Dc2" | "Dc3" | "Dc4";
const STAGE_KEYS: StageKey[] = ["Dt1", "Dt2", "Dt3", "Dt4", "", "Dc1", "Dc2", "Dc3", "Dc4"];
function formatPlanCell(value: string | number | null | undefined, asYear = false): string {
if (value === null || value === undefined || value === "") return "-";
if (asYear) return `${value}년`;
if (typeof value === "number") return value.toLocaleString();
const num = Number(value);
if (!Number.isNaN(num) && value.trim() !== "" && /^-?\d+(\.\d+)?$/.test(value.trim()))
return num.toLocaleString();
return String(value);
}
function getStagedValue(
data: NowDmgMtncGnsvyResponseDto | null | undefined,
fieldPrefix: string,
stage: StageKey,
): string | number | null | undefined {
if (!data) return undefined;
const key = `${fieldPrefix}${stage}` as keyof NowDmgMtncGnsvyResponseDto;
return data[key] as string | number | null | undefined;
}
function getPlanYearCells(data: NowDmgMtncGnsvyResponseDto | null | undefined): string[] {
return STAGE_KEYS.map((stage) => formatPlanCell(getStagedValue(data, "planYr", stage), true));
}
function getMetricCells(
data: NowDmgMtncGnsvyResponseDto | null | undefined,
fieldPrefix: string,
): string[] {
return STAGE_KEYS.map((stage) => formatPlanCell(getStagedValue(data, fieldPrefix, stage)));
}
function collectNodesByLevel(
nodes: LvNowDmgMtncGnsvyResponseDto[] | undefined,
targetLevel: number,
acc: LvNowDmgMtncGnsvyResponseDto[] = [],
): LvNowDmgMtncGnsvyResponseDto[] {
if (!nodes) return acc;
for (const node of nodes) {
if (node.level === targetLevel) acc.push(node);
if (node.children?.length) collectNodesByLevel(node.children, targetLevel, acc);
}
return acc;
}
function extractYearOptions(tree: LvNowDmgMtncGnsvyResponseDto[] | undefined): string[] {
const years = collectNodesByLevel(tree, 3)
.map((node) => node.gnsvyYr)
.filter((year): year is string => Boolean(year));
return [...new Set(years)];
}
interface FacilityOption {
code: string;
name: string;
}
function extractFacilityOptions(
tree: LvNowDmgMtncGnsvyResponseDto[] | undefined,
gnsvyYr: string,
): FacilityOption[] {
const yearNodes = collectNodesByLevel(tree, 3).filter((node) => node.gnsvyYr === gnsvyYr);
const options: FacilityOption[] = [];
function pushFacility(node: LvNowDmgMtncGnsvyResponseDto) {
if (!node.swtrmtFcltCd) return;
options.push({
code: node.swtrmtFcltCd,
name: node.swtrmtFcltNm ?? node.swtrmtFcltCd,
});
}
if (yearNodes.length > 0) {
for (const yearNode of yearNodes) {
for (const child of yearNode.children ?? []) pushFacility(child);
}
return options;
}
// level=3 응답이 시설 리프만 내려주는 경우
function walk(nodes: LvNowDmgMtncGnsvyResponseDto[] | undefined) {
if (!nodes) return;
for (const node of nodes) {
pushFacility(node);
walk(node.children);
}
}
walk(tree);
return options;
}
export {
extractFacilityOptions,
extractYearOptions,
formatPlanCell,
getMetricCells,
getPlanYearCells,
STAGE_KEYS,
};
export type { FacilityOption };
@@ -0,0 +1,257 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useAlertStore } from "@/shared/lib";
import {
type CmCdAdstDstrComboResponseDto,
useFindGugunCombo,
useFindSidoCombo,
} from "@/entities/adstdstr";
import { dcService, type NowDmgMtncGnsvyResponseDto, useLvNowDmgMtncGnsvy } 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 [planData, setPlanData] = useState<NowDmgMtncGnsvyResponseDto | null>(null);
const [isSearching, setIsSearching] = useState(false);
const [hasPrefillApplied, setHasPrefillApplied] = useState(false);
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 ?? "";
// TODO: T7 — level=1 응답 UI 활용 여부(현재는 캐시·호출만, 구군은 adstdstr)
useLvNowDmgMtncGnsvy({
level: "1",
ctpvNm: ctpvNm || undefined,
});
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("");
setPlanData(null);
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("");
setPlanData(null);
}
function handleGugunChange(value: string) {
setSggComCd(value);
setGnsvyYr("");
setSwtrmtFcltCd("");
setPlanData(null);
}
function handleYearChange(value: string) {
setGnsvyYr(value);
setSwtrmtFcltCd("");
setPlanData(null);
}
function handleFacilityChange(value: string) {
setSwtrmtFcltCd(value);
setPlanData(null);
}
async function handleSearch() {
if (!ctpvNm) {
alert("시도를 선택해주세요.");
return;
}
if (!sggNm) {
alert("구군을 선택해주세요.");
return;
}
if (!gnsvyYr) {
alert("연도를 선택해주세요.");
return;
}
if (!swtrmtFcltCd) {
alert("공공하수처리시설을 선택해주세요.");
return;
}
setIsSearching(true);
try {
const list = await dcService.getNowDmgMtncGnsvy({
swtrmtFcltCd,
ctpvNm,
sggNm,
targetYear: gnsvyYr,
});
if (!list.length) {
alert("조회된 데이터가 없습니다.");
setPlanData(null);
return;
}
setPlanData(list[0] ?? null);
} catch {
alert("데이터 조회 중 오류가 발생했습니다.");
setPlanData(null);
} finally {
setIsSearching(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="flex max-h-[90vh] flex-col overflow-hidden sm:max-w-[90rem]"
aria-describedby={undefined}
>
<DialogHeader>
<DialogTitle>기본 계획 찾기</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
{isSidoPending ? (
<Skeleton className="h-[6rem] w-full" />
) : (
<PreviousPlanSearchFilters
sidoComCd={sidoComCd}
sggComCd={sggComCd}
gnsvyYr={gnsvyYr}
swtrmtFcltCd={swtrmtFcltCd}
sidoOptions={sidoOptions}
gugunOptions={gugunOptions}
yearOptions={yearOptions}
facilityOptions={facilityOptions}
isGugunPending={isGugunPending}
isYearPending={Boolean(sggNm) && isYearPending}
isFacilityPending={Boolean(gnsvyYr) && isFacilityPending}
isSearching={isSearching}
onSidoChange={handleSidoChange}
onGugunChange={handleGugunChange}
onYearChange={handleYearChange}
onFacilityChange={handleFacilityChange}
onSearch={handleSearch}
/>
)}
<SectionCollapsible
title="기본계획파일(기본계획의 보고서를 트리에서 클릭하시면 파일을 다운받으실수 있습니다.)"
defaultOpen
>
<PreviousPlanFileSection key={sggNm || "empty"} gugunNm={sggNm} />
</SectionCollapsible>
<SectionCollapsible title="총설" defaultOpen>
{isSearching ? (
<Skeleton className="h-[8rem] w-full" />
) : (
<PreviousPlanSummaryTable data={planData} />
)}
</SectionCollapsible>
<SectionCollapsible title="처리구역별 현황" defaultOpen>
{isSearching ? (
<Skeleton className="h-[20rem] w-full" />
) : (
<PreviousPlanAreaTable data={planData} />
)}
</SectionCollapsible>
</div>
<DialogFooter>
<Button type="button" color="white" onClick={() => onOpenChange(false)}>
닫기
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { PreviousPlanLookupDialog };
@@ -0,0 +1,103 @@
"use client";
import type { NowDmgMtncGnsvyResponseDto } from "@/entities/dc";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
import { getMetricCells, getPlanYearCells } from "../../lib/previous-plan-table-utils";
const AREA_ROWS = [
{ label: "하수처리인구(인)", field: "swtrmtPpltnCnt" },
{ label: "자연적하수처리인구수(인)", field: "natrSwtrmtPpltnCnt" },
{ label: "사회적하수처리인구수(인)", field: "scilSwtrmtPpltnCnt" },
{ label: "하수처리구역면적", field: "swtrmtZoneArea" },
{ label: "물사용량원단위", field: "wtrUsqtyOrgnUnit" },
{ label: "오수전환율(%)", field: "drtwSwtcRt" },
{ label: "변동부하율(%)", field: "flctnLodRt" },
{ label: "오수원단위", field: "drtwOrgnUnit" },
{ label: "계획하수량합계(㎥/일)", field: "planSwageQntSum" },
{ label: "생활오수(㎥/일)", field: "lvngDrtwSum" },
{ label: "지하수(㎥/일)", field: "ugwtSum" },
{ label: "공장폐수(㎥/일)", field: "fctryWwtSum" },
{ label: "관광오수(㎥/일)", field: "toursmDrtwSum" },
{ label: "군부대(㎥/일)", field: "mtbsSum" },
{ label: "연계처리수(㎥/일)", field: "linkPrcsCntSum" },
{ label: "기타오수(㎥/일)", field: "etcDrtwSum" },
{ label: "지하수사용량(㎥/일)", field: "ugwtUseSum" },
{ label: "시설용량(㎥/일)", field: "fcltCpctSum" },
{ label: "배제방식", field: "exclsMethExpln" },
] as const;
interface PreviousPlanAreaTableProps {
data: NowDmgMtncGnsvyResponseDto | null;
}
function PreviousPlanAreaTable({ data }: PreviousPlanAreaTableProps) {
const yearCells = getPlanYearCells(data);
return (
<Table variant="secondary">
<colgroup>
<col width="16%" />
<col width="9.3%" />
<col width="9.3%" />
<col width="9.3%" />
<col width="9.3%" />
<col width="9.3%" />
<col width="9.3%" />
<col width="9.3%" />
<col width="9.3%" />
<col width="9.3%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead rowSpan={3} className="text-center">
구분
</TableHead>
<TableHead colSpan={4} className="text-center">
당초
</TableHead>
<TableHead rowSpan={2} className="text-center">
기준
</TableHead>
<TableHead colSpan={4} className="text-center">
변경
</TableHead>
</TableRow>
<TableRow>
<TableHead className="text-center">1단계</TableHead>
<TableHead className="text-center">2단계</TableHead>
<TableHead className="text-center">3단계</TableHead>
<TableHead className="text-center">4단계</TableHead>
<TableHead className="text-center">1단계</TableHead>
<TableHead className="text-center">2단계</TableHead>
<TableHead className="text-center">3단계</TableHead>
<TableHead className="text-center">4단계</TableHead>
</TableRow>
<TableRow>
{yearCells.map((cell, index) => (
<TableHead key={index} className="text-center font-normal">
{cell}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{AREA_ROWS.map((row) => {
const cells = getMetricCells(data, row.field);
return (
<TableRow key={row.field}>
<TableHead>{row.label}</TableHead>
{cells.map((cell, index) => (
<TableCell key={index} className="text-right">
{cell}
</TableCell>
))}
</TableRow>
);
})}
</TableBody>
</Table>
);
}
export { PreviousPlanAreaTable };
@@ -0,0 +1,224 @@
"use client";
import { useMemo, useState } from "react";
import { useAlertStore } from "@/shared/lib";
import { type ClsfSystmResponseDto, dcService, useClsfSystm, useFile } from "@/entities/dc";
import { Button, Skeleton } from "@common/components";
interface ClsfTreeNode {
node: ClsfSystmResponseDto;
children: ClsfTreeNode[];
}
interface LegacyFileItem {
no?: number | string;
fileTitle?: string;
regDt?: string;
fileSn?: string;
}
interface PreviousPlanFileSectionProps {
gugunNm: string;
}
function buildClsfTree(list: ClsfSystmResponseDto[]): ClsfTreeNode[] {
const byId = new Map<string, ClsfTreeNode>();
for (const item of list) {
byId.set(item.clsfSystmSn, { node: item, children: [] });
}
const roots: ClsfTreeNode[] = [];
for (const item of list) {
const current = byId.get(item.clsfSystmSn);
if (!current) continue;
const parent = item.upClsfSystmSn ? byId.get(item.upClsfSystmSn) : undefined;
if (parent) parent.children.push(current);
else roots.push(current);
}
function sortNodes(nodes: ClsfTreeNode[]) {
nodes.sort((a, b) => Number(a.node.sortSeq) - Number(b.node.sortSeq));
nodes.forEach((node) => sortNodes(node.children));
}
sortNodes(roots);
return roots;
}
// TODO: T2 — getFile Response DTO 확정 후 정규화 로직 교체 (레거시: no, fileTitle, regDt, fileSn)
function normalizeFileList(data: unknown): LegacyFileItem[] {
if (!Array.isArray(data)) return [];
return data.map((item) => {
if (!item || typeof item !== "object") return {};
const row = item as Record<string, unknown>;
return {
no: row.no as number | string | undefined,
fileTitle: (row.fileTitle ?? row.fileName ?? row.fileNm) as string | undefined,
regDt: row.regDt as string | undefined,
fileSn: (row.fileSn ?? row.fileId) as string | undefined,
};
});
}
function ClsfTreeItem({
item,
depth,
selectedSn,
onSelect,
}: {
item: ClsfTreeNode;
depth: number;
selectedSn: string;
onSelect: (sn: string) => void;
}) {
const isSelected = selectedSn === item.node.clsfSystmSn;
return (
<li>
<button
type="button"
className={`w-full rounded px-2 py-1 text-left text-sm hover:bg-muted ${
isSelected ? "bg-muted font-semibold" : ""
}`}
style={{ paddingLeft: `${0.5 + depth * 0.75}rem` }}
onClick={() => onSelect(item.node.clsfSystmSn)}
>
{item.node.clsfSystmNm}
</button>
{item.children.length > 0 && (
<ul className="m-0 list-none p-0">
{item.children.map((child) => (
<ClsfTreeItem
key={child.node.clsfSystmSn}
item={child}
depth={depth + 1}
selectedSn={selectedSn}
onSelect={onSelect}
/>
))}
</ul>
)}
</li>
);
}
function PreviousPlanFileSection({ gugunNm }: PreviousPlanFileSectionProps) {
const alert = useAlertStore((state) => state.setMessage);
const [selectedClsfSn, setSelectedClsfSn] = useState("");
const [isDownloading, setIsDownloading] = useState(false);
const { data: clsfList, isPending: isClsfPending } = useClsfSystm({
clsfSystmNm: gugunNm || undefined,
});
const { data: fileData, isPending: isFilePending } = useFile({
clsfSystmSn: selectedClsfSn || undefined,
});
const tree = useMemo(() => buildClsfTree(clsfList ?? []), [clsfList]);
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;
}
// TODO: T3 — blob / Content-Disposition / 게이트웨이 다운로드 UX 확정
await dcService.fileDownload({ filePath, fileName });
alert("다운로드 요청을 전송했습니다. (다운로드 UX TBD)");
} catch {
alert("파일 다운로드 중 오류가 발생했습니다.");
} finally {
setIsDownloading(false);
}
}
if (!gugunNm) {
return (
<p className="py-8 text-center text-sm text-muted-foreground">시도와 구군을 선택하세요.</p>
);
}
return (
<div className="flex h-[40rem] gap-2">
<div className="w-[40%] overflow-y-auto rounded border p-2">
{isClsfPending ? (
<Skeleton className="h-full w-full" />
) : tree.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">파일이 없습니다.</p>
) : (
<ul className="m-0 list-none p-0">
{tree.map((item) => (
<ClsfTreeItem
key={item.node.clsfSystmSn}
item={item}
depth={0}
selectedSn={selectedClsfSn}
onSelect={(sn) => setSelectedClsfSn(sn)}
/>
))}
</ul>
)}
</div>
<div className="w-[60%] overflow-y-auto rounded border p-2">
{!selectedClsfSn ? (
<p className="py-8 text-center text-sm text-muted-foreground">
트리에서 항목을 선택하세요.
</p>
) : isFilePending ? (
<Skeleton className="h-full w-full" />
) : files.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">파일이 없습니다.</p>
) : (
<table className="w-full border-collapse text-sm">
<thead>
<tr className="bg-muted/40">
<th className="border px-2 py-1">번호</th>
<th className="border px-2 py-1 text-left">파일명</th>
<th className="border px-2 py-1">등록일</th>
<th className="border px-2 py-1">다운로드</th>
</tr>
</thead>
<tbody>
{files.map((file, index) => (
<tr key={file.fileSn ?? `${file.fileTitle}-${index}`}>
<td className="border px-2 py-1 text-center">{file.no ?? index + 1}</td>
<td className="border px-2 py-1">{file.fileTitle ?? "-"}</td>
<td className="border px-2 py-1 text-center">{file.regDt ?? "-"}</td>
<td className="border px-2 py-1 text-center">
<Button
type="button"
color="white"
size="sm"
disabled={isDownloading}
onClick={() => handleDownload(file.fileSn, file.fileTitle)}
>
다운로드
</Button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}
export { PreviousPlanFileSection };
@@ -0,0 +1,156 @@
"use client";
import type { CmCdAdstDstrComboResponseDto } from "@/entities/adstdstr";
import {
Button,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Table,
TableCell,
TableHead,
TableHeader,
TableRow,
} 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 (
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
<Table variant="secondary" className="flex-1">
<colgroup>
<col width="12%" />
<col width="13%" />
<col width="12%" />
<col width="13%" />
<col width="12%" />
<col width="13%" />
<col width="12%" />
<col width="13%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead data-required>시도</TableHead>
<TableCell>
<Select value={sidoComCd || undefined} onValueChange={onSidoChange}>
<SelectTrigger className="w-full">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{sidoOptions.map((option) => (
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
{option.adstDstrNm}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableHead data-required>구군</TableHead>
<TableCell>
<Select
value={sggComCd || undefined}
onValueChange={onGugunChange}
disabled={!sidoComCd || isGugunPending}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{gugunOptions.map((option) => (
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
{option.adstDstrNm}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableHead data-required>연도</TableHead>
<TableCell>
<Select
value={gnsvyYr || undefined}
onValueChange={onYearChange}
disabled={!sggComCd || isYearPending}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{yearOptions.map((year) => (
<SelectItem key={year} value={year}>
{year}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableHead data-required>처리시설</TableHead>
<TableCell>
<Select
value={swtrmtFcltCd || undefined}
onValueChange={onFacilityChange}
disabled={!gnsvyYr || isFacilityPending}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{facilityOptions.map((option) => (
<SelectItem key={option.code} value={option.code}>
{option.name}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
</TableRow>
</TableHeader>
</Table>
<Button type="button" className="shrink-0" onClick={onSearch} disabled={isSearching}>
조회
</Button>
</div>
);
}
export { PreviousPlanSearchFilters };
@@ -0,0 +1,69 @@
"use client";
import type { NowDmgMtncGnsvyResponseDto } from "@/entities/dc";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
import { getPlanYearCells } from "../../lib/previous-plan-table-utils";
interface PreviousPlanSummaryTableProps {
data: NowDmgMtncGnsvyResponseDto | null;
}
function PreviousPlanSummaryTable({ data }: PreviousPlanSummaryTableProps) {
const yearCells = getPlanYearCells(data);
return (
<Table variant="secondary">
<colgroup>
<col width="10%" />
<col width="10%" />
<col width="10%" />
<col width="10%" />
<col width="10%" />
<col width="10%" />
<col width="10%" />
<col width="10%" />
<col width="10%" />
<col width="10%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead rowSpan={2} className="text-center">
구분
</TableHead>
<TableHead colSpan={4} className="text-center">
당초
</TableHead>
<TableHead rowSpan={2} className="text-center">
기준
</TableHead>
<TableHead colSpan={4} className="text-center">
변경
</TableHead>
</TableRow>
<TableRow>
<TableHead className="text-center">1단계</TableHead>
<TableHead className="text-center">2단계</TableHead>
<TableHead className="text-center">3단계</TableHead>
<TableHead className="text-center">4단계</TableHead>
<TableHead className="text-center">1단계</TableHead>
<TableHead className="text-center">2단계</TableHead>
<TableHead className="text-center">3단계</TableHead>
<TableHead className="text-center">4단계</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableHead>계획목표년도</TableHead>
{yearCells.map((cell, index) => (
<TableCell key={index} className="text-center">
{cell}
</TableCell>
))}
</TableRow>
</TableBody>
</Table>
);
}
export { PreviousPlanSummaryTable };
@@ -1,6 +1,8 @@
"use client";
import { useState } from "react";
import { useFormContext, useWatch } from "react-hook-form";
import dynamic from "next/dynamic";
import { Field } from "@/shared/ui/field";
import { SectionCollapsible } from "@/widgets/pbadms-sprt";
@@ -17,6 +19,14 @@ import {
import type { SewerBscPlanRegistFormData } from "../../lib/sewer-bsc-plan-regist-schema";
const PreviousPlanLookupDialog = dynamic(
() =>
import("../previous-plan-lookup-dialog").then((mod) => ({
default: mod.PreviousPlanLookupDialog,
})),
{ ssr: false },
);
const OTLN_ROWS = [
{ key: "sewerPlanPpltnCnt" as const, label: "계획인구" },
{ key: "sewerPrcsPpltnCnt" as const, label: "처리인구" },
@@ -25,11 +35,17 @@ const OTLN_ROWS = [
function OutlineFormSection() {
const { control, setValue, getValues } = useFormContext<SewerBscPlanRegistFormData>();
const [isLookupOpen, setIsLookupOpen] = useState(false);
const otlnList = useWatch({ control, name: "sewerBscPlanOtlnList" }) ?? [];
const frlvlGoalYr = useWatch({ control, name: "sewerBscPlanDtl.frlvlGoalYr" });
const sclvlGoalYr = useWatch({ control, name: "sewerBscPlanDtl.sclvlGoalYr" });
const thlvlGoalYr = useWatch({ control, name: "sewerBscPlanDtl.thlvlGoalYr" });
const folvlGoalYr = useWatch({ control, name: "sewerBscPlanDtl.folvlGoalYr" });
const [sido, gugun] = useWatch({
control,
name: ["sewerBscPlan.sido", "sewerBscPlan.gugun"],
});
const stepHeaders = [
{ label: "1단계", year: frlvlGoalYr },
@@ -54,7 +70,8 @@ function OutlineFormSection() {
return (
<SectionCollapsible title="사업개요">
<div className="mb-2 flex justify-end px-2">
<Button type="button" color="white" disabled>
{/* TODO: T6 — 부모 폼 수치 반영은 요구 없음(레거시 동일) */}
<Button type="button" color="white" onClick={() => setIsLookupOpen(true)}>
이전기본계획조회
</Button>
</div>
@@ -102,6 +119,13 @@ function OutlineFormSection() {
))}
</TableBody>
</Table>
<PreviousPlanLookupDialog
open={isLookupOpen}
onOpenChange={setIsLookupOpen}
initialSidoNm={sido}
initialGugunNm={gugun}
/>
</SectionCollapsible>
);
}