From e67492853c7f98defe62ef8ad330c6a4f8a03a00 Mon Sep 17 00:00:00 2001 From: bicochan Date: Fri, 31 Jul 2026 09:30:09 +0900 Subject: [PATCH] =?UTF-8?q?[as]=20WIP:=20=EA=B8=B0=EB=B3=B8=EA=B3=84?= =?UTF-8?q?=ED=9A=8D=EC=A1=B0=ED=9A=8C=20=EB=8B=A4=EC=9D=B4=EC=96=BC?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../as/docs/spec/previous-plan-lookup/SPEC.md | 441 ++++++++++++++++++ apps/as/src/entities/dc/api/dc-service.ts | 30 +- apps/as/src/entities/dc/api/queries.ts | 35 +- apps/as/src/entities/dc/index.ts | 20 +- apps/as/src/entities/dc/model/dc-interface.ts | 39 +- .../lib/previous-plan-table-utils.ts | 105 +++++ .../ui/previous-plan-lookup-dialog.tsx | 257 ++++++++++ .../previous-plan-area-table.tsx | 103 ++++ .../previous-plan-file-section.tsx | 224 +++++++++ .../previous-plan-search-filters.tsx | 156 +++++++ .../previous-plan-summary-table.tsx | 69 +++ .../ui/sections/outline-form-section.tsx | 26 +- 12 files changed, 1488 insertions(+), 17 deletions(-) create mode 100644 apps/as/docs/spec/previous-plan-lookup/SPEC.md create mode 100644 apps/as/src/pages/sewer-bsc-plan/lib/previous-plan-table-utils.ts create mode 100644 apps/as/src/pages/sewer-bsc-plan/ui/previous-plan-lookup-dialog.tsx create mode 100644 apps/as/src/pages/sewer-bsc-plan/ui/previous-plan/previous-plan-area-table.tsx create mode 100644 apps/as/src/pages/sewer-bsc-plan/ui/previous-plan/previous-plan-file-section.tsx create mode 100644 apps/as/src/pages/sewer-bsc-plan/ui/previous-plan/previous-plan-search-filters.tsx create mode 100644 apps/as/src/pages/sewer-bsc-plan/ui/previous-plan/previous-plan-summary-table.tsx 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/src/entities/dc/api/dc-service.ts b/apps/as/src/entities/dc/api/dc-service.ts index 423815cb7..ed8fa4f4a 100644 --- a/apps/as/src/entities/dc/api/dc-service.ts +++ b/apps/as/src/entities/dc/api/dc-service.ts @@ -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 { +async function getClsfSystm(params: ClsfSystmParamsDto): Promise { const response = await client.get(`${ENDPOINT.DC_SERVICE}/clsfSystm`, { 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, + }); + 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 index 9f28fcd95..8790d691d 100644 --- a/apps/as/src/entities/dc/api/queries.ts +++ b/apps/as/src/entities/dc/api/queries.ts @@ -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), }); } diff --git a/apps/as/src/entities/dc/index.ts b/apps/as/src/entities/dc/index.ts index 916014d96..b9df34de5 100644 --- a/apps/as/src/entities/dc/index.ts +++ b/apps/as/src/entities/dc/index.ts @@ -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"; diff --git a/apps/as/src/entities/dc/model/dc-interface.ts b/apps/as/src/entities/dc/model/dc-interface.ts index 83303afde..6a02a2b1c 100644 --- a/apps/as/src/entities/dc/model/dc-interface.ts +++ b/apps/as/src/entities/dc/model/dc-interface.ts @@ -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; getLvNowDmgMtncGnsvy(params: LvNowDmgMtncGnsvySearchDto): Promise; - getClsfSystm(params: ClsfSystmRequestDto): Promise; + getClsfSystm(params: ClsfSystmParamsDto): Promise; + getFile(params: FileParamsDto): Promise; + getFilePrcs(fileSn: string): Promise; + fileDownload(params: FileDownloadParamsDto): Promise; } 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/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..5c8c5ca11 --- /dev/null +++ b/apps/as/src/pages/sewer-bsc-plan/ui/previous-plan-lookup-dialog.tsx @@ -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(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 ( + + + + 기본 계획 찾기 + + +
+ {isSidoPending ? ( + + ) : ( + + )} + + + + + + + {isSearching ? ( + + ) : ( + + )} + + + + {isSearching ? ( + + ) : ( + + )} + +
+ + + + +
+
+ ); +} + +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..084ddf4b1 --- /dev/null +++ b/apps/as/src/pages/sewer-bsc-plan/ui/previous-plan/previous-plan-file-section.tsx @@ -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(); + 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; + 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 ( +
  • + + {item.children.length > 0 && ( +
      + {item.children.map((child) => ( + + ))} +
    + )} +
  • + ); +} + +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 ( +

    시도와 구군을 선택하세요.

    + ); + } + + 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..e0f39085a --- /dev/null +++ b/apps/as/src/pages/sewer-bsc-plan/ui/previous-plan/previous-plan-search-filters.tsx @@ -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 ( +
    + + + + + + + + + + + + + + 시도 + + + + 구군 + + + + 연도 + + + + 처리시설 + + + + + +
    + +
    + ); +} + +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/outline-form-section.tsx b/apps/as/src/pages/sewer-bsc-plan/ui/sections/outline-form-section.tsx index bf006f83d..fcb466cff 100644 --- a/apps/as/src/pages/sewer-bsc-plan/ui/sections/outline-form-section.tsx +++ b/apps/as/src/pages/sewer-bsc-plan/ui/sections/outline-form-section.tsx @@ -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(); + 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 (
    -
    @@ -102,6 +119,13 @@ function OutlineFormSection() { ))} + +
    ); }