This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
# 재원협의·실무협의 프론트엔드 개발 가이드
|
||||
|
||||
> **대상 앱**: `frontend/apps/as`
|
||||
> **작성일**: 2026-08-27
|
||||
> **개정일**: 2026-09-07
|
||||
> **버전**: 2.0
|
||||
> **Process**: PR-AS-003 (재원협의 관리 — 실무협의 및 AI 검증 통합)
|
||||
>
|
||||
> **SoT (2026-09-03 완결)**:
|
||||
>
|
||||
> - 프로세스·상태·버튼: `03.재원협의_실무협의_프로세스_통합_v완결.md`
|
||||
> - REST 설계: `05.재원협의_상태별_endpoint_설계.md`
|
||||
> - 공통코드: `FNRSSTTS` (`SV, ST, LE, FV, ER, SL, AC, TE, TP, RM, RB, RC, RA, AV, RK, RR, EP, TA`)
|
||||
>
|
||||
> **백엔드 서비스**:
|
||||
>
|
||||
> - `as-service` — `/api/v1/fnrs-cnsltn-mng`, `/api/v1/pwrk-cnsltn-mng`, `/api/v1/pwrk-cnsltn-doc-crt-rslt`, `/api/v1/dcrag-to`
|
||||
> - `as-elapr-service` — `/api/v1/elapr-dmnd`, `/api/v1/elapr-prgrs-stts`, `/api/v1/elapr-apln`
|
||||
>
|
||||
> **현재 프론트 상태**: 재원협의(`fnrs-cnsltn`)·전자결재(`elapr`)·DC RAG TO(`dcrag-to`)는 존재한다. 실무협의(`pwrk-cnsltn-`\*) 엔티티·화면은 **미구현**. 버튼 매트릭스·상태코드는 **v2.1 잔존**이며 v3.0(`FNRSSTTS`)과 불일치한다. [9절](#9-현재-코드-vs-to-be-갭) 참고.
|
||||
|
||||
---
|
||||
|
||||
## 1. 문서 목적
|
||||
|
||||
재원협의 4대 주체(기초·광역·환경청·공단)의 **상태 전이·버튼·API**를 `apps/as`에 구현할 때 쓰는 프론트엔드 가이드이다.
|
||||
|
||||
v1.0은 공단 접수자의 **자료검증 → 초안생성 → 웹기안기**만 다뤘다. v2.0은 2026-09-03 완결 프로세스 기준으로 **신청부터 최종종결(**`TA`**)까지**, 실무협의·AI 3파이프라인·결재선·보완 루프를 포함한다.
|
||||
|
||||
상세 표·시퀀스·DTO는 본문에 넣지 않고 레퍼런스로 분리한다. 구현 중에는 본문의 규칙만 보고, 표가 필요하면 해당 파일을 연다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 문서 맵
|
||||
|
||||
| 파일 | 내용 | 언제 보는가 |
|
||||
| -------------------------------------------------------- | ------------------------------------------------------------------------- | ----------------------- |
|
||||
| [development-guide.md](./development-guide.md) (본 문서) | 도메인, 핵심 규칙, 화면 흐름, 갭, 구현 순서 | 일상 구현 |
|
||||
| [ref-process-status.md](./ref-process-status.md) | 4-Lane 시퀀스, Activity, `FNRSSTTS`, 보완 3루프, AI 타임아웃, 결재선 복사 | 상태 전이·폴링 UX |
|
||||
| [ref-screen-buttons.md](./ref-screen-buttons.md) | 38행 화면·역할·버튼 매트릭스, 버튼 가시성 | `button-config.ts` 개편 |
|
||||
| [ref-endpoint.md](./ref-endpoint.md) | 버튼→API 총괄, 단계별 입출력, FE 호출 금지 API, Gap Analysis | 엔티티·service 매핑 |
|
||||
| [ref-frontend-entities.md](./ref-frontend-entities.md) | ENDPOINT, 슬라이스, Service 시그니처, 폴링, 웹기안기, DTO | 엔티티 레이어 구현 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 도메인 개요
|
||||
|
||||
실무협의서(PWRK)는 재원협의서(FNRS)의 **하위 문서**이다. 재원협의 1건당 실무협의서는 **1건만** 생성된다.
|
||||
|
||||
| 문서 | 번호 예시 | 필드 |
|
||||
| -------- | ----------------- | ----------------------------- |
|
||||
| 재원협의 | `F-20260824-0001` | `fnrsCnsltnDocNo` / `upDocNo` |
|
||||
| 실무협의 | `P-20260827-0001` | `pwrkCnsltnDocNo` |
|
||||
|
||||
진행상태는 **재원협의 마스터** `tb_as_fnrs_cnsltn_mng.prgrs_stts_com_cd` (`FNRSSTTS`)가 SSOT이다. 실무협의 문서의 `prgrsSttsComCd`는 연계 표시용이며, 화면 버튼은 재원협의 상태로 분기한다.
|
||||
|
||||
### 3-1. 4대 주체
|
||||
|
||||
| 역할 | 코드 | 화면 소유 | 핵심 구간 |
|
||||
| --------------- | ------------- | -------------- | ------------------------------------ |
|
||||
| 기초자치단체 | `ROLE_USER05` | 작성/수정/상세 | 신청, 3종 보완, 조치결과, 최종확인 |
|
||||
| 광역자치단체 | `ROLE_USER04` | 상세 | 경유 제출, 결과 전달 |
|
||||
| 유역·지방환경청 | `ROLE_USER03` | 상세 | 의뢰/자체종결, 1·2차 회신 |
|
||||
| 한국환경공단 | `ROLE_USER01` | 상세 + 결재선 | 파일검증, 검토자지정, 1·2차 기술검토 |
|
||||
|
||||
### 3-2. 핵심 식별자
|
||||
|
||||
| 필드 | 의미 | 비고 |
|
||||
| ----------------- | ---------------- | ----------------------------------------------------------- |
|
||||
| `fnrsCnsltnDocNo` | 재원협의문서번호 | 상태 전이·목록 PK |
|
||||
| `pwrkCnsltnDocNo` | 실무협의문서번호 | `P-YYYYMMDD-NNNN`. 검토자지정(`ER→AC`) 시 발급 |
|
||||
| `upDocNo` | 상위문서번호 | 재원협의 번호. `upDocSeComCd = "FNRS"` |
|
||||
| `crtDmndUuid` | 생성요청 UUID | 초안 폴링·웹기안기·다운로드 키 |
|
||||
| `verSn` | 버전일련번호 | 복합 PK `(crtDmndUuid, verSn)`. 저장 시 서버가 `+1` |
|
||||
| `orgnlOtptDocCn` | HWP 바이너리 | 목록/상세 응답은 항상 `null`. 다운로드·`webhwp/open`만 사용 |
|
||||
| `elaprCycl` | 결재 차수 | 1차 `TE`, 2차 `RK`. 금액검증 완료 후 1차 결재선 복사 |
|
||||
|
||||
### 3-3. IIPBS 약어
|
||||
|
||||
| 한글 | 약어 | 필드 예 |
|
||||
| ---- | ------------ | --------------------------- |
|
||||
| 재원 | FNRS | `fnrsCnsltnDocNo` |
|
||||
| 실무 | PWRK | `pwrkCnsltnDocNo` |
|
||||
| 협의 | CNSLTN | `fnrs-cnsltn-mng` |
|
||||
| 생성 | CRT | `crtDmndUuid` |
|
||||
| 요청 | DMND | `crtDmndUuid`, `elapr-dmnd` |
|
||||
| 검증 | VRFC | `vrfcSttsComCd` |
|
||||
| 보완 | SPLMNT | `splmntDmndList` |
|
||||
| 결재 | ELAPR / ATRZ | `elaprCycl`, `atrzSeComCd` |
|
||||
|
||||
---
|
||||
|
||||
## 4. 상태코드 한눈에 보기
|
||||
|
||||
상위코드 `FNRSSTTS`. v1.0의 `PWRKSTTS`(`DV`, `AR`, `TR` 등)는 **폐기**한다.
|
||||
|
||||
정방향:
|
||||
|
||||
```
|
||||
SV → ST → LE → FV → ER → AC → TE → TP → RM → RB → RC → RA → AV → RK → RR → EP → TA
|
||||
```
|
||||
|
||||
자체종결: `LE → EP → TA`
|
||||
보완: `ER|TE|RK → SL` 후 직전 상태로 복귀.
|
||||
|
||||
| 코드 | 명칭 | FE에서 할 일 |
|
||||
| ---- | ---------------------- | ------------------------------------------------- |
|
||||
| `SV` | 저장 | 기초 작성화면 저장/신청/삭제 |
|
||||
| `ST` | 신청완료 | 광역 환경청제출 |
|
||||
| `LE` | 환경청 검토 | 자체종결(`EP`) 또는 기술검토 의뢰(`FV`) |
|
||||
| `FV` | 파일검증 | AI 진행 표시. **30분** 후 `ER` Fallback |
|
||||
| `ER` | 기술검토 의뢰 | 공단 접수자: 파일보완 / 수동파일검증 / 검토자지정 |
|
||||
| `SL` | 보완요청 | 기초 수정화면. 복귀 대상은 이력의 직전 상태 |
|
||||
| `AC` | 초안생성 | AI 진행 표시. **30분** 후 `TE` Fallback |
|
||||
| `TE` | 기술검토 | 웹기안기·검토완료·보완. 결재선 1차 |
|
||||
| `TP` | 기술검토 회신 | 환경청 → 광역 `RM` |
|
||||
| `RM` | 재원협의결과회신 | 광역 → 기초 `RB` |
|
||||
| `RB` | 지자체회신 | 기초 조치결과 저장/협의회신 |
|
||||
| `RC` | 조치결과서 제출 | 광역 → 환경청 `RA` |
|
||||
| `RA` | 조치결과서제출(환경청) | 환경청 2차 의뢰 `AV` |
|
||||
| `AV` | 금액검증 | AI 진행 표시. **30분** 후 `RK` Fallback |
|
||||
| `RK` | 조치결과서검토 | 웹기안기·금액검증 재실행·보완. 결재선 2차 |
|
||||
| `RR` | 조치결과서검토 회신 | 환경청 → 광역 `EP` |
|
||||
| `EP` | 검토결과 회신 | 광역 → 기초 `TA` |
|
||||
| `TA` | 검토결과 회신(종료) | 기초 조회만. **고시(**`TB`**) 아님** |
|
||||
|
||||
코드 의미·구버전 대조·보완 루프·AI Fallback은 [ref-process-status.md](./ref-process-status.md).
|
||||
|
||||
---
|
||||
|
||||
## 5. 프론트가 지켜야 할 핵심 규칙
|
||||
|
||||
1. **상태 SSOT는** `FNRSSTTS`. 버튼 가시성은 `(dtlInstCd, prgrsSttsComCd, atrzSeComCd)`로만 계산한다. 공단은 결재선 구분(`ATRZ01` 접수자, `ATRZ02` 검토자, `ATRZ03` 승인자, `ATRZ99` 최종승인자)을 추가로 본다.
|
||||
2. **AI 콜백 API는 화면에서 호출하지 않는다.** `POST /dcrag/documents/verify-result`, `POST /dcrag/draft/complete`, `POST /dcrag/cost/verify-result`는 서버 전용이다.
|
||||
3. **초안생성 1차 트리거는 검토자지정이다.** `POST /elapr-dmnd` (`ER → AC`). 예전처럼 `POST /pwrk-cnsltn-mng`로 초안을 시작하지 않는다.
|
||||
4. **기술검토 의뢰는** `LE → FV` **전용 API** `POST /fnrs-cnsltn-mng/{docNo}/request-examine`. 현재 코드의 `updatePrgrsStts(ER)` 직행은 폐기.
|
||||
5. **수동 AI 재실행은 상태를 바꾸지 않는다.**
|
||||
|
||||
- `ER` 파일검증 → `POST /dcrag-to/documents/transfer`
|
||||
- `TE` 초안생성 → `POST /dcrag-to/documents/draft/generate` (타임아웃·실패 시에만)
|
||||
- `RK` 금액검증 → `POST /dcrag-to/documents/cost/verify`
|
||||
|
||||
6. **진행상태만 바꾸는 버튼은** `POST /fnrs-cnsltn-mng/doc/updatePrgrsStts`**를 재사용한다.** (`ST→LE`, `RM→RB`, `RC→RA`, `RR→EP`, `EP→TA`)
|
||||
7. **보완요청/보완완료는 공통 API** `reqsupplement` / `submitsupplement`. 복귀 상태(`ER`/`TE`/`RK`)는 서버가 이력에서 결정한다. 프론트는 직전 상태를 추측해 보내지 않는다.
|
||||
8. **결재 승인/회신은** `PUT /elapr-prgrs-stts/{id}/process` **하나**. 1차 최종승인자 → `TP`, 2차 → `RR`은 서버가 Feign으로 처리한다.
|
||||
9. **2차 결재선 복사는 백엔드**. `POST /elapr-apln/copy-line`은 `AV→RK` 콜백에서 호출한다. 프론트는 `RK`에서 2차 결재선을 **조회·표시**만 한다.
|
||||
10. **웹기안기는 `webhwp/open` 응답을 axios `blob`으로 조회한 뒤 `File`로 감싸 `HwpCtrl.Open`에 전달한다.** `blob:` Object URL과 `orgnlOtptDocCn`은 쓰지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 화면 데이터 흐름
|
||||
|
||||
재원협의 **목록 → 상세/작성**이 본체다. 실무협의 목록은 공단 검토 보조 화면이다.
|
||||
|
||||
```
|
||||
BEGIN
|
||||
CALL fnrsCnsltnMngService.findPage(...)
|
||||
ON 행 CLICK → /fnrs-cnsltn/{fnrsCnsltnDocNo}
|
||||
|
||||
CALL findById(fnrsCnsltnDocNo)
|
||||
COMPUTE buttons = getButtonConfigs(dtlInstCd, prgrsSttsComCd, atrzSeComCdList)
|
||||
|
||||
// ----- 기초 ROLE_USER05 -----
|
||||
ON [저장] (빈값/SV) → saveApply(prgrsSttsComCd: SV)
|
||||
ON [신청완료] (SV) → saveApply(prgrsSttsComCd: ST)
|
||||
ON [삭제] (SV) → remove
|
||||
ON [파일보완완료] (SL←ER) → submitsupplement
|
||||
ON [보완완료] (SL←TE|RK) → submitsupplement
|
||||
ON [저장] (RB) → submit-actn-result { isSubmit: false }
|
||||
ON [협의회신] (RB) → submit-actn-result { isSubmit: true }
|
||||
ON [결과회신] (TA) → API 없음 (조회)
|
||||
|
||||
// ----- 광역 ROLE_USER04 -----
|
||||
ON [환경청제출] (ST) → updatePrgrsStts(LE)
|
||||
ON [지자체회신] (RM) → updatePrgrsStts(RB)
|
||||
ON [조치결과서제출] (RC) → updatePrgrsStts(RA)
|
||||
ON [결과회신] (EP) → updatePrgrsStts(TA)
|
||||
|
||||
// ----- 환경청 ROLE_USER03 -----
|
||||
ON [결과회신] (LE 자체종결) → tech-review → EP
|
||||
ON [기술검토 의뢰] (LE) → request-examine → FV → START POLL FV
|
||||
ON [재원협의 결과 회신] (TP) → tech-review → RM
|
||||
ON [조치결과서 검토요청] (RA) → request-actn-review → AV → START POLL AV
|
||||
ON [결과회신] (RR) → updatePrgrsStts(EP)
|
||||
|
||||
// ----- 공단 ROLE_USER01 -----
|
||||
ON [파일보완] (ER, ATRZ01) → GET file-vrfc-results → 팝업 → reqsupplement
|
||||
ON [파일검증] (ER, ATRZ01) → dcrag-to transfer (상태 유지)
|
||||
ON [검토자지정] (ER, ATRZ01) → POST elapr-dmnd (docPrgrsSttsComCd: AC) → START POLL AC
|
||||
ON [실무협의서관리] (TE|RK) → GET webhwp/open (blob) → HwpCtrl.Open(File)
|
||||
ON [실무협의서생성] (TE, 실패/타임아웃) → dcrag-to draft/generate
|
||||
ON [검토결과조회] (TE) → pwrk-cnsltn-mng findById | findByUpDocNo
|
||||
ON [저장] (TE|RK) → pwrk-cnsltn-mng save-apply
|
||||
ON [검토완료] → elapr-prgrs-stts process (상신)
|
||||
ON [보완요청] (TE|RK) → 보완 팝업 → reqsupplement
|
||||
ON [금액검증] (RK) → dcrag-to cost/verify
|
||||
ON [결재(승인)] → process (중간)
|
||||
ON [결재(회신)] → process (최종 → TP 또는 RR)
|
||||
|
||||
// ----- AI 진행 중 (FV / AC / AV) -----
|
||||
POLL 상태 또는 draft status. 30분 안내. 종료 후 상세 invalidate
|
||||
END
|
||||
```
|
||||
|
||||
38행 버튼 표는 [ref-screen-buttons.md](./ref-screen-buttons.md). API 상세는 [ref-endpoint.md](./ref-endpoint.md).
|
||||
|
||||
---
|
||||
|
||||
## 7. 버튼 → API 축약
|
||||
|
||||
프론트가 치는 것만. 콜백·스케줄러는 제외.
|
||||
|
||||
| 주체 | 버튼 | From → To | Method & URI |
|
||||
| --------- | -------------------------- | ----------------------------- | --------------------------------------------------------------- | --- | ------------------------------------------------ |
|
||||
| 기초 | 저장 / 신청완료 | →`SV` / `SV→ST` | `POST /fnrs-cnsltn-mng/save-apply` |
|
||||
| 기초 | 삭제 | `SV` | `DELETE /fnrs-cnsltn-mng/{docNo}` |
|
||||
| 광역 | 환경청제출 | `ST→LE` | `POST /fnrs-cnsltn-mng/doc/updatePrgrsStts` |
|
||||
| 환경청 | 결과회신(자체종결) | `LE→EP` | `POST /fnrs-cnsltn-mng/{docNo}/tech-review` |
|
||||
| 환경청 | 기술검토 의뢰 | `LE→FV` | **신규** `POST /fnrs-cnsltn-mng/{docNo}/request-examine` |
|
||||
| 공단 접수 | 파일보완 팝업 | `ER` | `GET /fnrs-cnsltn-mng/{docNo}/file-vrfc-results` |
|
||||
| 공단 접수 | 파일보완요청 | `ER→SL` | `POST /fnrs-cnsltn-mng/{docNo}/reqsupplement` |
|
||||
| 공단 접수 | 파일검증 재실행 | `ER` 유지 | `POST /dcrag-to/documents/transfer` |
|
||||
| 공단 접수 | 검토자지정 | `ER→AC` | `POST /elapr-dmnd` |
|
||||
| 공단 검토 | 실무협의서관리 | `TE`/`RK` | `GET .../pwrk-cnsltn-doc-crt-rslt/{uuid}/webhwp/open` |
|
||||
| 공단 검토 | 실무협의서생성 | `TE` 유지 | `POST /dcrag-to/documents/draft/generate` |
|
||||
| 공단 검토 | 저장 | `TE`/`RK` | `POST /pwrk-cnsltn-mng/save-apply` |
|
||||
| 공단 | 검토완료 / 결재(승인·회신) | `TE` 유지 / `TE→TP` / `RK→RR` | `PUT /elapr-prgrs-stts/{id}/process` |
|
||||
| 공단 검토 | 보완요청 | `TE→SL` / `RK→SL` | `POST /fnrs-cnsltn-mng/{docNo}/reqsupplement` |
|
||||
| 기초 | 파일보완완료 / 보완완료 | `SL→ER | TE | RK` | `POST /fnrs-cnsltn-mng/{docNo}/submitsupplement` |
|
||||
| 환경청 | 재원협의 결과 회신 | `TP→RM` | `POST /fnrs-cnsltn-mng/{docNo}/tech-review` |
|
||||
| 광역 | 지자체회신 | `RM→RB` | `updatePrgrsStts` |
|
||||
| 기초 | 저장(조치) / 협의회신 | `RB` / `RB→RC` | **신규** `POST /pwrk-cnsltn-mng/{pwrkDocNo}/submit-actn-result` |
|
||||
| 광역 | 조치결과서제출 | `RC→RA` | `updatePrgrsStts` |
|
||||
| 환경청 | 조치결과서 검토요청 | `RA→AV` | **신규** `POST /fnrs-cnsltn-mng/{docNo}/request-actn-review` |
|
||||
| 공단 검토 | 금액검증 재실행 | `RK` 유지 | `POST /dcrag-to/documents/cost/verify` |
|
||||
| 환경청 | 결과회신 | `RR→EP` | `updatePrgrsStts` |
|
||||
| 광역 | 결과회신 | `EP→TA` | `updatePrgrsStts` |
|
||||
| 웹기안기 | 저장 | 버전 +1 | `POST /pwrk-cnsltn-doc-crt-rslt` multipart |
|
||||
| 공통 | 다운로드 | — | `GET /pwrk-cnsltn-doc-crt-rslt/{uuid}/download` |
|
||||
|
||||
신규 4개: `request-examine`, `submit-actn-result`, `request-actn-review`, (백엔드) `elapr-apln/copy-line`.
|
||||
|
||||
---
|
||||
@@ -0,0 +1,336 @@
|
||||
# 레퍼런스 — 상태별 Endpoint
|
||||
|
||||
> 상위: [개발가이드.md](./개발가이드.md)
|
||||
> SoT: `05.재원협의_상태별_endpoint_설계.md` (v1.0, 2026-09-03)
|
||||
> Gateway: `/as-service`, `/as-elapr-service`
|
||||
> Auth: `Authorization`, `X-Site-Id`, `token-id` (기존 `client` 인터셉터)
|
||||
> 날짜: `yyyy-MM-dd HH:mm:ss`
|
||||
|
||||
프론트가 치는 API만 상세히 적는다. AI 콜백·스케줄러·Feign은 [§6 호출 금지](#6-프론트-호출-금지).
|
||||
|
||||
---
|
||||
|
||||
## 1. 총괄 매트릭스 (38 + WebHWP)
|
||||
|
||||
| 순번 | 버튼 | From → To | 서비스 | Method & URI | 구분 |
|
||||
| :---: | --- | --- | --- | --- | --- |
|
||||
| 1 | 저장 | →`SV` | as | `POST /api/v1/fnrs-cnsltn-mng/save-apply` | 기존 |
|
||||
| 2 | 신청완료 | `SV→ST` | as | 동일 (`prgrsSttsComCd=ST`) | 기존 |
|
||||
| 3 | 삭제 | `SV` | as | `DELETE /api/v1/fnrs-cnsltn-mng/{docNo}` | 기존 |
|
||||
| 4 | 환경청제출 | `ST→LE` | as | `POST /api/v1/fnrs-cnsltn-mng/doc/updatePrgrsStts` | 기존 |
|
||||
| 5 | 결과회신(자체종결) | `LE→EP` | as | `POST /api/v1/fnrs-cnsltn-mng/{docNo}/tech-review` | 기존 |
|
||||
| 6 | 기술검토 의뢰 | `LE→FV` | as | `POST /api/v1/fnrs-cnsltn-mng/{docNo}/request-examine` | **신규** |
|
||||
| 7 | 파일검증 콜백 | `FV→ER` | as | `POST /api/v1/dcrag/documents/verify-result` | 서버 |
|
||||
| 8 | 파일보완 팝업 | `ER` | as | `GET /api/v1/fnrs-cnsltn-mng/{docNo}/file-vrfc-results` | 기존 |
|
||||
| 9 | 파일보완요청 | `ER→SL` | as | `POST /api/v1/fnrs-cnsltn-mng/{docNo}/reqsupplement` | 기존 |
|
||||
| 10 | 파일검증 재실행 | `ER` | as | `POST /api/v1/dcrag-to/documents/transfer` | 기존 |
|
||||
| 11 | 검토자지정 | `ER→AC` | elapr | `POST /api/v1/elapr-dmnd` | 기존 |
|
||||
| 12 | 초안 콜백 | `AC→TE` | as | `POST /api/v1/dcrag/draft/complete` | 서버 |
|
||||
| 13 | 실무협의서관리 | `TE` | as | `GET /api/v1/pwrk-cnsltn-mng/updocno/{upDocNo}` | 기존 |
|
||||
| 14 | 검토결과조회 | `TE` | as | `GET /api/v1/pwrk-cnsltn-mng/{pwrkDocNo}` | 기존 |
|
||||
| 15 | 실무협의서생성 | `TE` | as | `POST /api/v1/dcrag-to/documents/draft/generate` | 기존 |
|
||||
| 16 | 저장(실무) | `TE` | as | `POST /api/v1/pwrk-cnsltn-mng/save-apply` | 기존 |
|
||||
| 17 | 검토완료 | `TE` | elapr | `PUT /api/v1/elapr-prgrs-stts/{id}/process` | 기존 |
|
||||
| 18 | 보완요청 | `TE→SL` | as | `POST .../reqsupplement` | 기존 |
|
||||
| 19 | 결재(승인) | `TE` | elapr | `PUT .../process` | 기존 |
|
||||
| 20 | 결재(회신) | `TE→TP` | elapr | `PUT .../process` | 기존 |
|
||||
| 21 | 파일보완완료 | `SL→ER` | as | `POST .../submitsupplement` | 기존 |
|
||||
| 22 | 보완완료(기술) | `SL→TE` | as | 동일 | 기존 |
|
||||
| 23 | 보완완료(조치) | `SL→RK` | as | 동일 | 기존 |
|
||||
| 24 | 재원협의 결과 회신 | `TP→RM` | as | `POST .../{docNo}/tech-review` | 기존 |
|
||||
| 25 | 지자체회신 | `RM→RB` | as | `POST .../doc/updatePrgrsStts` | 기존 |
|
||||
| 26 | 저장(조치결과) | `RB` | as | `POST /api/v1/pwrk-cnsltn-mng/{pwrkDocNo}/submit-actn-result` | **신규** |
|
||||
| 27 | 협의회신 | `RB→RC` | as | 동일 (`isSubmit=true`) | **신규** |
|
||||
| 28 | 조치결과서제출 | `RC→RA` | as | `updatePrgrsStts` | 기존 |
|
||||
| 29 | 조치결과서 검토요청 | `RA→AV` | as | `POST /api/v1/fnrs-cnsltn-mng/{docNo}/request-actn-review` | **신규** |
|
||||
| 30 | 금액검증 콜백 | `AV→RK` | as | `POST /api/v1/dcrag/cost/verify-result` | 서버 |
|
||||
| 31 | 실무협의서관리 | `RK` | as | `GET /pwrk-cnsltn-mng/{pwrkDocNo}` | 기존 |
|
||||
| 32 | 금액검증 재실행 | `RK` | as | `POST /api/v1/dcrag-to/documents/cost/verify` | 기존 |
|
||||
| 33 | 보완요청(금액) | `RK→SL` | as | `reqsupplement` | 기존 |
|
||||
| 34 | 결재(승인) | `RK` | elapr | `process` | 기존 |
|
||||
| 35 | 결재(회신) | `RK→RR` | elapr | `process` | 기존 |
|
||||
| 36 | 결과회신 | `RR→EP` | as | `updatePrgrsStts` | 기존 |
|
||||
| 37 | 결과회신 | `EP→TA` | as | `updatePrgrsStts` | 기존 |
|
||||
| 38 | 결과회신(확인) | `TA` | — | 없음 | — |
|
||||
| W1 | 웹기안기 Open | — | as | `GET /api/v1/pwrk-cnsltn-doc-crt-rslt/{uuid}/webhwp/open` | 기존 |
|
||||
| W2 | HWPX 저장 | — | as | `POST /api/v1/pwrk-cnsltn-doc-crt-rslt` multipart | 기존 |
|
||||
| W3 | 다운로드 | — | as | `GET /api/v1/pwrk-cnsltn-doc-crt-rslt/{uuid}/download` | 기존 |
|
||||
| E-N | 결재선 복사 | `AV→RK` 부수 | elapr | `POST /api/v1/elapr-apln/copy-line` | **신규·서버** |
|
||||
|
||||
조회 공통: `GET /fnrs-cnsltn-mng`, `GET /fnrs-cnsltn-mng/{docNo}`, `GET /pwrk-cnsltn-mng`, `GET /dcrag-to/documents/draft/status/{crtDmndUuid}`.
|
||||
|
||||
---
|
||||
|
||||
## 2. 공통 요청
|
||||
|
||||
### `DocPrgrsUpdateRequestDto`
|
||||
|
||||
`POST /fnrs-cnsltn-mng/doc/updatePrgrsStts` — `ST→LE`, `RM→RB`, `RC→RA`, `RR→EP`, `EP→TA`.
|
||||
|
||||
```json
|
||||
{
|
||||
"docNo": "F-20260824-0001",
|
||||
"docSeComCd": "FNRS",
|
||||
"prgrsSttsComCd": "LE",
|
||||
"rgtrUuid": "optional-uuid",
|
||||
"rmrk": "optional"
|
||||
}
|
||||
```
|
||||
|
||||
응답: `204/200` void. 프론트 엔티티: 기존 `changePrgrsSttsComCd`.
|
||||
|
||||
### `FnrsCnsltnApplySaveRequestDto`
|
||||
|
||||
`save-apply`, `tech-review`, `reqsupplement`, `submitsupplement`가 공유. 핵심은 `fnrsCnsltnMng.prgrsSttsComCd`와 중첩 리스트(`bzScaleList`, `bizCstList`, `cklstResultList`, `esntlAtchFileList`, `splmntDmndList`).
|
||||
|
||||
---
|
||||
|
||||
## 3. 단계별 FE API
|
||||
|
||||
### 3-1. `SV → ST → LE`
|
||||
|
||||
**저장 / 신청완료** — `POST /fnrs-cnsltn-mng/save-apply`
|
||||
권한: `ROLE_USER05`. 임시저장은 `prgrsSttsComCd=SV`, 신청은 `ST`.
|
||||
응답: `string` (문서번호). 기존 `fnrsCnsltnMngService.saveApply`.
|
||||
|
||||
**삭제** — `DELETE /fnrs-cnsltn-mng/{docNo}` (`SV`만).
|
||||
|
||||
**환경청제출** — `updatePrgrsStts` `LE`. 권한: `ROLE_USER04`.
|
||||
|
||||
### 3-2. `LE → FV | EP`
|
||||
|
||||
**자체종결** — `POST /fnrs-cnsltn-mng/{docNo}/tech-review`
|
||||
Body: `FnrsCnsltnApplySaveRequestDto` (환경청 의견·체크리스트). `LE→EP`.
|
||||
기존 `techReview`. 현재 화면은 `TP`에서도 이 API를 쓰며 목표 상태를 `EP`로 넣고 있다 → **`TP`는 `RM`으로 바꿔야 함**.
|
||||
|
||||
**기술검토 의뢰 (신규)** — `POST /fnrs-cnsltn-mng/{docNo}/request-examine`
|
||||
|
||||
```json
|
||||
{ "prgrsSttsComCd": "FV" }
|
||||
```
|
||||
|
||||
응답: `string` (docNo). `LE→FV` + AI 파일검증 + 30분 타이머(서버).
|
||||
프론트: `fnrsCnsltnMngService.requestExamine(docNo, body)` 추가. **`updatePrgrsStts("ER")` 금지.**
|
||||
|
||||
### 3-3. `FV → ER → SL → ER`
|
||||
|
||||
파일검증 콜백은 서버. FE는 `FV`에서 상세 refetch.
|
||||
|
||||
**검증결과 목록** — `GET /fnrs-cnsltn-mng/{fnrsCnsltnDocNo}/file-vrfc-results`
|
||||
응답: `FnrsCnsltnFileVrfcRsltResponseDto[]`
|
||||
|
||||
| 필드 | 설명 |
|
||||
| --- | --- |
|
||||
| `fileSeComCd` | `ESSENTIAL` / `ADDITIONAL` |
|
||||
| `fileSeNm` | 구분명 |
|
||||
| `esntlAtchFileId` | 필수첨부 ID |
|
||||
| `atchFileNm` | 파일명 |
|
||||
| `vrfcRsltComCd` | `PASS` / `FAIL` / `WARN` |
|
||||
| `vrfcOpnn` | 검증의견 |
|
||||
|
||||
엔티티에 `findFileVrfcResults` 추가. 첨부 행 배지는 상세 `esntlFileList`/`etcFileList`의 `vrfcSttsComCd`, `vrfcRsltCn`, `vrfcDt`도 사용 (타입 확장 필요).
|
||||
|
||||
**파일보완요청** — `POST .../{docNo}/reqsupplement` (`ER→SL`).
|
||||
**파일보완완료** — `POST .../{docNo}/submitsupplement` (`SL→ER`). 복귀는 서버.
|
||||
|
||||
**파일검증 재실행** — `POST /dcrag-to/documents/transfer`
|
||||
|
||||
```json
|
||||
{ "fnrsCnsltnDocNo": "F-20260824-0001", "taskSeComCd": "SF", "taskSeCdNm": "하수처리시설" }
|
||||
```
|
||||
|
||||
응답: `{ resultCode, message, requestId }`. 상태 유지. 기존 `dcRagApiToService.requestDocumentTransfer`.
|
||||
|
||||
### 3-4. `ER → AC → TE → TP`
|
||||
|
||||
**검토자지정** — `POST /elapr-dmnd`
|
||||
기존 `elaprDmndService.save`.
|
||||
|
||||
| 필드 | 값 |
|
||||
| --- | --- |
|
||||
| `elaprDocNo` | 재원협의 번호 |
|
||||
| `elaprCycl` | `1` |
|
||||
| `docPrgrsSttsComCd` | **`AC`** (현행은 현재 상태 `ER`를 넣고 있음 → 수정) |
|
||||
| `elaprPrgrsSttsList[].atrzOrdr` | 1 검토자, 2 승인자, 3 최종 |
|
||||
|
||||
서버가 실무협의 문서 생성 + RAG 초안 트리거 + 30분 타이머. 응답의 문서번호/`crtDmndUuid`가 있으면 폴링 키로 쓴다. 없으면 `findByUpDocNo`로 복원.
|
||||
|
||||
**초안 상태 폴링** — `GET /dcrag-to/documents/draft/status/{crtDmndUuid}`
|
||||
기존 `getDraftGenerationStatus`. 주기 5~10초. 종료 `COMPLETED`/`FAILED`.
|
||||
|
||||
**수동 초안생성** — `POST /dcrag-to/documents/draft/generate` (`TE` 유지)
|
||||
|
||||
```json
|
||||
{
|
||||
"fnrsCnsltnDocNo": "F-...",
|
||||
"pwrkCnsltnDocNo": "P-...",
|
||||
"crtDmndUuid": "optional"
|
||||
}
|
||||
```
|
||||
|
||||
202 + `DcRagDraftGenerateResponseDto` (`crtDmndUuid`, `crtSttsComCd`, `estimatedMinutes`).
|
||||
v1.0 가이드의 “이 API를 프론트가 치면 안 된다”는 **폐기**. 1차 트리거는 검토자지정이고, 이 API는 Fallback 전용.
|
||||
|
||||
**실무 저장** — `POST /pwrk-cnsltn-mng/save-apply` → `string`.
|
||||
**보완요청/완료** — fnrs `reqsupplement` / `submitsupplement`.
|
||||
|
||||
**결재** — `PUT /elapr-prgrs-stts/{elaprPrgrsId}/process`
|
||||
|
||||
```json
|
||||
{
|
||||
"atrzPrgrsSttsComCd": "ATRZ_PRGRS_003",
|
||||
"atrzOpnnCn": "의견",
|
||||
"finalApprovalYn": false
|
||||
}
|
||||
```
|
||||
|
||||
반려: `ATRZ_PRGRS_004`. 기존 `elaprPrgrsSttsService.process`. 최종 시 서버가 `TP`/`RR` 전이.
|
||||
|
||||
### 3-5. `TP → RM → RB → RC`
|
||||
|
||||
**환경청 1차 회신** — `tech-review` (`TP→RM`).
|
||||
**광역 지자체회신** — `updatePrgrsStts` `RB`.
|
||||
|
||||
**조치결과 저장/제출 (신규)** — `POST /pwrk-cnsltn-mng/{pwrkDocNo}/submit-actn-result`
|
||||
|
||||
```json
|
||||
{
|
||||
"isSubmit": false,
|
||||
"actnList": [
|
||||
{
|
||||
"rvwOpnnSn": 1,
|
||||
"actnRsltCn": "수용 — 관로 연장 재산정",
|
||||
"atchFileGroupId": "optional"
|
||||
}
|
||||
],
|
||||
"totActnSummary": "제출 시 필수",
|
||||
"muniDocNo": "제출 시 필수"
|
||||
}
|
||||
```
|
||||
|
||||
- `isSubmit: false` → `RB` 유지
|
||||
- `isSubmit: true` → `RB→RC`
|
||||
응답: `string` (pwrkDocNo). DTO명 `PwrkActnResultSubmitDto`.
|
||||
|
||||
### 3-6. `RC → RA → AV → RK`
|
||||
|
||||
**광역 제출** — `updatePrgrsStts` `RA`.
|
||||
|
||||
**2차 의뢰 (신규)** — `POST /fnrs-cnsltn-mng/{docNo}/request-actn-review`
|
||||
|
||||
```json
|
||||
{
|
||||
"envoDocNo": "환경청 공문번호",
|
||||
"specialRqstCn": "중점 검증 요청 (선택)"
|
||||
}
|
||||
```
|
||||
|
||||
`RA→AV`. 응답: `string` (pwrkDocNo). DTO명 `EnvoActnReviewReqDto`.
|
||||
|
||||
금액검증 콜백은 서버. 콜백 후 서버가 `copy-line` 호출.
|
||||
|
||||
**금액검증 재실행** — `POST /dcrag-to/documents/cost/verify`
|
||||
|
||||
```json
|
||||
{
|
||||
"fnrsCnsltnDocNo": "F-...",
|
||||
"pwrkCnsltnDocNo": "P-..."
|
||||
}
|
||||
```
|
||||
|
||||
202. 기존 `requestCostVerification`. `RK` 유지.
|
||||
|
||||
### 3-7. `RK → RR | SL`
|
||||
|
||||
보완은 3-4와 동일 API. 팝업만 금액 체크.
|
||||
결재 `process` 2차 최종 → 서버가 `RR`.
|
||||
|
||||
### 3-8. `RR → EP → TA`
|
||||
|
||||
둘 다 `updatePrgrsStts`. `TA` 확인은 API 없음.
|
||||
|
||||
---
|
||||
|
||||
## 4. 웹기안기
|
||||
|
||||
설계서 기준 **기존**. 구현 시 컨트롤러 존재 여부를 확인한다.
|
||||
|
||||
| ID | 용도 | URI | 응답 |
|
||||
| --- | --- | --- | --- |
|
||||
| W1 | 웹기안기 Open | `GET .../pwrk-cnsltn-doc-crt-rslt/{crtDmndUuid}/webhwp/open` | `inline` HWPX blob → `HwpCtrl.Open(File)` |
|
||||
| W2 | 편집 저장 | `POST .../pwrk-cnsltn-doc-crt-rslt` multipart `file` + `crtDmndUuid` | 201, `verSn+1` |
|
||||
| W3 | PC 저장 | `GET .../{crtDmndUuid}/download` | `attachment` blob |
|
||||
|
||||
버전 지정: `GET .../{crtDmndUuid}/{verSn}/webhwp/open`, `.../{verSn}/download`.
|
||||
`/{uuid}/latest`, `/{uuid}/download`가 `/{uuid}/{verSn}`보다 우선. path를 섞지 말 것.
|
||||
|
||||
다운로드:
|
||||
|
||||
- axios `responseType: "blob"`
|
||||
- 파일명: `Content-Disposition` `filename*=UTF-8''...` (`parseFilenameFromResponse`)
|
||||
- 서버 파일명: `재원협의 실무협의서({bizNm})_v{verSn}.hwp`
|
||||
- Content-Type은 `application/x-hwp` 또는 `application/haansofthwpx`일 수 있음. 타입에 의존하지 말 것
|
||||
|
||||
Open은 axios `responseType: "blob"`으로 인증된 API를 호출하고, 응답 Blob을 HWPX `File`로 감싸 `HwpCtrl.Open`에 전달한다. `blob:` Object URL은 사용하지 않는다.
|
||||
|
||||
multipart `Content-Type` 헤더를 수동 지정하지 않는다 (boundary). JSON `saveJson`은 UI에서 쓰지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 전자결재 (`as-elapr-service`)
|
||||
|
||||
| API | URI | FE |
|
||||
| --- | --- | --- |
|
||||
| 검토자지정·1차 결재요청 | `POST /elapr-dmnd` | O |
|
||||
| 결재 처리 | `PUT /elapr-prgrs-stts/{id}/process` | O |
|
||||
| 결재선 복사 | `POST /elapr-apln/copy-line` | **X (서버)** |
|
||||
|
||||
`copy-line` body (참고만):
|
||||
|
||||
```json
|
||||
{
|
||||
"docNo": "P-20260827-0001",
|
||||
"sourceElaprCycl": 1,
|
||||
"targetElaprCycl": 2,
|
||||
"rgtrUuid": "system-uuid"
|
||||
}
|
||||
```
|
||||
|
||||
`RK`에서 2차 결재선은 기존 `findDocPrgrsByDocNo` / `elaprCycl=2` 조회로 표시.
|
||||
|
||||
---
|
||||
|
||||
## 6. 프론트 호출 금지
|
||||
|
||||
| URI | 이유 |
|
||||
| --- | --- |
|
||||
| `POST /dcrag/documents/verify-result` | AI→AS 콜백 |
|
||||
| `POST /dcrag/draft/complete` | AI→AS 콜백 |
|
||||
| `POST /dcrag/cost/verify-result` | AI→AS 콜백. 여기서 copy-line도 호출 |
|
||||
| `POST /elapr-apln/copy-line` | 콜백 Feign |
|
||||
| `POST /pwrk-cnsltn-mng` (`save` 초안 트리거) | v3.0 1차 트리거는 `elapr-dmnd`. 컨트롤러가 남아 있어도 화면에서 쓰지 않음 |
|
||||
|
||||
`POST /dcrag-to/documents/draft/generate`는 **Fallback에서만** 허용.
|
||||
|
||||
---
|
||||
|
||||
## 7. Gap Analysis (프론트 엔티티)
|
||||
|
||||
### 이미 있는 것
|
||||
|
||||
`fnrsCnsltnMngService`: `findPage`, `findById`, `save`, `saveApply`, `update`, `techReview`, `submitSupplement`, `submitReqSupplement`, `remove`, `changePrgrsSttsComCd`
|
||||
`dcRagApiToService`: `requestDocumentTransfer`, `requestDraftGeneration`, `getDraftGenerationStatus`, `requestCostVerification`
|
||||
`elaprDmndService.save`, `elaprPrgrsSttsService.process`
|
||||
|
||||
### 추가할 것
|
||||
|
||||
| 메서드 | URI |
|
||||
| --- | --- |
|
||||
| `requestExamine` | `POST /fnrs-cnsltn-mng/{id}/request-examine` |
|
||||
| `findFileVrfcResults` | `GET /fnrs-cnsltn-mng/{id}/file-vrfc-results` |
|
||||
| `requestActnReview` | `POST /fnrs-cnsltn-mng/{id}/request-actn-review` |
|
||||
| `pwrkCnsltnMngService.*` | 슬라이스 신규 |
|
||||
| `submitActnResult` | `POST /pwrk-cnsltn-mng/{id}/submit-actn-result` |
|
||||
| `pwrkCnsltnDocCrtRsltService.*` | 슬라이스 신규 |
|
||||
|
||||
`copyLine`은 프론트 서비스에 넣지 않는다.
|
||||
@@ -0,0 +1,347 @@
|
||||
# 레퍼런스 — 프론트 엔티티·DTO
|
||||
|
||||
> 상위: [개발가이드.md](./개발가이드.md)
|
||||
> 엔티티 규칙: `frontend/.cursor/skills/backend-controller-to-entity`
|
||||
|
||||
---
|
||||
|
||||
## 1. ENDPOINT
|
||||
|
||||
`apps/as/src/shared/config/api.ts`
|
||||
|
||||
```ts
|
||||
AS_SERVICE: {
|
||||
// 기존
|
||||
FNRS_CNSLTN_MNG: "/as-service/api/v1/fnrs-cnsltn-mng",
|
||||
DCRAG_TO: "/as-service/api/v1/dcrag-to",
|
||||
// 추가
|
||||
PWRK_CNSLTN_MNG: "/as-service/api/v1/pwrk-cnsltn-mng",
|
||||
PWRK_CNSLTN_DOC_CRT_RSLT: "/as-service/api/v1/pwrk-cnsltn-doc-crt-rslt",
|
||||
}
|
||||
|
||||
AS_ELAPR_SERVICE: {
|
||||
ELAPR_TMPLT: "/as-elapr-service/api/v1/elapr-tmplt",
|
||||
ELAPR_APLN: "/as-elapr-service/api/v1/elapr-apln",
|
||||
ELAPR_DMND: "/as-elapr-service/api/v1/elapr-dmnd",
|
||||
ELAPR_PRGRS_STTS: "/as-elapr-service/api/v1/elapr-prgrs-stts",
|
||||
}
|
||||
```
|
||||
|
||||
`copy-line`은 `ELAPR_APLN` 하위이나 FE에서 호출하지 않음.
|
||||
|
||||
---
|
||||
|
||||
## 2. 슬라이스 배치
|
||||
|
||||
```
|
||||
entities/fnrs-cnsltn/ # 확장
|
||||
entities/dcrag-to/ # 구현됨
|
||||
entities/elapr/ # 구현됨
|
||||
entities/pwrk-cnsltn-mng/ # 신규
|
||||
entities/pwrk-cnsltn-doc-crt-rslt/ # 신규
|
||||
```
|
||||
|
||||
```
|
||||
entities/pwrk-cnsltn-mng/
|
||||
├── model/
|
||||
│ ├── pwrk-cnsltn-mng-interface.ts
|
||||
│ └── pwrk-cnsltn-mng-payload-schema.ts
|
||||
├── api/
|
||||
│ ├── pwrk-cnsltn-mng-service.ts
|
||||
│ └── queries.ts
|
||||
└── index.ts
|
||||
```
|
||||
|
||||
`submit-actn-result` Request는 zod SSOT (`PwrkActnResultSubmitDto`).
|
||||
`request-examine` / `request-actn-review`도 fnrs payload-schema에 추가.
|
||||
|
||||
라우트:
|
||||
|
||||
| 화면 | 경로 |
|
||||
| --- | --- |
|
||||
| 재원협의 목록/상세/작성 | 기존 `app/(main)/(with-base-layout)/fnrs-cnsltn/...` |
|
||||
| 실무협의 목록 | `.../pwrk-cnsltn/page.tsx` (선택) |
|
||||
| 실무협의 상세 | `.../pwrk-cnsltn/[id]/page.tsx` (선택) |
|
||||
| 페이지 UI | `pages/fnrs-cnsltn/`, `pages/pwrk-cnsltn/` |
|
||||
|
||||
---
|
||||
|
||||
## 3. Service 시그니처
|
||||
|
||||
`fnrsCnsltnMngService`에 추가:
|
||||
|
||||
```ts
|
||||
requestExamine(docNo: string, body: FnrsExamineRequestDto): Promise<string>;
|
||||
findFileVrfcResults(fnrsCnsltnDocNo: string): Promise<FnrsCnsltnFileVrfcRsltResponseDto[]>;
|
||||
requestActnReview(docNo: string, body: EnvoActnReviewReqDto): Promise<string>;
|
||||
```
|
||||
|
||||
실무협의:
|
||||
|
||||
```ts
|
||||
export interface PwrkCnsltnMngService {
|
||||
findPage(searchDto: PwrkCnsltnMngSearchDto): Promise<PageResponse<PwrkCnsltnMngResponseDto>>;
|
||||
findById(pwrkCnsltnDocNo: string): Promise<PwrkCnsltnMngDetResponseDto>;
|
||||
findByUpDocNo(upDocNo: string): Promise<PwrkCnsltnMngDetResponseDto>;
|
||||
saveApply(body: PwrkCnsltnApplySaveRequestDto): Promise<string>;
|
||||
submitActnResult(
|
||||
pwrkCnsltnDocNo: string,
|
||||
body: PwrkActnResultSubmitDto,
|
||||
): Promise<string>;
|
||||
update(pwrkCnsltnDocNo: string, body: PwrkCnsltnMngUpdateRequestDto): Promise<string>;
|
||||
remove(pwrkCnsltnDocNo: string): Promise<void>;
|
||||
changePrgrsSttsComCd(body: DocPrgrsUpdateRequestDto): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
v1.0의 `save(): PwrkCnsltnMngSaveResponseDto`(초안 트리거)는 화면에서 사용하지 않는다. 컨트롤러가 남아 있으면 엔티티에 넣더라도 UI 연결 금지.
|
||||
|
||||
생성결과:
|
||||
|
||||
```ts
|
||||
export interface PwrkCnsltnDocCrtRsltService {
|
||||
findPage(
|
||||
searchDto: PwrkCnsltnDocCrtRsltSearchDto,
|
||||
): Promise<PageResponse<PwrkCnsltnDocCrtRsltResponseDto>>;
|
||||
findById(crtDmndUuid: string, verSn: number): Promise<PwrkCnsltnDocCrtRsltResponseDto>;
|
||||
findLatestByCrtDmndUuid(crtDmndUuid: string): Promise<PwrkCnsltnDocCrtRsltResponseDto>;
|
||||
openWebHwpLatest(crtDmndUuid: string): Promise<AxiosResponse<Blob>>;
|
||||
download(crtDmndUuid: string, verSn: number): Promise<AxiosResponse<Blob>>;
|
||||
downloadLatest(crtDmndUuid: string): Promise<AxiosResponse<Blob>>;
|
||||
saveMultipart(formData: FormData): Promise<PwrkCnsltnDocCrtRsltResponseDto>;
|
||||
updateMultipart(
|
||||
crtDmndUuid: string,
|
||||
verSn: number,
|
||||
formData: FormData,
|
||||
): Promise<PwrkCnsltnDocCrtRsltResponseDto>;
|
||||
remove(crtDmndUuid: string, verSn: number): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
`webhwp/open`은 인증된 axios 요청으로 Blob을 조회한다:
|
||||
|
||||
```ts
|
||||
async function openWebHwpLatest(crtDmndUuid: string) {
|
||||
return client.get<Blob>(`${BASE}/${crtDmndUuid}/webhwp/open`, {
|
||||
responseType: "blob",
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
응답 Blob은 HWPX `File`로 감싸 `HwpCtrl.Open(file, "HWPX", ...)`에 전달한다. `blob:` Object URL은 사용하지 않는다.
|
||||
|
||||
DC RAG TO (구현됨, 시그니처 유지):
|
||||
|
||||
```ts
|
||||
requestDocumentTransfer(body: DcRagDocumentTransferRequestDto): Promise<DcRagCommonResponseDto>;
|
||||
requestDraftGeneration(body: DcRagDraftGenerateRequestDto): Promise<DcRagDraftGenerateResponseDto>;
|
||||
getDraftGenerationStatus(crtDmndUuid: string): Promise<DcRagDraftStatusResponseDto>;
|
||||
requestCostVerification(body: DcRagCostVerifyRequestDto): Promise<DcRagCommonResponseDto>;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. DTO
|
||||
|
||||
타입명 = 백엔드 클래스명. Save/Apply는 zod.
|
||||
|
||||
### 4-1. 재원협의 추가
|
||||
|
||||
**`FnrsExamineRequestDto`**
|
||||
|
||||
| 필드 | 필수 | 값 |
|
||||
| --- | --- | --- |
|
||||
| `prgrsSttsComCd` | N | 기본 `FV` |
|
||||
|
||||
**`EnvoActnReviewReqDto`**
|
||||
|
||||
| 필드 | 필수 |
|
||||
| --- | --- |
|
||||
| `envoDocNo` | Y |
|
||||
| `specialRqstCn` | N |
|
||||
|
||||
**`FnrsCnsltnFileVrfcRsltResponseDto`**: [ref-endpoint.md](./ref-endpoint.md) §3-3.
|
||||
|
||||
첨부 검증 배지용으로 `AtflMngClientResponseDto` / `EsntlAtchFileMngTreeResponseDto`에 `vrfcDt`, `vrfcRsltCn`, `vrfcSttsComCd` 확장.
|
||||
|
||||
### 4-2. `PwrkActnResultSubmitDto` (zod)
|
||||
|
||||
| 필드 | 필수 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| `isSubmit` | Y | `false` 저장, `true` 협의회신 |
|
||||
| `actnList` | Y | |
|
||||
| `actnList[].rvwOpnnSn` | Y | |
|
||||
| `actnList[].actnRsltCn` | Y | |
|
||||
| `actnList[].atchFileGroupId` | N | |
|
||||
| `totActnSummary` | 제출 시 | |
|
||||
| `muniDocNo` | 제출 시 | |
|
||||
|
||||
### 4-3. `PwrkCnsltnMng*`
|
||||
|
||||
**Search**: `pwrkCnsltnDocNo`, `upDocNo`, `upDocSeComCd`, `prgrsSttsComCd`, `admdstCd`, `envoSeComCd`, `taskSeComCd`, `bizNm`
|
||||
|
||||
**DetResponse 중첩**
|
||||
|
||||
- `cnsltnList`: 협의내역 (`rvwOpnnSn`, `rvwOpnnClsfComCd`, `cnsltnCn`, `rfltYn`, …)
|
||||
- `pjtcoAjmtList`: 사업비조정
|
||||
- `crtDmndList[].crtRslt`: 생성요청 + 최신 결과
|
||||
- `splmntDmndList`: 보완요청
|
||||
- `sttsChgHstryList`: 상태변경이력 — `SL` 복귀 라벨에 사용
|
||||
- `trsmFileList`: 전송파일 (Java Det DTO 확인 후 반영)
|
||||
|
||||
마스터 조인: `bizNm`, `admdstCd`, `envoSeComCd`, `taskSeComCd`, `aplcntNm` 등.
|
||||
|
||||
`crtRslt.orgnlOtptDocCn`은 항상 `null`.
|
||||
|
||||
**상세 예**
|
||||
|
||||
```json
|
||||
{
|
||||
"pwrkCnsltnDocNo": "P-20260827-0001",
|
||||
"upDocNo": "F-20260824-0001",
|
||||
"upDocSeComCd": "FNRS",
|
||||
"prgrsSttsComCd": "TE",
|
||||
"cnsltnList": [],
|
||||
"pjtcoAjmtList": [],
|
||||
"crtDmndList": [
|
||||
{
|
||||
"crtDmndUuid": "d0545bef-735f-4d7b-a781-88e96e03577a",
|
||||
"pwrkCnsltnDocNo": "P-20260827-0001",
|
||||
"rqstrUuid": "...",
|
||||
"rqstrNm": "관리자",
|
||||
"crtSttsComCd": "C",
|
||||
"crtRslt": {
|
||||
"crtDmndUuid": "d0545bef-735f-4d7b-a781-88e96e03577a",
|
||||
"verSn": 1,
|
||||
"rgtrNm": "관리자",
|
||||
"orgnlOtptDocCn": null,
|
||||
"crtCmptnDt": "2026-08-27 10:47:01"
|
||||
}
|
||||
}
|
||||
],
|
||||
"splmntDmndList": [],
|
||||
"sttsChgHstryList": []
|
||||
}
|
||||
```
|
||||
|
||||
### 4-4. `PwrkCnsltnDocCrtRslt*`
|
||||
|
||||
복합 PK: `crtDmndUuid` + `verSn`
|
||||
|
||||
**Response**
|
||||
|
||||
| 필드 | 타입 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| `crtDmndUuid` | `string` | |
|
||||
| `verSn` | `number` | |
|
||||
| `rgtrUuid` / `rgtrNm` | `string` | |
|
||||
| `orgnlOtptDocCn` | `null` | 목록/상세 미포함 |
|
||||
| `crtCmptnDt` | `string` | |
|
||||
|
||||
**Search** (`PageableParams`): `crtDmndUuid`(이력 조회 시 필수), `pwrkCnsltnDocNo`, `verSn`, `rgtrUuid`
|
||||
|
||||
```http
|
||||
GET /as-service/api/v1/pwrk-cnsltn-doc-crt-rslt?page=0&size=10&crtDmndUuid={uuid}&sort=verSn,desc
|
||||
```
|
||||
|
||||
**Save (zod)**: `crtDmndUuid` 필수. `rgtrUuid` 생략 시 서버 로그인 사용자. `file`은 multipart part (DTO 필드 아님).
|
||||
|
||||
### 4-5. DC RAG (구현됨)
|
||||
|
||||
```ts
|
||||
interface DcRagDocumentTransferRequestDto {
|
||||
fnrsCnsltnDocNo: string;
|
||||
taskSeComCd?: string;
|
||||
taskSeCdNm?: string;
|
||||
}
|
||||
|
||||
interface DcRagDraftStatusResponseDto {
|
||||
crtDmndUuid: string;
|
||||
status: DraftStatus;
|
||||
statusName?: string;
|
||||
progressPercent?: number;
|
||||
events?: EventInfoDto[];
|
||||
startedDt?: string;
|
||||
estimatedCompletionDt?: string;
|
||||
errorMessage?: string | null;
|
||||
}
|
||||
|
||||
type DraftStatus =
|
||||
| "REQUESTED"
|
||||
| "DOWNLOADING"
|
||||
| "ANALYZING"
|
||||
| "GENERATING"
|
||||
| "COMPLETED"
|
||||
| "FAILED";
|
||||
```
|
||||
|
||||
`estimatedMinutes`는 초안 응답에 있을 수 있으나 UX 안내는 **30분 Fallback**을 따른다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 폴링 훅
|
||||
|
||||
초안 (`AC` 또는 `TE`이면서 생성 중):
|
||||
|
||||
```ts
|
||||
useQuery({
|
||||
queryKey: [...dcragToKeys.draftStatus(crtDmndUuid)],
|
||||
queryFn: () => dcRagApiToService.getDraftGenerationStatus(crtDmndUuid),
|
||||
enabled: Boolean(crtDmndUuid) && isPolling,
|
||||
refetchInterval: (query) => {
|
||||
const status = query.state.data?.status;
|
||||
if (status === "COMPLETED" || status === "FAILED") return false;
|
||||
return 5_000;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`COMPLETED` 시 `fnrs-cnsltn` 상세·`pwrk-cnsltn-mng` `findByUpDocNo` invalidate.
|
||||
|
||||
`FV`/`AV`는 `findById`를 5~10초 refetch. `prgrsSttsComCd`가 `ER`/`RK`가 되면 중단. 시작 시각 기준 30분 경과 안내 문구.
|
||||
|
||||
`crtDmndUuid` 복원: `findByUpDocNo(fnrsCnsltnDocNo).crtDmndList[0]`.
|
||||
|
||||
---
|
||||
|
||||
## 6. 다운로드
|
||||
|
||||
```ts
|
||||
const response = await client.get<Blob>(url, { responseType: "blob" });
|
||||
const filename = parseFilenameFromResponse(response);
|
||||
const objectUrl = window.URL.createObjectURL(response.data);
|
||||
```
|
||||
|
||||
기존 `attachService`와 동일.
|
||||
|
||||
---
|
||||
|
||||
## 7. 실무협의 목록 컬럼 (선택 화면)
|
||||
|
||||
| 표시명 | Field |
|
||||
| --- | --- |
|
||||
| 실무협의번호 | `pwrkCnsltnDocNo` |
|
||||
| 재원협의번호 | `upDocNo` |
|
||||
| 진행상태 | `prgrsSttsComCdNm` |
|
||||
| 사업명 | `bizNm` |
|
||||
| 시도/구군 | `sido` / `gugun` |
|
||||
| 신청자 | `aplcntNm` |
|
||||
| 등록일 | `regDt` |
|
||||
|
||||
행 클릭 → `/pwrk-cnsltn/{pwrkCnsltnDocNo}` 또는 재원협의 상세로 이동 (화면설계 확정 후).
|
||||
|
||||
---
|
||||
|
||||
## 8. `elapr-dmnd` 저장 시 주의
|
||||
|
||||
현행 `floating-action-buttons.tsx`:
|
||||
|
||||
```ts
|
||||
elaprCycl: data.detDto?.prgrsSttsComCd === "RK" ? 2 : 1,
|
||||
docPrgrsSttsComCd: data.detDto?.prgrsSttsComCd, // ER
|
||||
```
|
||||
|
||||
To-Be:
|
||||
|
||||
- 검토자지정은 **`ER`에서만**. `elaprCycl: 1`, `docPrgrsSttsComCd: "AC"`
|
||||
- 2차 결재선은 서버 `copy-line`. `RK`에서 검토자지정 팝업을 다시 열지 않음
|
||||
@@ -0,0 +1,246 @@
|
||||
# 레퍼런스 — 프로세스·상태 전이
|
||||
|
||||
> 상위: [개발가이드.md](./개발가이드.md)
|
||||
> SoT: `03.재원협의_실무협의_프로세스_통합_v완결.md` (v3.0, 2026-09-03)
|
||||
|
||||
---
|
||||
|
||||
## 1. 프로세스 개요
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --- | --- |
|
||||
| Process No. | PR-AS-003 |
|
||||
| 메뉴 | 행정지원 > 2.2.2 재원협의 > 2.2.2.1 재원협의 관리 |
|
||||
| 목적 | 기초 → 광역(경유) → 환경청(승인) → 공단(기술검토/AI) 4자 협의로 사업규모·국비·분담금을 확정 |
|
||||
|
||||
2-Pass:
|
||||
|
||||
- **Pass 1** 기술 타당성: `SV→ST→LE→FV→ER→AC→TE→TP→RM→RB`
|
||||
- **Pass 2** 조치결과·금액: `RB→RC→RA→AV→RK→RR→EP→TA`
|
||||
|
||||
자체종결(기술검토 생략): `LE→EP→TA`
|
||||
|
||||
---
|
||||
|
||||
## 2. 4-Lane 시퀀스
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor 기초 as 기초 (ROLE_USER05)
|
||||
actor 광역 as 광역 (ROLE_USER04)
|
||||
actor 환경청 as 환경청 (ROLE_USER03)
|
||||
actor 공단 as 공단 (ROLE_USER01)
|
||||
actor AI as AI 엔진
|
||||
|
||||
rect rgb(240, 248, 255)
|
||||
기초->>기초: [저장] SV
|
||||
기초->>광역: [신청완료] ST
|
||||
광역->>환경청: [환경청제출] LE
|
||||
end
|
||||
|
||||
rect rgb(255, 250, 240)
|
||||
alt 자체종결
|
||||
환경청->>광역: [결과회신] LE → EP
|
||||
광역->>기초: [결과회신] EP → TA
|
||||
else 기술검토 의뢰
|
||||
환경청->>AI: [기술검토 의뢰] LE → FV
|
||||
end
|
||||
end
|
||||
|
||||
rect rgb(245, 255, 245)
|
||||
AI-->>공단: 파일검증 콜백 FV → ER
|
||||
Note over 공단: 30분 미응답 시 ER + 수동 [파일검증]
|
||||
alt 파일 미비
|
||||
공단->>기초: [파일보완] ER → SL
|
||||
기초->>공단: [파일보완완료] SL → ER
|
||||
else 적격
|
||||
공단->>AI: [검토자지정] ER → AC
|
||||
end
|
||||
end
|
||||
|
||||
rect rgb(255, 245, 245)
|
||||
AI-->>공단: 초안 콜백 AC → TE
|
||||
Note over 공단: 30분 미응답 시 TE + 수동 [실무협의서생성]
|
||||
alt 설계 미비
|
||||
공단->>기초: [보완요청] TE → SL
|
||||
기초->>공단: [보완완료] SL → TE
|
||||
else 검토 완료
|
||||
공단->>공단: [검토완료] 상신, [결재(승인)] TE 유지
|
||||
공단->>환경청: [결재(회신)] TE → TP
|
||||
end
|
||||
end
|
||||
|
||||
rect rgb(250, 240, 255)
|
||||
환경청->>광역: [재원협의 결과 회신] TP → RM
|
||||
광역->>기초: [지자체회신] RM → RB
|
||||
기초->>광역: [협의회신] RB → RC
|
||||
end
|
||||
|
||||
rect rgb(240, 255, 255)
|
||||
광역->>환경청: [조치결과서제출] RC → RA
|
||||
환경청->>AI: [조치결과서 검토요청] RA → AV
|
||||
AI-->>공단: 금액검증 콜백 AV → RK
|
||||
Note over 공단: 30분 미응답 시 RK + 수동 [금액검증]
|
||||
Note over 공단: AV→RK 시 1차 결재선 → 2차 복사 (서버)
|
||||
end
|
||||
|
||||
rect rgb(255, 255, 230)
|
||||
alt 소명 미비
|
||||
공단->>기초: [보완요청] RK → SL
|
||||
기초->>공단: [보완완료] SL → RK
|
||||
else 확정
|
||||
공단->>환경청: [결재(회신)] RK → RR
|
||||
end
|
||||
end
|
||||
|
||||
rect rgb(255, 240, 240)
|
||||
환경청->>광역: [결과회신] RR → EP
|
||||
광역->>기초: [결과회신] EP → TA
|
||||
end
|
||||
```
|
||||
|
||||
거버넌스:
|
||||
|
||||
```
|
||||
기초 (USER05) --ST--> 광역 (USER04) --LE--> 환경청 (USER03)
|
||||
기초 <-----TA------ 광역 <-----EP/RM------ 환경청
|
||||
환경청 --FV/ER--> 공단 (USER01) --TP/RR--> 환경청
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Activity 정의 (14)
|
||||
|
||||
| No. | Activity | 주체 | From → To | FE 포인트 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | 재원협의 조회 | 기초 | — | 기본계획·자가점검 대조 |
|
||||
| 2 | 등록 및 제출 | 기초·광역 | `SV→ST`, 광역 `LE` | `save-apply`, `updatePrgrsStts` |
|
||||
| 3 | 접수 및 의뢰 | 환경청 | `LE→FV` / `EP` | 의뢰 vs 자체종결 분기 |
|
||||
| 4 | AI 파일검증 | 공단·AI | `FV→ER` / `SL` | 30분 Fallback, 파일보완 팝업 |
|
||||
| 5 | 자체종결 회신 | 환경청·광역 | `LE→EP→TA` | 공단 레인 없음 |
|
||||
| 6 | 검토자지정·초안 | 공단·AI | `ER→AC→TE` | `elapr-dmnd`, 초안 폴링 |
|
||||
| 7 | 1차 기술검토 | 공단 결재선 | `TE` / `SL` | 웹기안기, HWP 파싱은 서버 |
|
||||
| 8 | 기술검토 회신 | 공단 최종 | `TE→TP` | `process` 최종 |
|
||||
| 9 | 검토결과 회신 | 환경청·광역 | `TP→RM→RB` | 1차 회신. `EP`와 혼동 금지 |
|
||||
| 10 | 조치결과 작성 | 기초 | `RB→RC` | `submit-actn-result` |
|
||||
| 11 | 조치 제출·의뢰 | 광역·환경청 | `RC→RA→AV` | `request-actn-review` |
|
||||
| 12 | AI 금액검증 | AI·공단 | `AV→RK` | 결재선 복사(서버) |
|
||||
| 13 | 2차 검토·결재 | 공단 | `RK→RR` / `SL` | 2차 결재선 |
|
||||
| 14 | 최종 회신 | 환경청·광역·기초 | `RR→EP→TA` | 종결 |
|
||||
|
||||
---
|
||||
|
||||
## 4. `FNRSSTTS` 상세
|
||||
|
||||
관리: `tb_cs_lwpo_cd` / `tb_as_fnrs_cnsltn_mng.prgrs_stts_com_cd`
|
||||
|
||||
| 코드 | 국문 | 영문 | 화면 주체 | 전이 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `SV` | 저장 | Save | 기초 작성 | 신규/`SV` → `SV` |
|
||||
| `ST` | 신청완료 | Submit to Province | 기초→광역 | `SV→ST` |
|
||||
| `LE` | 환경청 검토 | Local Environment Review | 광역→환경청 | `ST→LE` |
|
||||
| `FV` | 파일검증 | File Verification | AI | `LE→FV` |
|
||||
| `ER` | 기술검토 의뢰 | Examine Request | 공단 접수자 | `FV→ER`, `SL→ER` |
|
||||
| `SL` | 보완요청 | Supplement Loop | 공단→기초 | `ER\|TE\|RK → SL` |
|
||||
| `AC` | 초안생성 | AI Auto Creation | AI | `ER→AC` |
|
||||
| `TE` | 기술검토 | Technical Examine | 공단 1차 결재 | `AC→TE`, `SL→TE` |
|
||||
| `TP` | 기술검토 회신 | Technical Pass to Basin | 공단→환경청 | `TE→TP` |
|
||||
| `RM` | 재원협의결과회신 | Result Message to Province | 환경청→광역 | `TP→RM` |
|
||||
| `RB` | 재원협의결과회신(지자체) | Result Back to Municipality | 광역→기초 | `RM→RB` |
|
||||
| `RC` | 조치결과서 제출 | Result Complete | 기초→광역 | `RB→RC` |
|
||||
| `RA` | 조치결과서 제출(환경청) | Result Ask to Basin | 광역→환경청 | `RC→RA` |
|
||||
| `AV` | 금액검증 | Amount Verification | AI | `RA→AV` |
|
||||
| `RK` | 조치결과서검토 | Result Check | 공단 2차 결재 | `AV→RK`, `SL→RK` |
|
||||
| `RR` | 조치결과서검토 회신 | Result Return to Basin | 공단→환경청 | `RK→RR` |
|
||||
| `EP` | 검토결과 회신 | Examine Pass to Province | 환경청→광역 | `LE→EP`(자체), `RR→EP`(최종) |
|
||||
| `TA` | 검토결과 회신(종료) | Technical Approval | 광역→기초 | `EP→TA` |
|
||||
|
||||
### 4-1. v2.1 → v3.0 대조 (프론트 폐기 대상)
|
||||
|
||||
| 구간 | v2.1 | v3.0 |
|
||||
| --- | --- | --- |
|
||||
| 의뢰 직후 파일검증 | `ER` | **`FV` → `ER`** |
|
||||
| 검토자지정 후 초안 | `EE` → `DV` | **`ER` → `AC` → `TE`** |
|
||||
| 1차 내부 결재 | `TR` → `TP` | **`TE` 유지 → `TP`** |
|
||||
| 환경청→광역 1차 회신 | `EP` | **`RM`** |
|
||||
| 금액검증 | `RA` | **`RA` → `AV` → `RK`** |
|
||||
| 공단 2차 회신 | `TP` | **`RR`** |
|
||||
| 최종 완료 | `TP` / 고시 `TB` | **`TA`** (고시 없음) |
|
||||
|
||||
가이드 v1.0 `PWRKSTTS`(`DV`, `AR`, `TR`, `AA` 등)도 동일하게 폐기.
|
||||
|
||||
---
|
||||
|
||||
## 5. 보완(`SL`) 3루프
|
||||
|
||||
복귀 상태는 `tb_as_doc_stts_chg_hstry` 직전 코드. 프론트는 복귀 코드를 요청에 넣지 않고, 라벨만 이력으로 고른다.
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
ER --> SL: 파일보완 팝업(파일리스트)
|
||||
SL --> ER: 파일보완완료
|
||||
TE --> SL: 보완 검토팝업()
|
||||
SL --> TE: 보완완료
|
||||
RK --> SL: 보완 검토팝업(금액)
|
||||
SL --> RK: 보완완료
|
||||
```
|
||||
|
||||
| 루프 | 팝업 | 요청 | 제출 버튼 |
|
||||
| --- | --- | --- | --- |
|
||||
| 파일 무결성 | 파일리스트 + 검증결과 | 공단 접수자 `ER` | [파일보완완료] |
|
||||
| 1차 기술 | 설계 미달·사유 | 공단 검토자 `TE` | [보완완료] |
|
||||
| 2차 금액 | 공종별 금액 체크 | 공단 검토자 `RK` | [보완완료] |
|
||||
|
||||
---
|
||||
|
||||
## 6. AI 3파이프라인·30분 Fallback
|
||||
|
||||
| ID | 단계 | 진입 → 완료 | 프론트 진입 | Fallback | 수동 재실행 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| AI-1 | 파일 무결성 | `FV→ER` | 환경청 [기술검토 의뢰] | 30분 → `ER` + [파일검증] | `POST /dcrag-to/documents/transfer` |
|
||||
| AI-2 | 초안 생성 | `AC→TE` | 공단 [검토자지정] | 30분 → `TE` + [실무협의서생성] | `POST /dcrag-to/documents/draft/generate` |
|
||||
| AI-3 | 금액 검증 | `AV→RK` | 환경청 [조치결과서 검토요청] | 30분 → `RK` + [금액검증] | `POST /dcrag-to/documents/cost/verify` |
|
||||
|
||||
- 예상 소요 안내는 **30분** (v1.0의 10분은 폐기).
|
||||
- 콜백 수신·스케줄러 전이는 서버. FE는 상세 refetch / draft status 폴링만.
|
||||
- `AC→TE` 성공 시에만 [실무협의서관리]·[검토결과조회] 활성. 실패·타임아웃이면 [실무협의서생성].
|
||||
|
||||
초안 폴링 이벤트 예:
|
||||
|
||||
| `eventType` | `eventMessage` |
|
||||
| --- | --- |
|
||||
| `REQUESTED` | 요청을 접수했습니다. |
|
||||
| `DOWNLOADING` | 신청서를 수신하는 중입니다. |
|
||||
| `ANALYZING` | 자료 준비가 완료되었습니다. |
|
||||
| `GENERATING` | 문서 저장이 완료되었습니다. |
|
||||
| `COMPLETED` | 문서 생성이 완료되었습니다. |
|
||||
| `FAILED` | `errorMessage` 표시 |
|
||||
|
||||
종료: `status === "COMPLETED" | "FAILED"` 또는 `events`에 해당 `eventType`.
|
||||
|
||||
---
|
||||
|
||||
## 7. 결재선 복사
|
||||
|
||||
규칙: `"금액검증후: 2차결재선 1차결재선 복사생성"`
|
||||
|
||||
1. `TE`에서 1차 결재선(`elaprCycl = 1`) 완료.
|
||||
2. `AV→RK` 콜백 시 서버가 `POST /elapr-apln/copy-line` (`source=1`, `target=2`).
|
||||
3. `RK` 화면은 2차 결재선을 조회해 검토자/승인자/최종승인자를 표시.
|
||||
4. 프론트는 copy-line을 **호출하지 않는다.**
|
||||
|
||||
결재 `process`:
|
||||
|
||||
- 상신·중간 승인: 문서 상태 `TE` 또는 `RK` 유지.
|
||||
- 1차 최종: 서버가 `TE→TP`.
|
||||
- 2차 최종: 서버가 `RK→RR`.
|
||||
- 반려: 직전 결재자 복귀. 문서 상태 유지.
|
||||
|
||||
---
|
||||
|
||||
## 8. 웹기안기
|
||||
|
||||
- `TE`/`RK` [실무협의서관리] → `webhwp/open` Blob 조회 후 팝업에서 `HwpCtrl.Open(File)`.
|
||||
- [검토완료] 시 HWP 파싱·DB 등재는 **서버**. FE는 결재 `process`만.
|
||||
- 편집본 저장: `POST /pwrk-cnsltn-doc-crt-rslt` multipart. `verSn`은 클라이언트가 보내지 않음.
|
||||
@@ -0,0 +1,173 @@
|
||||
# 레퍼런스 — 화면·버튼 매트릭스
|
||||
|
||||
> 상위: [개발가이드.md](./개발가이드.md)
|
||||
> SoT: `03` §4 화면 및 버튼별 상세 상태전이 명세서 (Row 4~42, 38행)
|
||||
|
||||
구현 위치: `pages/fnrs-cnsltn/lib/button-config.ts` + `ui/components/floating-action-buttons.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 1. 화면 구분
|
||||
|
||||
| 화면 | 소유 | 라우트 (현행) | 비고 |
|
||||
| --- | --- | --- | --- |
|
||||
| 작성화면 | 기초 `ROLE_USER05` | `/fnrs-cnsltn/new`, `/fnrs-cnsltn/{id}/edit` | `SV`, `RB` |
|
||||
| 수정화면 | 기초 | `/fnrs-cnsltn/{id}/edit` | `SL` 보완 |
|
||||
| 상세화면 | 광역·환경청·공단·기초(TA) | `/fnrs-cnsltn/{id}` | 조회 + 액션 버튼 |
|
||||
| 자동화 | AI | 버튼 없음 | `FV`/`AC`/`AV` 진행 패널 |
|
||||
|
||||
공단 상세는 결재선 구분으로 같은 상태라도 버튼이 다르다.
|
||||
|
||||
| `atrzSeComCd` | 역할 |
|
||||
| --- | --- |
|
||||
| `ATRZ01` | 접수자 |
|
||||
| `ATRZ02` | 검토자 |
|
||||
| `ATRZ03` | 승인자 |
|
||||
| `ATRZ99` | 최종승인자 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 38행 매트릭스
|
||||
|
||||
API 컬럼은 [ref-endpoint.md](./ref-endpoint.md)와 동일.
|
||||
|
||||
| 순번 | 화면 | 권한 | 결재선 | 상태 | 버튼 | 전이 | 다음 담당 | FE 동작 |
|
||||
| :---: | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| 1 | 작성 | USER05 | - | 빈값, `SV` | 저장 | `SV` | 본인 | `save-apply` `SV` |
|
||||
| 2 | 작성 | USER05 | - | `SV` | 신청완료 | `ST` | 광역 | `save-apply` `ST` |
|
||||
| 3 | 작성 | USER05 | - | `SV` | 삭제 | 삭제 | — | `DELETE` |
|
||||
| 4 | 상세 | USER04 | - | `ST` | 환경청제출 | `LE` | 환경청 | `updatePrgrsStts` |
|
||||
| 5 | 상세 | USER03 | - | `LE` | 결과회신 | `EP` | 광역 | `tech-review` 자체종결 |
|
||||
| 6 | 상세 | USER03 | - | `LE` | 기술검토 의뢰 | `FV` | 공단 접수 | **`request-examine`** |
|
||||
| 7 | 자동화 | AI | - | `FV` | (파일검증) | `ER` | 공단 접수 | FE 호출 없음. 진행 패널 |
|
||||
| 8 | 상세 | USER01 | 접수자 | `ER` | 파일보완 | `SL` | 기초 | 팝업(파일리스트) → `reqsupplement` |
|
||||
| 9 | 상세 | USER01 | 접수자 | `ER` | 파일검증 | `ER` | 본인 | `dcrag-to/transfer` 재실행 |
|
||||
| 10 | 상세 | USER01 | 접수자 | `ER` | 검토자지정 | `AC` | 다음 결재자 | `POST /elapr-dmnd` |
|
||||
| 11 | 자동화 | AI | - | `AC` | (초안생성) | `TE` | 공단 검토 | FE 호출 없음. 폴링 |
|
||||
| 12 | 상세 | USER01 | 검토자 | `TE` | 실무협의서관리 | 유지 | — | 웹기안기 Open. **초안 성공 시만** |
|
||||
| 13 | 상세 | USER01 | 검토자 | `TE` | 검토결과조회 | 유지 | — | 실무협의 상세 |
|
||||
| 14 | 상세 | USER01 | 검토자 | `TE` | 실무협의서생성 | 유지 | — | **실패/타임아웃/초안 없음** 시 |
|
||||
| 15 | 상세 | USER01 | 검토자 | `TE` | 저장 | `TE` | — | `pwrk save-apply` |
|
||||
| 16 | 상세 | USER01 | 검토자 | `TE` | 검토완료 | `TE` | 다음 결재자 | `elapr process` 상신. 반려 시 직전 결재자 |
|
||||
| 17 | 상세 | USER01 | 검토자 | `TE` | 보완요청 | `SL` | 기초 | 보완 팝업() → `reqsupplement` |
|
||||
| 18 | 상세 | USER01 | 승인자 | `TE` | 결재(승인) | `TE` | 다음 결재자 | `process` |
|
||||
| 19 | 상세 | USER01 | 최종승인자 | `TE` | 결재(회신) | `TP` | 환경청 | `process` 최종 |
|
||||
| 20 | 수정 | USER05 | - | `SL` | 파일보완완료 | `ER` | 공단 접수 | `submitsupplement` (직전=`ER`) |
|
||||
| 21 | 수정 | USER05 | - | `SL` | 보완완료 | `TE` | 공단 검토 | `submitsupplement` (직전=`TE`) |
|
||||
| 22 | 수정 | USER05 | - | `SL` | 보완완료 | `RK` | 공단 검토 | `submitsupplement` (직전=`RK`) |
|
||||
| 23 | 상세 | USER03 | - | `TP` | 재원협의 결과 회신 | `RM` | 광역 | `tech-review` |
|
||||
| 24 | 상세 | USER04 | - | `RM` | 지자체회신 | `RB` | 기초 | `updatePrgrsStts` |
|
||||
| 25 | 작성 | USER05 | - | `RB` | 저장 | `RB` | 본인 | **`submit-actn-result` `isSubmit:false`** |
|
||||
| 26 | 작성 | USER05 | - | `RB` | 협의회신 | `RC` | 광역 | **`submit-actn-result` `isSubmit:true`** |
|
||||
| 27 | 상세 | USER04 | - | `RC` | 조치결과서제출 | `RA` | 환경청 | `updatePrgrsStts` |
|
||||
| 28 | 상세 | USER03 | - | `RA` | 조치결과서 검토요청 | `AV` | 공단 검토 | **`request-actn-review`** |
|
||||
| 29 | 자동화 | AI | - | `AV` | (금액검증) | `RK` | 공단 검토 | FE 호출 없음. 진행 패널 |
|
||||
| 30 | 상세 | USER01 | 검토자 | `RK` | 실무협의서관리 | 유지 | — | 웹기안기 |
|
||||
| 31 | 상세 | USER01 | 검토자 | `RK` | 금액검증 | `RK` | — | `dcrag-to/cost/verify` |
|
||||
| 32 | 상세 | USER01 | 검토자 | `RK` | 저장 | `RK` | — | `pwrk save-apply` |
|
||||
| 33 | 상세 | USER01 | 검토자 | `RK` | 검토완료 | `RK` | 다음 결재자 | `process` |
|
||||
| 34 | 상세 | USER01 | 승인자 | `RK` | 결재(승인) | `RK` | 다음 결재자 | `process` |
|
||||
| 35 | 상세 | USER01 | 최종승인자 | `RK` | 결재(회신) | `RR` | 환경청 | `process` 최종 |
|
||||
| 36 | 상세 | USER03 | - | `RR` | 결과회신 | `EP` | 광역 | `updatePrgrsStts` |
|
||||
| 37 | 작성/상세 | USER04 | - | `EP` | 결과회신 | `TA` | 기초 | `updatePrgrsStts` |
|
||||
| 38 | 상세 | USER05 | - | `TA` | 결과회신 | — | — | **API 없음. 조회** |
|
||||
|
||||
`SL` 20~22행: 버튼은 직전 상태에 따라 **하나만** 노출. 파일 루프면 [파일보완완료], 그 외 [보완완료].
|
||||
|
||||
---
|
||||
|
||||
## 3. `button-config` 권장 구조
|
||||
|
||||
액션 키는 현행 `FNRS_CNSLTN_VIEW_BUTTON_ACTION`을 확장한다. 신규·이름 변경:
|
||||
|
||||
| 신규/변경 액션 | 라벨 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| `fileSplmnt` | 파일보완 | `ER` 접수자. 현행에 없음 |
|
||||
| `fileVrfc` | 파일검증 | 유지. `ER` 재실행 |
|
||||
| `dsgntRvwr` | 검토자지정 | 유지. 전이를 `AC`로 |
|
||||
| `pwrkCnsltnDocMng` | 실무협의서관리 | 유지. `TE`·`RK` |
|
||||
| `pwrkCnsltnDocCrt` | 실무협의서생성 | 유지. 실패/타임아웃만 |
|
||||
| `rvwRsltInq` | 검토결과조회 | 신규 |
|
||||
| `actnStrg` | 저장(조치결과) | `RB` |
|
||||
| `cnsltnRply` | 협의회신 | `RB→RC` |
|
||||
| `actnSbmsn` | 조치결과서제출 | 광역 `RC→RA` |
|
||||
| `rqstActnRvw` | 조치결과서 검토요청 | 환경청 `RA→AV` |
|
||||
| `costVrfc` | 금액검증 | `RK` |
|
||||
| `lclRply` | 지자체회신 | 광역 `RM→RB` |
|
||||
| `fnrsRsltRply` | 재원협의 결과 회신 | 환경청 `TP→RM` |
|
||||
| `rsltRply` | 결과회신 | `LE→EP`, `RR→EP`, `EP→TA`, `TA` 조회 |
|
||||
| `fileSplmntCmptn` | 파일보완완료 | `SL←ER` |
|
||||
| `splmntCmptn` | 보완완료 | `SL←TE\|RK` |
|
||||
|
||||
**삭제**: `fnrsCnsltnAncmnt`(고시→`TB`), `sncnAprv`(인가승인). `EP`/`TA`는 `rsltRply`로 통일.
|
||||
|
||||
### 3-1. 역할 × 상태 (공단 제외)
|
||||
|
||||
```
|
||||
ROLE_USER05
|
||||
"" | SV → 저장, 신청완료, 삭제
|
||||
SL → (직전 ER) 파일보완완료 | (직전 TE|RK) 보완완료
|
||||
RB → 저장(조치), 협의회신
|
||||
TA → 결과회신 (조회)
|
||||
|
||||
ROLE_USER04
|
||||
ST → 환경청제출 // 현행 반려(rjct→SV)는 SoT 38행에 없음. 유지 시 별도 합의
|
||||
RM → 지자체회신
|
||||
RC → 조치결과서제출
|
||||
EP → 결과회신
|
||||
|
||||
ROLE_USER03
|
||||
LE → 결과회신(자체종결), 기술검토 의뢰
|
||||
TP → 재원협의 결과 회신
|
||||
RA → 조치결과서 검토요청
|
||||
RR → 결과회신
|
||||
```
|
||||
|
||||
### 3-2. 공단 결재선 × 상태
|
||||
|
||||
```
|
||||
ER / ATRZ01 접수자
|
||||
→ 파일보완, 파일검증, 검토자지정
|
||||
|
||||
TE / ATRZ02 검토자
|
||||
→ (초안 성공) 실무협의서관리, 검토결과조회, 저장, 검토완료, 보완요청
|
||||
→ (초안 실패·타임아웃) 실무협의서생성 (+ 위 항목 중 관리/조회는 초안 생긴 뒤)
|
||||
|
||||
TE / ATRZ03 승인자 → 결재(승인)
|
||||
TE / ATRZ99 최종 → 결재(회신)
|
||||
|
||||
RK / ATRZ02 검토자
|
||||
→ 실무협의서관리, 금액검증, 저장, 검토완료, 보완요청
|
||||
RK / ATRZ03 → 결재(승인)
|
||||
RK / ATRZ99 → 결재(회신)
|
||||
```
|
||||
|
||||
`FV`, `AC`, `AV`: 액션 빈 배열 + 진행 패널.
|
||||
|
||||
현행 `TE`에서 `hasPendingApprovalRequest === false`이면 버튼을 숨긴다. v3.0에서는 검토자 버튼(저장·웹기안기·보완)은 결재 대기와 무관하게 두고, **검토완료/결재**만 결재건 존재 여부에 묶는다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 초안 성공 vs 실패 버튼
|
||||
|
||||
`TE`에서 `crtDmndList[].crtSttsComCd` / 폴링 `status`로 분기.
|
||||
|
||||
| 조건 | 노출 |
|
||||
| --- | --- |
|
||||
| `C` / `COMPLETED` | 실무협의서관리, 검토결과조회, 저장, 검토완료, 보완요청 |
|
||||
| `F` / `FAILED` 또는 30분 Fallback 후 초안 없음 | 실무협의서생성 |
|
||||
| `R`/`I` 생성 중 | 진행 패널. 위 버튼 비활성 (`AC`와 동일 UX, 상태가 이미 `TE`면 패널+생성 버튼) |
|
||||
|
||||
`RK`는 금액검증 성공이 기본. Fallback 후 [금액검증] 재실행만 추가 노출.
|
||||
|
||||
---
|
||||
|
||||
## 5. 현행 `button-config.ts`와의 차이
|
||||
|
||||
| 항목 | 현재 | To-Be |
|
||||
| --- | --- | --- |
|
||||
| USER05 | `""/SV/TA(고시)` | `SL`, `RB` 추가. `TA`는 조회 |
|
||||
| USER04 | `ST`(제출+반려), `EP`(인가승인) | `RM/RC/EP` 추가. 인가승인 삭제 |
|
||||
| USER03 | `LE`, `TP`(→EP로 회신) | `RA`, `RR` 추가. `TP`는 `RM` |
|
||||
| USER01 | `ER`(파일검증, 검토자지정), `TE` | `ER`에 파일보완. `RK` 전면 추가 |
|
||||
| USER01 ATRZ | `ER`, `TE`만 `needsRoleUser01AtrzResolve` | `RK` 포함 |
|
||||
@@ -1,800 +0,0 @@
|
||||
# 실무협의문서 개발 가이드
|
||||
|
||||
> **대상 앱**: `frontend/apps/as`
|
||||
> **작성일**: 2026-08-27
|
||||
> **버전**: 1.0
|
||||
> **SoT (백엔드)**:
|
||||
>
|
||||
> - `PwrkCnsltnDocCrtRsltController` — `/api/v1/pwrk-cnsltn-doc-crt-rslt`
|
||||
> - `PwrkCnsltnMngController` — `/api/v1/pwrk-cnsltn-mng`
|
||||
> - `DcRagApiToController` — `/api/v1/dcrag-to`
|
||||
> - 초안생성 데이터 설명 (`02.03.초안생성_데이터_설명.md`)
|
||||
>
|
||||
> **현재 프론트 상태**: `pwrk-cnsltn-`\* 엔티티·화면 **미구현**. 재원협의(`fnrs-cnsltn`)만 존재한다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 문서 목적
|
||||
|
||||
재원협의 접수자가 **자료 검증 → 실무협의서 초안 생성 → 한글 웹기안기 열람/다운로드 → 편집본 버전 저장**까지 구현할 때 사용하는 프론트엔드 개발 가이드이다.
|
||||
|
||||
백엔드 컨트롤러·DTO를 **API source of truth**로 두고, 초안생성 데이터 설명의 업무 시퀀스를 화면 동작에 매핑한다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 도메인 개요
|
||||
|
||||
실무협의서(PWRK)는 재원협의서(FNRS)의 **하위 문서**이다. 문서번호 규칙:
|
||||
|
||||
| 문서 | 번호 예시 | 필드 |
|
||||
| -------- | ----------------- | ----------------------------- |
|
||||
| 재원협의 | `F-20260824-0001` | `fnrsCnsltnDocNo` / `upDocNo` |
|
||||
| 실무협의 | `P-20260827-0001` | `pwrkCnsltnDocNo` |
|
||||
|
||||
재원협의 1건당 실무협의서는 **1건만** 생성된다. `POST /pwrk-cnsltn-mng`는 `upDocNo`로 기존 문서를 재사용하고, 없으면 문서번호를 발급한다.
|
||||
|
||||
### 2-1. 핵심 식별자
|
||||
|
||||
| 필드 | 의미 | 비고 |
|
||||
| ----------------- | ------------------------------ | --------------------------------------------------------- |
|
||||
| `pwrkCnsltnDocNo` | 실무협의문서번호 | 문서 PK. `P-YYYYMMDD-NNNN` |
|
||||
| `upDocNo` | 상위문서번호(재원협의) | `upDocSeComCd = "FNRS"` |
|
||||
| `crtDmndUuid` | 생성요청 UUID | 초안 생성 작업·결과·다운로드의 키 |
|
||||
| `verSn` | 버전일련번호 | 복합 PK `(crtDmndUuid, verSn)`. 저장 시 서버가 `+1` |
|
||||
| `orgnlOtptDocCn` | 원본출력문서내용(HWP 바이너리) | **목록/상세 응답에서는 항상** `null`. 다운로드 API만 제공 |
|
||||
|
||||
### 2-2. IIPBS 약어
|
||||
|
||||
| 한글 | 약어 | 필드 예 |
|
||||
| -------- | ------ | ----------------------------- |
|
||||
| 실무 | PWRK | `pwrkCnsltnDocNo` |
|
||||
| 협의 | CNSLTN | `pwrk-cnsltn-mng` |
|
||||
| 관리 | MNG | `PwrkCnsltnMng` |
|
||||
| 문서 | DOC | `pwrkCnsltnDocNo` |
|
||||
| 생성 | CRT | `crtDmndUuid`, `crtSttsComCd` |
|
||||
| 요청 | DMND | `crtDmndUuid` |
|
||||
| 결과 | RSLT | `crtRslt` |
|
||||
| 버전 | VER | `verSn` |
|
||||
| 일련번호 | SN | `verSn` |
|
||||
| 원본 | ORGNL | `orgnlOtptDocCn` |
|
||||
| 출력 | OTPT | `orgnlOtptDocCn` |
|
||||
| 내용 | CN | `orgnlOtptDocCn` |
|
||||
| 완료 | CMPTN | `crtCmptnDt` |
|
||||
| 일시 | DT | `crtCmptnDt` |
|
||||
| 검증 | VRFC | `vrfcSttsComCd` |
|
||||
| 전송 | TRSM | `trsmFileList` |
|
||||
| 보완 | SPLMNT | `splmntDmndList` |
|
||||
| 요청자 | RQSTR | `rqstrUuid`, `rqstrNm` |
|
||||
|
||||
---
|
||||
|
||||
## 3. 전체 업무 흐름
|
||||
|
||||
재원협의 **접수자**가 실무협의서 초안을 만드는 경로이다. 프론트는 DC RAG를 직접 호출하지 않고 **as-service**만 호출한다.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant User as 재원협의 접수자
|
||||
participant FE as AS Frontend
|
||||
participant AS as as-service
|
||||
participant RAG as DC RAG
|
||||
|
||||
User->>FE: [자료 검증 요청]
|
||||
FE->>AS: POST /dcrag-to/documents/transfer<br/>{ fnrsCnsltnDocNo }
|
||||
AS->>RAG: 자료전송 (IF-01)
|
||||
AS-->>FE: { resultCode: SUCCESS, requestId }
|
||||
Note over FE: 재원협의 상세 재조회
|
||||
AS-->>FE: esntlFileList / etcFileList 에<br/>vrfcDt, vrfcRsltCn, vrfcSttsComCd
|
||||
|
||||
User->>FE: [초안 생성]
|
||||
FE->>AS: POST /pwrk-cnsltn-mng<br/>{ upDocNo, upDocSeComCd: FNRS, prgrsSttsComCd: DV }
|
||||
alt 실무협의서 번호 없음
|
||||
AS->>AS: P-번호 발급 + 문서 등록
|
||||
else 이미 존재
|
||||
AS->>AS: 기존 pwrkCnsltnDocNo 재사용
|
||||
end
|
||||
AS->>RAG: 초안생성 비동기 요청 (IF-03-1)
|
||||
AS-->>FE: 201 { pwrkCnsltnDocNo, crtDmndUuid, crtSttsComCd: R, estimatedMinutes: 10 }
|
||||
|
||||
loop 약 10분, status !== COMPLETED/FAILED
|
||||
FE->>AS: GET /dcrag-to/documents/draft/status/{crtDmndUuid}
|
||||
AS-->>FE: { status, events[], progressPercent }
|
||||
end
|
||||
|
||||
Note over FE: events 에 COMPLETED 가 있으면<br/>웹기안기 열기 / 다운로드 가능
|
||||
|
||||
User->>FE: [문서 열기] 또는 [다운로드]
|
||||
FE->>AS: GET /pwrk-cnsltn-doc-crt-rslt/{crtDmndUuid}/webhwp/open<br/>또는 /download
|
||||
|
||||
User->>FE: 웹기안기 편집 후 [저장]
|
||||
FE->>AS: POST /pwrk-cnsltn-doc-crt-rslt (multipart)<br/>crtDmndUuid + file
|
||||
AS-->>FE: 201 { verSn: N+1, ... }
|
||||
```
|
||||
|
||||
### 3-1. 프론트가 호출하면 안 되는 API
|
||||
|
||||
| API | 이유 |
|
||||
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `POST /dcrag-to/documents/draft/generate` | 초안 생성은 `POST /pwrk-cnsltn-mng`가 문서번호 발급 + DC RAG 호출을 **한 트랜잭션**으로 처리한다. 프론트는 관리 API만 호출한다. |
|
||||
|
||||
---
|
||||
|
||||
## 4. 화면 데이터 흐름
|
||||
|
||||
초안 생성은 **재원협의 상세**에서 시작하고, 실무협의 목록/상세는 별도 화면이다.
|
||||
|
||||
```
|
||||
BEGIN
|
||||
|
||||
// ===== 재원협의 상세 (기존 fnrs-cnsltn view) =====
|
||||
CALL findById(fnrsCnsltnDocNo)
|
||||
DISPLAY 재원협의 상세 + 필수/기타 첨부
|
||||
|
||||
ON [자료 검증 요청] 버튼 CLICK DO
|
||||
CALL requestDocumentTransfer({ fnrsCnsltnDocNo })
|
||||
CALL findById(fnrsCnsltnDocNo) // 검증결과 필드 반영
|
||||
DISPLAY 첨부 리스트 (vrfcSttsComCd: PASS | FAIL)
|
||||
END ON
|
||||
|
||||
ON [초안 생성] 버튼 CLICK DO
|
||||
CALL pwrkCnsltnMngService.save({
|
||||
upDocNo: fnrsCnsltnDocNo,
|
||||
upDocSeComCd: "FNRS",
|
||||
prgrsSttsComCd: "DV"
|
||||
})
|
||||
// → pwrkCnsltnDocNo, crtDmndUuid, estimatedMinutes
|
||||
START POLL getDraftGenerationStatus(crtDmndUuid)
|
||||
DISPLAY 초안 생성 진행률 (약 10분)
|
||||
END ON
|
||||
|
||||
ON POLL status = COMPLETED 또는 events.eventType 에 COMPLETED DO
|
||||
ENABLE [웹기안기 열기] [다운로드]
|
||||
END ON
|
||||
|
||||
ON [웹기안기 열기] 버튼 CLICK DO
|
||||
CALL HwpCtrl.Open( webhwpOpenUrl(crtDmndUuid) ) // TBD: 컨트롤러 미구현
|
||||
END ON
|
||||
|
||||
ON [다운로드] 버튼 CLICK DO
|
||||
CALL downloadLatest(crtDmndUuid) // blob + Content-Disposition
|
||||
SAVE 로컬 파일
|
||||
END ON
|
||||
|
||||
// ===== 실무협의 목록 =====
|
||||
CALL pwrkCnsltnMngService.findPage(searchDto + pageable)
|
||||
DISPLAY 실무협의 문서 그리드
|
||||
|
||||
ON 행 CLICK DO
|
||||
NAVIGATE 실무협의 상세 (pwrkCnsltnDocNo)
|
||||
END ON
|
||||
|
||||
// ===== 실무협의 상세 / 재원협의에서 연계 조회 =====
|
||||
CALL findById(pwrkCnsltnDocNo)
|
||||
// 또는 재원협의 번호로: findByUpDocNo(fnrsCnsltnDocNo)
|
||||
DISPLAY 마스터 + cnsltnList + pjtcoAjmtList + crtDmndList.crtRslt
|
||||
// crtRslt.orgnlOtptDocCn 은 항상 null — 바이너리는 download API
|
||||
|
||||
ON 버전 이력 조회 DO
|
||||
CALL pwrkCnsltnDocCrtRsltService.findPage({ crtDmndUuid, sort: "verSn,desc" })
|
||||
DISPLAY 버전 목록
|
||||
END ON
|
||||
|
||||
ON 웹기안기 편집 완료 [저장] DO
|
||||
CALL saveMultipart({ crtDmndUuid, file }) // 서버가 verSn + 1
|
||||
INVALIDATE 상세·버전 목록
|
||||
END ON
|
||||
|
||||
END
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. API 명세
|
||||
|
||||
### 5-1. 공통
|
||||
|
||||
| 항목 | 값 |
|
||||
| ----------- | ----------------------------------------------------------------- |
|
||||
| Gateway | `/as-service` |
|
||||
| Base prefix | `ENDPOINT.AS_SERVICE.*` (`apps/as/src/shared/config/api.ts`) |
|
||||
| Auth 헤더 | `Authorization`, `X-Site-Id`, `token-id` (기존 `client` 인터셉터) |
|
||||
| 날짜 포맷 | `yyyy-MM-dd HH:mm:ss` |
|
||||
|
||||
추가할 ENDPOINT:
|
||||
|
||||
```ts
|
||||
AS_SERVICE: {
|
||||
// 기존 …
|
||||
PWRK_CNSLTN_MNG: "/as-service/api/v1/pwrk-cnsltn-mng",
|
||||
PWRK_CNSLTN_DOC_CRT_RSLT: "/as-service/api/v1/pwrk-cnsltn-doc-crt-rslt",
|
||||
DCRAG_TO: "/as-service/api/v1/dcrag-to",
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5-2. DC RAG 연계 (`DcRagApiToController`)
|
||||
|
||||
프론트 엔티티 슬라이스: `entities/dcrag-to/`
|
||||
|
||||
| 기능 | Controller | Method | URL | Request | Response |
|
||||
| ----------------- | -------------------------- | ------ | ------------------------------------------------ | --------------------------------- | ------------------------------------- |
|
||||
| 자료전송요청 | `requestDocumentTransfer` | POST | `/dcrag-to/documents/transfer` | `DcRagDocumentTransferRequestDto` | `DcRagCommonResponseDto` |
|
||||
| 초안생성 진행상태 | `getDraftGenerationStatus` | GET | `/dcrag-to/documents/draft/status/{crtDmndUuid}` | path | `DcRagDraftStatusResponseDto` |
|
||||
| 금액검증요청 | `requestCostVerification` | POST | `/dcrag-to/documents/cost/verify` | `DcRagCostVerifyRequestDto` | `DcRagCommonResponseDto` (202) |
|
||||
| 초안생성요청 | `requestDraftGeneration` | POST | `/dcrag-to/documents/draft/generate` | `DcRagDraftGenerateRequestDto` | `DcRagDraftGenerateResponseDto` (202) |
|
||||
|
||||
**화면에서 쓰는 것**: 자료전송 + 상태 폴링 (+ 추후 금액검증).
|
||||
초안생성요청은 5-3의 `pwrkCnsltnMngService.save`로 대체한다.
|
||||
|
||||
#### 자료전송 Request / Response
|
||||
|
||||
```json
|
||||
POST /as-service/api/v1/dcrag-to/documents/transfer
|
||||
{ "fnrsCnsltnDocNo": "F-20260824-0001" }
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"resultCode": "SUCCESS",
|
||||
"message": "자료전송요청 접수 완료",
|
||||
"requestId": "F-20260824-0001"
|
||||
}
|
||||
```
|
||||
|
||||
자료 검증이 **완료된 뒤** 재원협의 상세를 다시 조회하면, 필수첨부(`esntlFileList`)·기타첨부(`etcFileList`) 각 파일에 검증 결과가 붙는다.
|
||||
|
||||
| 필드 | 예 | 의미 |
|
||||
| --------------- | ----------------------------- | ------------ |
|
||||
| `vrfcDt` | `2026-08-25 18:13:49` | 검증일시 |
|
||||
| `vrfcRsltCn` | `정상 확인 (재원협의 신청서)` | 검증결과내용 |
|
||||
| `vrfcSttsComCd` | `PASS` / `FAIL` | 검증상태 |
|
||||
|
||||
프론트 `FnrsCnsltnMngDetResponseDto` / `AtflMngClientResponseDto` / `EsntlAtchFileMngTreeResponseDto`에 위 3필드가 **아직 없다**. 재원협의 상세 첨부 영역에 배지를 그리려면 타입을 확장해야 한다.
|
||||
|
||||
#### 초안생성 상태 폴링
|
||||
|
||||
```http
|
||||
GET /as-service/api/v1/dcrag-to/documents/draft/status/{crtDmndUuid}
|
||||
```
|
||||
|
||||
| 필드 | 설명 |
|
||||
| ----------------------- | --------------------------------------------------------------------------------- |
|
||||
| `status` | `REQUESTED` / `DOWNLOADING` / `ANALYZING` / `GENERATING` / `COMPLETED` / `FAILED` |
|
||||
| `statusName` | 표시명 (예: `생성 완료`) |
|
||||
| `progressPercent` | 0~100 |
|
||||
| `events[]` | `{ eventType, eventMessage, occurredDt }` |
|
||||
| `startedDt` | 시작일시 |
|
||||
| `estimatedCompletionDt` | 예상 완료일시 |
|
||||
| `errorMessage` | 실패 시 메시지 |
|
||||
|
||||
폴링 규칙:
|
||||
|
||||
- 주기: **5~10초** (TBD, UX 확정).
|
||||
- 예상 소요: **약 10분** (`estimatedMinutes`).
|
||||
- 종료 조건: `status === "COMPLETED"` **또는** `events`에 `eventType === "COMPLETED"`.
|
||||
- 실패: `status === "FAILED"` → 폴링 중단, `errorMessage` 표시.
|
||||
- 완료 전에는 웹기안기 열기·다운로드 버튼을 **비활성**.
|
||||
|
||||
이벤트 순서 예시:
|
||||
|
||||
| eventType | eventMessage |
|
||||
| ------------- | --------------------------- |
|
||||
| `REQUESTED` | 요청을 접수했습니다. |
|
||||
| `DOWNLOADING` | 신청서를 수신하는 중입니다. |
|
||||
| `ANALYZING` | 자료 준비가 완료되었습니다. |
|
||||
| `GENERATING` | 문서 저장이 완료되었습니다. |
|
||||
| `COMPLETED` | 문서 생성이 완료되었습니다. |
|
||||
|
||||
---
|
||||
|
||||
### 5-3. 실무협의관리 (`PwrkCnsltnMngController`)
|
||||
|
||||
프론트 엔티티 슬라이스: `entities/pwrk-cnsltn-mng/`
|
||||
|
||||
| 기능 | Controller | Method | URL | Request | Response |
|
||||
| ------------------- | ---------------------- | ------ | ----------------------------------------------------- | ----------------------------------- | ------------------------------------ |
|
||||
| 페이징 조회 | `findPage` | GET | `/pwrk-cnsltn-mng` | `PwrkCnsltnMngSearchDto` + Pageable | `Page<PwrkCnsltnMngResponseDto>` |
|
||||
| 상세 (문서번호) | `findById` | GET | `/pwrk-cnsltn-mng/{pwrkCnsltnDocNo}` | path | `PwrkCnsltnMngDetResponseDto` |
|
||||
| 상세 (재원협의번호) | `findByUpDocNo` | GET | `/pwrk-cnsltn-mng/updocno/{upDocNo}` | path | `PwrkCnsltnMngDetResponseDto` |
|
||||
| 등록 + 초안생성 | `save` | POST | `/pwrk-cnsltn-mng` | `PwrkCnsltnMngSaveRequestDto` | `PwrkCnsltnMngSaveResponseDto` (201) |
|
||||
| 신청 통합 저장 | `saveApply` | POST | `/pwrk-cnsltn-mng/save-apply` | `PwrkCnsltnApplySaveRequestDto` | `string` (문서번호, 201) |
|
||||
| 수정 | `update` | PUT | `/pwrk-cnsltn-mng/{pwrkCnsltnDocNo}` | `PwrkCnsltnMngUpdateRequestDto` | `string` |
|
||||
| 삭제 | `delete` → `remove` | DELETE | `/pwrk-cnsltn-mng/{pwrkCnsltnDocNo}` | path | 204 |
|
||||
| 보완완료 제출 | `submitSupplement` | POST | `/pwrk-cnsltn-mng/{pwrkCnsltnDocNo}/submitsupplement` | `PwrkCnsltnApplySaveRequestDto` | `string` |
|
||||
| 보완요청 등록 | `submitReqSupplement` | POST | `/pwrk-cnsltn-mng/{pwrkCnsltnDocNo}/reqsupplement` | `PwrkCnsltnApplySaveRequestDto` | `string` |
|
||||
| 진행상태 수정 | `changePrgrsSttsComCd` | POST | `/pwrk-cnsltn-mng/doc/updatePrgrsStts` | `DocPrgrsUpdateRequestDto` | 200 |
|
||||
|
||||
목록 기본 호출:
|
||||
|
||||
```http
|
||||
GET /as-service/api/v1/pwrk-cnsltn-mng?page=0&size=10&sort=pwrkCnsltnDocNo,desc
|
||||
```
|
||||
|
||||
#### 초안 생성 Request / Response (`save`)
|
||||
|
||||
```json
|
||||
POST /as-service/api/v1/pwrk-cnsltn-mng
|
||||
{
|
||||
"upDocNo": "F-20260824-0001",
|
||||
"upDocSeComCd": "FNRS",
|
||||
"prgrsSttsComCd": "DV"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"pwrkCnsltnDocNo": "P-20260826-0001",
|
||||
"upDocNo": "F-20260824-0001",
|
||||
"upDocSeComCd": "FNRS",
|
||||
"prgrsSttsComCd": "DV",
|
||||
"resultCode": "ACCEPTED",
|
||||
"message": "초안 생성 작업이 접수되었습니다.",
|
||||
"crtDmndUuid": "382f2821-9231-42e6-8309-4a1718a2ead8",
|
||||
"crtSttsComCd": "R",
|
||||
"requestId": "382f2821-9231-42e6-8309-4a1718a2ead8",
|
||||
"estimatedMinutes": 10
|
||||
}
|
||||
```
|
||||
|
||||
백엔드 동작 (`PwrkCnsltnMngService.save`):
|
||||
|
||||
1. `upDocNo`로 기존 실무협의서가 있으면 **그 번호를 재사용** (재원협의 1 : 실무협의 1).
|
||||
2. 없으면 `docNoMngService.issueDocNo(PWRK)`로 `P-` 번호 발급 후 insert.
|
||||
3. DC RAG 초안생성을 비동기로 호출하고 `crtDmndUuid`를 응답에 실어 반환.
|
||||
4. DC RAG 실패 시에도 **문서는 롤백하지 않고** 로그만 남긴다. 프론트는 `resultCode`가 `ACCEPTED`/`SUCCESS`가 아니면 토스트로 실패를 알린다.
|
||||
|
||||
#### 상세 응답 구조 (`PwrkCnsltnMngDetResponseDto`)
|
||||
|
||||
재원협의 상세에서 `GET .../updocno/{fnrsCnsltnDocNo}`로 연계 데이터를 한 번에 가져온다.
|
||||
|
||||
```json
|
||||
{
|
||||
"pwrkCnsltnDocNo": "P-20260827-0001",
|
||||
"upDocNo": "F-20260824-0001",
|
||||
"upDocSeComCd": "FNRS",
|
||||
"prgrsSttsComCd": "DV",
|
||||
"cnsltnList": [],
|
||||
"pjtcoAjmtList": [],
|
||||
"crtDmndList": [
|
||||
{
|
||||
"crtDmndUuid": "d0545bef-735f-4d7b-a781-88e96e03577a",
|
||||
"pwrkCnsltnDocNo": "P-20260827-0001",
|
||||
"rqstrUuid": "65a00f65-8460-49af-98ec-042977e56f4b",
|
||||
"rqstrNm": "관리자",
|
||||
"crtSttsComCd": "C",
|
||||
"crtRslt": {
|
||||
"crtDmndUuid": "d0545bef-735f-4d7b-a781-88e96e03577a",
|
||||
"verSn": 1,
|
||||
"rgtrUuid": "...",
|
||||
"rgtrNm": "관리자",
|
||||
"orgnlOtptDocCn": null,
|
||||
"crtCmptnDt": "2026-08-27 10:47:01"
|
||||
}
|
||||
}
|
||||
],
|
||||
"splmntDmndList": [],
|
||||
"trsmFileList": []
|
||||
}
|
||||
```
|
||||
|
||||
`crtRslt`는 최신 버전 메타만 포함한다. `orgnlOtptDocCn`은 절대 쓰지 말고 download API를 호출한다.
|
||||
|
||||
마스터에 재원협의 사업정보가 조인된다: `bizNm`, `admdstCd`, `envoSeComCd`, `taskSeComCd`, `aplcntNm` 등.
|
||||
|
||||
---
|
||||
|
||||
### 5-4. 문서생성결과 (`PwrkCnsltnDocCrtRsltController`)
|
||||
|
||||
프론트 엔티티 슬라이스: `entities/pwrk-cnsltn-doc-crt-rslt/`
|
||||
복합 PK: `crtDmndUuid` + `verSn`
|
||||
|
||||
| 기능 | Controller | Method | URL | Request | Response |
|
||||
| ------------------ | ------------------------- | ------ | ---------------------------------- | ----------------------------------------------- | --------------------------------------- |
|
||||
| 버전 이력 페이징 | `findPage` | GET | `/pwrk-cnsltn-doc-crt-rslt` | `PwrkCnsltnDocCrtRsltSearchDto` + Pageable | `Page<PwrkCnsltnDocCrtRsltResponseDto>` |
|
||||
| 특정 버전 상세 | `findById` | GET | `/{crtDmndUuid}/{verSn}` | path | `PwrkCnsltnDocCrtRsltResponseDto` |
|
||||
| 최신 버전 상세 | `findLatestByCrtDmndUuid` | GET | `/{crtDmndUuid}/latest` | path | `PwrkCnsltnDocCrtRsltResponseDto` |
|
||||
| 특정 버전 다운로드 | `download` | GET | `/{crtDmndUuid}/{verSn}/download` | path | `byte[]` (attachment) |
|
||||
| 최신 버전 다운로드 | `downloadLatest` | GET | `/{crtDmndUuid}/download` | path | `byte[]` (attachment) |
|
||||
| 등록 (multipart) | `saveMultipart` | POST | `/` `multipart/form-data` | `PwrkCnsltnDocCrtRsltSaveRequestDto` + `file` | `PwrkCnsltnDocCrtRsltResponseDto` (201) |
|
||||
| 등록 (JSON) | `saveJson` | POST | `/` `application/json` | `PwrkCnsltnDocCrtRsltSaveRequestDto` | 201 |
|
||||
| 수정 (multipart) | `updateMultipart` | PUT | `/{crtDmndUuid}/{verSn}` multipart | `PwrkCnsltnDocCrtRsltUpdateRequestDto` + `file` | ResponseDto |
|
||||
| 수정 (JSON) | `updateJson` | PUT | `/{crtDmndUuid}/{verSn}` json | UpdateRequestDto | ResponseDto |
|
||||
| 삭제 | `delete` → `remove` | DELETE | `/{crtDmndUuid}/{verSn}` | path | 204 |
|
||||
|
||||
Spring 매핑 주의: `/{crtDmndUuid}/latest` 와 `/{crtDmndUuid}/download` 는 `/{crtDmndUuid}/{verSn}` 보다 **구체 경로가 우선**한다. 프론트 path를 섞지 말 것.
|
||||
|
||||
버전 이력 조회 예:
|
||||
|
||||
```http
|
||||
GET /as-service/api/v1/pwrk-cnsltn-doc-crt-rslt?page=0&size=10&crtDmndUuid={uuid}&sort=verSn,desc
|
||||
```
|
||||
|
||||
#### 다운로드 (attachment)
|
||||
|
||||
화면 [다운로드] 버튼 → PC 저장.
|
||||
|
||||
```http
|
||||
GET /as-service/api/v1/pwrk-cnsltn-doc-crt-rslt/{crtDmndUuid}/download
|
||||
GET /as-service/api/v1/pwrk-cnsltn-doc-crt-rslt/{crtDmndUuid}/{verSn}/download
|
||||
```
|
||||
|
||||
- axios: `responseType: "blob"`.
|
||||
- 파일명: `Content-Disposition`의 `filename*=UTF-8''...` (`parseFilenameFromResponse` 재사용).
|
||||
- 서버 파일명 규칙: `재원협의 실무협의서({bizNm})_v{verSn}.hwp`.
|
||||
- **현재 컨트롤러 Content-Type**: `application/x-hwp`.
|
||||
데이터 설명 예시는 `application/haansofthwpx`. 프론트는 Content-Type에 의존하지 말고 blob으로 저장한다.
|
||||
|
||||
기존 `attachService` 다운로드와 동일 패턴:
|
||||
|
||||
```ts
|
||||
const response = await client.get<Blob>(url, { responseType: "blob" });
|
||||
const filename = parseFilenameFromResponse(response);
|
||||
const objectUrl = window.URL.createObjectURL(response.data);
|
||||
```
|
||||
|
||||
#### 웹기안기 열기 (inline) — TBD
|
||||
|
||||
데이터 설명 기준 엔드포인트. **현재** `PwrkCnsltnDocCrtRsltController`**에는 없음.** 백엔드 추가 후 연결한다.
|
||||
|
||||
```http
|
||||
GET /as-service/api/v1/pwrk-cnsltn-doc-crt-rslt/{crtDmndUuid}/webhwp/open
|
||||
GET /as-service/api/v1/pwrk-cnsltn-doc-crt-rslt/{crtDmndUuid}/{verSn}/webhwp/open
|
||||
```
|
||||
|
||||
의도:
|
||||
|
||||
- `Content-Disposition: inline`
|
||||
- 한컴 웹기안기 `HwpCtrl.Open(url)` 이 이 URL을 직접 호출
|
||||
- 브라우저 다운로드 창이 뜨지 않고 캔버스에 로드
|
||||
|
||||
프론트는 **blob fetch 후 Object URL을 넘기지 않는다.** 인증 헤더가 필요한 절대 URL을 기안기에 전달하는 방식이므로, 쿠키/게이트웨이 인증 가능 여부를 백엔드와 확인한다 (TBD).
|
||||
|
||||
#### 신규 버전 등록 (웹기안기 저장)
|
||||
|
||||
서버가 최신 `verSn + 1`을 부여한다. 클라이언트가 `verSn`을 보내지 않는다.
|
||||
|
||||
```http
|
||||
POST /as-service/api/v1/pwrk-cnsltn-doc-crt-rslt
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
crtDmndUuid: {uuid}
|
||||
file: 재원협의 실무협의서_v2.hwpx
|
||||
```
|
||||
|
||||
화면에서는 **multipart만** 사용한다. JSON `saveJson`은 바이너리를 body에 넣는 경로라 UI에서 쓰지 않는다.
|
||||
|
||||
`file` part 이름: `"file"`. DTO 필드는 `@ModelAttribute` 로 평문 필드(`crtDmndUuid`) 또는 `saveDto` part.
|
||||
|
||||
---
|
||||
|
||||
## 6. DTO 매핑 (프론트 타입명 = 백엔드 클래스명)
|
||||
|
||||
### 6-1. `PwrkCnsltnDocCrtRslt*`
|
||||
|
||||
| 계층 | 파일 | 정의 |
|
||||
| ------------------------------------ | -------------------------------------------- | ----------------------------------------------- |
|
||||
| Save Request | `pwrk-cnsltn-doc-crt-rslt-payload-schema.ts` | zod SSOT → `PwrkCnsltnDocCrtRsltSaveRequestDto` |
|
||||
| Response / Search / Update / Service | `pwrk-cnsltn-doc-crt-rslt-interface.ts` | `interface` |
|
||||
|
||||
**Response**
|
||||
|
||||
| 필드 | 타입 | 비고 |
|
||||
| ---------------- | -------- | --------------------- |
|
||||
| `crtDmndUuid` | `string` | UUID |
|
||||
| `verSn` | `number` | BigDecimal |
|
||||
| `rgtrUuid` | `string` | |
|
||||
| `rgtrNm` | `string` | 서버 enrich |
|
||||
| `orgnlOtptDocCn` | `null` | 목록/상세 미포함 |
|
||||
| `crtCmptnDt` | `string` | `yyyy-MM-dd HH:mm:ss` |
|
||||
|
||||
**Search** (`PageableParams` 확장)
|
||||
|
||||
| 필드 | 비고 |
|
||||
| ----------------- | ---------------------- |
|
||||
| `crtDmndUuid` | 버전 이력 조회 시 필수 |
|
||||
| `pwrkCnsltnDocNo` | |
|
||||
| `verSn` | |
|
||||
| `rgtrUuid` | |
|
||||
|
||||
**Save Request (zod)**
|
||||
|
||||
| 필드 | 필수 | 비고 |
|
||||
| ------------- | ---- | ------------------------------------ |
|
||||
| `crtDmndUuid` | Y | |
|
||||
| `rgtrUuid` | N | 미전송 시 서버가 로그인 사용자 사용 |
|
||||
| `file` | UI | multipart `file` part. DTO 필드 아님 |
|
||||
|
||||
**Update Request**
|
||||
|
||||
| 필드 | 비고 |
|
||||
| ------------ | --------- |
|
||||
| `rgtrUuid` | |
|
||||
| `crtCmptnDt` | |
|
||||
| `file` | multipart |
|
||||
|
||||
### 6-2. `PwrkCnsltnMng*`
|
||||
|
||||
**Search**
|
||||
|
||||
`pwrkCnsltnDocNo`, `upDocNo`, `upDocSeComCd`, `prgrsSttsComCd`, `admdstCd`, `envoSeComCd`, `taskSeComCd`, `bizNm`
|
||||
|
||||
**Save Request (초안 생성)** — 필드가 얇으므로 interface만으로도 가능. 권장은 zod.
|
||||
|
||||
| 필드 | 필수 | 값 |
|
||||
| ----------------- | ---- | ----------------- |
|
||||
| `upDocNo` | Y | 재원협의 문서번호 |
|
||||
| `upDocSeComCd` | | `"FNRS"` |
|
||||
| `prgrsSttsComCd` | | `"DV"` (초안생성) |
|
||||
| `pwrkCnsltnDocNo` | N | 서버 발급 |
|
||||
|
||||
**Save Response** — `save`만 이 DTO. `fnrsCnsltnMngService.save`가 `string`을 반환하는 것과 **다름**.
|
||||
|
||||
| 필드 | 의미 |
|
||||
| ------------------ | --------------------------------- |
|
||||
| `pwrkCnsltnDocNo` | 발급/재사용 번호 |
|
||||
| `resultCode` | `ACCEPTED` / `SUCCESS` / 실패코드 |
|
||||
| `crtDmndUuid` | 폴링 키 |
|
||||
| `crtSttsComCd` | `R` 생성요청 |
|
||||
| `estimatedMinutes` | 기본 10 |
|
||||
|
||||
**DetResponse 중첩**
|
||||
|
||||
- `cnsltnList`: 협의내역 (`rvwOpnnSn`, `rvwOpnnClsfComCd`, `cnsltnCn`, `rfltYn`, …)
|
||||
- `pjtcoAjmtList`: 사업비조정
|
||||
- `crtDmndList[].crtRslt`: 생성요청 + 최신 결과
|
||||
- `splmntDmndList`: 보완요청
|
||||
- `sttsChgHstryList`: 상태변경이력
|
||||
- `trsmFileList`: 전송파일 (데이터 설명 기준. Det DTO Java 필드 확인 후 타입 반영)
|
||||
|
||||
### 6-3. DC RAG
|
||||
|
||||
```ts
|
||||
interface DcRagDocumentTransferRequestDto {
|
||||
fnrsCnsltnDocNo: string;
|
||||
taskSeComCd?: string;
|
||||
taskSeCdNm?: string;
|
||||
}
|
||||
|
||||
interface DcRagCommonResponseDto {
|
||||
resultCode: string;
|
||||
message?: string;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
interface DcRagDraftStatusResponseDto {
|
||||
crtDmndUuid: string;
|
||||
status: DraftStatus;
|
||||
statusName?: string;
|
||||
progressPercent?: number;
|
||||
events?: EventInfoDto[];
|
||||
startedDt?: string;
|
||||
estimatedCompletionDt?: string;
|
||||
errorMessage?: string | null;
|
||||
}
|
||||
|
||||
type DraftStatus =
|
||||
| "REQUESTED"
|
||||
| "DOWNLOADING"
|
||||
| "ANALYZING"
|
||||
| "GENERATING"
|
||||
| "COMPLETED"
|
||||
| "FAILED";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 공통코드
|
||||
|
||||
| 상위코드 | 필드 | 값 | 명칭 |
|
||||
| ----------------------- | ---------------- | --------------- | ------------- |
|
||||
| `ASDOCSE` | `upDocSeComCd` | `FNRS` | 재원협의 |
|
||||
| `ASDOCSE` | 문서구분 | `PWRK` | 실무협의 |
|
||||
| `PWRKSTTS` | `prgrsSttsComCd` | `DV` | 초안생성 |
|
||||
| `PWRKSTTS` | | `AR` | 검토자지정 |
|
||||
| `PWRKSTTS` | | `SV` | 작성중 |
|
||||
| `PWRKSTTS` | | `RB` | 검토회신 |
|
||||
| `PWRKSTTS` | | `RC` | 협의회신 |
|
||||
| `PWRKSTTS` | | `TR` | 검토결과 회신 |
|
||||
| `PWRKSTTS` | | `SL` | 보완요청 |
|
||||
| `PWRKSTTS` | | `AA` | 금액조정중 |
|
||||
| `AICRTSTTS` / `CRTSTTS` | `crtSttsComCd` | `R` | 생성요청 |
|
||||
| | | `I` | 생성중 |
|
||||
| | | `C` | 생성완료 |
|
||||
| | | `F` | 생성실패 |
|
||||
| (검증) | `vrfcSttsComCd` | `PASS` / `FAIL` | |
|
||||
|
||||
`CrtDmndResponseDto`는 `@CodeName(upCd = "CRTSTTS")`, 생성요청 단건 DTO는 `AICRTSTTS`를 쓴다. 코드값이 `R/I/C/F`로 같으므로 UI는 코드값으로 분기하고, 표시명은 응답의 `*Nm` 필드를 쓴다.
|
||||
|
||||
초안 생성 직후 `crtSttsComCd = R`, 폴링 `COMPLETED` 이후 상세의 `crtSttsComCd = C`.
|
||||
|
||||
---
|
||||
|
||||
## 8. 프론트엔드 구현 가이드
|
||||
|
||||
### 8-1. 기술 스택·원칙
|
||||
|
||||
- App Router + FSD (`app/` → `pages/` → `widgets/` → `entities/` → `shared/`)
|
||||
- React Query. ad-hoc `useQuery` + service 직접 호출 금지
|
||||
- `@common/components`, `useDataTable`, `@tanstack/react-table`
|
||||
- `'use client'`는 Web API(다운로드, 폴링, 웹기안기)가 있는 페이지만
|
||||
- 함수명 = Controller public 메서드명 (`delete` → `remove`)
|
||||
- 타입명 = 백엔드 DTO명. 프론트 전용 축약 금지
|
||||
- Save/Apply Request = zod SSOT. Response는 `interface`
|
||||
|
||||
참조 슬라이스: `entities/fnrs-cnsltn/`, `entities/sewer-bsc-plan/`
|
||||
|
||||
### 8-2. 모듈·파일 배치
|
||||
|
||||
| 구분 | 경로 |
|
||||
| -------------------- | ------------------------------------------------------------------- |
|
||||
| ENDPOINT | `apps/as/src/shared/config/api.ts` |
|
||||
| 실무협의 관리 엔티티 | `entities/pwrk-cnsltn-mng/` |
|
||||
| 생성결과 엔티티 | `entities/pwrk-cnsltn-doc-crt-rslt/` |
|
||||
| DC RAG 엔티티 | `entities/dcrag-to/` |
|
||||
| 목록 라우트 | `app/(main)/(with-base-layout)/pwrk-cnsltn/page.tsx` |
|
||||
| 상세 라우트 | `app/(main)/(with-base-layout)/pwrk-cnsltn/[id]/page.tsx` |
|
||||
| 페이지 UI | `pages/pwrk-cnsltn/` |
|
||||
| 재원협의 상세 연계 | `pages/fnrs-cnsltn/ui/fnrs-view-page.tsx` (버튼·폴링·첨부 검증배지) |
|
||||
|
||||
엔티티 내부:
|
||||
|
||||
```
|
||||
entities/pwrk-cnsltn-mng/
|
||||
├── model/
|
||||
│ ├── pwrk-cnsltn-mng-interface.ts
|
||||
│ └── pwrk-cnsltn-mng-payload-schema.ts
|
||||
├── api/
|
||||
│ ├── pwrk-cnsltn-mng-service.ts
|
||||
│ └── queries.ts
|
||||
└── index.ts
|
||||
|
||||
entities/pwrk-cnsltn-doc-crt-rslt/
|
||||
├── model/
|
||||
│ ├── pwrk-cnsltn-doc-crt-rslt-interface.ts
|
||||
│ └── pwrk-cnsltn-doc-crt-rslt-payload-schema.ts
|
||||
├── api/
|
||||
│ ├── pwrk-cnsltn-doc-crt-rslt-service.ts
|
||||
│ └── queries.ts
|
||||
└── index.ts
|
||||
```
|
||||
|
||||
### 8-3. Service 시그니처 요지
|
||||
|
||||
`pwrkCnsltnMngService.save` 반환 타입이 `string`이 아니라 `PwrkCnsltnMngSaveResponseDto` 인 점을 `fnrs-cnsltn`과 혼동하지 말 것.
|
||||
|
||||
```ts
|
||||
export interface PwrkCnsltnMngService {
|
||||
findPage(searchDto: PwrkCnsltnMngSearchDto): Promise<PageResponse<PwrkCnsltnMngResponseDto>>;
|
||||
findById(pwrkCnsltnDocNo: string): Promise<PwrkCnsltnMngDetResponseDto>;
|
||||
findByUpDocNo(upDocNo: string): Promise<PwrkCnsltnMngDetResponseDto>;
|
||||
save(body: PwrkCnsltnMngSaveRequestDto): Promise<PwrkCnsltnMngSaveResponseDto>;
|
||||
saveApply(body: PwrkCnsltnApplySaveRequestDto): Promise<string>;
|
||||
update(pwrkCnsltnDocNo: string, body: PwrkCnsltnMngUpdateRequestDto): Promise<string>;
|
||||
submitSupplement(pwrkCnsltnDocNo: string, body: PwrkCnsltnApplySaveRequestDto): Promise<string>;
|
||||
submitReqSupplement(
|
||||
pwrkCnsltnDocNo: string,
|
||||
body: PwrkCnsltnApplySaveRequestDto,
|
||||
): Promise<string>;
|
||||
remove(pwrkCnsltnDocNo: string): Promise<void>;
|
||||
changePrgrsSttsComCd(body: DocPrgrsUpdateRequestDto): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PwrkCnsltnDocCrtRsltService {
|
||||
findPage(
|
||||
searchDto: PwrkCnsltnDocCrtRsltSearchDto,
|
||||
): Promise<PageResponse<PwrkCnsltnDocCrtRsltResponseDto>>;
|
||||
findById(crtDmndUuid: string, verSn: number): Promise<PwrkCnsltnDocCrtRsltResponseDto>;
|
||||
findLatestByCrtDmndUuid(crtDmndUuid: string): Promise<PwrkCnsltnDocCrtRsltResponseDto>;
|
||||
download(crtDmndUuid: string, verSn: number): Promise<AxiosResponse<Blob>>;
|
||||
downloadLatest(crtDmndUuid: string): Promise<AxiosResponse<Blob>>;
|
||||
saveMultipart(formData: FormData): Promise<PwrkCnsltnDocCrtRsltResponseDto>;
|
||||
updateMultipart(
|
||||
crtDmndUuid: string,
|
||||
verSn: number,
|
||||
formData: FormData,
|
||||
): Promise<PwrkCnsltnDocCrtRsltResponseDto>;
|
||||
remove(crtDmndUuid: string, verSn: number): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
multipart `Content-Type`은 axios가 boundary를 붙이도록 **헤더를 수동 지정하지 않는다.**
|
||||
|
||||
### 8-4. 폴링 훅
|
||||
|
||||
`useQuery` + `refetchInterval`:
|
||||
|
||||
```ts
|
||||
useQuery({
|
||||
queryKey: [...dcragToKeys.draftStatus(crtDmndUuid)],
|
||||
queryFn: () => dcragToService.getDraftGenerationStatus(crtDmndUuid),
|
||||
enabled: Boolean(crtDmndUuid) && isPolling,
|
||||
refetchInterval: (query) => {
|
||||
const status = query.state.data?.status;
|
||||
if (status === "COMPLETED" || status === "FAILED") return false;
|
||||
return 5_000;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`COMPLETED` 시 `pwrkCnsltnMng` 상세 쿼리를 invalidate 하여 `crtDmndList[].crtRslt`를 갱신한다.
|
||||
|
||||
### 8-5. 재원협의 상세에 붙일 UI
|
||||
|
||||
`pages/fnrs-cnsltn` 조회 모드:
|
||||
|
||||
1. **자료 검증 요청** — `requestDocumentTransfer` → 상세 refetch → 첨부 행에 PASS/FAIL 배지 (`vrfcSttsComCd`, `vrfcRsltCn`, `vrfcDt`).
|
||||
2. **초안 생성** — 검증이 끝난 뒤 활성. `pwrkCnsltnMngService.save`.
|
||||
3. **진행 표시** — `progressPercent` + 이벤트 타임라인. `estimatedMinutes`(10)를 안내.
|
||||
4. **웹기안기 열기 / 다운로드** — `COMPLETED` 이후에만.
|
||||
|
||||
`findByUpDocNo(fnrsCnsltnDocNo)`로 이미 초안이 있으면 `crtDmndUuid`를 복원해 폴링·열기를 이어간다.
|
||||
|
||||
### 8-6. 실무협의 목록 컬럼 (권장)
|
||||
|
||||
마스터 응답 + 재원협의 조인 필드 기준. 화면설계 확정 전 TBD.
|
||||
|
||||
| 표시명 | Field |
|
||||
| ------------ | ------------------ |
|
||||
| 실무협의번호 | `pwrkCnsltnDocNo` |
|
||||
| 재원협의번호 | `upDocNo` |
|
||||
| 진행상태 | `prgrsSttsComCdNm` |
|
||||
| 사업명 | `bizNm` |
|
||||
| 시도/구군 | `sido` / `gugun` |
|
||||
| 신청자 | `aplcntNm` |
|
||||
| 등록일 | `regDt` |
|
||||
|
||||
행 클릭 → `/pwrk-cnsltn/{pwrkCnsltnDocNo}`.
|
||||
|
||||
---
|
||||
|
||||
## 9. 구현 순서
|
||||
|
||||
```
|
||||
Task Progress:
|
||||
- [ ] 1. ENDPOINT 3개 추가 (PWRK_CNSLTN_MNG, PWRK_CNSLTN_DOC_CRT_RSLT, DCRAG_TO)
|
||||
- [ ] 2. entities/dcrag-to (transfer + draft status)
|
||||
- [ ] 3. entities/pwrk-cnsltn-mng (findPage, findById, findByUpDocNo, save)
|
||||
- [ ] 4. entities/pwrk-cnsltn-doc-crt-rslt (findPage, downloadLatest, saveMultipart)
|
||||
- [ ] 5. 재원협의 상세: 검증결과 3필드 타입 확장 + 배지
|
||||
- [ ] 6. 재원협의 상세: 자료검증 / 초안생성 / 폴링 UI
|
||||
- [ ] 7. 실무협의 목록·상세 라우트
|
||||
- [ ] 8. 다운로드 (blob)
|
||||
- [ ] 9. 웹기안기 Open — 백엔드 webhwp/open 확인 후
|
||||
- [ ] 10. 웹기안기 저장 → saveMultipart (verSn + 1)
|
||||
```
|
||||
|
||||
1~4는 엔티티 레이어만으로 먼저 닫는다. 화면은 5부터.
|
||||
|
||||
---
|
||||
|
||||
## 10. TBD · 백엔드 확인
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `webhwp/open` | 데이터 설명에는 있으나 **현재 Controller에 없음**. 추가 일정·인증 방식 확인 |
|
||||
| Content-Type | Controller `application/x-hwp` vs 설명 `application/haansofthwpx` |
|
||||
| 파일 확장자 | 다운로드 파일명이 `.hwp`로 고정. 실제 바이너리가 hwpx일 수 있음 |
|
||||
| 폴링 주기 | 5초 vs 10초, 탭 비활성 시 중단 여부 |
|
||||
| 검증 필드 타입 | `FnrsCnsltnMngDetResponseDto.esntlFileList` / `etcFileList` Java 타입이 아직 `FnrsCnsltnAtflResponseDto`가 아님. 런타임 JSON에 `vrfc*`가 오는지 확인 |
|
||||
| `trsmFileList` | 데이터 설명 상세 예시에는 있으나 Det DTO Java 선언과 불일치 가능 |
|
||||
| `crtSttsComCd` 상위코드 | `CRTSTTS` vs `AICRTSTTS` |
|
||||
| 웹기안기 라이선스 URL | `HwpCtrl.Open`에 넘길 게이트웨이 절대 경로 (쿠키 인증 가능 여부) |
|
||||
| 초안 생성 1회 제한 | `requestDraftGeneration` 주석에 “1회 제한”. 재클릭 UX (비활성 / 재요청 확인) |
|
||||
| 자료전송 허용 확장자 | `.doc .docx .hwp .hwpx .md .pdf .txt .xls .xlsm .xlsx`. 이미지 첨부 시 DC RAG 실패 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 참고
|
||||
|
||||
| 자료 | 위치 |
|
||||
| ---------------------- | ------------------------------------------------------------------------------ |
|
||||
| 생성결과 컨트롤러 | `backend/.../pwrkcnsltndoccrtrslt/PwrkCnsltnDocCrtRsltController.java` |
|
||||
| 실무협의관리 컨트롤러 | `backend/.../pwrkcnsltnmng/PwrkCnsltnMngController.java` |
|
||||
| DC RAG TO 컨트롤러 | `backend/.../dcrag/DcRagApiToController.java` |
|
||||
| 초안생성 데이터 설명 | `02.03.초안생성_데이터_설명.md` |
|
||||
| 엔티티 구현 스킬 | `frontend/.cursor/skills/backend-controller-to-entity/` |
|
||||
| 재원협의 엔티티 (패턴) | `apps/as/src/entities/fnrs-cnsltn/` |
|
||||
| IIPBS 단어목록 | `frontend/docs/IIPBS_단어목록.md` |
|
||||
| 진행상태 코드 | `backend/IIPBS_CS/docs/2026/05.행정지원/01.문서상태별_사용자_상태변경_이력.md` |
|
||||
@@ -2,6 +2,12 @@ import type { NextConfig } from "next";
|
||||
|
||||
const subDomain = process.env.NEXT_PUBLIC_SUB_DOMAIN || "http://localhost:13000";
|
||||
|
||||
const webhwpCtrlBase = (
|
||||
process.env.NEXT_PUBLIC_WEBHYP_CTRL_URL || "http://hwp.onasys.co.kr/webhwpctrl"
|
||||
).replace(/\/$/, "");
|
||||
|
||||
const webhwpAssetPrefixes = ["js", "hwpsdk", "dist", "commonFrame", "wasm"] as const;
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
reactStrictMode: true,
|
||||
@@ -30,6 +36,10 @@ const nextConfig: NextConfig = {
|
||||
source: "/cms/:path*",
|
||||
destination: `${subDomain}/cms/:path*`,
|
||||
},
|
||||
...webhwpAssetPrefixes.map((prefix) => ({
|
||||
source: `/webhwpctrl/${prefix}/:path*`,
|
||||
destination: `${webhwpCtrlBase}/${prefix}/:path*`,
|
||||
})),
|
||||
],
|
||||
fallback: [],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<!--
|
||||
~ Copyright 2026 Hancom Inc. All rights reserved.
|
||||
~
|
||||
~ https://www.hancom.com/
|
||||
-->
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en-us">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||
<title>Hwp SDK Wasm Sample</title>
|
||||
<script type="text/javascript" data-type="css/app" data-framework="aori|lab" src="js/loadVersion.js"></script>
|
||||
<script type="text/javascript" data-type="css/app" data-framework="aori|lab" src="js/loadHead.js"></script>
|
||||
<script data-main="js/main" data-bundle="dist/main_bundle" src="js/loadMain.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="status" style="display:none;">
|
||||
<div class="sppiner-content">
|
||||
<div class="spinner" id='spinner'></div>
|
||||
<div class="emscripten" id="statustext"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="status_downloading">
|
||||
<div class="status-content">
|
||||
<div class="emscriptenDownloading">
|
||||
<span id="statusDownloadingText">구성 중 ... 0% </span>
|
||||
<span id="statusDownloadingPercent" class="emscriptenDownloadingPercent"></span>
|
||||
</div>
|
||||
<div class="emscriptenDownloadingInfo"></div>
|
||||
<progress id="download_progress" value="0" max="100"></progress>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<iframe id="download_iframe" style="width:0px;height:0px;border:0;display:none;"></iframe>
|
||||
<script type="text/javascript" src="hwpsdk/loadHwpsdk.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,66 @@
|
||||
<!doctype html>
|
||||
<html lang="ko" style="height: 100%">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<title>웹한글 기안기 브리지</title>
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="http://hwp.onasys.co.kr/webhwpctrl/js/hwpctrlapp/utils/util.js"
|
||||
></script>
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="http://hwp.onasys.co.kr/webhwpctrl/js/hwpctrlapp/hwpCtrlApp.js"
|
||||
></script>
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="http://hwp.onasys.co.kr/webhwpctrl/js/webhwpctrl.js"
|
||||
></script>
|
||||
<style>
|
||||
html,
|
||||
body,
|
||||
#hwpctrl {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
#hwpctrl_frame {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="hwpctrl"></div>
|
||||
<script type="text/javascript">
|
||||
(function () {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var baseUrl = params.get("baseurl") || "http://hwp.onasys.co.kr/webhwpctrl";
|
||||
if (baseUrl.charAt(baseUrl.length - 1) !== "/") {
|
||||
baseUrl += "/";
|
||||
}
|
||||
|
||||
var HwpCtrl = BuildWebHwpCtrl(
|
||||
"hwpctrl",
|
||||
baseUrl,
|
||||
function () {
|
||||
try {
|
||||
window.parent.postMessage({ type: "hangulgiangi-ready" }, "*");
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
},
|
||||
2,
|
||||
);
|
||||
|
||||
// 부모(HangulGiangi)가 contentWindow.HwpCtrl 로 접근
|
||||
window.HwpCtrl = HwpCtrl;
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,7 +3,13 @@ import { AxiosResponse } from "axios";
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type { AttachService } from "../model/attach-interface";
|
||||
import type { Attach, AttachService } from "../model/attach-interface";
|
||||
|
||||
async function findByAtflCd(atflCd: string): Promise<Attach[]> {
|
||||
if (!atflCd) return [];
|
||||
const response = await client.get<Attach[]>(`${ENDPOINT.ATTACH_SERVICE.ATTACHMENTS}/${atflCd}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function deleteAttachmentByAtflId(atflId: string): Promise<void> {
|
||||
if (!atflId) return;
|
||||
@@ -23,6 +29,7 @@ async function downloadAttachmentByAtflId(atflId: string): Promise<AxiosResponse
|
||||
}
|
||||
|
||||
export const attachService = {
|
||||
findByAtflCd,
|
||||
deleteAttachmentByAtflId,
|
||||
downloadAttachmentByAtflCd,
|
||||
downloadAttachmentByAtflId,
|
||||
|
||||
@@ -4,4 +4,5 @@ export {
|
||||
useDownloadAttachmentByAtflCd,
|
||||
useDownloadAttachmentByAtflId,
|
||||
} from "./api/queries";
|
||||
export { parseFilenameFromResponse } from "./lib/parse-filename-from-response";
|
||||
export type { Attach, AttachService } from "./model/attach-interface";
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface Attach {
|
||||
}
|
||||
|
||||
export interface AttachService {
|
||||
findByAtflCd: (atflCd: string) => Promise<Attach[]>;
|
||||
deleteAttachmentByAtflId: (atflId: string) => Promise<void>;
|
||||
downloadAttachmentByAtflCd: (atflCd: string) => Promise<AxiosResponse>;
|
||||
downloadAttachmentByAtflId: (atflId: string) => Promise<AxiosResponse>;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type {
|
||||
DcRagApiToService,
|
||||
DcRagCommonResponseDto,
|
||||
DcRagDraftGenerateResponseDto,
|
||||
DcRagDraftStatusResponseDto,
|
||||
} from "../model/dcrag-to-interface";
|
||||
import type {
|
||||
DcRagCostVerifyRequestDto,
|
||||
DcRagDocumentTransferRequestDto,
|
||||
DcRagDraftGenerateRequestDto,
|
||||
} from "../model/dcrag-to-payload-schema";
|
||||
|
||||
const BASE = ENDPOINT.AS_SERVICE.DCRAG_TO;
|
||||
|
||||
async function requestDocumentTransfer(
|
||||
body: DcRagDocumentTransferRequestDto,
|
||||
): Promise<DcRagCommonResponseDto> {
|
||||
const response = await client.post<DcRagCommonResponseDto>(`${BASE}/documents/transfer`, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function requestDraftGeneration(
|
||||
body: DcRagDraftGenerateRequestDto,
|
||||
): Promise<DcRagDraftGenerateResponseDto> {
|
||||
const response = await client.post<DcRagDraftGenerateResponseDto>(
|
||||
`${BASE}/documents/draft/generate`,
|
||||
body,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function getDraftGenerationStatus(crtDmndUuid: string): Promise<DcRagDraftStatusResponseDto> {
|
||||
const response = await client.get<DcRagDraftStatusResponseDto>(
|
||||
`${BASE}/documents/draft/status/${crtDmndUuid}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function requestCostVerification(
|
||||
body: DcRagCostVerifyRequestDto,
|
||||
): Promise<DcRagCommonResponseDto> {
|
||||
const response = await client.post<DcRagCommonResponseDto>(`${BASE}/documents/cost/verify`, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const dcRagApiToService = {
|
||||
requestDocumentTransfer,
|
||||
requestDraftGeneration,
|
||||
getDraftGenerationStatus,
|
||||
requestCostVerification,
|
||||
} satisfies DcRagApiToService;
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type { DcRagDraftStatusResponseDto } from "../model/dcrag-to-interface";
|
||||
import type {
|
||||
DcRagCostVerifyRequestDto,
|
||||
DcRagDocumentTransferRequestDto,
|
||||
DcRagDraftGenerateRequestDto,
|
||||
} from "../model/dcrag-to-payload-schema";
|
||||
|
||||
import { dcRagApiToService } from "./dcrag-to-service";
|
||||
|
||||
export const dcRagApiToBaseKey = [ENDPOINT.AS_SERVICE.DCRAG_TO] as const;
|
||||
|
||||
const DRAFT_STATUS_POLL_INTERVAL_MS = 5_000;
|
||||
|
||||
function isDraftGenerationSettled(data: DcRagDraftStatusResponseDto | undefined): boolean {
|
||||
if (!data) return false;
|
||||
if (data.status === "COMPLETED" || data.status === "FAILED") return true;
|
||||
return data.events?.some((event) => event.eventType === "COMPLETED") ?? false;
|
||||
}
|
||||
|
||||
export function useDraftGenerationStatus(crtDmndUuid: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [...dcRagApiToBaseKey, "getDraftGenerationStatus", crtDmndUuid],
|
||||
queryFn: () => dcRagApiToService.getDraftGenerationStatus(crtDmndUuid!),
|
||||
enabled: Boolean(crtDmndUuid),
|
||||
refetchInterval: (query) =>
|
||||
isDraftGenerationSettled(query.state.data) ? false : DRAFT_STATUS_POLL_INTERVAL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRequestDocumentTransfer() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: DcRagDocumentTransferRequestDto) =>
|
||||
dcRagApiToService.requestDocumentTransfer(body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: dcRagApiToBaseKey });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRequestDraftGeneration() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: DcRagDraftGenerateRequestDto) =>
|
||||
dcRagApiToService.requestDraftGeneration(body),
|
||||
onSuccess: (_data, body) => {
|
||||
queryClient.invalidateQueries({ queryKey: dcRagApiToBaseKey });
|
||||
if (body.crtDmndUuid) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...dcRagApiToBaseKey, "getDraftGenerationStatus", body.crtDmndUuid],
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRequestCostVerification() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: DcRagCostVerifyRequestDto) =>
|
||||
dcRagApiToService.requestCostVerification(body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: dcRagApiToBaseKey });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export { dcRagApiToService } from "./api/dcrag-to-service";
|
||||
export { dcRagApiToBaseKey } from "./api/queries";
|
||||
export {
|
||||
useDraftGenerationStatus,
|
||||
useRequestCostVerification,
|
||||
useRequestDocumentTransfer,
|
||||
useRequestDraftGeneration,
|
||||
} from "./api/queries";
|
||||
export type {
|
||||
DcRagApiToService,
|
||||
DcRagCommonResponseDto,
|
||||
DcRagDraftGenerateResponseDto,
|
||||
DcRagDraftStatusResponseDto,
|
||||
EventInfoDto,
|
||||
} from "./model/dcrag-to-interface";
|
||||
export type {
|
||||
DcRagCostVerifyRequestDto,
|
||||
DcRagDocumentTransferRequestDto,
|
||||
DcRagDraftGenerateRequestDto,
|
||||
DcRagFileInfoDto,
|
||||
} from "./model/dcrag-to-payload-schema";
|
||||
export {
|
||||
dcRagCostVerifyRequestSchema,
|
||||
dcRagDocumentTransferRequestSchema,
|
||||
dcRagDraftGenerateRequestSchema,
|
||||
dcRagFileInfoSchema,
|
||||
} from "./model/dcrag-to-payload-schema";
|
||||
@@ -0,0 +1,52 @@
|
||||
import type {
|
||||
DcRagCostVerifyRequestDto,
|
||||
DcRagDocumentTransferRequestDto,
|
||||
DcRagDraftGenerateRequestDto,
|
||||
} from "./dcrag-to-payload-schema";
|
||||
|
||||
/** 백엔드 DcRagCommonResponseDto */
|
||||
export interface DcRagCommonResponseDto {
|
||||
resultCode?: string;
|
||||
message?: string;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
/** 백엔드 DcRagDraftGenerateResponseDto */
|
||||
export interface DcRagDraftGenerateResponseDto {
|
||||
resultCode?: string;
|
||||
message?: string;
|
||||
crtDmndUuid?: string;
|
||||
/** R:생성요청, I:생성중, C:생성완료, F:생성실패 */
|
||||
crtSttsComCd?: "R" | "I" | "C" | "F";
|
||||
requestId?: string;
|
||||
estimatedMinutes?: number;
|
||||
}
|
||||
|
||||
/** 백엔드 DcRagDraftStatusResponseDto.EventInfoDto */
|
||||
export interface EventInfoDto {
|
||||
eventType?: string;
|
||||
eventMessage?: string;
|
||||
occurredDt?: string;
|
||||
}
|
||||
|
||||
/** 백엔드 DcRagDraftStatusResponseDto */
|
||||
export interface DcRagDraftStatusResponseDto {
|
||||
crtDmndUuid?: string;
|
||||
status?: "REQUESTED" | "DOWNLOADING" | "ANALYZING" | "GENERATING" | "COMPLETED" | "FAILED";
|
||||
statusName?: string;
|
||||
progressPercent?: number;
|
||||
events?: EventInfoDto[];
|
||||
startedDt?: string;
|
||||
estimatedCompletionDt?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
/** Controller public 메서드명·시그니처와 동일 */
|
||||
export interface DcRagApiToService {
|
||||
requestDocumentTransfer(body: DcRagDocumentTransferRequestDto): Promise<DcRagCommonResponseDto>;
|
||||
requestDraftGeneration(
|
||||
body: DcRagDraftGenerateRequestDto,
|
||||
): Promise<DcRagDraftGenerateResponseDto>;
|
||||
getDraftGenerationStatus(crtDmndUuid: string): Promise<DcRagDraftStatusResponseDto>;
|
||||
requestCostVerification(body: DcRagCostVerifyRequestDto): Promise<DcRagCommonResponseDto>;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/** 백엔드 DcRagFileInfoDto */
|
||||
export const dcRagFileInfoSchema = z.object({
|
||||
fileId: z.string().optional(),
|
||||
fileGroupId: z.string().optional(),
|
||||
fileName: z.string().optional(),
|
||||
physicalPath: z.string().optional(),
|
||||
downloadUrl: z.string().optional(),
|
||||
fileSize: z.number().optional(),
|
||||
esntlAtchFileId: z.string().optional(),
|
||||
esntlAtchFileNm: z.string().optional(),
|
||||
});
|
||||
|
||||
/** 백엔드 DcRagDocumentTransferRequestDto */
|
||||
export const dcRagDocumentTransferRequestSchema = z.object({
|
||||
fnrsCnsltnDocNo: z.string(),
|
||||
taskSeComCd: z.string().optional(),
|
||||
taskSeCdNm: z.string().optional(),
|
||||
essentialFiles: z.array(dcRagFileInfoSchema).optional(),
|
||||
etcFiles: z.array(dcRagFileInfoSchema).optional(),
|
||||
callbackUrl: z.string().optional(),
|
||||
});
|
||||
|
||||
/** 백엔드 DcRagDraftGenerateRequestDto */
|
||||
export const dcRagDraftGenerateRequestSchema = z.object({
|
||||
fnrsCnsltnDocNo: z.string(),
|
||||
pwrkCnsltnDocNo: z.string(),
|
||||
crtDmndUuid: z.string().optional(),
|
||||
essentialFiles: z.array(dcRagFileInfoSchema).optional(),
|
||||
etcFiles: z.array(dcRagFileInfoSchema).optional(),
|
||||
rqstrUuid: z.string().optional(),
|
||||
callbackUrl: z.string().optional(),
|
||||
});
|
||||
|
||||
/** 백엔드 DcRagCostVerifyRequestDto */
|
||||
export const dcRagCostVerifyRequestSchema = z.object({
|
||||
fnrsCnsltnDocNo: z.string(),
|
||||
pwrkCnsltnDocNo: z.string(),
|
||||
});
|
||||
|
||||
export type DcRagFileInfoDto = z.infer<typeof dcRagFileInfoSchema>;
|
||||
export type DcRagDocumentTransferRequestDto = z.infer<typeof dcRagDocumentTransferRequestSchema>;
|
||||
export type DcRagDraftGenerateRequestDto = z.infer<typeof dcRagDraftGenerateRequestSchema>;
|
||||
export type DcRagCostVerifyRequestDto = z.infer<typeof dcRagCostVerifyRequestSchema>;
|
||||
@@ -5,6 +5,7 @@ import type { PageResponse } from "@/shared/model";
|
||||
import type {
|
||||
DocPrgrsUpdateRequestDto,
|
||||
FnrsCnsltnApplySaveRequestDto,
|
||||
FnrsCnsltnFileVrfcRsltResponseDto,
|
||||
FnrsCnsltnMngDetResponseDto,
|
||||
FnrsCnsltnMngResponseDto,
|
||||
FnrsCnsltnMngSaveRequestDto,
|
||||
@@ -12,6 +13,10 @@ import type {
|
||||
FnrsCnsltnMngService,
|
||||
FnrsCnsltnMngUpdateRequestDto,
|
||||
} from "../model/fnrs-cnsltn-interface";
|
||||
import type {
|
||||
EnvoActnReviewReqDto,
|
||||
FnrsExamineRequestDto,
|
||||
} from "../model/fnrs-cnsltn-payload-schema";
|
||||
|
||||
const BASE = ENDPOINT.AS_SERVICE.FNRS_CNSLTN_MNG;
|
||||
|
||||
@@ -51,6 +56,16 @@ async function techReview(id: string, body: FnrsCnsltnApplySaveRequestDto): Prom
|
||||
await client.post<void>(`${BASE}/${id}/tech-review`, body);
|
||||
}
|
||||
|
||||
async function requestExamine(docNo: string, body: FnrsExamineRequestDto): Promise<string> {
|
||||
const response = await client.post<string>(`${BASE}/${docNo}/request-examine`, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function requestActnReview(docNo: string, body: EnvoActnReviewReqDto): Promise<string> {
|
||||
const response = await client.post<string>(`${BASE}/${docNo}/request-actn-review`, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function submitSupplement(id: string, body: FnrsCnsltnApplySaveRequestDto): Promise<string> {
|
||||
const response = await client.post<string>(`${BASE}/${id}/submitsupplement`, body);
|
||||
return response.data;
|
||||
@@ -68,6 +83,15 @@ async function remove(fnrsCnsltnDocNo: string): Promise<void> {
|
||||
await client.delete<void>(`${BASE}/${fnrsCnsltnDocNo}`);
|
||||
}
|
||||
|
||||
async function findFileVrfcResults(
|
||||
fnrsCnsltnDocNo: string,
|
||||
): Promise<FnrsCnsltnFileVrfcRsltResponseDto[]> {
|
||||
const response = await client.get<FnrsCnsltnFileVrfcRsltResponseDto[]>(
|
||||
`${BASE}/${fnrsCnsltnDocNo}/file-vrfc-results`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function changePrgrsSttsComCd(body: DocPrgrsUpdateRequestDto): Promise<void> {
|
||||
await client.post<void>(`${BASE}/doc/updatePrgrsStts`, body);
|
||||
}
|
||||
@@ -79,8 +103,11 @@ export const fnrsCnsltnMngService = {
|
||||
saveApply,
|
||||
update,
|
||||
techReview,
|
||||
requestExamine,
|
||||
requestActnReview,
|
||||
submitSupplement,
|
||||
submitReqSupplement,
|
||||
remove,
|
||||
findFileVrfcResults,
|
||||
changePrgrsSttsComCd,
|
||||
} satisfies FnrsCnsltnMngService;
|
||||
|
||||
@@ -7,6 +7,10 @@ import type {
|
||||
FnrsCnsltnMngSearchDto,
|
||||
FnrsCnsltnMngUpdateRequestDto,
|
||||
} from "../model/fnrs-cnsltn-interface";
|
||||
import type {
|
||||
EnvoActnReviewReqDto,
|
||||
FnrsExamineRequestDto,
|
||||
} from "../model/fnrs-cnsltn-payload-schema";
|
||||
|
||||
import { fnrsCnsltnMngService } from "./fnrs-cnsltn-service";
|
||||
|
||||
@@ -14,6 +18,7 @@ export const fnrsCnsltnKeys = {
|
||||
all: ["fnrs-cnsltn"] as const,
|
||||
list: () => [...fnrsCnsltnKeys.all, "list"] as const,
|
||||
detail: (id: string) => [...fnrsCnsltnKeys.all, "detail", id] as const,
|
||||
fileVrfcResults: (id: string) => [...fnrsCnsltnKeys.all, "fileVrfcResults", id] as const,
|
||||
};
|
||||
|
||||
export function useFnrsCnsltnMngPage(params: FnrsCnsltnMngSearchDto) {
|
||||
@@ -31,6 +36,14 @@ export function useFnrsCnsltnMng(fnrsCnsltnDocNo: string | null | undefined) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useFnrsCnsltnFileVrfcResults(fnrsCnsltnDocNo: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: fnrsCnsltnKeys.fileVrfcResults(fnrsCnsltnDocNo!),
|
||||
queryFn: () => fnrsCnsltnMngService.findFileVrfcResults(fnrsCnsltnDocNo!),
|
||||
enabled: Boolean(fnrsCnsltnDocNo),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveFnrsCnsltnMng() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -84,6 +97,34 @@ export function useTechReviewFnrsCnsltnMng() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useRequestExamineFnrsCnsltnMng() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ docNo, body }: { docNo: string; body: FnrsExamineRequestDto }) =>
|
||||
fnrsCnsltnMngService.requestExamine(docNo, body),
|
||||
onSuccess: (_data, { docNo }) => {
|
||||
queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: fnrsCnsltnKeys.detail(docNo),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRequestActnReviewFnrsCnsltnMng() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ docNo, body }: { docNo: string; body: EnvoActnReviewReqDto }) =>
|
||||
fnrsCnsltnMngService.requestActnReview(docNo, body),
|
||||
onSuccess: (_data, { docNo }) => {
|
||||
queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: fnrsCnsltnKeys.detail(docNo),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSubmitSupplementFnrsCnsltnMng() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -3,8 +3,11 @@ export { fnrsCnsltnKeys } from "./api/queries";
|
||||
export {
|
||||
useChangePrgrsSttsComCd,
|
||||
useDeleteFnrsCnsltnMng,
|
||||
useFnrsCnsltnFileVrfcResults,
|
||||
useFnrsCnsltnMng,
|
||||
useFnrsCnsltnMngPage,
|
||||
useRequestActnReviewFnrsCnsltnMng,
|
||||
useRequestExamineFnrsCnsltnMng,
|
||||
useSaveApplyFnrsCnsltnMng,
|
||||
useSaveFnrsCnsltnMng,
|
||||
useSubmitReqSupplementFnrsCnsltnMng,
|
||||
@@ -25,6 +28,7 @@ export type {
|
||||
EsntlAtchFileItem,
|
||||
EsntlAtchFileMngTreeResponseDto,
|
||||
FnrsCnsltnApplySaveRequestDto,
|
||||
FnrsCnsltnFileVrfcRsltResponseDto,
|
||||
FnrsCnsltnMngDetResponseDto,
|
||||
FnrsCnsltnMngResponseDto,
|
||||
FnrsCnsltnMngSaveRequestDto,
|
||||
@@ -37,3 +41,11 @@ export type {
|
||||
FnrsCnsltnSplmntDmndResponseDto,
|
||||
FnrsSharItem,
|
||||
} from "./model/fnrs-cnsltn-interface";
|
||||
export type {
|
||||
EnvoActnReviewReqDto,
|
||||
FnrsExamineRequestDto,
|
||||
} from "./model/fnrs-cnsltn-payload-schema";
|
||||
export {
|
||||
envoActnReviewReqSchema,
|
||||
fnrsExamineRequestSchema,
|
||||
} from "./model/fnrs-cnsltn-payload-schema";
|
||||
|
||||
@@ -16,6 +16,11 @@ import type {
|
||||
} from "@/shared/model";
|
||||
import type { FnrsSharBizCstTreeDto } from "@/entities/fnrs-shar-se";
|
||||
|
||||
import type {
|
||||
EnvoActnReviewReqDto,
|
||||
FnrsExamineRequestDto,
|
||||
} from "./fnrs-cnsltn-payload-schema";
|
||||
|
||||
export type {
|
||||
AtflMngClientResponseDto,
|
||||
BizCstTreeDto,
|
||||
@@ -79,6 +84,8 @@ export interface FnrsCnsltnMngSearchDto extends PageableParams {
|
||||
atchFileGroupId?: string;
|
||||
sharAtchFileGroupId?: string;
|
||||
prgrsSttsComCd?: string;
|
||||
rtlztnFcltSeComCd?: string;
|
||||
rtlztnUsgSeComCd?: string;
|
||||
}
|
||||
|
||||
interface FnrsCnsltnMngResponseExtension {
|
||||
@@ -86,6 +93,10 @@ interface FnrsCnsltnMngResponseExtension {
|
||||
envoSeCdNm?: string;
|
||||
taskSeCdNm?: string;
|
||||
prgrsSttsCdNm?: string;
|
||||
beforePrgrsSttsComCd?: string;
|
||||
beforePrgrsSttsCdNm?: string;
|
||||
rtlztnFcltSeComNm?: string;
|
||||
rtlztnUsgSeComNm?: string;
|
||||
regDt?: string;
|
||||
rgtr?: string;
|
||||
rgtrNm?: string;
|
||||
@@ -163,6 +174,22 @@ export interface FnrsCnsltnApplySaveRequestDto extends PbadmsApplySaveHeader {
|
||||
fnrsSharList?: FnrsSharItem[];
|
||||
}
|
||||
|
||||
/** 백엔드 FnrsCnsltnFileVrfcRsltResponseDto */
|
||||
export interface FnrsCnsltnFileVrfcRsltResponseDto {
|
||||
fileSeComCd?: string;
|
||||
fileSeNm?: string;
|
||||
esntlAtchFileId?: string;
|
||||
atchFileGroupId?: string;
|
||||
atflId?: string;
|
||||
orgnlFileNm?: string;
|
||||
atflFlieSz?: number;
|
||||
vrfcSttsComCd?: string;
|
||||
vrfcSttsComNm?: string;
|
||||
vrfcRsltCn?: string;
|
||||
vrfcDt?: string;
|
||||
regDt?: string;
|
||||
}
|
||||
|
||||
/** Controller public 메서드명·시그니처와 동일 */
|
||||
export interface FnrsCnsltnMngService {
|
||||
findPage(searchDto: FnrsCnsltnMngSearchDto): Promise<PageResponse<FnrsCnsltnMngResponseDto>>;
|
||||
@@ -171,8 +198,11 @@ export interface FnrsCnsltnMngService {
|
||||
saveApply(body: FnrsCnsltnApplySaveRequestDto): Promise<string>;
|
||||
update(fnrsCnsltnDocNo: string, body: FnrsCnsltnMngUpdateRequestDto): Promise<string>;
|
||||
techReview(id: string, body: FnrsCnsltnApplySaveRequestDto): Promise<void>;
|
||||
requestExamine(docNo: string, body: FnrsExamineRequestDto): Promise<string>;
|
||||
requestActnReview(docNo: string, body: EnvoActnReviewReqDto): Promise<string>;
|
||||
submitSupplement(id: string, body: FnrsCnsltnApplySaveRequestDto): Promise<string>;
|
||||
submitReqSupplement(id: string, body: FnrsCnsltnApplySaveRequestDto): Promise<string>;
|
||||
remove(fnrsCnsltnDocNo: string): Promise<void>;
|
||||
findFileVrfcResults(fnrsCnsltnDocNo: string): Promise<FnrsCnsltnFileVrfcRsltResponseDto[]>;
|
||||
changePrgrsSttsComCd(body: DocPrgrsUpdateRequestDto): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/** 백엔드 FnrsExamineRequestDto */
|
||||
export const fnrsExamineRequestSchema = z.object({
|
||||
prgrsSttsComCd: z.string().optional(),
|
||||
});
|
||||
|
||||
/** 백엔드 EnvoActnReviewReqDto */
|
||||
export const envoActnReviewReqSchema = z.object({
|
||||
envoDocNo: z.string().min(1),
|
||||
specialRqstCn: z.string().optional(),
|
||||
});
|
||||
|
||||
export type FnrsExamineRequestDto = z.infer<typeof fnrsExamineRequestSchema>;
|
||||
export type EnvoActnReviewReqDto = z.infer<typeof envoActnReviewReqSchema>;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type {
|
||||
PwrkCnsltnDocCrtRsltResponseDto,
|
||||
PwrkCnsltnDocCrtRsltService,
|
||||
} from "../model/pwrk-cnsltn-doc-crt-rslt-interface";
|
||||
|
||||
const BASE = ENDPOINT.AS_SERVICE.PWRK_CNSLTN_DOC_CRT_RSLT;
|
||||
|
||||
async function findLatestByCrtDmndUuid(
|
||||
crtDmndUuid: string,
|
||||
): Promise<PwrkCnsltnDocCrtRsltResponseDto> {
|
||||
const response = await client.get<PwrkCnsltnDocCrtRsltResponseDto>(
|
||||
`${BASE}/${crtDmndUuid}/latest`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function openWebHwpLatest(crtDmndUuid: string) {
|
||||
return client.get<Blob>(`${BASE}/${crtDmndUuid}/webhwp/open`, {
|
||||
responseType: "blob",
|
||||
});
|
||||
}
|
||||
|
||||
async function download(crtDmndUuid: string, verSn: number) {
|
||||
return client.get<Blob>(`${BASE}/${crtDmndUuid}/${verSn}/download`, {
|
||||
responseType: "blob",
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadLatest(crtDmndUuid: string) {
|
||||
return client.get<Blob>(`${BASE}/${crtDmndUuid}/download`, {
|
||||
responseType: "blob",
|
||||
});
|
||||
}
|
||||
|
||||
async function saveMultipart(formData: FormData): Promise<PwrkCnsltnDocCrtRsltResponseDto> {
|
||||
const response = await client.post<PwrkCnsltnDocCrtRsltResponseDto>(BASE, formData, {
|
||||
headers: { "Content-Type": undefined },
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const pwrkCnsltnDocCrtRsltService = {
|
||||
findLatestByCrtDmndUuid,
|
||||
openWebHwpLatest,
|
||||
download,
|
||||
downloadLatest,
|
||||
saveMultipart,
|
||||
} satisfies PwrkCnsltnDocCrtRsltService;
|
||||
@@ -0,0 +1,9 @@
|
||||
export { pwrkCnsltnDocCrtRsltService } from "./api/pwrk-cnsltn-doc-crt-rslt-service";
|
||||
export type {
|
||||
PwrkCnsltnDocCrtRsltResponseDto,
|
||||
PwrkCnsltnDocCrtRsltService,
|
||||
} from "./model/pwrk-cnsltn-doc-crt-rslt-interface";
|
||||
export {
|
||||
type PwrkCnsltnDocCrtRsltSaveRequestDto,
|
||||
pwrkCnsltnDocCrtRsltSaveRequestSchema,
|
||||
} from "./model/pwrk-cnsltn-doc-crt-rslt-payload-schema";
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import type { AxiosResponse } from "axios";
|
||||
|
||||
export interface PwrkCnsltnDocCrtRsltResponseDto {
|
||||
crtDmndUuid: string;
|
||||
verSn: number;
|
||||
rgtrUuid?: string;
|
||||
rgtrNm?: string;
|
||||
orgnlOtptDocCn: null;
|
||||
crtCmptnDt?: string;
|
||||
}
|
||||
|
||||
export interface PwrkCnsltnDocCrtRsltService {
|
||||
findLatestByCrtDmndUuid(crtDmndUuid: string): Promise<PwrkCnsltnDocCrtRsltResponseDto>;
|
||||
openWebHwpLatest(crtDmndUuid: string): Promise<AxiosResponse<Blob>>;
|
||||
download(crtDmndUuid: string, verSn: number): Promise<AxiosResponse<Blob>>;
|
||||
downloadLatest(crtDmndUuid: string): Promise<AxiosResponse<Blob>>;
|
||||
saveMultipart(formData: FormData): Promise<PwrkCnsltnDocCrtRsltResponseDto>;
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const pwrkCnsltnDocCrtRsltSaveRequestSchema = z.object({
|
||||
crtDmndUuid: z.string().uuid(),
|
||||
});
|
||||
|
||||
export type PwrkCnsltnDocCrtRsltSaveRequestDto = z.infer<
|
||||
typeof pwrkCnsltnDocCrtRsltSaveRequestSchema
|
||||
>;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type {
|
||||
PwrkCnsltnMngDetResponseDto,
|
||||
PwrkCnsltnMngService,
|
||||
} from "../model/pwrk-cnsltn-mng-interface";
|
||||
|
||||
const BASE = ENDPOINT.AS_SERVICE.PWRK_CNSLTN_MNG;
|
||||
|
||||
async function findByUpDocNo(upDocNo: string): Promise<PwrkCnsltnMngDetResponseDto> {
|
||||
const response = await client.get<PwrkCnsltnMngDetResponseDto>(`${BASE}/updocno/${upDocNo}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const pwrkCnsltnMngService = {
|
||||
findByUpDocNo,
|
||||
} satisfies PwrkCnsltnMngService;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { pwrkCnsltnMngService } from "./pwrk-cnsltn-mng-service";
|
||||
|
||||
export const pwrkCnsltnMngKeys = {
|
||||
all: ["pwrk-cnsltn-mng"] as const,
|
||||
byUpDocNo: (upDocNo: string) => [...pwrkCnsltnMngKeys.all, "by-up-doc-no", upDocNo] as const,
|
||||
};
|
||||
|
||||
export function usePwrkCnsltnMngByUpDocNo(upDocNo: string | null | undefined, isEnabled = true) {
|
||||
return useQuery({
|
||||
queryKey: pwrkCnsltnMngKeys.byUpDocNo(upDocNo!),
|
||||
queryFn: () => pwrkCnsltnMngService.findByUpDocNo(upDocNo!),
|
||||
enabled: Boolean(upDocNo) && isEnabled,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export { pwrkCnsltnMngService } from "./api/pwrk-cnsltn-mng-service";
|
||||
export { pwrkCnsltnMngKeys, usePwrkCnsltnMngByUpDocNo } from "./api/queries";
|
||||
export { findCompletedPwrkDraft } from "./lib/find-completed-draft";
|
||||
export type {
|
||||
PwrkCnsltnDocCrtDmndResponseDto,
|
||||
PwrkCnsltnDocCrtRsltResponseDto,
|
||||
PwrkCnsltnMngDetResponseDto,
|
||||
PwrkCnsltnMngService,
|
||||
} from "./model/pwrk-cnsltn-mng-interface";
|
||||
@@ -0,0 +1,12 @@
|
||||
import type {
|
||||
PwrkCnsltnDocCrtDmndResponseDto,
|
||||
PwrkCnsltnMngDetResponseDto,
|
||||
} from "../model/pwrk-cnsltn-mng-interface";
|
||||
|
||||
export function findCompletedPwrkDraft(
|
||||
data: PwrkCnsltnMngDetResponseDto | null | undefined,
|
||||
): PwrkCnsltnDocCrtDmndResponseDto | undefined {
|
||||
return data?.crtDmndList?.find(
|
||||
(item) => item.crtSttsComCd === "C" && Boolean(item.crtDmndUuid) && Boolean(item.crtRslt),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export interface PwrkCnsltnDocCrtRsltResponseDto {
|
||||
crtDmndUuid: string;
|
||||
verSn: number;
|
||||
rgtrUuid?: string;
|
||||
rgtrNm?: string;
|
||||
orgnlOtptDocCn: null;
|
||||
crtCmptnDt?: string;
|
||||
}
|
||||
|
||||
export interface PwrkCnsltnDocCrtDmndResponseDto {
|
||||
crtDmndUuid: string;
|
||||
pwrkCnsltnDocNo: string;
|
||||
crtSttsComCd?: string;
|
||||
crtSttsComCdNm?: string;
|
||||
failRsn?: string;
|
||||
dmndDt?: string;
|
||||
cmptnDt?: string;
|
||||
crtRslt?: PwrkCnsltnDocCrtRsltResponseDto | null;
|
||||
}
|
||||
|
||||
export interface PwrkCnsltnMngDetResponseDto {
|
||||
pwrkCnsltnDocNo: string;
|
||||
upDocNo: string;
|
||||
upDocSeComCd?: string;
|
||||
prgrsSttsComCd?: string;
|
||||
bizNm?: string;
|
||||
crtDmndList?: PwrkCnsltnDocCrtDmndResponseDto[];
|
||||
crtRsltList?: PwrkCnsltnDocCrtRsltResponseDto[];
|
||||
}
|
||||
|
||||
export interface PwrkCnsltnMngService {
|
||||
findByUpDocNo(upDocNo: string): Promise<PwrkCnsltnMngDetResponseDto>;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
const AI_PENDING_STATUS_CODES = ["FV", "AC", "AV"] as const;
|
||||
|
||||
type AiPendingStatusCode = (typeof AI_PENDING_STATUS_CODES)[number];
|
||||
|
||||
const AI_PENDING_MESSAGES: Record<AiPendingStatusCode, string> = {
|
||||
FV: "AI 파일 검증이 진행 중입니다",
|
||||
AC: "AI 실무협의서 초안 생성이 진행 중입니다",
|
||||
AV: "AI 금액 검증이 진행 중입니다",
|
||||
};
|
||||
|
||||
function isAiPendingStatus(prgrsSttsComCd: string): boolean {
|
||||
return AI_PENDING_STATUS_CODES.includes(prgrsSttsComCd as AiPendingStatusCode);
|
||||
}
|
||||
|
||||
function getAiPendingMessage(prgrsSttsComCd: string): string {
|
||||
if (!isAiPendingStatus(prgrsSttsComCd)) return "";
|
||||
return AI_PENDING_MESSAGES[prgrsSttsComCd as AiPendingStatusCode];
|
||||
}
|
||||
|
||||
export { AI_PENDING_STATUS_CODES, getAiPendingMessage, isAiPendingStatus };
|
||||
export type { AiPendingStatusCode };
|
||||
@@ -8,7 +8,7 @@ export const FNRS_CNSLTN_VIEW_BUTTON_ACTION = {
|
||||
sncnAprv: "sncnAprv",
|
||||
rvwRply: "rvwRply",
|
||||
rqstTechRvw: "rqstTechRvw",
|
||||
fileVrfc: "fileVrfc",
|
||||
fileSplmnt: "fileSplmnt",
|
||||
dsgntRvwr: "dsgntRvwr",
|
||||
pwrkCnsltnDocCrt: "pwrkCnsltnDocCrt",
|
||||
pwrkCnsltnDocMng: "pwrkCnsltnDocMng",
|
||||
@@ -46,14 +46,14 @@ const ROLE_STATUS_BUTTON_ACTIONS: Record<string, Record<string, FnrsCnsltnViewBu
|
||||
},
|
||||
};
|
||||
|
||||
const ROLE_USER01_ATRZ_STATUS_CODES = ["ER", "TE"] as const;
|
||||
const ROLE_USER01_ATRZ_STATUS_CODES = ["ER", "TE", "RK"] as const;
|
||||
|
||||
const ROLE_USER01_ATRZ_STATUS_ACTIONS: Record<
|
||||
string,
|
||||
Record<string, FnrsCnsltnViewButtonAction[]>
|
||||
> = {
|
||||
ER: {
|
||||
ATRZ01: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.fileVrfc, FNRS_CNSLTN_VIEW_BUTTON_ACTION.dsgntRvwr],
|
||||
ATRZ01: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.fileSplmnt, FNRS_CNSLTN_VIEW_BUTTON_ACTION.dsgntRvwr],
|
||||
},
|
||||
TE: {
|
||||
ATRZ02: [
|
||||
@@ -66,6 +66,9 @@ const ROLE_USER01_ATRZ_STATUS_ACTIONS: Record<
|
||||
ATRZ03: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzAprv],
|
||||
ATRZ99: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzRply],
|
||||
},
|
||||
RK: {
|
||||
ATRZ02: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
|
||||
},
|
||||
};
|
||||
|
||||
function resolveButtonConfigs(actions: FnrsCnsltnViewButtonAction[]): FnrsCnsltnViewButtonAction[] {
|
||||
@@ -74,17 +77,20 @@ function resolveButtonConfigs(actions: FnrsCnsltnViewButtonAction[]): FnrsCnsltn
|
||||
|
||||
interface GetButtonConfigsOptions {
|
||||
hasPendingApprovalRequest?: boolean;
|
||||
hasCompletedDraft?: boolean;
|
||||
}
|
||||
|
||||
const PENDING_APPROVAL_ACTIONS = new Set<FnrsCnsltnViewButtonAction>([
|
||||
FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwCmptn,
|
||||
FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzAprv,
|
||||
FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzRply,
|
||||
]);
|
||||
|
||||
function resolveRoleUser01AtrzButtonActions(
|
||||
prgrsSttsComCd: string,
|
||||
atrzSeComCdList: string[],
|
||||
options?: GetButtonConfigsOptions,
|
||||
): FnrsCnsltnViewButtonAction[] {
|
||||
if (prgrsSttsComCd === "TE" && options?.hasPendingApprovalRequest === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const atrzStatusMap = ROLE_USER01_ATRZ_STATUS_ACTIONS[prgrsSttsComCd];
|
||||
if (!atrzStatusMap || atrzSeComCdList.length === 0) return [];
|
||||
|
||||
@@ -102,7 +108,20 @@ function resolveRoleUser01AtrzButtonActions(
|
||||
}
|
||||
}
|
||||
|
||||
return actions;
|
||||
return actions.filter((action) => {
|
||||
if (prgrsSttsComCd === "TE" && options?.hasCompletedDraft != null) {
|
||||
if (action === FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng) {
|
||||
return options.hasCompletedDraft;
|
||||
}
|
||||
if (action === FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocCrt) {
|
||||
return !options.hasCompletedDraft;
|
||||
}
|
||||
}
|
||||
|
||||
const needsPendingApproval = PENDING_APPROVAL_ACTIONS.has(action);
|
||||
|
||||
return !needsPendingApproval || options?.hasPendingApprovalRequest !== false;
|
||||
});
|
||||
}
|
||||
|
||||
export function needsRoleUser01AtrzResolve(
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
BzScaleItem,
|
||||
CklstResultItem,
|
||||
FnrsCnsltnApplySaveRequestDto,
|
||||
FnrsCnsltnFileVrfcRsltResponseDto,
|
||||
FnrsCnsltnMngDetResponseDto,
|
||||
FnrsCnsltnMngResponseDto,
|
||||
FnrsCnsltnSplmntDmndItem,
|
||||
@@ -111,6 +112,23 @@ export function buildSupplementReqPayload(
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFileSplmntReqPayload(
|
||||
data: FnrsCnsltnMngDetResponseDto,
|
||||
selected: FnrsCnsltnFileVrfcRsltResponseDto[],
|
||||
checklistFormState: ChecklistFormState,
|
||||
): FnrsCnsltnApplySaveRequestDto {
|
||||
const newItems: FnrsCnsltnSplmntDmndItem[] = selected.map((row) => ({
|
||||
splmntArtclComCd: row.fileSeComCd,
|
||||
splmntDmndCn: row.vrfcRsltCn,
|
||||
cmptnYn: "N",
|
||||
}));
|
||||
|
||||
return {
|
||||
...buildSavePayload(data, checklistFormState, "LOCAL", "SL"),
|
||||
splmntDmndList: [...newItems],
|
||||
};
|
||||
}
|
||||
|
||||
export function toSplmntDmndSaveItem(item: FnrsCnsltnSplmntDmndItem): FnrsCnsltnSplmntDmndItem {
|
||||
return {
|
||||
splmntDmndSn: item.splmntDmndSn,
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
|
||||
import type { FnrsCnsltnFileVrfcRsltResponseDto } from "@/entities/fnrs-cnsltn";
|
||||
import { Checkbox } from "@common/components";
|
||||
|
||||
const FILE_SE_COM_CD_MAP = {
|
||||
ESNTL: "필수",
|
||||
ETC: "기타",
|
||||
};
|
||||
|
||||
const columnHelper = createColumnHelper<FnrsCnsltnFileVrfcRsltResponseDto>();
|
||||
|
||||
function formatOptional(value: string | undefined) {
|
||||
return value?.trim() ? value : "-";
|
||||
}
|
||||
|
||||
export function getFileVrfcRsltRowKey(row: FnrsCnsltnFileVrfcRsltResponseDto, index: number) {
|
||||
if (row.atflId) return row.atflId;
|
||||
return [row.esntlAtchFileId, row.fileSeComCd, row.orgnlFileNm, String(index)]
|
||||
.filter(Boolean)
|
||||
.join("::");
|
||||
}
|
||||
|
||||
export function createFileVrfcSplmntColumns() {
|
||||
return [
|
||||
columnHelper.display({
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllRowsSelected()
|
||||
? true
|
||||
: table.getIsSomeRowsSelected()
|
||||
? "indeterminate"
|
||||
: false
|
||||
}
|
||||
onCheckedChange={(checked) => table.toggleAllRowsSelected(!!checked)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
aria-label="전체 선택"
|
||||
/>
|
||||
),
|
||||
meta: { align: "center", width: 48 },
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
disabled={!row.getCanSelect()}
|
||||
onCheckedChange={(checked) => row.toggleSelected(!!checked)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
aria-label="행 선택"
|
||||
/>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("fileSeComCd", {
|
||||
header: () => <p className="whitespace-nowrap">파일유형</p>,
|
||||
meta: { align: "left" },
|
||||
cell: ({ getValue }) => (
|
||||
<p>{formatOptional(FILE_SE_COM_CD_MAP[getValue() as keyof typeof FILE_SE_COM_CD_MAP])}</p>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("fileSeNm", {
|
||||
header: () => <p className="whitespace-nowrap">파일구분</p>,
|
||||
meta: { align: "left" },
|
||||
cell: ({ getValue }) => <p className="whitespace-nowrap">{formatOptional(getValue())}</p>,
|
||||
}),
|
||||
columnHelper.accessor("vrfcRsltCn", {
|
||||
header: "검증내역",
|
||||
meta: { align: "left" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type { UseQueryResult } from "@tanstack/react-query";
|
||||
import { getCoreRowModel, type RowSelectionState } from "@tanstack/react-table";
|
||||
|
||||
import { useDataTable } from "@/shared/lib";
|
||||
import type { PageResponse } from "@/shared/model";
|
||||
import {
|
||||
type FnrsCnsltnFileVrfcRsltResponseDto,
|
||||
useFnrsCnsltnFileVrfcResults,
|
||||
} from "@/entities/fnrs-cnsltn";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@common/components";
|
||||
|
||||
import {
|
||||
createFileVrfcSplmntColumns,
|
||||
getFileVrfcRsltRowKey,
|
||||
} from "../../model/file-vrfc-splmnt-columns";
|
||||
|
||||
interface FileVrfcSplmntDialogProps {
|
||||
open: boolean;
|
||||
fnrsCnsltnDocNo: string;
|
||||
isSubmitting?: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (selected: FnrsCnsltnFileVrfcRsltResponseDto[]) => void;
|
||||
}
|
||||
|
||||
function toFileVrfcPageResponse(
|
||||
rows: FnrsCnsltnFileVrfcRsltResponseDto[],
|
||||
): PageResponse<FnrsCnsltnFileVrfcRsltResponseDto> {
|
||||
const content = rows.filter((row) => row.vrfcSttsComCd === "WARNING");
|
||||
const totalElements = content.length;
|
||||
return {
|
||||
content,
|
||||
pageable: {
|
||||
sort: { empty: true, sorted: false, unsorted: true },
|
||||
offset: 0,
|
||||
pageNumber: 0,
|
||||
pageSize: totalElements || 1,
|
||||
paged: false,
|
||||
unpaged: true,
|
||||
},
|
||||
sort: { empty: true, sorted: false, unsorted: true },
|
||||
totalPages: 1,
|
||||
totalElements,
|
||||
number: 0,
|
||||
numberOfElements: totalElements,
|
||||
size: totalElements || 1,
|
||||
first: true,
|
||||
last: true,
|
||||
empty: totalElements === 0,
|
||||
};
|
||||
}
|
||||
|
||||
function FileVrfcSplmntDialog({
|
||||
open,
|
||||
fnrsCnsltnDocNo,
|
||||
isSubmitting = false,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: FileVrfcSplmntDialogProps) {
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
|
||||
const listQuery = useFnrsCnsltnFileVrfcResults(open ? fnrsCnsltnDocNo : null);
|
||||
|
||||
const pageData = useMemo(
|
||||
() => (listQuery.data ? toFileVrfcPageResponse(listQuery.data) : undefined),
|
||||
[listQuery.data],
|
||||
);
|
||||
|
||||
const pageQuery = useMemo(
|
||||
() =>
|
||||
({
|
||||
isPending: listQuery.isPending,
|
||||
isError: listQuery.isError,
|
||||
isSuccess: listQuery.isSuccess,
|
||||
data: pageData,
|
||||
error: listQuery.error,
|
||||
status: listQuery.status,
|
||||
fetchStatus: listQuery.fetchStatus,
|
||||
}) as UseQueryResult<PageResponse<FnrsCnsltnFileVrfcRsltResponseDto>>,
|
||||
[
|
||||
listQuery.error,
|
||||
listQuery.fetchStatus,
|
||||
listQuery.isError,
|
||||
listQuery.isPending,
|
||||
listQuery.isSuccess,
|
||||
listQuery.status,
|
||||
pageData,
|
||||
],
|
||||
);
|
||||
|
||||
const columns = useMemo(() => createFileVrfcSplmntColumns(), []);
|
||||
|
||||
const { tableComponent } = useDataTable({
|
||||
query: pageQuery,
|
||||
tableOptions: {
|
||||
columns,
|
||||
state: { rowSelection },
|
||||
enableRowSelection: true,
|
||||
enableMultiRowSelection: true,
|
||||
getRowId: (row, index) => getFileVrfcRsltRowKey(row, index),
|
||||
onRowSelectionChange: (updater) => {
|
||||
const next = typeof updater === "function" ? updater(rowSelection) : updater;
|
||||
setRowSelection(next);
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
},
|
||||
dataTableOptions: {
|
||||
viewPagination: false,
|
||||
},
|
||||
});
|
||||
|
||||
const selectedRows = useMemo(() => {
|
||||
const selectedIds = new Set(Object.keys(rowSelection));
|
||||
const content = pageData?.content ?? [];
|
||||
return content.filter((row, index) => selectedIds.has(getFileVrfcRsltRowKey(row, index)));
|
||||
}, [pageData?.content, rowSelection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setRowSelection({});
|
||||
}, [open]);
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
if (!nextOpen) onClose();
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (selectedRows.length === 0) return;
|
||||
onConfirm(selectedRows);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-[80rem]" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>파일보완</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">{tableComponent}</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" color="white" onClick={onClose} disabled={isSubmitting}>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
disabled={isSubmitting || selectedRows.length === 0}
|
||||
>
|
||||
보완요청
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { FileVrfcSplmntDialog };
|
||||
export type { FileVrfcSplmntDialogProps };
|
||||
@@ -22,10 +22,13 @@ import {
|
||||
import {
|
||||
type DocPrgrsUpdateRequestDto,
|
||||
type FnrsCnsltnApplySaveRequestDto,
|
||||
type FnrsCnsltnFileVrfcRsltResponseDto,
|
||||
fnrsCnsltnKeys,
|
||||
type FnrsCnsltnMngDetResponseDto,
|
||||
fnrsCnsltnMngService,
|
||||
type FnrsExamineRequestDto,
|
||||
} from "@/entities/fnrs-cnsltn";
|
||||
import { findCompletedPwrkDraft, usePwrkCnsltnMngByUpDocNo } from "@/entities/pwrk-cnsltn-mng";
|
||||
import { SplmntDmndDialog, type SplmntDmndDialogConfirmPayload } from "@/widgets/pbadms-sprt";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
@@ -37,6 +40,7 @@ import {
|
||||
} from "../../lib/button-config";
|
||||
import { computeReviewStepChecklistProgress } from "../../lib/checklist-utils";
|
||||
import {
|
||||
buildFileSplmntReqPayload,
|
||||
buildSavePayload,
|
||||
buildSupplementReqPayload,
|
||||
buildTechReviewReplyPayload,
|
||||
@@ -45,6 +49,9 @@ import {
|
||||
import { type ChecklistFormState } from "../../model/view-types";
|
||||
import type { TechReviewOpinionSectionHandle } from "../sections/tech-review-opinion-section";
|
||||
|
||||
import { FileVrfcSplmntDialog } from "./file-vrfc-splmnt-dialog";
|
||||
import { PwrkCnsltnDocMngDialog } from "./pwrk-cnsltn-doc-mng-dialog";
|
||||
|
||||
interface FloatingActionButtonsProps {
|
||||
data: FnrsCnsltnMngDetResponseDto;
|
||||
docSeComCd: string;
|
||||
@@ -56,9 +63,17 @@ interface FloatingActionButtonsProps {
|
||||
checklistFormState: ChecklistFormState;
|
||||
techReviewOpinionRef?: React.RefObject<TechReviewOpinionSectionHandle | null>;
|
||||
opnrNm?: string;
|
||||
isAiPending?: boolean;
|
||||
}
|
||||
|
||||
type ActiveDialog = "elaprTmplt" | "rjct" | "dmndSplmnt" | "elaprApproval" | null;
|
||||
type ActiveDialog =
|
||||
| "elaprTmplt"
|
||||
| "rjct"
|
||||
| "dmndSplmnt"
|
||||
| "fileSplmnt"
|
||||
| "elaprApproval"
|
||||
| "pwrkCnsltnDocMng"
|
||||
| null;
|
||||
|
||||
function FloatingActionButtons({
|
||||
data,
|
||||
@@ -71,6 +86,7 @@ function FloatingActionButtons({
|
||||
checklistFormState,
|
||||
techReviewOpinionRef,
|
||||
opnrNm,
|
||||
isAiPending = false,
|
||||
}: FloatingActionButtonsProps) {
|
||||
const router = useRouter();
|
||||
const { confirm } = useConfirm();
|
||||
@@ -85,8 +101,14 @@ function FloatingActionButtons({
|
||||
|
||||
const needsAtrzResolve = needsRoleUser01AtrzResolve(dtlInstCd, prgrsSttsComCd);
|
||||
const needsTeApprovalCheck = needsAtrzResolve && prgrsSttsComCd === "TE";
|
||||
const needsPwrkDraftCheck = dtlInstCd === "ROLE_USER01" && ["TE", "RK"].includes(prgrsSttsComCd);
|
||||
const { data: elaprDocPrgrsList = [], isLoading: isElaprDocPrgrsLoading } =
|
||||
useElaprDocPrgrsByDocNo(needsTeApprovalCheck ? docNo : null);
|
||||
const { data: pwrkCnsltnData, isLoading: isPwrkCnsltnLoading } = usePwrkCnsltnMngByUpDocNo(
|
||||
docNo,
|
||||
needsPwrkDraftCheck,
|
||||
);
|
||||
const hasCompletedDraft = Boolean(findCompletedPwrkDraft(pwrkCnsltnData));
|
||||
|
||||
const hasPendingApprovalRequest = hasCurrentUserPendingApprovalRequest(
|
||||
elaprDocPrgrsList,
|
||||
@@ -96,17 +118,22 @@ function FloatingActionButtons({
|
||||
const actionButtons = useMemo(() => {
|
||||
if (needsAtrzResolve && isRvwLoginUserLoading) return [];
|
||||
if (needsTeApprovalCheck && isElaprDocPrgrsLoading) return [];
|
||||
if (needsPwrkDraftCheck && isPwrkCnsltnLoading) return [];
|
||||
return getButtonConfigs(dtlInstCd, prgrsSttsComCd, atrzSeComCdList, {
|
||||
hasPendingApprovalRequest: needsTeApprovalCheck ? hasPendingApprovalRequest : undefined,
|
||||
hasCompletedDraft: needsPwrkDraftCheck ? hasCompletedDraft : undefined,
|
||||
});
|
||||
}, [
|
||||
atrzSeComCdList,
|
||||
dtlInstCd,
|
||||
hasCompletedDraft,
|
||||
hasPendingApprovalRequest,
|
||||
isElaprDocPrgrsLoading,
|
||||
isPwrkCnsltnLoading,
|
||||
isRvwLoginUserLoading,
|
||||
needsAtrzResolve,
|
||||
needsTeApprovalCheck,
|
||||
needsPwrkDraftCheck,
|
||||
prgrsSttsComCd,
|
||||
]);
|
||||
|
||||
@@ -299,10 +326,8 @@ function FloatingActionButtons({
|
||||
}
|
||||
|
||||
async function handleRqstTechRvwClick() {
|
||||
const payload: DocPrgrsUpdateRequestDto = {
|
||||
docNo,
|
||||
docSeComCd,
|
||||
prgrsSttsComCd: "ER",
|
||||
const payload: FnrsExamineRequestDto = {
|
||||
prgrsSttsComCd: "FV",
|
||||
};
|
||||
await executeAction({
|
||||
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.rqstTechRvw,
|
||||
@@ -317,7 +342,7 @@ function FloatingActionButtons({
|
||||
dmndTtl: data.detDto?.bizNm ?? "",
|
||||
dmndCn: data.detDto?.bizCn ?? "",
|
||||
elaprCycl: data.detDto?.prgrsSttsComCd === "RK" ? 2 : 1,
|
||||
docPrgrsSttsComCd: data.detDto?.prgrsSttsComCd,
|
||||
docPrgrsSttsComCd: "AC",
|
||||
elaprPrgrsSttsList: buildElaprPrgrsSttsListFromAplnRows(aplnRows),
|
||||
};
|
||||
await executeAction({
|
||||
@@ -396,6 +421,20 @@ function FloatingActionButtons({
|
||||
});
|
||||
}
|
||||
|
||||
function handleFileSplmntClick() {
|
||||
setActiveDialog("fileSplmnt");
|
||||
}
|
||||
|
||||
async function handleFileSplmntConfirm(selected: FnrsCnsltnFileVrfcRsltResponseDto[]) {
|
||||
setActiveDialog(null);
|
||||
const requestPayload = buildFileSplmntReqPayload(data, selected, checklistFormState);
|
||||
await executeAction({
|
||||
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.fileSplmnt,
|
||||
request: requestPayload,
|
||||
successMessage: "파일보완 요청이 처리되었습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sticky bottom-0 z-40 !-mb-2 border-t bg-white pb-8 pt-6 shadow-[0_-4px_12px_rgba(0,0,0,0.06)]">
|
||||
@@ -403,130 +442,156 @@ function FloatingActionButtons({
|
||||
<Button color="white" type="button" onClick={handleGoToList} disabled={isProcessing}>
|
||||
목록
|
||||
</Button>
|
||||
{canEdit && (
|
||||
<Button color="gray" onClick={handleEdit} disabled={isProcessing}>
|
||||
수정
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.strg) && (
|
||||
<Button onClick={handleStrgClick} disabled={isProcessing}>
|
||||
저장
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.aply) && (
|
||||
<Button color="green" onClick={handleAplyClick} disabled={isProcessing}>
|
||||
신청완료
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.del) && (
|
||||
<Button color="red" onClick={handleDelClick} disabled={isProcessing}>
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.fnrsCnsltnAncmnt) && (
|
||||
<Button onClick={handleFnrsCnsltnAncmntClick} disabled={isProcessing}>
|
||||
재원협의 고시
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.envoSbmsn) && (
|
||||
<Button onClick={handleEnvoSbmsnClick} disabled={isProcessing}>
|
||||
환경청제출
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rjct) && (
|
||||
<Button color="red" onClick={handleRjctClick} disabled={isProcessing}>
|
||||
반려
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.sncnAprv) && (
|
||||
<Button onClick={handleSncnAprvClick} disabled={isProcessing}>
|
||||
인가승인
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRply) && (
|
||||
<Button color="green" onClick={handleRvwRplyClick} disabled={isProcessing}>
|
||||
검토결과 회신
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rqstTechRvw) && (
|
||||
<Button onClick={handleRqstTechRvwClick} disabled={isProcessing}>
|
||||
기술검토 의뢰
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.fileVrfc) && (
|
||||
<Button color="gray" onClick={() => {}} disabled={isProcessing}>
|
||||
파일 검증
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.dsgntRvwr) && (
|
||||
<Button onClick={() => setActiveDialog("elaprTmplt")} disabled={isProcessing}>
|
||||
검토자지정
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocCrt) && (
|
||||
<Button color="orange" onClick={() => {}} disabled={isProcessing}>
|
||||
실무협의서 초안생성
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng) && (
|
||||
<Button color="orange" onClick={() => {}} disabled={isProcessing}>
|
||||
실무협의서 관리
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwStrg) && (
|
||||
<Button onClick={handleRvwStrgClick} disabled={isProcessing}>
|
||||
저장
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwCmptn) && (
|
||||
<Button color="green" onClick={handleRvwCmptnClick} disabled={isProcessing}>
|
||||
검토완료
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.dmndSplmnt) && (
|
||||
<Button color="gray" onClick={handleDmndSplmntClick} disabled={isProcessing}>
|
||||
보완요청
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzAprv) && (
|
||||
<Button onClick={() => setActiveDialog("elaprApproval")} disabled={isProcessing}>
|
||||
결재(승인)
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzRply) && (
|
||||
<Button onClick={() => setActiveDialog("elaprApproval")} disabled={isProcessing}>
|
||||
결재(회신)
|
||||
</Button>
|
||||
{!isAiPending && (
|
||||
<>
|
||||
{canEdit && (
|
||||
<Button color="gray" onClick={handleEdit} disabled={isProcessing}>
|
||||
수정
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.strg) && (
|
||||
<Button onClick={handleStrgClick} disabled={isProcessing}>
|
||||
저장
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.aply) && (
|
||||
<Button color="green" onClick={handleAplyClick} disabled={isProcessing}>
|
||||
신청완료
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.del) && (
|
||||
<Button color="red" onClick={handleDelClick} disabled={isProcessing}>
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.fnrsCnsltnAncmnt) && (
|
||||
<Button onClick={handleFnrsCnsltnAncmntClick} disabled={isProcessing}>
|
||||
재원협의 고시
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.envoSbmsn) && (
|
||||
<Button onClick={handleEnvoSbmsnClick} disabled={isProcessing}>
|
||||
환경청제출
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rjct) && (
|
||||
<Button color="red" onClick={handleRjctClick} disabled={isProcessing}>
|
||||
반려
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.sncnAprv) && (
|
||||
<Button onClick={handleSncnAprvClick} disabled={isProcessing}>
|
||||
인가승인
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRply) && (
|
||||
<Button color="green" onClick={handleRvwRplyClick} disabled={isProcessing}>
|
||||
검토결과 회신
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rqstTechRvw) && (
|
||||
<Button onClick={handleRqstTechRvwClick} disabled={isProcessing}>
|
||||
기술검토 의뢰
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.fileSplmnt) && (
|
||||
<Button color="gray" onClick={handleFileSplmntClick} disabled={isProcessing}>
|
||||
파일보완 요청
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.dsgntRvwr) && (
|
||||
<Button onClick={() => setActiveDialog("elaprTmplt")} disabled={isProcessing}>
|
||||
검토자지정
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocCrt) && (
|
||||
<Button color="orange" onClick={() => {}} disabled={isProcessing}>
|
||||
실무협의서 초안생성
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng) && (
|
||||
<Button
|
||||
color="orange"
|
||||
onClick={() => setActiveDialog("pwrkCnsltnDocMng")}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
실무협의서 관리
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwStrg) && (
|
||||
<Button onClick={handleRvwStrgClick} disabled={isProcessing}>
|
||||
저장
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwCmptn) && (
|
||||
<Button color="green" onClick={handleRvwCmptnClick} disabled={isProcessing}>
|
||||
검토완료
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.dmndSplmnt) && (
|
||||
<Button color="gray" onClick={handleDmndSplmntClick} disabled={isProcessing}>
|
||||
보완요청
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzAprv) && (
|
||||
<Button onClick={() => setActiveDialog("elaprApproval")} disabled={isProcessing}>
|
||||
결재(승인)
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzRply) && (
|
||||
<Button onClick={() => setActiveDialog("elaprApproval")} disabled={isProcessing}>
|
||||
결재(회신)
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElaprTmpltPickerDialog
|
||||
open={activeDialog === "elaprTmplt"}
|
||||
onClose={() => setActiveDialog(null)}
|
||||
docSeComCd={docSeComCd}
|
||||
onConfirm={handleElaprTmpltConfirm}
|
||||
/>
|
||||
{!isAiPending && (
|
||||
<>
|
||||
<ElaprTmpltPickerDialog
|
||||
open={activeDialog === "elaprTmplt"}
|
||||
onClose={() => setActiveDialog(null)}
|
||||
docSeComCd={docSeComCd}
|
||||
onConfirm={handleElaprTmpltConfirm}
|
||||
/>
|
||||
|
||||
<SplmntDmndDialog
|
||||
splmntArtclUpCd="SPLMNTARTCL"
|
||||
open={activeDialog === "dmndSplmnt"}
|
||||
onOpenChange={() => setActiveDialog(null)}
|
||||
isSubmitting={isProcessing}
|
||||
onConfirm={handleDmndSplmntConfirm}
|
||||
/>
|
||||
<SplmntDmndDialog
|
||||
splmntArtclUpCd="SPLMNTARTCL"
|
||||
open={activeDialog === "dmndSplmnt"}
|
||||
onOpenChange={() => setActiveDialog(null)}
|
||||
isSubmitting={isProcessing}
|
||||
onConfirm={handleDmndSplmntConfirm}
|
||||
/>
|
||||
|
||||
<ElaprApprovalDialog
|
||||
open={activeDialog === "elaprApproval"}
|
||||
onOpenChange={(open) => setActiveDialog(open ? "elaprApproval" : null)}
|
||||
elaprDocNo={data.detDto?.fnrsCnsltnDocNo ?? fnrsCnsltnDocNo}
|
||||
currentUserUuid={userInfo?.userUnqId ?? ""}
|
||||
onSuccess={() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: fnrsCnsltnKeys.detail(fnrsCnsltnDocNo),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<FileVrfcSplmntDialog
|
||||
open={activeDialog === "fileSplmnt"}
|
||||
fnrsCnsltnDocNo={docNo}
|
||||
isSubmitting={isProcessing}
|
||||
onClose={() => setActiveDialog(null)}
|
||||
onConfirm={handleFileSplmntConfirm}
|
||||
/>
|
||||
|
||||
<ElaprApprovalDialog
|
||||
open={activeDialog === "elaprApproval"}
|
||||
onOpenChange={(open) => setActiveDialog(open ? "elaprApproval" : null)}
|
||||
elaprDocNo={data.detDto?.fnrsCnsltnDocNo ?? fnrsCnsltnDocNo}
|
||||
currentUserUuid={userInfo?.userUnqId ?? ""}
|
||||
onSuccess={() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: fnrsCnsltnKeys.detail(fnrsCnsltnDocNo),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<PwrkCnsltnDocMngDialog
|
||||
open={activeDialog === "pwrkCnsltnDocMng"}
|
||||
fnrsCnsltnDocNo={docNo}
|
||||
onClose={() => setActiveDialog(null)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -602,6 +667,19 @@ async function handleDmndSplmnt(
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRequestExamine(
|
||||
context: ActionHandlerContext<FnrsExamineRequestDto>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const docNo = context.data.detDto?.fnrsCnsltnDocNo;
|
||||
if (!docNo) throw new Error("문서번호가 없습니다.");
|
||||
await fnrsCnsltnMngService.requestExamine(docNo, context.request);
|
||||
context.onSuccess?.();
|
||||
} catch (error) {
|
||||
context.onError?.(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAtrzAprv(context: ActionHandlerContext): Promise<void> {
|
||||
// TODO: 결재 승인 처리 API 호출
|
||||
context.onSuccess?.();
|
||||
@@ -621,8 +699,8 @@ const buttonActionHandlers: Record<FnrsCnsltnViewButtonAction, ActionHandler> =
|
||||
rjct: handleChangePrgrsSttsComCd,
|
||||
sncnAprv: handleChangePrgrsSttsComCd,
|
||||
rvwRply: handleTechReview,
|
||||
rqstTechRvw: handleChangePrgrsSttsComCd,
|
||||
fileVrfc: () => Promise.resolve(),
|
||||
rqstTechRvw: handleRequestExamine,
|
||||
fileSplmnt: handleDmndSplmnt,
|
||||
dsgntRvwr: handleDsgntRvwr,
|
||||
pwrkCnsltnDocCrt: () => Promise.resolve(),
|
||||
pwrkCnsltnDocMng: () => Promise.resolve(),
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { parseFilenameFromResponse } from "@/entities/attach";
|
||||
import {
|
||||
pwrkCnsltnDocCrtRsltSaveRequestSchema,
|
||||
pwrkCnsltnDocCrtRsltService,
|
||||
} from "@/entities/pwrk-cnsltn-doc-crt-rslt";
|
||||
import {
|
||||
findCompletedPwrkDraft,
|
||||
pwrkCnsltnMngKeys,
|
||||
usePwrkCnsltnMngByUpDocNo,
|
||||
} from "@/entities/pwrk-cnsltn-mng";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Skeleton,
|
||||
} from "@common/components";
|
||||
|
||||
const HangulGiangi = dynamic(
|
||||
() => import("@/widgets/hangulgiangi").then((module) => module.HangulGiangi),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <Skeleton className="h-[70vh] min-h-[40rem] w-full" />,
|
||||
},
|
||||
);
|
||||
|
||||
interface PwrkCnsltnDocMngDialogProps {
|
||||
open: boolean;
|
||||
fnrsCnsltnDocNo: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function PwrkCnsltnDocMngDialog({ open, fnrsCnsltnDocNo, onClose }: PwrkCnsltnDocMngDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const {
|
||||
data: pwrkCnsltnData,
|
||||
isLoading: isPwrkCnsltnLoading,
|
||||
isError: isPwrkCnsltnError,
|
||||
} = usePwrkCnsltnMngByUpDocNo(fnrsCnsltnDocNo, open);
|
||||
const completedDraft = findCompletedPwrkDraft(pwrkCnsltnData);
|
||||
const crtDmndUuid = completedDraft?.crtDmndUuid;
|
||||
const {
|
||||
data: documentBlob,
|
||||
isLoading: isDocumentLoading,
|
||||
isError: isDocumentError,
|
||||
} = useQuery({
|
||||
queryKey: ["pwrk-cnsltn-doc-crt-rslt", "open-latest", crtDmndUuid],
|
||||
queryFn: () => pwrkCnsltnDocCrtRsltService.openWebHwpLatest(crtDmndUuid!),
|
||||
select: (response) => response.data,
|
||||
enabled: open && Boolean(crtDmndUuid),
|
||||
});
|
||||
const documentFileName = `실무협의서_${pwrkCnsltnData?.pwrkCnsltnDocNo ?? crtDmndUuid}.hwpx`;
|
||||
const isLoading = isPwrkCnsltnLoading || isDocumentLoading;
|
||||
const isError = isPwrkCnsltnError || isDocumentError;
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
if (!nextOpen) onClose();
|
||||
}
|
||||
|
||||
async function handleSaveDocument(file: Blob) {
|
||||
if (!completedDraft) throw new Error("저장할 실무협의서 초안이 없습니다.");
|
||||
|
||||
const { crtDmndUuid } = pwrkCnsltnDocCrtRsltSaveRequestSchema.parse({
|
||||
crtDmndUuid: completedDraft.crtDmndUuid,
|
||||
});
|
||||
const formData = new FormData();
|
||||
formData.append("crtDmndUuid", crtDmndUuid);
|
||||
formData.append("file", file, documentFileName);
|
||||
|
||||
const savedDocument = await pwrkCnsltnDocCrtRsltService.saveMultipart(formData);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: pwrkCnsltnMngKeys.byUpDocNo(fnrsCnsltnDocNo),
|
||||
});
|
||||
alert(`실무협의서를 저장했습니다. (버전 ${savedDocument.verSn})`);
|
||||
}
|
||||
|
||||
async function handleDownload() {
|
||||
if (!completedDraft) throw new Error("다운로드할 실무협의서 초안이 없습니다.");
|
||||
|
||||
const response = await pwrkCnsltnDocCrtRsltService.downloadLatest(completedDraft.crtDmndUuid);
|
||||
const objectUrl = window.URL.createObjectURL(response.data);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = parseFilenameFromResponse(response);
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
window.URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
className="max-h-[96vh] max-w-[80vw] overflow-y-auto"
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>실무협의서 관리</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading ? <Skeleton className="h-[70vh] min-h-[40rem] w-full" /> : null}
|
||||
{isError ? (
|
||||
<p className="py-16 text-center text-sm text-destructive">
|
||||
실무협의서 정보를 불러오지 못했습니다.
|
||||
</p>
|
||||
) : null}
|
||||
{!isLoading && !isError && !completedDraft ? (
|
||||
<p className="py-16 text-center text-sm text-muted-foreground">
|
||||
생성 완료된 실무협의서 초안이 없습니다.
|
||||
</p>
|
||||
) : null}
|
||||
{completedDraft ? (
|
||||
documentBlob ? (
|
||||
<HangulGiangi
|
||||
documentBlob={documentBlob}
|
||||
documentKey={completedDraft.crtDmndUuid}
|
||||
documentFileName={documentFileName}
|
||||
onSaveDocument={handleSaveDocument}
|
||||
onDownload={handleDownload}
|
||||
/>
|
||||
) : null
|
||||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" color="white" onClick={onClose}>
|
||||
닫기
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { PwrkCnsltnDocMngDialog };
|
||||
export type { PwrkCnsltnDocMngDialogProps };
|
||||
@@ -12,8 +12,8 @@ interface FnrsDetailGateProps {
|
||||
|
||||
function FnrsDetailGate({ id }: FnrsDetailGateProps) {
|
||||
const userInfo = useUserStore((state) => state.userInfo);
|
||||
const { data, isLoading, isError } = useFnrsCnsltnMng(id);
|
||||
const shouldRedirectToEdit = canEditPbadmsDoc({
|
||||
const { data, isLoading, isFetching, isError } = useFnrsCnsltnMng(id);
|
||||
const canEdit = canEditPbadmsDoc({
|
||||
docSeComCd: "FNRS",
|
||||
prgrsSttsComCd: data?.detDto?.prgrsSttsComCd,
|
||||
rgtr: data?.detDto?.rgtr,
|
||||
@@ -22,9 +22,9 @@ function FnrsDetailGate({ id }: FnrsDetailGateProps) {
|
||||
|
||||
return (
|
||||
<PbadmsDetailGate
|
||||
isPending={!userInfo || isLoading}
|
||||
isPending={!userInfo || isLoading || isFetching}
|
||||
isError={isError}
|
||||
shouldRedirectToEdit={Boolean(userInfo) && shouldRedirectToEdit}
|
||||
shouldRedirectToEdit={!isFetching && Boolean(userInfo) && canEdit}
|
||||
editHref={`/fnrs-cnsltn/${id}/edit`}
|
||||
>
|
||||
<FnrsViewPage id={id} />
|
||||
|
||||
@@ -88,7 +88,7 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
|
||||
|
||||
function buildFormPayload(
|
||||
data: FnrsCnsltnRegistFormData,
|
||||
prgrsSttsComCd: "SV" | "ST" | "TE",
|
||||
prgrsSttsComCd: string,
|
||||
): FnrsCnsltnApplySaveRequestDto | undefined {
|
||||
const withTotals = recalculateFnrsSharTotals(data.fnrsSharList, sharItems);
|
||||
const validationMessage = validateBizCstSharBalance(
|
||||
@@ -123,7 +123,7 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
({ splmntAtchFileNm, splmntAtchFileAtflId, splmntDmndAtchFileNm, ...item }) => ({
|
||||
...item,
|
||||
...(prgrsSttsComCd === "TE" ? { cmptnYn: "Y" } : null),
|
||||
...(prgrsSttsComCd !== "SV" && prgrsSttsComCd !== "ST" && { cmptnYn: "Y" }),
|
||||
}),
|
||||
),
|
||||
};
|
||||
@@ -139,11 +139,12 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
|
||||
}
|
||||
|
||||
async function submitSupplementForm(data: FnrsCnsltnRegistFormData): Promise<string | undefined> {
|
||||
const docNo = initialData?.detDto?.fnrsCnsltnDocNo;
|
||||
if (!docNo) return undefined;
|
||||
const beforePrgrsSttsComCd = initialData?.detDto?.beforePrgrsSttsComCd;
|
||||
if (!beforePrgrsSttsComCd) return undefined;
|
||||
|
||||
const payload = buildFormPayload(data, "TE");
|
||||
if (!payload) return undefined;
|
||||
const docNo = initialData?.detDto?.fnrsCnsltnDocNo;
|
||||
const payload = buildFormPayload(data, beforePrgrsSttsComCd);
|
||||
if (!docNo || !payload) return undefined;
|
||||
|
||||
return fnrsCnsltnMngService.submitSupplement(docNo, payload);
|
||||
}
|
||||
@@ -171,7 +172,7 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
|
||||
if (!result) return;
|
||||
|
||||
alert("보완완료 처리되었습니다.");
|
||||
queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
|
||||
await queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
|
||||
router.push(`/fnrs-cnsltn/${docNo}`);
|
||||
} catch (error) {
|
||||
console.error("보완완료 실패:", error);
|
||||
@@ -190,7 +191,7 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
|
||||
if (!fnrsCnsltnDocNo) return;
|
||||
|
||||
alert("저장되었습니다.");
|
||||
queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
|
||||
await queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
|
||||
router.push(`/fnrs-cnsltn/${fnrsCnsltnDocNo}/edit`);
|
||||
} catch (error) {
|
||||
console.error("저장 실패:", error);
|
||||
@@ -219,7 +220,7 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
|
||||
if (!fnrsCnsltnDocNo) return;
|
||||
|
||||
alert("신청 완료되었습니다.");
|
||||
queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
|
||||
await queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
|
||||
router.push(`/fnrs-cnsltn/${fnrsCnsltnDocNo}`);
|
||||
} catch (error) {
|
||||
console.error("신청 실패:", error);
|
||||
@@ -248,7 +249,7 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
|
||||
setIsSubmitting(true);
|
||||
await fnrsCnsltnMngService.remove(docNo);
|
||||
alert("삭제되었습니다.");
|
||||
queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
|
||||
await queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
|
||||
router.push("/fnrs-cnsltn");
|
||||
} catch (error) {
|
||||
console.error("삭제 실패:", error);
|
||||
@@ -262,7 +263,9 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSave)} className="mt-4">
|
||||
<SectionCollapsibleGroup>
|
||||
<SectionCollapsibleToggle />
|
||||
<div className="sticky top-[6rem] z-30 bg-container px-4 py-2">
|
||||
<SectionCollapsibleToggle />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{initialData?.detDto?.fnrsCnsltnDocNo && (
|
||||
<ProgressStatusSection
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useMemo, useRef } from "react";
|
||||
|
||||
import { useUserStore } from "@/shared/lib";
|
||||
import { SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
|
||||
import { SectionCollapsibleGroup, SectionCollapsibleToggle, TextShimmer } from "@/shared/ui";
|
||||
import { ElaprDocPrgrsStatus } from "@/entities/elapr";
|
||||
import { useFnrsCnsltnMng } from "@/entities/fnrs-cnsltn";
|
||||
import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw";
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "@/widgets/pbadms-sprt";
|
||||
import { Skeleton } from "@common/components";
|
||||
|
||||
import { getAiPendingMessage, isAiPendingStatus } from "../lib/ai-pending-utils";
|
||||
import { needsRoleUser01AtrzResolve } from "../lib/button-config";
|
||||
import { getEditableReviewStep } from "../lib/review-step-utils";
|
||||
import { EMPTY_CKLST_RESULT_LIST, useChecklistFormState } from "../lib/use-checklist-form-state";
|
||||
@@ -93,11 +94,25 @@ function FnrsViewPage({ id }: FnrsViewPageProps) {
|
||||
userInfo?.dtlInstCd,
|
||||
detDto.prgrsSttsComCd || "",
|
||||
);
|
||||
const isAiPending = isAiPendingStatus(detDto.prgrsSttsComCd || "");
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<SectionCollapsibleGroup>
|
||||
<SectionCollapsibleToggle />
|
||||
<div className="sticky top-[6rem] z-30 flex items-center gap-4 bg-container px-4 py-2">
|
||||
<SectionCollapsibleToggle />
|
||||
{isAiPending && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="relative flex size-3">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75"></span>
|
||||
<span className="relative inline-flex size-3 rounded-full bg-primary"></span>
|
||||
</span>
|
||||
<TextShimmer active className="text-xl">
|
||||
{getAiPendingMessage(detDto.prgrsSttsComCd || "")}
|
||||
</TextShimmer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<ProgressStatusSection
|
||||
columns={PROGRESS_STATUS_COLUMNS}
|
||||
@@ -137,6 +152,7 @@ function FnrsViewPage({ id }: FnrsViewPageProps) {
|
||||
checklistFormState={checklistFormState}
|
||||
techReviewOpinionRef={techReviewOpinionRef}
|
||||
opnrNm={userInfo?.userName}
|
||||
isAiPending={isAiPending}
|
||||
/>
|
||||
</div>
|
||||
</SectionCollapsibleGroup>
|
||||
|
||||
@@ -25,6 +25,9 @@ export const ENDPOINT = {
|
||||
DESIGN_CNSTN_RVW_OPNN: "/as-service/api/v1/design-cnstn-rvw-opnn",
|
||||
SEWER_BSC_PLAN: "/as-service/api/v1/sewer-bsc-plans",
|
||||
DASHBOARD: "/as-service/api/v1/dashboard",
|
||||
DCRAG_TO: "/as-service/api/v1/dcrag-to",
|
||||
PWRK_CNSLTN_MNG: "/as-service/api/v1/pwrk-cnsltn-mng",
|
||||
PWRK_CNSLTN_DOC_CRT_RSLT: "/as-service/api/v1/pwrk-cnsltn-doc-crt-rslt",
|
||||
},
|
||||
USER_SERVICE: {
|
||||
LOGIN: "/user-service/login",
|
||||
|
||||
@@ -10,3 +10,7 @@ export const REFRESH_TOKEN = process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-t
|
||||
export const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id";
|
||||
export const COOKIE_DOMAIN = process.env.NEXT_PUBLIC_COOKIE_DOMAIN;
|
||||
export const AS_DASHBOARD_BBS_ID = process.env.NEXT_PUBLIC_AS_DASHBOARD_BBS_ID;
|
||||
|
||||
/** 웹한글 기안기(WebHWP) 컨트롤 URL */
|
||||
export const WEBHYP_CTRL_URL =
|
||||
process.env.NEXT_PUBLIC_WEBHYP_CTRL_URL || "http://hwp.onasys.co.kr/webhwpctrl";
|
||||
|
||||
@@ -59,7 +59,7 @@ function SectionCollapsibleToggle() {
|
||||
const { batchOpen, expandAll, collapseAll } = group;
|
||||
|
||||
return (
|
||||
<div className="sticky top-[6rem] z-30 flex justify-start gap-2 bg-container px-4 py-2">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { HangulGiangi, type HangulGiangiProps } from "./ui/hangul-giangi";
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { HwpCtrl, HwpCtrlFrameWindow } from "../model/hwp-ctrl";
|
||||
|
||||
import { HWP_CTRL_IFRAME_NAME } from "./hangul-giangi-constants";
|
||||
|
||||
interface WindowWithNamedHwpFrame extends Window {
|
||||
Document_HwpCtrl?: Window & HwpCtrlFrameWindow;
|
||||
}
|
||||
|
||||
interface HwpCtrlRuntime extends HwpCtrl {
|
||||
Impl?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* iframe ref 우선, 없으면 named frame 폴백으로 HwpCtrl을 반환한다.
|
||||
* 로컬 `/webhwpctrl` 브리지는 same-origin이라 contentWindow 접근이 가능하다.
|
||||
* BuildWebHwpCtrl 콜백 전에는 Impl이 없어 Open이 실패하므로, Impl이 붙은 뒤에만 반환한다.
|
||||
*/
|
||||
function getHwpCtrl(iframe: HTMLIFrameElement | null): HwpCtrl | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
|
||||
try {
|
||||
const fromRef = iframe?.contentWindow as HwpCtrlFrameWindow | null | undefined;
|
||||
if (isHwpCtrlInitialized(fromRef?.HwpCtrl)) return fromRef.HwpCtrl;
|
||||
|
||||
const namedFrame = (window as WindowWithNamedHwpFrame)[HWP_CTRL_IFRAME_NAME];
|
||||
if (isHwpCtrlInitialized(namedFrame?.HwpCtrl)) return namedFrame.HwpCtrl;
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isHwpCtrlInitialized(ctrl: HwpCtrl | null | undefined): ctrl is HwpCtrl {
|
||||
return Boolean(ctrl && (ctrl as HwpCtrlRuntime).Impl);
|
||||
}
|
||||
|
||||
export { getHwpCtrl };
|
||||
@@ -0,0 +1,7 @@
|
||||
/** iframe name — 한컴 named-iframe / 브리지 contentWindow 접근용 */
|
||||
export const HWP_CTRL_IFRAME_NAME = "Document_HwpCtrl";
|
||||
|
||||
/** 로컬 BuildWebHwpCtrl 브리지 (same-origin) */
|
||||
export const DEFAULT_BRIDGE_URL = "/webhwpctrl/index.html";
|
||||
|
||||
export const HANGULGIANGI_READY_MESSAGE_TYPE = "hangulgiangi-ready";
|
||||
@@ -0,0 +1,45 @@
|
||||
/** 한컴 웹한글 기안기 HwpCtrl 콜백 결과 (문서화되지 않은 필드 허용) */
|
||||
export interface HwpCtrlCallbackResult {
|
||||
result?: boolean | string | number;
|
||||
message?: string;
|
||||
data?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type HwpCtrlCallback = (res: HwpCtrlCallbackResult) => void;
|
||||
|
||||
export interface HwpCtrlOpenCallbackResult {
|
||||
result: boolean;
|
||||
FileName?: string;
|
||||
Size?: number;
|
||||
CallbackUserData?: unknown;
|
||||
message?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type HwpCtrlOpenCallback = (result: HwpCtrlOpenCallbackResult) => void;
|
||||
|
||||
/**
|
||||
* 실무협의서 관리에서 사용하는 HwpCtrl 메서드만 선언.
|
||||
* 실제 런타임 객체는 iframe contentWindow에 존재한다.
|
||||
*/
|
||||
export interface HwpCtrl {
|
||||
Open: (
|
||||
source: File,
|
||||
format: string,
|
||||
option: string,
|
||||
callback?: HwpCtrlOpenCallback,
|
||||
callbackUserData?: unknown,
|
||||
) => boolean;
|
||||
SaveAs: (
|
||||
fileName: string,
|
||||
format: string,
|
||||
option: string,
|
||||
callback?: HwpCtrlCallback,
|
||||
extra?: Record<string, unknown>,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export interface HwpCtrlFrameWindow {
|
||||
HwpCtrl?: HwpCtrl;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { Download, Fullscreen, Save } from "lucide-react";
|
||||
|
||||
import { Button } from "@common/components";
|
||||
|
||||
interface HangulGiangiToolbarProps {
|
||||
isBusy?: boolean;
|
||||
onSave: () => void;
|
||||
onDownload: () => void;
|
||||
onFullscreen: () => void;
|
||||
}
|
||||
|
||||
function HangulGiangiToolbar({
|
||||
isBusy = false,
|
||||
onSave,
|
||||
onDownload,
|
||||
onFullscreen,
|
||||
}: HangulGiangiToolbarProps) {
|
||||
return (
|
||||
<div className="flex w-full flex-nowrap items-center gap-2 border-b border-border bg-background p-3">
|
||||
<Button type="button" color="white" size="sm" disabled={isBusy} onClick={onSave}>
|
||||
<Save className="!size-5" />
|
||||
저장
|
||||
</Button>
|
||||
<Button type="button" color="white" size="sm" disabled={isBusy} onClick={onDownload}>
|
||||
<Download className="!size-5" />
|
||||
다운로드
|
||||
</Button>
|
||||
<Button type="button" color="white" size="sm" onClick={onFullscreen}>
|
||||
<Fullscreen className="!size-5" />
|
||||
전체화면
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { HangulGiangiToolbar };
|
||||
@@ -0,0 +1,345 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { WEBHYP_CTRL_URL } from "@/shared/config";
|
||||
import { cn, ScrollArea, ScrollBar } from "@common/components";
|
||||
|
||||
import { getHwpCtrl } from "../lib/get-hwp-ctrl";
|
||||
import {
|
||||
DEFAULT_BRIDGE_URL,
|
||||
HANGULGIANGI_READY_MESSAGE_TYPE,
|
||||
HWP_CTRL_IFRAME_NAME,
|
||||
} from "../lib/hangul-giangi-constants";
|
||||
import type { HwpCtrl } from "../model/hwp-ctrl";
|
||||
|
||||
import { HangulGiangiToolbar } from "./hangul-giangi-toolbar";
|
||||
|
||||
function HangulGiangi({
|
||||
documentBlob,
|
||||
documentKey,
|
||||
documentFileName,
|
||||
onSaveDocument,
|
||||
onDownload,
|
||||
bridgeUrl = DEFAULT_BRIDGE_URL,
|
||||
ctrlBaseUrl = WEBHYP_CTRL_URL,
|
||||
className,
|
||||
onReady,
|
||||
}: HangulGiangiProps) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const openedDocumentKeyRef = useRef<string | null>(null);
|
||||
const openingDocumentKeyRef = useRef<string | null>(null);
|
||||
const attemptedDocumentKeyRef = useRef<string | null>(null);
|
||||
const logSequenceRef = useRef(0);
|
||||
const logEndRef = useRef<HTMLDivElement>(null);
|
||||
const [isCtrlReady, setIsCtrlReady] = useState(false);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [logs, setLogs] = useState<HangulGiangiLog[]>([]);
|
||||
|
||||
const iframeSrc = useMemo(() => buildBridgeSrc(bridgeUrl, ctrlBaseUrl), [bridgeUrl, ctrlBaseUrl]);
|
||||
const appendLog = useCallback((message: string) => {
|
||||
console.log(message);
|
||||
setLogs((currentLogs) => [
|
||||
...currentLogs,
|
||||
{
|
||||
id: ++logSequenceRef.current,
|
||||
timestamp: formatLogTimestamp(new Date()),
|
||||
message,
|
||||
},
|
||||
]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
logEndRef.current?.scrollIntoView({ block: "nearest" });
|
||||
}, [logs]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleMessage(event: MessageEvent) {
|
||||
if (event.origin !== window.location.origin) return;
|
||||
if (event.data?.type !== HANGULGIANGI_READY_MESSAGE_TYPE) return;
|
||||
if (iframeRef.current && event.source !== iframeRef.current.contentWindow) return;
|
||||
|
||||
setIsCtrlReady(true);
|
||||
appendLog("기안기 초기화가 완료되었습니다.");
|
||||
onReady?.();
|
||||
}
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
return () => window.removeEventListener("message", handleMessage);
|
||||
}, [appendLog, onReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCtrlReady) return;
|
||||
|
||||
const startedAt = Date.now();
|
||||
const timer = window.setInterval(() => {
|
||||
if (getHwpCtrl(iframeRef.current)) {
|
||||
setIsCtrlReady(true);
|
||||
appendLog("기안기 초기화가 완료되었습니다.");
|
||||
onReady?.();
|
||||
window.clearInterval(timer);
|
||||
return;
|
||||
}
|
||||
if (Date.now() - startedAt > HWP_CTRL_READY_TIMEOUT_MS) {
|
||||
appendLog("웹한글 기안기 초기화 시간이 초과되었습니다.");
|
||||
window.clearInterval(timer);
|
||||
}
|
||||
}, HWP_CTRL_POLL_INTERVAL_MS);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [appendLog, isCtrlReady, onReady]);
|
||||
|
||||
const handleOpen = useCallback(() => {
|
||||
const ctrl = resolveHwpCtrl(iframeRef.current, isCtrlReady);
|
||||
if (!ctrl) return;
|
||||
|
||||
setIsBusy(true);
|
||||
appendLog("실무협의서를 불러오고 있습니다.");
|
||||
openingDocumentKeyRef.current = documentKey;
|
||||
attemptedDocumentKeyRef.current = documentKey;
|
||||
try {
|
||||
const documentFile = new File([documentBlob], documentFileName, {
|
||||
type: HWPX_CONTENT_TYPE,
|
||||
});
|
||||
const isAccepted = ctrl.Open(
|
||||
documentFile,
|
||||
"HWPX",
|
||||
"",
|
||||
(result) => {
|
||||
const callbackDocumentKey = getCallbackDocumentKey(result.CallbackUserData, documentKey);
|
||||
if (openingDocumentKeyRef.current !== callbackDocumentKey) return;
|
||||
|
||||
openingDocumentKeyRef.current = null;
|
||||
setIsBusy(false);
|
||||
if (!result.result) {
|
||||
appendLog(result.message ?? "문서를 열지 못했습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
openedDocumentKeyRef.current = callbackDocumentKey;
|
||||
appendLog("실무협의서를 열었습니다.");
|
||||
},
|
||||
{ documentKey } satisfies OpenCallbackUserData,
|
||||
);
|
||||
|
||||
if (isAccepted) return;
|
||||
|
||||
openingDocumentKeyRef.current = null;
|
||||
setIsBusy(false);
|
||||
appendLog("웹한글 기안기가 문서 열기 요청을 접수하지 못했습니다.");
|
||||
} catch (error) {
|
||||
openingDocumentKeyRef.current = null;
|
||||
setIsBusy(false);
|
||||
appendLog(getErrorMessage(error, "문서를 열지 못했습니다."));
|
||||
}
|
||||
}, [appendLog, documentBlob, documentFileName, documentKey, isCtrlReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isCtrlReady ||
|
||||
openedDocumentKeyRef.current === documentKey ||
|
||||
openingDocumentKeyRef.current === documentKey ||
|
||||
attemptedDocumentKeyRef.current === documentKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
handleOpen();
|
||||
}, [documentKey, handleOpen, isCtrlReady]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const ctrl = resolveHwpCtrl(iframeRef.current, isCtrlReady);
|
||||
if (!ctrl) return;
|
||||
|
||||
setIsBusy(true);
|
||||
appendLog("실무협의서를 저장하고 있습니다.");
|
||||
try {
|
||||
ctrl.SaveAs(documentFileName, "HWPX", "download:false", async (result) => {
|
||||
try {
|
||||
if (result.result === false) {
|
||||
throw new Error(result.message ?? "기안기에서 문서를 추출하지 못했습니다.");
|
||||
}
|
||||
|
||||
const documentBlob = decodeDocumentData(result.data);
|
||||
if (!documentBlob) {
|
||||
throw new Error("기안기 저장 결과에 문서 데이터가 없습니다.");
|
||||
}
|
||||
|
||||
await onSaveDocument(documentBlob);
|
||||
appendLog("실무협의서를 새 버전으로 저장했습니다.");
|
||||
} catch (error) {
|
||||
appendLog(getErrorMessage(error, "문서 저장에 실패했습니다."));
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
setIsBusy(false);
|
||||
appendLog(getErrorMessage(error, "문서 저장에 실패했습니다."));
|
||||
}
|
||||
}, [appendLog, documentFileName, isCtrlReady, onSaveDocument]);
|
||||
|
||||
const handleDownload = useCallback(async () => {
|
||||
setIsBusy(true);
|
||||
appendLog("실무협의서를 다운로드하고 있습니다.");
|
||||
try {
|
||||
await onDownload();
|
||||
appendLog("실무협의서를 다운로드했습니다.");
|
||||
} catch (error) {
|
||||
appendLog(getErrorMessage(error, "문서 다운로드에 실패했습니다."));
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}, [appendLog, onDownload]);
|
||||
|
||||
const handleFullscreen = useCallback(async () => {
|
||||
if (!iframeRef.current) {
|
||||
appendLog("전체화면으로 표시할 웹한글 기안기를 찾지 못했습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
await iframeRef.current.requestFullscreen();
|
||||
}, [appendLog]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full flex-col overflow-hidden rounded-md border border-border",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<HangulGiangiToolbar
|
||||
isBusy={isBusy || !isCtrlReady}
|
||||
onSave={handleSave}
|
||||
onDownload={() => void handleDownload()}
|
||||
onFullscreen={() => void handleFullscreen()}
|
||||
/>
|
||||
|
||||
<ScrollArea
|
||||
className="h-14 shrink-0 border-b border-border bg-muted"
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
aria-label="웹한글 기안기 작업 로그"
|
||||
>
|
||||
<div className="min-w-max px-3 py-2 text-lg text-muted-foreground">
|
||||
{logs.length > 0 ? (
|
||||
logs.map((log) => (
|
||||
<p key={log.id} className="whitespace-nowrap">
|
||||
<time className="text-foreground/60 mr-2">{log.timestamp}</time>
|
||||
{log.message}
|
||||
</p>
|
||||
))
|
||||
) : (
|
||||
<p className="whitespace-nowrap">기안기를 불러오고 있습니다.</p>
|
||||
)}
|
||||
<div ref={logEndRef} />
|
||||
</div>
|
||||
<ScrollBar orientation="vertical" />
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
id={HWP_CTRL_IFRAME_NAME}
|
||||
name={HWP_CTRL_IFRAME_NAME}
|
||||
title="웹한글 기안기"
|
||||
src={iframeSrc}
|
||||
className="fullscreen:h-screen fullscreen:min-h-0 fullscreen:w-screen h-[70vh] min-h-[40rem] w-full border-0 bg-background"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveHwpCtrl(iframe: HTMLIFrameElement | null, isCtrlReady: boolean): HwpCtrl | null {
|
||||
const ctrl = getHwpCtrl(iframe);
|
||||
if (ctrl) return ctrl;
|
||||
|
||||
alert(
|
||||
isCtrlReady
|
||||
? "웹한글 기안기 컨트롤을 찾지 못했습니다. 브리지 페이지를 확인하세요."
|
||||
: "웹한글 기안기가 아직 초기화되지 않았습니다. 잠시 후 다시 시도하세요.",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildBridgeSrc(bridgeUrl: string, ctrlBaseUrl: string): string {
|
||||
const base = ctrlBaseUrl.replace(/\/$/, "");
|
||||
const url = new URL(
|
||||
bridgeUrl,
|
||||
typeof window !== "undefined" ? window.location.origin : "http://localhost",
|
||||
);
|
||||
url.searchParams.set("baseurl", base);
|
||||
if (bridgeUrl.startsWith("/")) return `${url.pathname}${url.search}`;
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function decodeDocumentData(data: unknown): Blob | null {
|
||||
if (data instanceof Blob) return data;
|
||||
if (data instanceof ArrayBuffer) {
|
||||
return new Blob([data], { type: HWPX_CONTENT_TYPE });
|
||||
}
|
||||
if (ArrayBuffer.isView(data)) {
|
||||
const bytes = new Uint8Array(data.byteLength);
|
||||
bytes.set(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
|
||||
return new Blob([bytes], { type: HWPX_CONTENT_TYPE });
|
||||
}
|
||||
if (typeof data !== "string" || data.length === 0) return null;
|
||||
|
||||
const encodedData = data.includes(",") ? data.slice(data.indexOf(",") + 1) : data;
|
||||
const binary = window.atob(encodedData);
|
||||
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
||||
return new Blob([bytes], { type: HWPX_CONTENT_TYPE });
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown, fallbackMessage: string): string {
|
||||
return error instanceof Error ? error.message : fallbackMessage;
|
||||
}
|
||||
|
||||
function formatLogTimestamp(date: Date): string {
|
||||
return date.toLocaleTimeString("ko-KR", {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function getCallbackDocumentKey(callbackUserData: unknown, fallbackDocumentKey: string): string {
|
||||
if (
|
||||
typeof callbackUserData === "object" &&
|
||||
callbackUserData !== null &&
|
||||
"documentKey" in callbackUserData &&
|
||||
typeof callbackUserData.documentKey === "string"
|
||||
) {
|
||||
return callbackUserData.documentKey;
|
||||
}
|
||||
return fallbackDocumentKey;
|
||||
}
|
||||
|
||||
const HWP_CTRL_POLL_INTERVAL_MS = 500;
|
||||
const HWP_CTRL_READY_TIMEOUT_MS = 60_000;
|
||||
const HWPX_CONTENT_TYPE = "application/haansofthwpx";
|
||||
|
||||
interface OpenCallbackUserData {
|
||||
documentKey: string;
|
||||
}
|
||||
|
||||
interface HangulGiangiLog {
|
||||
id: number;
|
||||
timestamp: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface HangulGiangiProps {
|
||||
documentBlob: Blob;
|
||||
documentKey: string;
|
||||
documentFileName: string;
|
||||
onSaveDocument: (file: Blob) => Promise<void>;
|
||||
onDownload: () => Promise<void>;
|
||||
bridgeUrl?: string;
|
||||
ctrlBaseUrl?: string;
|
||||
className?: string;
|
||||
onReady?: () => void;
|
||||
}
|
||||
|
||||
export { HangulGiangi };
|
||||
export type { HangulGiangiProps };
|
||||
@@ -456,11 +456,12 @@ function AttachmentsFormSection({
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label>
|
||||
<label htmlFor={`${file.esntlAtchFileId}-file-input`}>
|
||||
<input
|
||||
type="file"
|
||||
id={`${file.esntlAtchFileId}-file-input`}
|
||||
multiple
|
||||
className="hidden"
|
||||
className="peer sr-only"
|
||||
accept={toFileAccept(file.esntlAtchFileRmrk) || undefined}
|
||||
onChange={(e) => {
|
||||
void handleEssentialFileUpload(
|
||||
@@ -470,7 +471,12 @@ function AttachmentsFormSection({
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2"
|
||||
asChild
|
||||
>
|
||||
<span>파일추가</span>
|
||||
</Button>
|
||||
</label>
|
||||
@@ -489,17 +495,23 @@ function AttachmentsFormSection({
|
||||
<div className="px-2">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-xl font-semibold">기타 첨부파일</h3>
|
||||
<label>
|
||||
<label htmlFor="etc-file-input">
|
||||
<input
|
||||
type="file"
|
||||
id="etc-file-input"
|
||||
multiple
|
||||
className="hidden"
|
||||
className="peer sr-only"
|
||||
onChange={(e) => {
|
||||
void handleEtcFileUpload(e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2"
|
||||
asChild
|
||||
>
|
||||
<span>파일추가</span>
|
||||
</Button>
|
||||
</label>
|
||||
|
||||
@@ -44,9 +44,9 @@ function PbadmsDetailGate({
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldRedirectToEdit) return;
|
||||
if (isPending || !shouldRedirectToEdit) return;
|
||||
router.replace(editHref);
|
||||
}, [editHref, router, shouldRedirectToEdit]);
|
||||
}, [editHref, isPending, router, shouldRedirectToEdit]);
|
||||
|
||||
if (isPending || shouldRedirectToEdit) return <DetailSkeleton />;
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { useEffect, useId, useState } from "react";
|
||||
|
||||
import { Paperclip, X } from "lucide-react";
|
||||
|
||||
import { getDefaultFileKey, useTus } from "@/shared/lib";
|
||||
import { TextShimmer } from "@/shared/ui";
|
||||
import { attachService } from "@/entities/attach";
|
||||
@@ -36,7 +38,7 @@ interface SplmntDmndRow {
|
||||
splmntArtclComCd: string;
|
||||
splmntDmndCn: string;
|
||||
splmntDmndAtchFileGroupId: string;
|
||||
uploadedFile: UploadedSplmntFile | null;
|
||||
uploadedFiles: UploadedSplmntFile[];
|
||||
}
|
||||
|
||||
interface SplmntDmndDialogConfirmItem {
|
||||
@@ -67,7 +69,7 @@ function createEmptyRow(): SplmntDmndRow {
|
||||
splmntArtclComCd: "",
|
||||
splmntDmndCn: "",
|
||||
splmntDmndAtchFileGroupId: "",
|
||||
uploadedFile: null,
|
||||
uploadedFiles: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -105,11 +107,13 @@ function SplmntDmndDialog({
|
||||
|
||||
setRows((prev) =>
|
||||
prev.map((row) => {
|
||||
if (row.rowKey !== rowKey || row.uploadedFile?.fileKey !== fileKey) return row;
|
||||
if (row.rowKey !== rowKey) return row;
|
||||
return {
|
||||
...row,
|
||||
splmntDmndAtchFileGroupId: atflCd,
|
||||
uploadedFile: { ...row.uploadedFile, atflCd, atflId },
|
||||
uploadedFiles: row.uploadedFiles.map((file) =>
|
||||
file.fileKey === fileKey ? { ...file, atflCd, atflId } : file,
|
||||
),
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -120,8 +124,11 @@ function SplmntDmndDialog({
|
||||
|
||||
setRows((prev) =>
|
||||
prev.map((row) => {
|
||||
if (row.rowKey !== rowKey || row.uploadedFile?.fileKey !== fileKey) return row;
|
||||
return { ...row, uploadedFile: null };
|
||||
if (row.rowKey !== rowKey) return row;
|
||||
return {
|
||||
...row,
|
||||
uploadedFiles: row.uploadedFiles.filter((file) => file.fileKey !== fileKey),
|
||||
};
|
||||
}),
|
||||
);
|
||||
},
|
||||
@@ -132,8 +139,8 @@ function SplmntDmndDialog({
|
||||
setRows([createEmptyRow()]);
|
||||
}, [open]);
|
||||
|
||||
const isAnyUploading = rows.some(
|
||||
(row) => row.uploadedFile != null && (isUploading[row.uploadedFile.fileKey] ?? false),
|
||||
const isAnyUploading = rows.some((row) =>
|
||||
row.uploadedFiles.some((file) => isUploading[file.fileKey] ?? false),
|
||||
);
|
||||
const canSubmit = isRowsValid(rows) && !isAnyUploading && !isSubmitting;
|
||||
|
||||
@@ -152,30 +159,42 @@ function SplmntDmndDialog({
|
||||
async function handleFileUpload(rowKey: string, files: FileList | null) {
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const file = files[0];
|
||||
const fileKey = createUploadKey(rowKey, file);
|
||||
const targetRow = rows.find((row) => row.rowKey === rowKey);
|
||||
const existingAtflCd = targetRow?.splmntDmndAtchFileGroupId;
|
||||
let existingAtflCd = targetRow?.splmntDmndAtchFileGroupId ?? "";
|
||||
|
||||
updateRow(rowKey, {
|
||||
uploadedFile: { fileName: file.name, fileKey },
|
||||
});
|
||||
for (const file of Array.from(files)) {
|
||||
const fileKey = createUploadKey(rowKey, file);
|
||||
|
||||
try {
|
||||
await uploadFileAsync(file, {
|
||||
fileKey,
|
||||
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
|
||||
});
|
||||
} catch {
|
||||
// onError callback handles cleanup
|
||||
setRows((prev) =>
|
||||
prev.map((row) =>
|
||||
row.rowKey === rowKey
|
||||
? {
|
||||
...row,
|
||||
uploadedFiles: [...row.uploadedFiles, { fileName: file.name, fileKey }],
|
||||
}
|
||||
: row,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
const atflCd = await uploadFileAsync(file, {
|
||||
fileKey,
|
||||
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
|
||||
});
|
||||
|
||||
if (!existingAtflCd) existingAtflCd = atflCd;
|
||||
} catch {
|
||||
// onError callback handles cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileRemove(rowKey: string) {
|
||||
async function handleFileRemove(rowKey: string, fileIndex: number) {
|
||||
const targetRow = rows.find((row) => row.rowKey === rowKey);
|
||||
if (!targetRow?.uploadedFile) return;
|
||||
const targetFile = targetRow?.uploadedFiles[fileIndex];
|
||||
if (!targetRow || !targetFile) return;
|
||||
|
||||
const { fileKey, atflId } = targetRow.uploadedFile;
|
||||
const { fileKey, atflId } = targetFile;
|
||||
uploads[fileKey]?.abort();
|
||||
|
||||
if (atflId) {
|
||||
@@ -186,9 +205,10 @@ function SplmntDmndDialog({
|
||||
}
|
||||
}
|
||||
|
||||
const nextFiles = targetRow.uploadedFiles.filter((_, index) => index !== fileIndex);
|
||||
updateRow(rowKey, {
|
||||
uploadedFile: null,
|
||||
splmntDmndAtchFileGroupId: "",
|
||||
uploadedFiles: nextFiles,
|
||||
splmntDmndAtchFileGroupId: nextFiles.length > 0 ? targetRow.splmntDmndAtchFileGroupId : "",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -237,9 +257,9 @@ function SplmntDmndDialog({
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => {
|
||||
const uploading = row.uploadedFile
|
||||
? (isUploading[row.uploadedFile.fileKey] ?? false)
|
||||
: false;
|
||||
const isRowUploading = row.uploadedFiles.some(
|
||||
(file) => isUploading[file.fileKey] ?? false,
|
||||
);
|
||||
const fileInputId = `${formId}-${row.rowKey}-file`;
|
||||
|
||||
return (
|
||||
@@ -266,40 +286,58 @@ function SplmntDmndDialog({
|
||||
className="min-h-[8rem] !text-xl"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<div className="flex items-center gap-2">
|
||||
{row.uploadedFile ? (
|
||||
<div className="flex items-stretch gap-2">
|
||||
<TextShimmer className="text-xl" active={uploading}>
|
||||
{row.uploadedFile.fileName}
|
||||
</TextShimmer>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
className="size-8 shrink-0 !bg-destructive"
|
||||
disabled={uploading}
|
||||
onClick={() => void handleFileRemove(row.rowKey)}
|
||||
>
|
||||
<i className="diveicon di-x text-xs" aria-label="삭제" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button type="button" size="sm" disabled={uploading} asChild>
|
||||
<label htmlFor={fileInputId} className="cursor-pointer">
|
||||
파일추가
|
||||
<input
|
||||
id={fileInputId}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void handleFileUpload(row.rowKey, event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<TableCell className="space-y-2 align-top">
|
||||
{row.uploadedFiles.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{row.uploadedFiles.map((file, fileIndex) => {
|
||||
const uploading = isUploading[file.fileKey] ?? false;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={file.fileKey}
|
||||
className="inline-flex items-center justify-between gap-2 rounded-sm border bg-accent p-2"
|
||||
>
|
||||
<Paperclip className="size-5 shrink-0" />
|
||||
<TextShimmer className="text-xl" active={uploading}>
|
||||
{file.fileName}
|
||||
</TextShimmer>
|
||||
<button
|
||||
type="button"
|
||||
disabled={uploading}
|
||||
onClick={() => void handleFileRemove(row.rowKey, fileIndex)}
|
||||
>
|
||||
<X
|
||||
className="size-5 shrink-0 text-destructive"
|
||||
aria-label="삭제"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<label htmlFor={fileInputId} className="cursor-pointer">
|
||||
<input
|
||||
id={fileInputId}
|
||||
type="file"
|
||||
multiple
|
||||
disabled={isRowUploading}
|
||||
className="peer sr-only"
|
||||
onChange={(event) => {
|
||||
void handleFileUpload(row.rowKey, event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2"
|
||||
disabled={isRowUploading}
|
||||
asChild
|
||||
>
|
||||
<span>파일추가</span>
|
||||
</Button>
|
||||
</label>
|
||||
</TableCell>
|
||||
{rows.length > 1 ? (
|
||||
<TableCell align="center" className="align-top">
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
|
||||
import { Paperclip } from "lucide-react";
|
||||
import { Paperclip, X } from "lucide-react";
|
||||
|
||||
import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib";
|
||||
import { TextShimmer } from "@/shared/ui";
|
||||
import { SectionCollapsible } from "@/shared/ui";
|
||||
import { attachService, useDownloadAttachmentByAtflCd } from "@/entities/attach";
|
||||
import { attachService } from "@/entities/attach";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -42,8 +42,6 @@ interface SplmntDmndFormItem {
|
||||
splmntPicNm?: string;
|
||||
splmntCmptnDt?: string;
|
||||
splmntAtchFileGroupId?: string;
|
||||
splmntAtchFileNm?: string;
|
||||
splmntAtchFileAtflId?: string;
|
||||
cmptnYn?: string;
|
||||
}
|
||||
|
||||
@@ -75,9 +73,10 @@ function SplmntDmndFormSection({ readOnly = false }: SplmntDmndFormSectionProps)
|
||||
const formId = useId();
|
||||
const { control, setValue, getValues } = useFormContext<SplmntDmndFormData>();
|
||||
const splmntDmndList = useWatch({ control, name: "splmntDmndList" }) ?? [];
|
||||
const [uploadedFiles, setUploadedFiles] = useState<Record<string, UploadedSplmntFile | null>>({});
|
||||
const [uploadedFiles, setUploadedFiles] = useState<Record<string, UploadedSplmntFile[]>>({});
|
||||
const initialFilesMappedRef = useRef(false);
|
||||
const { mutate: downloadAttachment } = useDownloadAttachmentByAtflCd();
|
||||
const isMountedRef = useRef(true);
|
||||
const atflCdByRowRef = useRef<Record<string, string>>({});
|
||||
|
||||
const { uploadFileAsync, isUploading, uploads } = useTus({
|
||||
onSuccess: (atflCd, atflId, _file, fileKey) => {
|
||||
@@ -86,55 +85,93 @@ function SplmntDmndFormSection({ readOnly = false }: SplmntDmndFormSectionProps)
|
||||
if (!rowKey) return;
|
||||
|
||||
setUploadedFiles((prev) => {
|
||||
const current = prev[rowKey];
|
||||
if (!current || current.fileKey !== fileKey) return prev;
|
||||
const current = prev[rowKey] ?? [];
|
||||
return {
|
||||
...prev,
|
||||
[rowKey]: { ...current, atflCd, atflId },
|
||||
[rowKey]: current.map((item) =>
|
||||
item.fileKey === fileKey ? { ...item, atflCd, atflId } : item,
|
||||
),
|
||||
};
|
||||
});
|
||||
atflCdByRowRef.current[rowKey] = atflCd;
|
||||
|
||||
const list = getValues("splmntDmndList") ?? [];
|
||||
const index = list.findIndex((item, idx) => getRowKey(item, idx) === rowKey);
|
||||
if (index < 0) return;
|
||||
|
||||
setValue(`splmntDmndList.${index}.splmntAtchFileGroupId`, atflCd, { shouldDirty: true });
|
||||
setValue(`splmntDmndList.${index}.splmntAtchFileAtflId`, atflId, { shouldDirty: true });
|
||||
},
|
||||
onError: (_error, _file, fileKey) => {
|
||||
const rowKey = getRowKeyFromFileKey(fileKey);
|
||||
if (!rowKey) return;
|
||||
|
||||
setUploadedFiles((prev) => {
|
||||
const current = prev[rowKey];
|
||||
if (!current || current.fileKey !== fileKey) return prev;
|
||||
return { ...prev, [rowKey]: null };
|
||||
const nextFiles = (prev[rowKey] ?? []).filter((item) => item.fileKey !== fileKey);
|
||||
return { ...prev, [rowKey]: nextFiles };
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
isMountedRef.current = false;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialFilesMappedRef.current) return;
|
||||
if (splmntDmndList.length === 0) return;
|
||||
initialFilesMappedRef.current = true;
|
||||
|
||||
const rowsWithFiles = splmntDmndList.flatMap((item, index) => {
|
||||
if (!item.splmntAtchFileGroupId) return [];
|
||||
|
||||
const initial: Record<string, UploadedSplmntFile | null> = {};
|
||||
for (let index = 0; index < splmntDmndList.length; index += 1) {
|
||||
const item = splmntDmndList[index];
|
||||
const rowKey = getRowKey(item, index);
|
||||
if (item.splmntAtchFileGroupId && item.splmntAtchFileNm) {
|
||||
initial[rowKey] = {
|
||||
fileName: item.splmntAtchFileNm,
|
||||
fileKey: `${SPLMNT_CMPTN_FILE_KEY_PREFIX}${rowKey}::initial`,
|
||||
atflCd: item.splmntAtchFileGroupId,
|
||||
atflId: item.splmntAtchFileAtflId,
|
||||
};
|
||||
} else {
|
||||
initial[rowKey] = null;
|
||||
}
|
||||
atflCdByRowRef.current[rowKey] = item.splmntAtchFileGroupId;
|
||||
return [{ rowKey, atflCd: item.splmntAtchFileGroupId }];
|
||||
});
|
||||
|
||||
async function mapInitialFiles() {
|
||||
const results = await Promise.all(
|
||||
rowsWithFiles.map(async ({ rowKey, atflCd }) => {
|
||||
try {
|
||||
const files = await attachService.findByAtflCd(atflCd);
|
||||
return {
|
||||
rowKey,
|
||||
files: files.map((file) => ({
|
||||
fileName: file.orgnlFileNm,
|
||||
fileKey: `${SPLMNT_CMPTN_FILE_KEY_PREFIX}${rowKey}::server::${file.atflId}`,
|
||||
atflCd: file.atflCd,
|
||||
atflId: file.atflId,
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("보완완료 첨부파일 조회 실패:", error);
|
||||
return { rowKey, files: [] };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
setUploadedFiles((prev) => {
|
||||
const next = { ...prev };
|
||||
|
||||
for (const { rowKey, files } of results) {
|
||||
const localFiles = prev[rowKey] ?? [];
|
||||
const serverAtflIds = new Set(files.map((file) => file.atflId));
|
||||
next[rowKey] = [
|
||||
...files,
|
||||
...localFiles.filter((file) => !file.atflId || !serverAtflIds.has(file.atflId)),
|
||||
];
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
setUploadedFiles(initial);
|
||||
initialFilesMappedRef.current = true;
|
||||
void mapInitialFiles();
|
||||
}, [splmntDmndList]);
|
||||
|
||||
if (splmntDmndList.length === 0) return null;
|
||||
@@ -144,30 +181,37 @@ function SplmntDmndFormSection({ readOnly = false }: SplmntDmndFormSectionProps)
|
||||
|
||||
const item = splmntDmndList[index];
|
||||
const rowKey = getRowKey(item, index);
|
||||
const file = files[0];
|
||||
const fileKey = createUploadKey(rowKey, file);
|
||||
const existingAtflCd = item.splmntAtchFileGroupId;
|
||||
let existingAtflCd = atflCdByRowRef.current[rowKey] || item.splmntAtchFileGroupId || "";
|
||||
|
||||
setUploadedFiles((prev) => ({
|
||||
...prev,
|
||||
[rowKey]: { fileName: file.name, fileKey },
|
||||
}));
|
||||
setValue(`splmntDmndList.${index}.splmntAtchFileNm`, file.name, { shouldDirty: true });
|
||||
for (const file of Array.from(files)) {
|
||||
const fileKey = createUploadKey(rowKey, file);
|
||||
|
||||
try {
|
||||
await uploadFileAsync(file, {
|
||||
fileKey,
|
||||
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
|
||||
});
|
||||
} catch {
|
||||
// onError callback handles cleanup
|
||||
setUploadedFiles((prev) => ({
|
||||
...prev,
|
||||
[rowKey]: [...(prev[rowKey] ?? []), { fileName: file.name, fileKey }],
|
||||
}));
|
||||
|
||||
try {
|
||||
const atflCd = await uploadFileAsync(file, {
|
||||
fileKey,
|
||||
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
|
||||
});
|
||||
|
||||
if (!existingAtflCd) {
|
||||
existingAtflCd = atflCd;
|
||||
atflCdByRowRef.current[rowKey] = atflCd;
|
||||
}
|
||||
} catch {
|
||||
// onError callback handles cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileRemove(index: number) {
|
||||
async function handleFileRemove(index: number, fileIndex: number) {
|
||||
const item = splmntDmndList[index];
|
||||
const rowKey = getRowKey(item, index);
|
||||
const uploaded = uploadedFiles[rowKey];
|
||||
const currentFiles = uploadedFiles[rowKey] ?? [];
|
||||
const uploaded = currentFiles[fileIndex];
|
||||
if (!uploaded) return;
|
||||
|
||||
const { fileKey, atflId } = uploaded;
|
||||
@@ -181,10 +225,13 @@ function SplmntDmndFormSection({ readOnly = false }: SplmntDmndFormSectionProps)
|
||||
}
|
||||
}
|
||||
|
||||
setUploadedFiles((prev) => ({ ...prev, [rowKey]: null }));
|
||||
const nextFiles = currentFiles.filter((_, currentIndex) => currentIndex !== fileIndex);
|
||||
setUploadedFiles((prev) => ({ ...prev, [rowKey]: nextFiles }));
|
||||
|
||||
if (nextFiles.length > 0) return;
|
||||
|
||||
delete atflCdByRowRef.current[rowKey];
|
||||
setValue(`splmntDmndList.${index}.splmntAtchFileGroupId`, "", { shouldDirty: true });
|
||||
setValue(`splmntDmndList.${index}.splmntAtchFileNm`, "", { shouldDirty: true });
|
||||
setValue(`splmntDmndList.${index}.splmntAtchFileAtflId`, "", { shouldDirty: true });
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -212,8 +259,8 @@ function SplmntDmndFormSection({ readOnly = false }: SplmntDmndFormSectionProps)
|
||||
<TableBody>
|
||||
{splmntDmndList.map((item, index) => {
|
||||
const rowKey = getRowKey(item, index);
|
||||
const uploaded = uploadedFiles[rowKey] ?? null;
|
||||
const uploading = uploaded ? (isUploading[uploaded.fileKey] ?? false) : false;
|
||||
const rowFiles = uploadedFiles[rowKey] ?? [];
|
||||
const isRowUploading = rowFiles.some((file) => isUploading[file.fileKey] ?? false);
|
||||
const fileInputId = `${formId}-${rowKey}-file`;
|
||||
const isRowReadOnly = readOnly || item.cmptnYn === "Y";
|
||||
|
||||
@@ -229,13 +276,10 @@ function SplmntDmndFormSection({ readOnly = false }: SplmntDmndFormSectionProps)
|
||||
<div className="space-y-2">
|
||||
<div className="whitespace-pre-wrap">{item.splmntDmndCn || "-"}</div>
|
||||
{item.splmntDmndAtchFileGroupId ? (
|
||||
<a
|
||||
className="inline-flex cursor-pointer items-start gap-1 text-blue-600 hover:underline"
|
||||
onClick={() => downloadAttachment(item.splmntDmndAtchFileGroupId!)}
|
||||
>
|
||||
<span className="inline-flex items-start gap-1">
|
||||
<Paperclip className="my-1 size-5 shrink-0" />
|
||||
{item.splmntDmndAtchFileNm || "다운로드"}
|
||||
</a>
|
||||
{item.splmntDmndAtchFileNm || "첨부파일"}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -258,54 +302,64 @@ function SplmntDmndFormSection({ readOnly = false }: SplmntDmndFormSectionProps)
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
{uploaded ? (
|
||||
<div className="flex items-stretch gap-2">
|
||||
{uploaded.atflCd || item.splmntAtchFileGroupId ? (
|
||||
<a
|
||||
className="inline-flex cursor-pointer items-start gap-1 text-blue-600 hover:underline"
|
||||
onClick={() =>
|
||||
downloadAttachment((uploaded.atflCd || item.splmntAtchFileGroupId)!)
|
||||
}
|
||||
>
|
||||
<Paperclip className="my-1 size-5 shrink-0" />
|
||||
{uploaded.fileName}
|
||||
</a>
|
||||
) : (
|
||||
<TextShimmer className="text-xl" active={uploading}>
|
||||
{uploaded.fileName}
|
||||
</TextShimmer>
|
||||
)}
|
||||
{!isRowReadOnly ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
className="size-8 shrink-0 !bg-destructive"
|
||||
disabled={uploading}
|
||||
onClick={() => void handleFileRemove(index)}
|
||||
>
|
||||
<i className="diveicon di-x text-xs" aria-label="삭제" />
|
||||
</Button>
|
||||
) : null}
|
||||
<TableCell className="space-y-2 align-top">
|
||||
{rowFiles.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{rowFiles.map((file, fileIndex) => {
|
||||
const uploading = isUploading[file.fileKey] ?? false;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={file.fileKey}
|
||||
className="inline-flex items-center justify-between gap-2 rounded-sm border bg-accent p-2"
|
||||
>
|
||||
<Paperclip className="size-5 shrink-0" />
|
||||
<TextShimmer className="text-xl" active={uploading}>
|
||||
{file.fileName}
|
||||
</TextShimmer>
|
||||
{!isRowReadOnly ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={uploading}
|
||||
onClick={() => void handleFileRemove(index, fileIndex)}
|
||||
>
|
||||
<X
|
||||
className="size-5 shrink-0 text-destructive"
|
||||
aria-label="삭제"
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : isRowReadOnly ? (
|
||||
) : null}
|
||||
{!isRowReadOnly ? (
|
||||
<label htmlFor={fileInputId} className="block cursor-pointer">
|
||||
<input
|
||||
id={fileInputId}
|
||||
type="file"
|
||||
multiple
|
||||
disabled={isRowUploading}
|
||||
className="peer sr-only"
|
||||
onChange={(event) => {
|
||||
void handleFileUpload(index, event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2"
|
||||
disabled={isRowUploading}
|
||||
asChild
|
||||
>
|
||||
<span>파일추가</span>
|
||||
</Button>
|
||||
</label>
|
||||
) : rowFiles.length === 0 ? (
|
||||
<span>-</span>
|
||||
) : (
|
||||
<Button type="button" size="sm" disabled={uploading} asChild>
|
||||
<label htmlFor={fileInputId} className="cursor-pointer">
|
||||
파일추가
|
||||
<input
|
||||
id={fileInputId}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void handleFileUpload(index, event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</Button>
|
||||
)}
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell align="center" className="align-top">
|
||||
<Badge size="lg" color={item.cmptnYn === "Y" ? "green" : "gray"}>
|
||||
|
||||
+2
-2
@@ -12,8 +12,8 @@ NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
|
||||
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
|
||||
NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y}
|
||||
SERVER_API_URL = http://dev.onasys.co.kr:8000
|
||||
# SEWERAGE_API_URL = http://localhost:8883
|
||||
SEWERAGE_API_URL = http://con.mind-one.co.kr:11083
|
||||
SEWERAGE_API_URL = http://localhost:8883
|
||||
# SEWERAGE_API_URL = http://con.mind-one.co.kr:11083
|
||||
DC_URL = https://apigateway.apps.rsonesoft.com
|
||||
# NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
|
||||
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /auth/login
|
||||
|
||||
@@ -6,6 +6,39 @@
|
||||
현재 목표는 리팩토링 전용 작업이 아니라, 하수관로 운영관리 시스템 2차사업 고도화를 수행하는 것이다.
|
||||
즉, 기존 `sn` 화면과 흐름을 안정적으로 유지하면서 2차 요구사항에 해당하는 화면 개발, 기능 확장, 백엔드 연동 보완을 수행해야 한다.
|
||||
|
||||
## 사용자 작업 범위: UI/UX 및 퍼블리싱
|
||||
|
||||
현재 사용자와 진행하는 작업의 기본 범위는 UI/UX 및 프론트엔드 퍼블리싱으로 제한한다.
|
||||
사용자가 별도로 명시하지 않는 한 아래 기준을 모든 후속 작업에 적용한다.
|
||||
|
||||
### 허용 범위
|
||||
|
||||
- 화면 레이아웃과 시각적 위계 조정
|
||||
- JSX 마크업, Tailwind CSS, CSS 스타일 수정
|
||||
- 색상, 여백, 간격, 크기, 타이포그래피, 아이콘, 테두리, 배경 조정
|
||||
- 반응형 화면, 접근성, 포커스와 시각적 상태 개선
|
||||
- 표시 전용 컴포넌트 구성과 퍼블리싱 품질 개선
|
||||
- 기존 화면 내용과 동작을 유지하는 범위의 UI 구조 정리
|
||||
|
||||
### 기본 금지 범위
|
||||
|
||||
- 백엔드 코드, API 계약, 요청·응답 데이터 구조 변경
|
||||
- `.env*`, middleware, 프록시, 인증과 권한 설정 변경
|
||||
- 업무 로직, 상태 모델, 데이터 가공 및 저장 흐름 변경
|
||||
- 데이터베이스, 서버 프로세스와 배포 설정 변경
|
||||
- 패키지 설치·업데이트와 의존성 구조 변경
|
||||
- 사용자 요청 없이 화면의 문구, 데이터 항목, 기능 동작 변경
|
||||
|
||||
금지 범위의 변경이 필요해 보이면 직접 수정하지 않고 원인과 필요한 작업만 사용자에게 설명한다.
|
||||
|
||||
### UI 검증 안전 기준
|
||||
|
||||
- 사용자가 실행한 개발 서버가 있는지 실제 앱 포트까지 먼저 확인한다.
|
||||
- 개발 서버 실행 중에는 같은 앱의 `next build`를 실행하지 않는다.
|
||||
- UI 작업의 기본 검증은 실행 중인 개발 서버의 HMR, 브라우저 화면 확인, `git diff --check`로 제한한다.
|
||||
- 개발 서버 종료, `.next` 정리, 전체 빌드와 프로세스 재시작은 사용자가 명시적으로 승인한 경우에만 수행한다.
|
||||
- UI 변경 전후에 기존 문구, 데이터, 이벤트와 업무 동작이 유지되는지 확인한다.
|
||||
|
||||
---
|
||||
|
||||
## 공통 시작 기준
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/** 시설 상세정보 화면의 운영정보 입력 영역을 공통 형식으로 표시한다. */
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 화면명: 노후관로 레이어 설정
|
||||
* 사업차수와 구조적·운영적 상태등급별 지도 표시 조건을 설정한다.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -54,23 +58,18 @@ export default function AgedPipeLayerSettingPanel({
|
||||
};
|
||||
|
||||
const inputClass =
|
||||
"h-[20px] !rounded border border-input bg-background px-[1px] text-center text-[1.1rem] leading-none text-foreground";
|
||||
const numberInputClass = inputClass + " w-[40px]";
|
||||
"h-[26px] !rounded border border-input bg-background px-2 py-0 text-center text-[12px] leading-none text-foreground";
|
||||
const numberInputClass = inputClass + " min-w-0 flex-1";
|
||||
const applyButtonClass =
|
||||
"grid h-[16px] w-[16px] shrink-0 place-items-center rounded-full bg-primary text-[0.85rem] font-bold leading-none text-primary-foreground";
|
||||
const indicator = (
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-primary" />
|
||||
);
|
||||
|
||||
"h-[26px] shrink-0 rounded border border-[#A9C5E5] bg-[#EDF4FC] px-2 py-0 text-[12px] font-medium leading-none text-[#2463A6]";
|
||||
const settingRow = (
|
||||
<div className="flex min-h-[26px] items-center gap-1 whitespace-nowrap text-[1.15rem] font-medium text-label">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
{indicator}
|
||||
<div className="flex min-h-[26px] flex-col items-stretch gap-1.5 whitespace-nowrap text-[1.15rem] font-medium text-label">
|
||||
<div className="flex min-w-0 items-center">
|
||||
<label htmlFor="aged-pipe-elapsed-years" className="shrink-0">
|
||||
노후관로 경과년도(년)
|
||||
</label>
|
||||
</div>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
<div className="flex w-full items-center gap-1">
|
||||
<input
|
||||
id="aged-pipe-elapsed-years"
|
||||
className={numberInputClass}
|
||||
@@ -95,7 +94,7 @@ export default function AgedPipeLayerSettingPanel({
|
||||
title="적용"
|
||||
aria-label="경과년도 적용"
|
||||
>
|
||||
<span className="relative left-[1px]">✓</span>
|
||||
확인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 화면명: 노후관로 목록
|
||||
* 노후관로를 검색하고 목록·엑셀 다운로드·지도 위치 확인 기능을 제공한다.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
@@ -57,7 +61,7 @@ export default function AgedPipePopupPage() {
|
||||
const sggOptions = useMemo(
|
||||
() =>
|
||||
filters.ctpvCd === "0"
|
||||
? sgg
|
||||
? []
|
||||
: sgg.filter((item) =>
|
||||
String(item.val).startsWith(filters.ctpvCd.slice(0, 2)),
|
||||
),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/** 노후관로 목록의 엑셀 다운로드 데이터와 서식을 생성한다. */
|
||||
"use client";
|
||||
|
||||
import { postBlobData } from "@/components/lib/api";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/** 노후관로 검색 조건, 표시값 변환 및 지도 연계 유틸을 제공한다. */
|
||||
"use client";
|
||||
|
||||
import type { SelectDataType } from "@common/business_components";
|
||||
|
||||
+59
-24
@@ -1,15 +1,28 @@
|
||||
/**
|
||||
* 화면명: 시도별 노후관로현황
|
||||
* 기준연도별 시도 단위 노후관로 현황을 조회하고 엑셀로 내려받는다.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button, Input } from "@common/components";
|
||||
import { StandardPopupLayout } from "@common/business_components";
|
||||
import {
|
||||
StandardDataTable,
|
||||
StandardPopupLayout,
|
||||
} from "@common/business_components";
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
type PaginationState,
|
||||
} from "@tanstack/react-table";
|
||||
import { getRawJson } from "@/components/lib/api";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefinitions";
|
||||
import {
|
||||
AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS,
|
||||
buildAgedPipeStatusByCtpvHeadList,
|
||||
buildAgedPipeStatusYearOptions,
|
||||
getAgedPipeStatusByCtpvValidationMessage,
|
||||
normalizeAgedPipeStatusByCtpvResponse,
|
||||
@@ -20,7 +33,6 @@ import {
|
||||
type AgedPipeStatusByCtpvRow,
|
||||
} from "./agedPipeStatusByCtpvUtils";
|
||||
import { downloadAgedPipeStatusByCtpvExcel } from "./agedPipeStatusByCtpvExport";
|
||||
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
|
||||
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
|
||||
|
||||
// CS 목록과 동일하게 한 페이지에는 최대 10개 시도만 표시한다.
|
||||
@@ -52,21 +64,48 @@ export default function AgedPipeStatusByCtpvPopupPage() {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const yearOptions = useMemo(() => buildAgedPipeStatusYearOptions(), []);
|
||||
const headList = useMemo(
|
||||
const columns = useMemo<ColumnDef<AgedPipeStatusByCtpvRow>[]>(
|
||||
() => [
|
||||
{ id: "rowNumber", title: "순번", widthPercent: 7 },
|
||||
...buildAgedPipeStatusByCtpvHeadList(years),
|
||||
{ accessorKey: "rowNumber", header: "순번" },
|
||||
{ accessorKey: "ctpvNm", header: "시도" },
|
||||
...years.map((year) => ({
|
||||
id: `year_${year}`,
|
||||
header: `${year}년`,
|
||||
columns: [
|
||||
{
|
||||
accessorKey: `year${year}TotalLengthKm`,
|
||||
header: "전체관로연장(km)",
|
||||
},
|
||||
{
|
||||
accessorKey: `year${year}AgedLengthKm`,
|
||||
header: "노후관로연장(km)",
|
||||
},
|
||||
{
|
||||
accessorKey: `year${year}AgedRate`,
|
||||
header: "노후화율(%)",
|
||||
},
|
||||
],
|
||||
})),
|
||||
],
|
||||
[years],
|
||||
);
|
||||
const pageRows = useMemo(
|
||||
() =>
|
||||
rows.slice(
|
||||
(currentPage - 1) * AGED_PIPE_STATUS_PAGE_SIZE,
|
||||
currentPage * AGED_PIPE_STATUS_PAGE_SIZE,
|
||||
),
|
||||
[currentPage, rows],
|
||||
);
|
||||
|
||||
const pagination: PaginationState = {
|
||||
pageIndex: currentPage - 1,
|
||||
pageSize: AGED_PIPE_STATUS_PAGE_SIZE,
|
||||
};
|
||||
const table = useReactTable({
|
||||
data: rows,
|
||||
columns,
|
||||
state: { pagination },
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onPaginationChange: (updater) => {
|
||||
const next =
|
||||
typeof updater === "function" ? updater(pagination) : updater;
|
||||
setCurrentPage(next.pageIndex + 1);
|
||||
},
|
||||
});
|
||||
|
||||
const loadRows = useCallback(
|
||||
async (nextFilters: AgedPipeStatusByCtpvFilters) => {
|
||||
@@ -249,23 +288,19 @@ export default function AgedPipeStatusByCtpvPopupPage() {
|
||||
</div>
|
||||
</PopupSearchPanel>
|
||||
|
||||
<MapPopupDataTable
|
||||
rows={
|
||||
pageRows as Array<AgedPipeStatusByCtpvRow & Record<string, unknown>>
|
||||
}
|
||||
headlist={headList}
|
||||
<StandardDataTable
|
||||
table={table}
|
||||
className="!w-full !max-w-full"
|
||||
title="시도별 노후관로 목록"
|
||||
total={rows.length}
|
||||
page={currentPage}
|
||||
pageSize={AGED_PIPE_STATUS_PAGE_SIZE}
|
||||
onPageChange={setCurrentPage}
|
||||
scrollMode={years.length > 2 ? "horizontal" : "none"}
|
||||
minTableWidth={years.length > 2 ? "120rem" : "100%"}
|
||||
scrollMode="horizontal"
|
||||
minTableWidth={years.length > 0 ? "120rem" : "100%"}
|
||||
actions={
|
||||
<Button type="button" onClick={handleDownload}>
|
||||
엑셀다운로드
|
||||
</Button>
|
||||
}
|
||||
flushOuterMargin
|
||||
/>
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
|
||||
+65
-16
@@ -1,32 +1,81 @@
|
||||
/** 시도별 노후관로 현황의 연도 그룹형 엑셀 데이터를 생성한다. */
|
||||
"use client";
|
||||
|
||||
import { postBlobData } from "@/components/lib/api";
|
||||
import { fileDownload } from "@/components/lib/utils/fileDownload";
|
||||
import * as XLSX from "xlsx";
|
||||
import { getRawJson } from "@/components/lib/api";
|
||||
import {
|
||||
getAgedPipeStatusByCtpvValidationMessage,
|
||||
normalizeAgedPipeStatusByCtpvFilters,
|
||||
type AgedPipeStatusByCtpvFilters,
|
||||
type AgedPipeStatusByCtpvResponse,
|
||||
} from "./agedPipeStatusByCtpvUtils";
|
||||
|
||||
/** 시도별 노후관로현황 XLSX 다운로드를 요청한다. */
|
||||
/** 적용조건의 시도별 노후관로현황 전체를 조회해 연도 그룹 Excel로 생성한다. */
|
||||
export const downloadAgedPipeStatusByCtpvExcel = async (
|
||||
filters: AgedPipeStatusByCtpvFilters,
|
||||
) => {
|
||||
const validationMessage = getAgedPipeStatusByCtpvValidationMessage(filters);
|
||||
if (validationMessage) throw new Error(validationMessage);
|
||||
|
||||
const normalizedFilters = normalizeAgedPipeStatusByCtpvFilters(filters);
|
||||
const { blob, fileName } = await postBlobData(
|
||||
"v1/swagePpl/aged-status-by-ctpv/exportJobs/download",
|
||||
{
|
||||
startYear: normalizedFilters.startYear,
|
||||
endYear: normalizedFilters.endYear,
|
||||
elapsedYears: normalizedFilters.elapsedYears,
|
||||
format: "xlsx",
|
||||
},
|
||||
const normalized = normalizeAgedPipeStatusByCtpvFilters(filters);
|
||||
const params = new URLSearchParams({
|
||||
startYear: String(normalized.startYear),
|
||||
endYear: String(normalized.endYear),
|
||||
elapsedYears: String(normalized.elapsedYears),
|
||||
});
|
||||
const response: any = await getRawJson(
|
||||
`v1/swagePpl/aged-status-by-ctpv?${params.toString()}`,
|
||||
);
|
||||
const payload = (response?.data ?? response) as AgedPipeStatusByCtpvResponse;
|
||||
const years = Array.isArray(payload?.years)
|
||||
? payload.years.map(Number).filter(Number.isFinite)
|
||||
: [];
|
||||
const rows = Array.isArray(payload?.rows) ? payload.rows : [];
|
||||
if (rows.length === 0) throw new Error("다운로드할 데이터가 없습니다.");
|
||||
|
||||
const resolvedFileName = fileName || "시도별_노후관로현황.xlsx";
|
||||
fileDownload(blob, resolvedFileName);
|
||||
return resolvedFileName;
|
||||
const headers = ["순번", "시도"];
|
||||
years.forEach((year) => headers.push(`${year}년`, "", ""));
|
||||
const subHeaders = ["", ""];
|
||||
years.forEach(() =>
|
||||
subHeaders.push("전체관로연장(km)", "노후관로연장(km)", "노후화율(%)"),
|
||||
);
|
||||
const data = rows.map((row, rowIndex) => {
|
||||
const statsByYear = new Map(
|
||||
(row.yearStats ?? []).map((stat) => [Number(stat.year), stat]),
|
||||
);
|
||||
const values: Array<string | number> = [rowIndex + 1, row.ctpvNm || "-"];
|
||||
years.forEach((year) => {
|
||||
const stat = statsByYear.get(year);
|
||||
values.push(
|
||||
stat ? Number(stat.totalLengthKm ?? 0) : "-",
|
||||
stat ? Number(stat.agedLengthKm ?? 0) : "-",
|
||||
stat ? Number(stat.agedRate ?? 0) : "-",
|
||||
);
|
||||
});
|
||||
return values;
|
||||
});
|
||||
|
||||
const worksheet = XLSX.utils.aoa_to_sheet([headers, subHeaders, ...data]);
|
||||
worksheet["!merges"] = [
|
||||
{ s: { r: 0, c: 0 }, e: { r: 1, c: 0 } },
|
||||
{ s: { r: 0, c: 1 }, e: { r: 1, c: 1 } },
|
||||
...years.map((_, index) => ({
|
||||
s: { r: 0, c: 2 + index * 3 },
|
||||
e: { r: 0, c: 4 + index * 3 },
|
||||
})),
|
||||
];
|
||||
worksheet["!cols"] = [
|
||||
{ wch: 8 },
|
||||
{ wch: 18 },
|
||||
...years.flatMap(() => [{ wch: 18 }, { wch: 18 }, { wch: 14 }]),
|
||||
];
|
||||
worksheet["!autofilter"] = {
|
||||
ref: `A2:${XLSX.utils.encode_col(headers.length - 1)}${data.length + 2}`,
|
||||
};
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, "시도별 노후관로현황");
|
||||
XLSX.writeFile(workbook, "시도별_노후관로현황.xlsx", {
|
||||
bookType: "xlsx",
|
||||
compression: true,
|
||||
cellStyles: true,
|
||||
});
|
||||
};
|
||||
|
||||
+5
-4
@@ -1,3 +1,4 @@
|
||||
/** 시도별 노후관로 현황의 조회 연도와 그룹 컬럼을 구성한다. */
|
||||
"use client";
|
||||
|
||||
import type { UiHeadProps } from "@common/business_components/ui";
|
||||
@@ -65,16 +66,16 @@ export const normalizeAgedPipeStatusByCtpvFilters = (
|
||||
filters: Partial<AgedPipeStatusByCtpvFilters>,
|
||||
): AgedPipeStatusByCtpvFilters => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
let startYear = Math.trunc(Number(filters.startYear ?? currentYear));
|
||||
let endYear = Math.trunc(Number(filters.endYear ?? startYear));
|
||||
let startYear = Math.trunc(Number(filters.startYear ?? currentYear - 2));
|
||||
let endYear = Math.trunc(Number(filters.endYear ?? currentYear));
|
||||
let elapsedYears = Math.trunc(
|
||||
Number(
|
||||
filters.elapsedYears ?? AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS,
|
||||
),
|
||||
);
|
||||
|
||||
if (!Number.isFinite(startYear)) startYear = currentYear;
|
||||
if (!Number.isFinite(endYear)) endYear = startYear;
|
||||
if (!Number.isFinite(startYear)) startYear = currentYear - 2;
|
||||
if (!Number.isFinite(endYear)) endYear = currentYear;
|
||||
if (
|
||||
!Number.isFinite(elapsedYears) ||
|
||||
elapsedYears < 1 ||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
/**
|
||||
* 화면명: 분석도구 메뉴
|
||||
* 지도에서 실행할 분석·현황 팝업 메뉴를 펼치고 선택한다.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type ComponentType, type ReactNode } from "react";
|
||||
import {
|
||||
ChartColumnBig,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
PanelsTopLeft,
|
||||
PencilRuler,
|
||||
@@ -32,10 +35,10 @@ type MenuIconProps = {
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
const InftwtMenuIcon = ({ isActive }: MenuIconProps) => (
|
||||
const InftwtMenuIcon = () => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={`h-[15px] w-[15px] shrink-0 ${isActive ? "text-primary" : "text-label"}`}
|
||||
className="h-[15px] w-[15px] shrink-0 text-current"
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
>
|
||||
@@ -167,31 +170,44 @@ export default function AnalysisPopupMenu({
|
||||
}, [initialActiveKey]);
|
||||
|
||||
return (
|
||||
<div className="absolute right-[15px] top-[8px] z-[45]">
|
||||
<div className="flex w-[154px] flex-col overflow-hidden rounded-[6px] border border-border bg-background shadow-[0_8px_18px_rgba(15,23,42,0.14)]">
|
||||
<div className="flex h-[36px] shrink-0 items-center justify-between gap-2 border-b border-border bg-secondary pl-[10px] pr-[4px] text-[13px] font-bold text-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<PanelsTopLeft className="h-4 w-4 text-primary" />
|
||||
<span>분석도구</span>
|
||||
<div className="pointer-events-none absolute right-[68px] top-[16px] z-[45] flex flex-col items-end gap-[8px]">
|
||||
<button
|
||||
type="button"
|
||||
className={`pointer-events-auto flex h-[40px] w-[40px] shrink-0 items-center justify-center overflow-hidden rounded-[3px] p-0 shadow-[0_1px_2px_rgba(60,64,67,0.3),0_1px_3px_1px_rgba(60,64,67,0.15)] ${
|
||||
isOpen
|
||||
? "bg-[#0279FA] text-white"
|
||||
: "bg-white text-[#666666] hover:bg-[#0279FA] hover:text-white"
|
||||
}`}
|
||||
onClick={() => setIsOpen((value) => !value)}
|
||||
title={isOpen ? "분석 메뉴 숨기기" : "분석도구"}
|
||||
aria-label={isOpen ? "분석 메뉴 숨기기" : "분석 메뉴 보이기"}
|
||||
aria-expanded={isOpen}
|
||||
aria-controls="analysis-popup-menu"
|
||||
>
|
||||
<PanelsTopLeft className="h-[20px] w-[20px]" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
{isOpen ? (
|
||||
<div
|
||||
id="analysis-popup-menu"
|
||||
className="pointer-events-auto flex w-max min-w-[154px] flex-col overflow-hidden rounded-[6px] bg-background shadow-[0_1px_2px_rgba(60,64,67,0.3),0_1px_3px_1px_rgba(60,64,67,0.15)]"
|
||||
>
|
||||
<div className="flex h-[36px] shrink-0 items-center justify-between gap-2 border-b border-border bg-white pl-[10px] pr-[4px] text-[13px] font-bold text-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<PanelsTopLeft className="h-4 w-4 text-primary" />
|
||||
<span>분석도구</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-[28px] w-[28px] shrink-0 items-center justify-center rounded-[4px] text-label hover:bg-accent hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[#0279FA]"
|
||||
onClick={() => setIsOpen(false)}
|
||||
title="분석 메뉴 숨기기"
|
||||
aria-label="분석 메뉴 숨기기"
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-[28px] w-[28px] shrink-0 items-center justify-center rounded-[4px] text-label transition hover:bg-accent hover:text-primary"
|
||||
onClick={() => setIsOpen((value) => !value)}
|
||||
title={isOpen ? "분석 메뉴 숨기기" : "분석 메뉴 보이기"}
|
||||
aria-label={isOpen ? "분석 메뉴 숨기기" : "분석 메뉴 보이기"}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
{isOpen ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isOpen ? (
|
||||
<div className="flex flex-col gap-[3px] p-[6px]">
|
||||
{ANALYSIS_POPUP_ITEMS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
@@ -208,7 +224,7 @@ export default function AnalysisPopupMenu({
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
className={`relative flex h-[33px] items-center gap-[7px] rounded-[5px] border px-[8px] pl-[10px] text-left text-[12px] font-semibold transition ${
|
||||
className={`relative flex h-[33px] items-center gap-[7px] rounded-[5px] border px-[8px] pl-[10px] text-left text-[12px] font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[#0279FA] ${
|
||||
isActive
|
||||
? "border-primary bg-secondary font-bold text-primary before:absolute before:bottom-[7px] before:left-0 before:top-[7px] before:w-[3px] before:rounded-r-[2px] before:bg-primary"
|
||||
: "border-transparent bg-background text-foreground hover:border-border hover:bg-accent hover:text-primary"
|
||||
@@ -244,19 +260,17 @@ export default function AnalysisPopupMenu({
|
||||
>
|
||||
{item.renderIcon ? item.renderIcon({ isActive }) : null}
|
||||
{Icon ? (
|
||||
<Icon
|
||||
className={`h-[15px] w-[15px] shrink-0 ${
|
||||
isActive ? "text-primary" : "text-label"
|
||||
}`}
|
||||
/>
|
||||
<Icon className="h-[15px] w-[15px] shrink-0 text-current" />
|
||||
) : null}
|
||||
<span className="whitespace-nowrap">{label}</span>
|
||||
<span className="whitespace-nowrap" title={label}>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/** 지도 시설 상세정보의 코드값을 업무 표시명으로 변환한다. */
|
||||
import { MS_SWAGE_PPL_USE_CODES, MS_SWAGE_PPL_CODES, MS_SWAGE_MHL_CODES, MS_SWAGE_MHL_USE_CODES, DRTW_SEWAGE_PUMP_USE_CODES } from './table';
|
||||
|
||||
interface CodeMapperProps {
|
||||
@@ -159,4 +160,4 @@ export const reverseCodeMapper = ({ layerName, data }: CodeMapperProps): any =>
|
||||
};
|
||||
|
||||
// 기본 내보내기
|
||||
export default { codeMapper, reverseCodeMapper };
|
||||
export default { codeMapper, reverseCodeMapper };
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/** 선택한 지도 레이어 종류에 맞는 시설 상세 컴포넌트를 반환한다. */
|
||||
import MsSwagePpl from "../popupComponent/MsSwagePpl"; // 하수관로
|
||||
import sewagePump from "../popupComponent/sewagePump"; // 하수펌프
|
||||
import smh from "../popupComponent/smh";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/** 지도 시설 상세 및 목록에서 사용하는 컬럼·코드 정의를 제공한다. */
|
||||
// 하수관로
|
||||
export const MsSwagePplCol = [
|
||||
{ id: 'fclt_mng_no', title: '시설관리번호' },
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 화면명: 관로조사 레이어 설정
|
||||
* 사업차수와 조사 종류·상태등급별 지도 표시 조건을 설정한다.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import type { ExmnMapFilters, ExmnMapJobType } from "./exmnMapTypes";
|
||||
@@ -30,7 +34,7 @@ export default function ExmnLayerSettingPanel({
|
||||
});
|
||||
};
|
||||
const selectClass =
|
||||
"h-[20px] w-[62px] rounded border border-input bg-background px-[1px] text-center text-[1.1rem] leading-none text-foreground outline-none focus:border-primary";
|
||||
"h-[26px] w-full rounded border border-input bg-background px-2 py-0 text-center text-[12px] leading-none text-foreground outline-none focus:border-primary";
|
||||
const labelClass = "shrink-0 text-[1.15rem] font-medium text-label";
|
||||
const title = jobType === "PPL" ? "관로조사" : "맨홀조사";
|
||||
|
||||
@@ -41,8 +45,8 @@ export default function ExmnLayerSettingPanel({
|
||||
onEnabledChange={onEnabledChange}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="space-y-[1px]">
|
||||
<label className="flex min-h-[24px] items-center justify-between ">
|
||||
<div className="space-y-[6px]">
|
||||
<label className="flex min-h-[24px] flex-col items-stretch gap-1">
|
||||
<span className={`flex items-center gap-1 ${labelClass}`}>
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${jobType === "PPL" ? "bg-[#facc15]" : "bg-[#8b5cf6]"}`}
|
||||
@@ -59,7 +63,7 @@ export default function ExmnLayerSettingPanel({
|
||||
onChange={(event) => updateFilter("bizCycl", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex min-h-[24px] items-center justify-between ">
|
||||
<label className="flex min-h-[24px] flex-col items-stretch gap-1">
|
||||
<span className={labelClass}>구조적 상태등급</span>
|
||||
<select
|
||||
className={selectClass}
|
||||
@@ -75,7 +79,7 @@ export default function ExmnLayerSettingPanel({
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex min-h-[24px] items-center justify-between ">
|
||||
<label className="flex min-h-[24px] flex-col items-stretch gap-1">
|
||||
<span className={labelClass}>운영적 상태등급</span>
|
||||
<select
|
||||
className={selectClass}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 화면명: 관로조사 결과
|
||||
* 선택 시설의 조사 결과와 상세 판정 정보를 표시한다.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/** 관로조사 지도 데이터 조회 API와 응답 변환 함수를 제공한다. */
|
||||
import { getRawJson } from "../../../../components/lib/api";
|
||||
import type { ExmnMapFilters, ExmnMapJobType } from "./exmnMapTypes";
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/** 관로조사 지도 화면에서 사용하는 타입·상수와 표시 조건을 정의한다. */
|
||||
export const EXMN_RESULT_ROOT_NODE_ID = "SN_EXMN_RESULT";
|
||||
export const EXMN_RESULT_PPL_NODE_ID = "SN_EXMN_RESULT_PPL";
|
||||
export const EXMN_RESULT_MNHL_NODE_ID = "SN_EXMN_RESULT_MNHL";
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* 화면명: 침수흔적도 상세정보
|
||||
* 지도에서 선택한 침수흔적의 기본정보·강우정보·피해정보를 표시한다.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
formatFloodArea,
|
||||
formatFloodDepth,
|
||||
type FldngHstryItem,
|
||||
} from "./fldngHstryTypes";
|
||||
import type { FldngHstryItem } from "./fldngHstryTypes";
|
||||
import { formatFldngHstryArea, formatFldngHstryDepth } from "./fldngHstryUtils";
|
||||
|
||||
type Props = {
|
||||
detail: FldngHstryItem;
|
||||
@@ -50,8 +51,8 @@ export default function FldngHstryDetailPanel({
|
||||
const yearRows = useMemo(
|
||||
() =>
|
||||
[
|
||||
["침수심", `${detail.floodDepthM.toFixed(2)} m`],
|
||||
["침수면적", formatFloodArea(detail.floodAreaHa)],
|
||||
["침수심", `${formatFldngHstryDepth(detail.floodDepthM)} m`],
|
||||
["침수면적", formatFldngHstryArea(detail.floodAreaHa)],
|
||||
["침수년도", `${detail.floodYear}년`],
|
||||
["등급", detail.gradeLabel],
|
||||
["침수구역", detail.districtName],
|
||||
@@ -63,7 +64,7 @@ export default function FldngHstryDetailPanel({
|
||||
() =>
|
||||
[
|
||||
["침수재해명", detail.disasterName],
|
||||
["침수면적", formatFloodArea(detail.floodAreaHa)],
|
||||
["침수면적", formatFldngHstryArea(detail.floodAreaHa)],
|
||||
["침수원인상세명", detail.causeDetailName],
|
||||
["침수시작년월일", detail.startDate],
|
||||
["침수종료년월일", detail.endDate],
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* 화면명: 침수흔적위선 연계정보
|
||||
* 우측 정보 화면의 심선·위선 목록, 선택 상태와 화면 생명주기 캐시를 관리한다.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import GisApp from "@common/business_components/ui/map/lib/gis.core";
|
||||
import type Feature from "ol/Feature.js";
|
||||
import VectorSource from "ol/source/Vector.js";
|
||||
|
||||
import { loadFldngHstryDetail } from "./fldngHstryClient";
|
||||
import {
|
||||
FLDNG_HSTRY_DEPTH_LAYER_TYPE,
|
||||
FLDNG_HSTRY_LAYER_TYPE,
|
||||
type FldngHstryDetailCacheValue,
|
||||
} from "./fldngHstryTypes";
|
||||
|
||||
type FldngHstryGisLayer = {
|
||||
searchLayer?: { getSource?: () => VectorSource | null } | null;
|
||||
clearSearchLayer: (option: unknown) => void;
|
||||
ensureSearchLayer: (option: unknown) => {
|
||||
set: (key: string, value: unknown) => void;
|
||||
setSource: (source: VectorSource) => void;
|
||||
};
|
||||
showSearchResultsFromGeoJSON: (
|
||||
layerTypeName: string,
|
||||
featureId: string,
|
||||
) => void | Promise<void>;
|
||||
};
|
||||
|
||||
type FldngHstryScrollButtonProps = {
|
||||
targetRef: RefObject<HTMLDivElement | null>;
|
||||
direction: -1 | 1;
|
||||
label: string;
|
||||
children: string;
|
||||
};
|
||||
|
||||
const FLDNG_HSTRY_SCROLL_CLICK_DISTANCE = 120;
|
||||
const FLDNG_HSTRY_SCROLL_HOLD_DELAY = 300;
|
||||
const FLDNG_HSTRY_SCROLL_HOLD_SPEED = 4;
|
||||
|
||||
/** 짧게 누르면 한 칸, 길게 누르면 목록을 해당 방향으로 계속 이동한다. */
|
||||
const FldngHstryScrollButton = ({
|
||||
targetRef,
|
||||
direction,
|
||||
label,
|
||||
children,
|
||||
}: FldngHstryScrollButtonProps) => {
|
||||
const holdTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
const pointerClickHandledRef = useRef(false);
|
||||
|
||||
const stopContinuousScroll = useCallback(() => {
|
||||
if (holdTimerRef.current !== null) {
|
||||
clearTimeout(holdTimerRef.current);
|
||||
holdTimerRef.current = null;
|
||||
}
|
||||
if (animationFrameRef.current !== null) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scrollOnce = useCallback(() => {
|
||||
targetRef.current?.scrollBy({
|
||||
left: direction * FLDNG_HSTRY_SCROLL_CLICK_DISTANCE,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, [direction, targetRef]);
|
||||
|
||||
const startContinuousScroll = useCallback(() => {
|
||||
const scroll = () => {
|
||||
const target = targetRef.current;
|
||||
if (!target) {
|
||||
stopContinuousScroll();
|
||||
return;
|
||||
}
|
||||
target.scrollLeft += direction * FLDNG_HSTRY_SCROLL_HOLD_SPEED;
|
||||
animationFrameRef.current = requestAnimationFrame(scroll);
|
||||
};
|
||||
animationFrameRef.current = requestAnimationFrame(scroll);
|
||||
}, [direction, stopContinuousScroll, targetRef]);
|
||||
|
||||
useEffect(() => stopContinuousScroll, [stopContinuousScroll]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
className="shrink-0 select-none px-1 text-lg leading-none text-foreground"
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0) return;
|
||||
stopContinuousScroll();
|
||||
pointerClickHandledRef.current = true;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
scrollOnce();
|
||||
holdTimerRef.current = setTimeout(
|
||||
startContinuousScroll,
|
||||
FLDNG_HSTRY_SCROLL_HOLD_DELAY,
|
||||
);
|
||||
}}
|
||||
onPointerUp={stopContinuousScroll}
|
||||
onPointerCancel={() => {
|
||||
stopContinuousScroll();
|
||||
pointerClickHandledRef.current = false;
|
||||
}}
|
||||
onLostPointerCapture={stopContinuousScroll}
|
||||
onClick={() => {
|
||||
if (pointerClickHandledRef.current) {
|
||||
pointerClickHandledRef.current = false;
|
||||
return;
|
||||
}
|
||||
scrollOnce();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const cloneFldngHstryFeature = (feature: Feature) => {
|
||||
const clonedFeature = feature.clone();
|
||||
const featureId = feature.getId();
|
||||
if (featureId !== undefined) clonedFeature.setId(featureId);
|
||||
return clonedFeature;
|
||||
};
|
||||
|
||||
const displayCachedFldngHstryFeatures = (
|
||||
gisLayer: FldngHstryGisLayer,
|
||||
layerTypeName: string,
|
||||
features: Feature[],
|
||||
) => {
|
||||
const matched = Object.values(GisApp.layerCode ?? {}).find(
|
||||
(item: any) => item?.lyrTyNm === layerTypeName,
|
||||
) as any;
|
||||
if (!matched || features.length === 0) return;
|
||||
|
||||
gisLayer.clearSearchLayer(matched);
|
||||
const layer = gisLayer.ensureSearchLayer(matched);
|
||||
layer.set("gobjTy", matched.gobjTy);
|
||||
const clonedFeatures = features.map(cloneFldngHstryFeature);
|
||||
layer.setSource(new VectorSource({ features: clonedFeatures }));
|
||||
(GisApp.Module.select as any)?.selectFeature(clonedFeatures[0], false);
|
||||
};
|
||||
|
||||
const showFldngHstryFeature = async (
|
||||
cache: Map<string, Promise<Feature[]>>,
|
||||
gisLayer: FldngHstryGisLayer,
|
||||
layerTypeName: string,
|
||||
featureId: string,
|
||||
) => {
|
||||
const cacheKey = `${layerTypeName}:${featureId}`;
|
||||
const cachedRequest = cache.get(cacheKey);
|
||||
if (cachedRequest) {
|
||||
displayCachedFldngHstryFeatures(
|
||||
gisLayer,
|
||||
layerTypeName,
|
||||
await cachedRequest,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await gisLayer.showSearchResultsFromGeoJSON(layerTypeName, featureId);
|
||||
const source = gisLayer.searchLayer?.getSource?.();
|
||||
if (!source) return;
|
||||
|
||||
const featureRequest = new Promise<Feature[]>((resolve, reject) => {
|
||||
const resolveFeatures = () => {
|
||||
const features = source.getFeatures();
|
||||
if (features.length > 0) {
|
||||
resolve(features.map(cloneFldngHstryFeature));
|
||||
} else {
|
||||
reject(new Error("침수흔적 위치 정보가 없습니다."));
|
||||
}
|
||||
};
|
||||
const rejectFeatures = () =>
|
||||
reject(new Error("침수흔적 위치 조회에 실패했습니다."));
|
||||
|
||||
if (source.getFeatures().length > 0) {
|
||||
resolveFeatures();
|
||||
return;
|
||||
}
|
||||
source.once("featuresloadend", resolveFeatures);
|
||||
source.once("featuresloaderror", rejectFeatures);
|
||||
});
|
||||
|
||||
cache.set(cacheKey, featureRequest);
|
||||
try {
|
||||
await featureRequest;
|
||||
} catch (error) {
|
||||
cache.delete(cacheKey);
|
||||
console.warn(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const useFldngHstryFeatureClick = () => {
|
||||
const fldngHstryDetailCacheRef = useRef(
|
||||
new Map<string, Promise<FldngHstryDetailCacheValue>>(),
|
||||
);
|
||||
const fldngHstryFeatureCacheRef = useRef(
|
||||
new Map<string, Promise<Feature[]>>(),
|
||||
);
|
||||
const [fldngHstryDepthRows, setFldngHstryDepthRows] = useState<
|
||||
Record<string, unknown>[]
|
||||
>([]);
|
||||
const [fldngHstryWtlvRows, setFldngHstryWtlvRows] = useState<
|
||||
Record<string, unknown>[]
|
||||
>([]);
|
||||
const [selectedFldngHstryDepthFid, setSelectedFldngHstryDepthFid] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [selectedFldngHstryWtlvFid, setSelectedFldngHstryWtlvFid] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const fldngHstryDepthYearListRef = useRef<HTMLDivElement>(null);
|
||||
const fldngHstryWtlvListRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const detailCache = fldngHstryDetailCacheRef.current;
|
||||
const featureCache = fldngHstryFeatureCacheRef.current;
|
||||
return () => {
|
||||
detailCache.clear();
|
||||
featureCache.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const resetFldngHstryFeatureClick = useCallback(() => {
|
||||
setFldngHstryDepthRows([]);
|
||||
setFldngHstryWtlvRows([]);
|
||||
setSelectedFldngHstryDepthFid(null);
|
||||
setSelectedFldngHstryWtlvFid(null);
|
||||
}, []);
|
||||
|
||||
const loadFldngHstryFeatureClickDetail = useCallback(
|
||||
async (fldngHstryFid: string) => {
|
||||
const cacheKey = `${FLDNG_HSTRY_LAYER_TYPE}:${fldngHstryFid}`;
|
||||
let request = fldngHstryDetailCacheRef.current.get(cacheKey);
|
||||
if (!request) {
|
||||
request = loadFldngHstryDetail(fldngHstryFid);
|
||||
fldngHstryDetailCacheRef.current.set(cacheKey, request);
|
||||
}
|
||||
|
||||
const detail = await request;
|
||||
if (!detail.complete) fldngHstryDetailCacheRef.current.delete(cacheKey);
|
||||
setFldngHstryDepthRows(detail.fldngHstryDepthRows);
|
||||
setFldngHstryWtlvRows(detail.fldngHstryWtlvRows);
|
||||
setSelectedFldngHstryDepthFid(null);
|
||||
setSelectedFldngHstryWtlvFid(null);
|
||||
return detail;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const headName = useMemo(
|
||||
() => (
|
||||
<div className="flex w-full min-w-0 flex-col items-start gap-2">
|
||||
<span>기본정보</span>
|
||||
{fldngHstryDepthRows.length > 0 && (
|
||||
<div className="flex w-full items-center gap-1">
|
||||
<span className="w-9 shrink-0 text-[1.1rem] font-semibold">
|
||||
심선
|
||||
</span>
|
||||
<FldngHstryScrollButton
|
||||
targetRef={fldngHstryDepthYearListRef}
|
||||
direction={-1}
|
||||
label="이전 연도"
|
||||
>
|
||||
‹
|
||||
</FldngHstryScrollButton>
|
||||
<div
|
||||
ref={fldngHstryDepthYearListRef}
|
||||
className="flex min-w-0 flex-1 gap-2 overflow-x-auto whitespace-nowrap [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
{[...fldngHstryDepthRows]
|
||||
.map((row, index) => ({
|
||||
row,
|
||||
index,
|
||||
year: String(
|
||||
row.fldng_yr ?? row.fldngYr ?? row.yr ?? "",
|
||||
).trim(),
|
||||
}))
|
||||
.filter(({ year }) => Boolean(year))
|
||||
.sort((left, right) => {
|
||||
const yearOrder = left.year.localeCompare(
|
||||
right.year,
|
||||
undefined,
|
||||
{ numeric: true },
|
||||
);
|
||||
return yearOrder !== 0 ? yearOrder : left.index - right.index;
|
||||
})
|
||||
.map(({ row, index, year }) => {
|
||||
const depthFid = String(
|
||||
row.fldng_dpth_map_sn ?? row.fid ?? "",
|
||||
).trim();
|
||||
return (
|
||||
<button
|
||||
key={`${depthFid || "row"}-${index}`}
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (!depthFid) return;
|
||||
setSelectedFldngHstryDepthFid(depthFid);
|
||||
setSelectedFldngHstryWtlvFid(null);
|
||||
await showFldngHstryFeature(
|
||||
fldngHstryFeatureCacheRef.current,
|
||||
GisApp.Module.layer as any,
|
||||
FLDNG_HSTRY_DEPTH_LAYER_TYPE,
|
||||
`${FLDNG_HSTRY_DEPTH_LAYER_TYPE}.${depthFid.replace(new RegExp(`^${FLDNG_HSTRY_DEPTH_LAYER_TYPE}\\.`), "")}`,
|
||||
);
|
||||
}}
|
||||
className={`shrink-0 rounded border px-3 py-1 text-[1.2rem] font-medium leading-5 ${selectedFldngHstryDepthFid === depthFid ? "border-blue-600 bg-white text-blue-700" : "border-border bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
{year}년
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<FldngHstryScrollButton
|
||||
targetRef={fldngHstryDepthYearListRef}
|
||||
direction={1}
|
||||
label="다음 연도"
|
||||
>
|
||||
›
|
||||
</FldngHstryScrollButton>
|
||||
</div>
|
||||
)}
|
||||
{fldngHstryWtlvRows.length > 0 && (
|
||||
<div className="flex w-full items-center gap-1">
|
||||
<span className="w-9 shrink-0 text-[1.1rem] font-semibold">
|
||||
위선
|
||||
</span>
|
||||
<FldngHstryScrollButton
|
||||
targetRef={fldngHstryWtlvListRef}
|
||||
direction={-1}
|
||||
label="이전 위선"
|
||||
>
|
||||
‹
|
||||
</FldngHstryScrollButton>
|
||||
<div
|
||||
ref={fldngHstryWtlvListRef}
|
||||
className="flex min-w-0 flex-1 gap-2 overflow-x-auto whitespace-nowrap [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
{fldngHstryWtlvRows.map((row, index) => {
|
||||
const waterLevelFid = String(row.fid ?? "").trim();
|
||||
const disasterName = String(
|
||||
row.fldng_dstr_nm ?? row.fldngDstrNm ?? "",
|
||||
).trim();
|
||||
return (
|
||||
<button
|
||||
key={`${waterLevelFid || "wtlv"}-${index}`}
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (!waterLevelFid) return;
|
||||
setSelectedFldngHstryWtlvFid(waterLevelFid);
|
||||
setSelectedFldngHstryDepthFid(null);
|
||||
await showFldngHstryFeature(
|
||||
fldngHstryFeatureCacheRef.current,
|
||||
GisApp.Module.layer as any,
|
||||
FLDNG_HSTRY_LAYER_TYPE,
|
||||
`${FLDNG_HSTRY_LAYER_TYPE}.${waterLevelFid.replace(new RegExp(`^${FLDNG_HSTRY_LAYER_TYPE}\\.`), "")}`,
|
||||
);
|
||||
}}
|
||||
className={`shrink-0 rounded border px-3 py-1 text-[1.2rem] font-medium leading-5 ${selectedFldngHstryWtlvFid === waterLevelFid ? "border-blue-600 bg-white text-blue-700" : "border-border bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
{disasterName || "재해명 없음"}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<FldngHstryScrollButton
|
||||
targetRef={fldngHstryWtlvListRef}
|
||||
direction={1}
|
||||
label="다음 위선"
|
||||
>
|
||||
›
|
||||
</FldngHstryScrollButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
[
|
||||
fldngHstryDepthRows,
|
||||
fldngHstryWtlvRows,
|
||||
selectedFldngHstryDepthFid,
|
||||
selectedFldngHstryWtlvFid,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
hasFldngHstryLinkedFeatures:
|
||||
fldngHstryDepthRows.length > 0 || fldngHstryWtlvRows.length > 0,
|
||||
fldngHstryFeatureClickHeadName: headName,
|
||||
loadFldngHstryFeatureClickDetail,
|
||||
resetFldngHstryFeatureClick,
|
||||
};
|
||||
};
|
||||
+26
-46
@@ -1,16 +1,21 @@
|
||||
/**
|
||||
* 화면명: 침수흔적도 레이어 설정
|
||||
* 침수흔적위선·심선의 표시 여부와 조회 연도 범위를 설정한다.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { SelectBox } from "@common/business_components/ui";
|
||||
import {
|
||||
FLOOD_HISTORY_MIN_YEAR,
|
||||
normalizeFloodHistoryYearRange,
|
||||
type FloodHistoryYearRange,
|
||||
FLDNG_HSTRY_MIN_YEAR,
|
||||
type FldngHstryYearRange,
|
||||
} from "./fldngHstryTypes";
|
||||
import { normalizeFldngHstryYearRange } from "./fldngHstryUtils";
|
||||
import LayerSettingCard from "../layerSetting/LayerSettingCard";
|
||||
|
||||
type Props = {
|
||||
yearRange: FloodHistoryYearRange;
|
||||
yearRange: FldngHstryYearRange;
|
||||
enabled: boolean;
|
||||
onYearRangeChange: (yearRange: FloodHistoryYearRange) => void;
|
||||
onYearRangeChange: (yearRange: FldngHstryYearRange) => void;
|
||||
onEnabledChange: (enabled: boolean) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
@@ -23,10 +28,10 @@ export default function FldngHstryLayerSettingPanel({
|
||||
onEnabledChange,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const normalizedRange = normalizeFloodHistoryYearRange(yearRange);
|
||||
const normalizedRange = normalizeFldngHstryYearRange(yearRange);
|
||||
const currentYear = new Date().getFullYear();
|
||||
const yearOptions = Array.from(
|
||||
{ length: currentYear - FLOOD_HISTORY_MIN_YEAR + 1 },
|
||||
{ length: currentYear - FLDNG_HSTRY_MIN_YEAR + 1 },
|
||||
(_, index) => String(currentYear - index),
|
||||
);
|
||||
const startYearOptions = yearOptions
|
||||
@@ -36,11 +41,11 @@ export default function FldngHstryLayerSettingPanel({
|
||||
.filter((year) => Number(year) >= Number(normalizedRange.startYear))
|
||||
.map((year) => ({ val: year, text: year }));
|
||||
const yearSelectClass =
|
||||
"h-[20px] w-[62px] !rounded border border-input bg-background px-[1px] text-center text-[1.1rem] leading-none text-foreground";
|
||||
"!h-[26px] w-full !rounded border border-input bg-background px-2 py-0 text-center text-[12px] leading-none text-foreground";
|
||||
|
||||
const changeYear = (key: keyof FloodHistoryYearRange, value: string) => {
|
||||
const changeYear = (key: keyof FldngHstryYearRange, value: string) => {
|
||||
onYearRangeChange(
|
||||
normalizeFloodHistoryYearRange({
|
||||
normalizeFldngHstryYearRange({
|
||||
...normalizedRange,
|
||||
[key]: value,
|
||||
}),
|
||||
@@ -48,41 +53,16 @@ export default function FldngHstryLayerSettingPanel({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-visible rounded-[6px] border border-border bg-background shadow-blur">
|
||||
<div className="flex min-h-[28px] items-center justify-between border-b border-border px-2">
|
||||
<span className="text-[1.15rem] font-semibold text-label">
|
||||
침수흔적도
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
aria-label="침수년도 필터 적용 여부"
|
||||
title={enabled ? "침수년도 필터 적용 중" : "침수년도 필터 미적용"}
|
||||
className={`relative h-[16px] w-[28px] rounded-full border transition-colors ${enabled ? "border-primary bg-primary" : "border-[#94a3b8] bg-[#cbd5e1]"}`}
|
||||
onClick={() => onEnabledChange(!enabled)}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1/2 h-[12px] w-[12px] -translate-y-1/2 rounded-full bg-white shadow-sm transition-[left] ${enabled ? "left-[14px]" : "left-[2px]"}`}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="침수년도 설정 닫기"
|
||||
title="닫기"
|
||||
className="grid h-[20px] w-[20px] place-items-center bg-transparent text-[1.3rem] leading-none text-muted-foreground hover:text-foreground"
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-[30px] items-center gap-1.5 px-2 py-1 text-[1.15rem] font-medium text-label">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-primary" />
|
||||
<LayerSettingCard
|
||||
title="침수흔적도"
|
||||
enabled={enabled}
|
||||
onEnabledChange={onEnabledChange}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5 font-medium text-label">
|
||||
<span className="shrink-0">침수년도</span>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
||||
<div className="w-[62px]">
|
||||
<div className="flex w-full items-center gap-1.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<SelectBox
|
||||
className="w-full"
|
||||
selectClass={yearSelectClass}
|
||||
@@ -97,7 +77,7 @@ export default function FldngHstryLayerSettingPanel({
|
||||
<span className="px-[1px] text-[0.95rem] font-normal text-muted-foreground">
|
||||
~
|
||||
</span>
|
||||
<div className="w-[62px]">
|
||||
<div className="min-w-0 flex-1">
|
||||
<SelectBox
|
||||
className="w-full"
|
||||
selectClass={yearSelectClass}
|
||||
@@ -111,6 +91,6 @@ export default function FldngHstryLayerSettingPanel({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LayerSettingCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* 침수발생이력 popup
|
||||
* 화면명: 침수발생이력 목록
|
||||
* 침수발생이력을 검색하고 엑셀 다운로드 및 지도 위치 확인 기능을 제공한다.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
@@ -16,22 +17,24 @@ import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
|
||||
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
|
||||
import {
|
||||
FLOOD_HISTORY_LAYER_TYPE,
|
||||
FLDNG_HSTRY_LAYER_TYPE,
|
||||
FLDNG_HSTRY_DEFAULT_FILTERS,
|
||||
FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE,
|
||||
FLDNG_HSTRY_DOWNLOAD_HEADERS,
|
||||
FLDNG_HSTRY_LIST_HEADLIST,
|
||||
FLDNG_HSTRY_PAGE_SIZE,
|
||||
FLDNG_HSTRY_ROW_SELECT_MESSAGE,
|
||||
type FldngHstryApiItem,
|
||||
type FldngHstryFilters,
|
||||
type FldngHstryTableRow,
|
||||
} from "./fldngHstryTypes";
|
||||
import {
|
||||
attachFldngHstryGeometry,
|
||||
buildFldngHstryListQueryString,
|
||||
buildFldngHstryYearOptions,
|
||||
normalizeFldngHstryFilters,
|
||||
toFldngHstryTableRows,
|
||||
type FldngHstryApiItem,
|
||||
type FldngHstryFilters,
|
||||
type FldngHstryTableRow,
|
||||
} from "./fldngHstryTypes";
|
||||
} from "./fldngHstryUtils";
|
||||
|
||||
const unwrapPayload = <T,>(response: any): T =>
|
||||
(response?.data ?? response) as T;
|
||||
@@ -49,8 +52,8 @@ const resolveOptionText = (
|
||||
|
||||
const resolveFldngHstryMapFeatureFid = (row: FldngHstryTableRow) => {
|
||||
const fid = String(row.fid ?? "").trim();
|
||||
if (fid.startsWith(`${FLOOD_HISTORY_LAYER_TYPE}.`)) return fid;
|
||||
if (fid) return `${FLOOD_HISTORY_LAYER_TYPE}.${fid}`;
|
||||
if (fid.startsWith(`${FLDNG_HSTRY_LAYER_TYPE}.`)) return fid;
|
||||
if (fid) return `${FLDNG_HSTRY_LAYER_TYPE}.${fid}`;
|
||||
|
||||
return "";
|
||||
};
|
||||
@@ -122,11 +125,10 @@ export default function FldngHstryPopupPage() {
|
||||
|
||||
const fldngYearOptions = useMemo(() => buildFldngHstryYearOptions(), []);
|
||||
|
||||
|
||||
const sggOptions = useMemo(
|
||||
() =>
|
||||
filters.ctpvCd === "0"
|
||||
? sgg
|
||||
? []
|
||||
: sgg.filter((item) =>
|
||||
String(item.val).startsWith(filters.ctpvCd.slice(0, 2)),
|
||||
),
|
||||
@@ -156,11 +158,15 @@ export default function FldngHstryPopupPage() {
|
||||
...hydrated,
|
||||
ctpvNm:
|
||||
resolveOptionText(hydrated.ctpvCd ?? "", ctpv as any, 2) ||
|
||||
String((hydrated as any).ctpvNm ?? (hydrated as any).ctpv_nm ?? "").trim() ||
|
||||
String(
|
||||
(hydrated as any).ctpvNm ?? (hydrated as any).ctpv_nm ?? "",
|
||||
).trim() ||
|
||||
String(hydrated.ctpvCd ?? "").trim(),
|
||||
sggNm:
|
||||
resolveOptionText(hydrated.sggCd ?? "", sgg as any, 5) ||
|
||||
String((hydrated as any).sggNm ?? (hydrated as any).sgg_nm ?? "").trim() ||
|
||||
String(
|
||||
(hydrated as any).sggNm ?? (hydrated as any).sgg_nm ?? "",
|
||||
).trim() ||
|
||||
String(hydrated.sggCd ?? "").trim(),
|
||||
};
|
||||
});
|
||||
@@ -232,7 +238,7 @@ export default function FldngHstryPopupPage() {
|
||||
{
|
||||
type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SELECT_FEATURE,
|
||||
payload: {
|
||||
layerNm: FLOOD_HISTORY_LAYER_TYPE,
|
||||
layerNm: FLDNG_HSTRY_LAYER_TYPE,
|
||||
fid: mapFeatureFid,
|
||||
},
|
||||
},
|
||||
@@ -261,8 +267,7 @@ export default function FldngHstryPopupPage() {
|
||||
try {
|
||||
const response = await fetchAllPages({
|
||||
batchSize: FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE,
|
||||
fetchPage: (page, size) =>
|
||||
fetchRows(page + 1, appliedFilters, size),
|
||||
fetchPage: (page, size) => fetchRows(page + 1, appliedFilters, size),
|
||||
getItems: (page) => page.rows,
|
||||
getTotal: (page) => page.total,
|
||||
});
|
||||
@@ -361,7 +366,9 @@ export default function FldngHstryPopupPage() {
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<span className="whitespace-nowrap text-[1.3rem] text-label">강우시작일</span>
|
||||
<span className="whitespace-nowrap text-[1.3rem] text-label">
|
||||
강우시작일
|
||||
</span>
|
||||
<Input
|
||||
className="w-[11rem]"
|
||||
type="date"
|
||||
@@ -369,13 +376,32 @@ export default function FldngHstryPopupPage() {
|
||||
min={filters.floodYear ? `${filters.floodYear}-01-01` : undefined}
|
||||
max={filters.floodYear ? `${filters.floodYear}-12-31` : undefined}
|
||||
disabled={!filters.floodYear}
|
||||
onChange={(event) => setFilters((current) => ({ ...current, rainStartDate: event.target.value }))}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
rainStartDate: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<span className="whitespace-nowrap text-[1.3rem] text-label">침수지구명</span>
|
||||
<Input type="text" value={filters.districtName} onChange={(event) => setFilters((current) => ({ ...current, districtName: event.target.value }))} onKeyDown={(event) => { if (event.key === "Enter") void handleSearch(1); }} />
|
||||
<span className="whitespace-nowrap text-[1.3rem] text-label">
|
||||
침수지구명
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
value={filters.districtName}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
districtName: event.target.value,
|
||||
}))
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void handleSearch(1);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/** 침수흔적 강우분석과 연결된 심선·위선 목록 조회를 담당한다. */
|
||||
|
||||
import { getData } from "@/components/lib/api";
|
||||
|
||||
import type { FldngHstryDetailCacheValue } from "./fldngHstryTypes";
|
||||
|
||||
const normalizeFldngHstryRainAnalysisPayload = (data: any) => {
|
||||
if (!data || typeof data !== "object") return {};
|
||||
return {
|
||||
rain_bgng_tm: data.rainBgngTm ?? data.rain_bgng_tm ?? null,
|
||||
rainBgngTm: data.rainBgngTm ?? data.rain_bgng_tm ?? null,
|
||||
rain_end_tm: data.rainEndTm ?? data.rain_end_tm ?? null,
|
||||
rainEndTm: data.rainEndTm ?? data.rain_end_tm ?? null,
|
||||
rain_tot: data.rainTot ?? data.rain_tot ?? null,
|
||||
rainTot: data.rainTot ?? data.rain_tot ?? null,
|
||||
rain_str: data.rainStr ?? data.rain_str ?? null,
|
||||
rainStr: data.rainStr ?? data.rain_str ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
export const loadFldngHstryDetail = async (
|
||||
fldngHstryFid: string,
|
||||
): Promise<FldngHstryDetailCacheValue> => {
|
||||
const [rainResult, depthResult] = await Promise.allSettled([
|
||||
getData(`v1/fldng/hstry/rain-anls/${encodeURIComponent(fldngHstryFid)}`),
|
||||
getData(`v1/fldng/hstry/depth-map/${encodeURIComponent(fldngHstryFid)}`),
|
||||
]);
|
||||
|
||||
if (rainResult.status === "rejected") {
|
||||
console.warn("침수강우분석 조회 실패:", rainResult.reason);
|
||||
}
|
||||
if (depthResult.status === "rejected") {
|
||||
console.warn("침수깊이지도 조회 실패:", depthResult.reason);
|
||||
}
|
||||
|
||||
const depthPayload =
|
||||
depthResult.status === "fulfilled"
|
||||
? (depthResult.value?.data ?? depthResult.value)
|
||||
: null;
|
||||
const depthItems = Array.isArray(depthPayload)
|
||||
? depthPayload
|
||||
: depthPayload?.depthMaps;
|
||||
const waterLevelItems = Array.isArray(depthPayload?.waterLevelMaps)
|
||||
? depthPayload.waterLevelMaps
|
||||
: [];
|
||||
|
||||
return {
|
||||
rainAnalysis:
|
||||
rainResult.status === "fulfilled"
|
||||
? normalizeFldngHstryRainAnalysisPayload(rainResult.value)
|
||||
: {},
|
||||
fldngHstryDepthRows: Array.isArray(depthItems)
|
||||
? depthItems.filter((row) => row && typeof row === "object")
|
||||
: [],
|
||||
fldngHstryWtlvRows: waterLevelItems.filter(
|
||||
(row: unknown) => row && typeof row === "object",
|
||||
),
|
||||
complete:
|
||||
rainResult.status === "fulfilled" && depthResult.status === "fulfilled",
|
||||
};
|
||||
};
|
||||
@@ -1,62 +1,27 @@
|
||||
"use client";
|
||||
|
||||
/** 침수흔적 화면에서 사용하는 타입·상수와 화면 설정값을 정의한다. */
|
||||
import type { UiHeadProps } from "@common/business_components/ui";
|
||||
|
||||
export const FLOOD_HISTORY_POPUP_KEY = "fldngHstryPopup";
|
||||
export const FLDNG_HSTRY_POPUP_KEY = "fldngHstryPopup";
|
||||
export const FLDNG_HSTRY_ROW_SELECT_MESSAGE = "SN_FLOOD_HISTORY_ROW_SELECT";
|
||||
export const FLOOD_HISTORY_LAYER_TYPE = "tb_ms_fldng_wtlv_map";
|
||||
export const FLOOD_HISTORY_DEPTH_LAYER_TYPE = "tb_ms_fldng_dpth_map";
|
||||
export const FLOOD_HISTORY_PREVIEW_LAYER_TYPE = "tb_sn_flood_trace_mock";
|
||||
export const FLOOD_HISTORY_LAYER_NAME = "침수흔적위선";
|
||||
export const FLOOD_HISTORY_DEPTH_LAYER_NAME = "침수흔적심선";
|
||||
export const FLOOD_HISTORY_ROOT_NAME = "침수흔적도";
|
||||
export const FLOOD_HISTORY_AREA_LAYER_ID = "SN_FLOOD_HISTORY_AREA";
|
||||
export const FLOOD_HISTORY_DEPTH_LAYER_ID = "SN_FLOOD_HISTORY_DEPTH";
|
||||
export const FLDNG_HSTRY_LAYER_TYPE = "tb_ms_fldng_wtlv_map";
|
||||
export const FLDNG_HSTRY_DEPTH_LAYER_TYPE = "tb_ms_fldng_dpth_map";
|
||||
export const FLDNG_HSTRY_PREVIEW_LAYER_TYPE = "tb_sn_flood_trace_mock";
|
||||
export const FLDNG_HSTRY_LAYER_NAME = "침수흔적위선";
|
||||
export const FLDNG_HSTRY_DEPTH_LAYER_NAME = "침수흔적심선";
|
||||
export const FLDNG_HSTRY_ROOT_NAME = "침수흔적도";
|
||||
export const FLDNG_HSTRY_AREA_LAYER_ID = "SN_FLOOD_HISTORY_AREA";
|
||||
export const FLDNG_HSTRY_DEPTH_LAYER_ID = "SN_FLOOD_HISTORY_DEPTH";
|
||||
export const FLDNG_HSTRY_PAGE_SIZE = 10;
|
||||
export const FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE = 2000;
|
||||
export const FLOOD_HISTORY_MIN_YEAR = 2002;
|
||||
export const FLDNG_HSTRY_MIN_YEAR = 2002;
|
||||
|
||||
/** 지도 침수흔적위선·심선이 공통으로 사용하는 침수년도 범위다. */
|
||||
export type FloodHistoryYearRange = {
|
||||
startYear: string;
|
||||
endYear: string;
|
||||
export type FldngHstryDetailCacheValue = {
|
||||
rainAnalysis: Record<string, unknown>;
|
||||
fldngHstryDepthRows: Record<string, unknown>[];
|
||||
fldngHstryWtlvRows: Record<string, unknown>[];
|
||||
complete: boolean;
|
||||
};
|
||||
|
||||
/** 침수년도 설정의 초기 범위는 데이터 최소연도부터 현재년도까지다. */
|
||||
export const buildFloodHistoryDefaultYearRange = (
|
||||
currentYear = new Date().getFullYear(),
|
||||
): FloodHistoryYearRange => ({
|
||||
startYear: String(FLOOD_HISTORY_MIN_YEAR),
|
||||
endYear: String(currentYear),
|
||||
});
|
||||
|
||||
/** 연도 설정값을 4자리 숫자로 정규화하고 유효하지 않으면 기본 범위를 사용한다. */
|
||||
export const normalizeFloodHistoryYearRange = (
|
||||
range?: Partial<FloodHistoryYearRange> | null,
|
||||
fallback: FloodHistoryYearRange = buildFloodHistoryDefaultYearRange(),
|
||||
): FloodHistoryYearRange => {
|
||||
const normalizeYear = (value: unknown) =>
|
||||
String(value ?? "")
|
||||
.replace(/[^0-9]/g, "")
|
||||
.slice(0, 4);
|
||||
const startYear = normalizeYear(range?.startYear);
|
||||
const endYear = normalizeYear(range?.endYear);
|
||||
return {
|
||||
startYear: /^\d{4}$/.test(startYear) ? startYear : fallback.startYear,
|
||||
endYear: /^\d{4}$/.test(endYear) ? endYear : fallback.endYear,
|
||||
};
|
||||
};
|
||||
|
||||
/** 위선·심선 WFS에 동일하게 결합할 침수년도 CQL 조건을 생성한다. */
|
||||
export const buildFloodHistoryYearCql = (
|
||||
range: FloodHistoryYearRange,
|
||||
enabled: boolean,
|
||||
) => {
|
||||
if (!enabled) return "";
|
||||
const normalized = normalizeFloodHistoryYearRange(range);
|
||||
return `(fldng_yr >= '${normalized.startYear}' AND fldng_yr <= '${normalized.endYear}')`;
|
||||
};
|
||||
|
||||
export type FldngHstryYearRange = { startYear: string; endYear: string };
|
||||
export type FldngHstryFilters = {
|
||||
ctpvCd: string;
|
||||
sggCd: string;
|
||||
@@ -65,47 +30,44 @@ export type FldngHstryFilters = {
|
||||
rainStartDate: string;
|
||||
districtName: string;
|
||||
};
|
||||
|
||||
export type FldngHstryDepthZone = {
|
||||
zoneId: string;
|
||||
depthLabel: string;
|
||||
color: string;
|
||||
ring: [number, number][];
|
||||
};
|
||||
|
||||
export type FldngHstryApiItem = {
|
||||
fid: string; // FID
|
||||
fldngMapFid?: string; // 침수흔적도지도 FID
|
||||
fldngWtlvMapSn?: number | null; // 침수흔적도지도 일련번호
|
||||
taskClCd?: string; // 업무 구분 코드
|
||||
fldngDstrNm?: string; // 침수재해명
|
||||
exmnYr?: string; // 조사년도
|
||||
avgFldngWtlv?: number | null; // 평균침수위
|
||||
fldngArea?: number | null; // 침수면적
|
||||
dtlOcrnCsCn?: string; // 상세발생원인내용
|
||||
stdgCd?: string; // 표준지역코드
|
||||
ctpvCd?: string; // 시도코드
|
||||
sggCd?: string; // 시군구코드
|
||||
dataSn?: number | null; // 데이터 일련번호
|
||||
bgngYmd?: string; // 시작일자
|
||||
endYmd?: string; // 종료일자
|
||||
fldngBgngHm?: string; // 침수시작시분
|
||||
fldngEndHm?: string; // 침수종료시분
|
||||
fldngCsDtlCd?: string; // 침수원인상세코드
|
||||
fldngDstrctNm?: string; // 침수지구명
|
||||
damAmt?: number | null; // 피해금액
|
||||
fldngHr?: number | null; // 침수시간
|
||||
fldngYr?: string; // 침수년도
|
||||
cstrnFbctnYn?: string; // 공사여부
|
||||
atchFileSn?: number | null; // 첨부파일 일련번호
|
||||
confArea?: number | null; // 확정면적
|
||||
confLen?: number | null; // 확정연장
|
||||
rainBgngTm?: string | null; // 강우시작일시
|
||||
rainEndTm?: string | null; // 강우종료일시
|
||||
rainTot?: number | null; // 총강우량
|
||||
rainStr?: number | null; // 강우강도
|
||||
fid: string;
|
||||
fldngMapFid?: string;
|
||||
fldngWtlvMapSn?: number | null;
|
||||
taskClCd?: string;
|
||||
fldngDstrNm?: string;
|
||||
exmnYr?: string;
|
||||
avgFldngWtlv?: number | null;
|
||||
fldngArea?: number | null;
|
||||
dtlOcrnCsCn?: string;
|
||||
stdgCd?: string;
|
||||
ctpvCd?: string;
|
||||
sggCd?: string;
|
||||
dataSn?: number | null;
|
||||
bgngYmd?: string;
|
||||
endYmd?: string;
|
||||
fldngBgngHm?: string;
|
||||
fldngEndHm?: string;
|
||||
fldngCsDtlCd?: string;
|
||||
fldngDstrctNm?: string;
|
||||
damAmt?: number | null;
|
||||
fldngHr?: number | null;
|
||||
fldngYr?: string;
|
||||
cstrnFbctnYn?: string;
|
||||
atchFileSn?: number | null;
|
||||
confArea?: number | null;
|
||||
confLen?: number | null;
|
||||
rainBgngTm?: string | null;
|
||||
rainEndTm?: string | null;
|
||||
rainTot?: number | null;
|
||||
rainStr?: number | null;
|
||||
};
|
||||
|
||||
export type FldngHstryItem = FldngHstryApiItem & {
|
||||
historyId: string;
|
||||
ctpvNm: string;
|
||||
@@ -135,7 +97,6 @@ export type FldngHstryItem = FldngHstryApiItem & {
|
||||
areaRing: [number, number][];
|
||||
depthZones: FldngHstryDepthZone[];
|
||||
};
|
||||
|
||||
export type FldngHstryTableRow = FldngHstryItem & {
|
||||
rowNo: number;
|
||||
floodYearDisplay: string;
|
||||
@@ -158,7 +119,6 @@ export const FLDNG_HSTRY_DEFAULT_FILTERS: FldngHstryFilters = {
|
||||
rainStartDate: "",
|
||||
districtName: "",
|
||||
};
|
||||
|
||||
export const FLDNG_HSTRY_DEPTH_LEGEND_ITEMS = [
|
||||
{ color: "#fff27a", label: "0.0~0.5m" },
|
||||
{ color: "#c2f13d", label: "0.5~1.0m" },
|
||||
@@ -166,7 +126,6 @@ export const FLDNG_HSTRY_DEPTH_LEGEND_ITEMS = [
|
||||
{ color: "#8f8bff", label: "2.0~5.0m" },
|
||||
{ color: "#f2528b", label: "5.0m 이상" },
|
||||
] as const;
|
||||
|
||||
export const FLDNG_HSTRY_LIST_HEADLIST: UiHeadProps[] = [
|
||||
{ id: "rowNo", title: "순번", widthPercent: 3 },
|
||||
{ id: "floodYearDisplay", title: "침수\n년도", widthPercent: 4 },
|
||||
@@ -180,11 +139,10 @@ export const FLDNG_HSTRY_LIST_HEADLIST: UiHeadProps[] = [
|
||||
{ id: "floodHourDisplay", title: "침수시간", widthPercent: 7 },
|
||||
{ id: "districtName", title: "침수지구명", widthPercent: 10 },
|
||||
{ id: "avgFloodDepthDisplay", title: "평균\n침수위", widthPercent: 7 },
|
||||
{ id: "floodAreaDisplay", title: "침수\n면적\(ha)", widthPercent: 7 },
|
||||
{ id: "floodAreaDisplay", title: "침수\n면적(ha)", widthPercent: 7 },
|
||||
{ id: "exmnYearDisplay", title: "조사년도", widthPercent: 6 },
|
||||
{ id: "damageAmountDisplay", title: "피해금액", widthPercent: 9 },
|
||||
];
|
||||
|
||||
export const FLDNG_HSTRY_DOWNLOAD_HEADERS: Record<string, string> = {
|
||||
rowNo: "순번",
|
||||
floodYearDisplay: "침수년도",
|
||||
@@ -204,373 +162,3 @@ export const FLDNG_HSTRY_DOWNLOAD_HEADERS: Record<string, string> = {
|
||||
exmnYearDisplay: "조사년도",
|
||||
damageAmountDisplay: "피해금액",
|
||||
};
|
||||
|
||||
const toRadian = (degree: number) => (degree * Math.PI) / 180;
|
||||
|
||||
const createIrregularRing = ({
|
||||
centerX,
|
||||
centerY,
|
||||
radiusX,
|
||||
radiusY,
|
||||
scales,
|
||||
rotationDeg = 0,
|
||||
}: {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
radiusX: number;
|
||||
radiusY: number;
|
||||
scales: number[];
|
||||
rotationDeg?: number;
|
||||
}): [number, number][] => {
|
||||
const rotation = toRadian(rotationDeg);
|
||||
const points = scales.map((scale, index) => {
|
||||
const angle = rotation + (Math.PI * 2 * index) / scales.length;
|
||||
const jitterX = radiusX * scale * Math.cos(angle);
|
||||
const jitterY = radiusY * scale * Math.sin(angle);
|
||||
return [centerX + jitterX, centerY + jitterY] as [number, number];
|
||||
});
|
||||
|
||||
return points.length > 0 ? [...points, points[0]!] : [];
|
||||
};
|
||||
|
||||
const toNumericSeed = (value: string) =>
|
||||
Array.from(value).reduce(
|
||||
(sum, char, index) => sum + char.charCodeAt(0) * (index + 1),
|
||||
0,
|
||||
);
|
||||
|
||||
const buildGeometrySeed = (fid: string) => {
|
||||
const seed = toNumericSeed(fid);
|
||||
return {
|
||||
centerX: 1050000 + (seed % 120000),
|
||||
centerY: 1680000 + (seed % 150000),
|
||||
width: 14000 + (seed % 9000),
|
||||
height: 11000 + (seed % 7000),
|
||||
};
|
||||
};
|
||||
|
||||
const createFldngHstryDepthZones = (
|
||||
historyId: string,
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
): FldngHstryDepthZone[] => {
|
||||
return [
|
||||
{
|
||||
zoneId: `${historyId}-depth-1`,
|
||||
depthLabel: "0.0~0.5m",
|
||||
color: "#fff27a",
|
||||
ring: createIrregularRing({
|
||||
centerX: centerX - 2600,
|
||||
centerY: centerY - 1200,
|
||||
radiusX: 2900,
|
||||
radiusY: 2300,
|
||||
rotationDeg: -18,
|
||||
scales: [1.02, 0.78, 0.9, 1.08, 0.86, 0.72, 0.94, 1.04],
|
||||
}),
|
||||
},
|
||||
{
|
||||
zoneId: `${historyId}-depth-2`,
|
||||
depthLabel: "0.5~1.0m",
|
||||
color: "#c2f13d",
|
||||
ring: createIrregularRing({
|
||||
centerX: centerX + 1800,
|
||||
centerY: centerY - 600,
|
||||
radiusX: 3500,
|
||||
radiusY: 2800,
|
||||
rotationDeg: 12,
|
||||
scales: [1.04, 0.88, 1.02, 0.8, 0.92, 1.08, 0.84, 0.95],
|
||||
}),
|
||||
},
|
||||
{
|
||||
zoneId: `${historyId}-depth-3`,
|
||||
depthLabel: "1.0~2.0m",
|
||||
color: "#67d8ff",
|
||||
ring: createIrregularRing({
|
||||
centerX,
|
||||
centerY: centerY + 1800,
|
||||
radiusX: 4700,
|
||||
radiusY: 3900,
|
||||
rotationDeg: 8,
|
||||
scales: [1.12, 0.86, 0.94, 1.06, 0.82, 0.97, 1.04, 0.9, 1.08, 0.84],
|
||||
}),
|
||||
},
|
||||
{
|
||||
zoneId: `${historyId}-depth-4`,
|
||||
depthLabel: "2.0~5.0m",
|
||||
color: "#8f8bff",
|
||||
ring: createIrregularRing({
|
||||
centerX: centerX + 3200,
|
||||
centerY: centerY + 3200,
|
||||
radiusX: 2600,
|
||||
radiusY: 2000,
|
||||
rotationDeg: 26,
|
||||
scales: [1.05, 0.82, 1.08, 0.88, 0.96, 0.78, 1.02, 0.9],
|
||||
}),
|
||||
},
|
||||
{
|
||||
zoneId: `${historyId}-depth-5`,
|
||||
depthLabel: "5.0m 이상",
|
||||
color: "#f2528b",
|
||||
ring: createIrregularRing({
|
||||
centerX: centerX + 400,
|
||||
centerY: centerY - 3600,
|
||||
radiusX: 1200,
|
||||
radiusY: 1050,
|
||||
rotationDeg: -10,
|
||||
scales: [1.0, 0.8, 1.06, 0.84, 0.94, 0.76, 0.98],
|
||||
}),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const normalizeDate = (value?: string | null) => {
|
||||
const source = String(value ?? "").trim();
|
||||
if (!/^\d{8}$/.test(source)) return "";
|
||||
return `${source.slice(0, 4)}-${source.slice(4, 6)}-${source.slice(6, 8)}`;
|
||||
};
|
||||
|
||||
const normalizeHm = (value?: string | null) => {
|
||||
const digits = String(value ?? "").replace(/[^0-9]/g, "");
|
||||
if (!digits) return "";
|
||||
if (digits.length === 1) return `0${digits}:00`;
|
||||
if (digits.length === 2) return `${digits}:00`;
|
||||
if (digits.length === 3)
|
||||
return `0${digits.slice(0, 1)}:${digits.slice(1, 3)}`;
|
||||
return `${digits.slice(0, 2)}:${digits.slice(2, 4)}`;
|
||||
};
|
||||
|
||||
const normalizeDateTime = (value?: string | null) => {
|
||||
const source = String(value ?? "").trim();
|
||||
if (!source) return "";
|
||||
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(source)) return source;
|
||||
const date = new Date(source);
|
||||
if (Number.isNaN(date.getTime()))
|
||||
return source.slice(0, 16).replace("T", " ");
|
||||
const yyyy = date.getFullYear();
|
||||
const mm = `${date.getMonth() + 1}`.padStart(2, "0");
|
||||
const dd = `${date.getDate()}`.padStart(2, "0");
|
||||
const hh = `${date.getHours()}`.padStart(2, "0");
|
||||
const mi = `${date.getMinutes()}`.padStart(2, "0");
|
||||
return `${yyyy}-${mm}-${dd} ${hh}:${mi}`;
|
||||
};
|
||||
|
||||
const formatNumber = (value?: number | null, digits = 1, suffix = "") => {
|
||||
const numeric = Number(value ?? 0);
|
||||
if (!Number.isFinite(numeric)) return `0${suffix}`;
|
||||
return `${numeric.toFixed(digits)}${suffix}`;
|
||||
};
|
||||
|
||||
const formatInteger = (value?: number | null, suffix = "") => {
|
||||
const numeric = Number(value ?? 0);
|
||||
if (!Number.isFinite(numeric)) return `0${suffix}`;
|
||||
return `${numeric.toLocaleString("ko-KR")}${suffix}`;
|
||||
};
|
||||
|
||||
const formatOptionalNumber = (
|
||||
value?: number | null,
|
||||
digits = 1,
|
||||
suffix = "",
|
||||
) => {
|
||||
if (value === null || value === undefined) return "-";
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return "-";
|
||||
return `${numeric.toFixed(digits)}${suffix}`;
|
||||
};
|
||||
|
||||
const buildCauseDetailName = (item: FldngHstryApiItem) => {
|
||||
const direct = String(item.dtlOcrnCsCn ?? "").trim();
|
||||
if (direct) return direct;
|
||||
const code = String(item.fldngCsDtlCd ?? "").trim();
|
||||
return code ? `원인코드 ${code}` : "-";
|
||||
};
|
||||
|
||||
const buildGradeLabel = (avgFloodDepthM: number) => {
|
||||
if (avgFloodDepthM >= 2) return "3등급";
|
||||
if (avgFloodDepthM >= 1) return "2등급";
|
||||
return "1등급";
|
||||
};
|
||||
|
||||
export const normalizeFldngHstryFilters = (
|
||||
filters: FldngHstryFilters,
|
||||
): FldngHstryFilters => ({
|
||||
ctpvCd: String(filters.ctpvCd ?? "0") || "0",
|
||||
sggCd: String(filters.sggCd ?? "0") || "0",
|
||||
floodYear: String(filters.floodYear ?? "")
|
||||
.replace(/[^0-9]/g, "")
|
||||
.slice(0, 4),
|
||||
disasterName: String(filters.disasterName ?? "").trim(),
|
||||
rainStartDate: String(filters.rainStartDate ?? "").trim(),
|
||||
districtName: String(filters.districtName ?? "").trim(),
|
||||
});
|
||||
|
||||
export const buildFldngHstryYearOptions = () => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
return Array.from({ length: 30 }, (_, index) => String(currentYear - index));
|
||||
};
|
||||
|
||||
export const buildFldngHstryListQueryString = (
|
||||
filters: FldngHstryFilters,
|
||||
page: number,
|
||||
size: number,
|
||||
) => {
|
||||
const normalized = normalizeFldngHstryFilters(filters);
|
||||
const params = new URLSearchParams({
|
||||
page: String(page - 1),
|
||||
size: String(size),
|
||||
});
|
||||
|
||||
if (normalized.ctpvCd !== "0")
|
||||
params.set("ctpvCd", normalized.ctpvCd.slice(0, 2));
|
||||
if (normalized.sggCd !== "0")
|
||||
params.set("sggCd", normalized.sggCd.slice(0, 5));
|
||||
if (normalized.floodYear) params.set("fldngYr", normalized.floodYear);
|
||||
if (normalized.disasterName)
|
||||
params.set("fldngDstrNm", normalized.disasterName);
|
||||
if (normalized.rainStartDate)
|
||||
params.set("rainBgngTm", normalized.rainStartDate);
|
||||
if (normalized.districtName)
|
||||
params.set("fldngDstrctNm", normalized.districtName);
|
||||
return params.toString();
|
||||
};
|
||||
|
||||
export const formatFloodArea = (value: number) =>
|
||||
Number(value ?? 0).toLocaleString("ko-KR", {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
export const formatFloodDepth = (value: number) => value.toFixed(2);
|
||||
|
||||
export const attachFldngHstryGeometry = (
|
||||
item: FldngHstryApiItem,
|
||||
): FldngHstryItem => {
|
||||
const historyId = String(item.fid ?? "");
|
||||
const geometrySeed = buildGeometrySeed(historyId);
|
||||
const startDate = normalizeDate(item.bgngYmd);
|
||||
const endDate = normalizeDate(item.endYmd);
|
||||
const startTime = normalizeHm(item.fldngBgngHm);
|
||||
const endTime = normalizeHm(item.fldngEndHm);
|
||||
const avgFloodDepthM = Number(item.avgFldngWtlv ?? 0);
|
||||
const floodAreaHa = Number(item.fldngArea ?? 0);
|
||||
|
||||
return {
|
||||
...item,
|
||||
historyId,
|
||||
ctpvNm: "",
|
||||
sggNm: "",
|
||||
floodYear: String(item.fldngYr ?? "").trim(),
|
||||
exmnYear: String(item.exmnYr ?? "").trim(),
|
||||
disasterName: String(item.fldngDstrNm ?? "").trim(),
|
||||
districtName: String(item.fldngDstrctNm ?? "").trim(),
|
||||
floodAreaHa,
|
||||
avgFloodDepthM,
|
||||
floodDepthM: avgFloodDepthM,
|
||||
gradeLabel: buildGradeLabel(avgFloodDepthM),
|
||||
startDate,
|
||||
endDate,
|
||||
startTime,
|
||||
endTime,
|
||||
floodHour: formatNumber(Number(item.fldngHr ?? 0), 1),
|
||||
causeDetailCode: String(item.fldngCsDtlCd ?? "").trim(),
|
||||
causeDetailName: buildCauseDetailName(item),
|
||||
damageAmount: formatInteger(Number(item.damAmt ?? 0)),
|
||||
rainStartDateTime: normalizeDateTime(item.rainBgngTm),
|
||||
rainEndDateTime: normalizeDateTime(item.rainEndTm),
|
||||
rainStartTime: normalizeDateTime(item.rainBgngTm).slice(11, 16),
|
||||
rainEndTime: normalizeDateTime(item.rainEndTm).slice(11, 16),
|
||||
totalRainfallMm: Number(item.rainTot ?? 0),
|
||||
rainfallIntensityMmHr: Number(item.rainStr ?? 0),
|
||||
areaRing: createIrregularRing({
|
||||
centerX: geometrySeed.centerX,
|
||||
centerY: geometrySeed.centerY,
|
||||
radiusX: geometrySeed.width / 2,
|
||||
radiusY: geometrySeed.height / 2,
|
||||
rotationDeg: 9,
|
||||
scales: [1.08, 0.9, 1.04, 0.82, 0.98, 1.12, 0.88, 0.96, 1.03, 0.86],
|
||||
}),
|
||||
depthZones: createFldngHstryDepthZones(
|
||||
historyId,
|
||||
geometrySeed.centerX,
|
||||
geometrySeed.centerY,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
export const toFldngHstryTableRows = (
|
||||
items: FldngHstryItem[],
|
||||
page: number,
|
||||
pageSize = FLDNG_HSTRY_PAGE_SIZE,
|
||||
): FldngHstryTableRow[] =>
|
||||
items.map((item, index) => ({
|
||||
...item,
|
||||
rowNo: (page - 1) * pageSize + index + 1,
|
||||
floodYearDisplay: item.floodYear || "-",
|
||||
floodHourDisplay: item.floodHour || "-",
|
||||
avgFloodDepthDisplay: formatFloodDepth(item.avgFloodDepthM),
|
||||
floodAreaDisplay: formatFloodArea(item.floodAreaHa),
|
||||
exmnYearDisplay: item.exmnYear || "-",
|
||||
damageAmountDisplay: item.damageAmount || "-",
|
||||
rainBgngTmDisplay: item.rainStartDateTime || "-",
|
||||
rainEndTmDisplay: item.rainEndDateTime || "-",
|
||||
rainTotDisplay: formatOptionalNumber(item.rainTot, 1),
|
||||
rainStrDisplay: formatOptionalNumber(item.rainStr, 1),
|
||||
}));
|
||||
|
||||
const resolveFldngHstryNodeName = (node: any) =>
|
||||
String(
|
||||
node?.text ??
|
||||
node?.data?.lyrNm ??
|
||||
node?.data?.name ??
|
||||
node?.lyrNm ??
|
||||
node?.name ??
|
||||
"",
|
||||
).trim();
|
||||
|
||||
const resolveFldngHstryLayerType = (node: any) =>
|
||||
String(
|
||||
node?.data?.lyrTyNm ??
|
||||
node?.data?.lyrTypeNm ??
|
||||
node?.lyrTyNm ??
|
||||
node?.lyrTypeNm ??
|
||||
"",
|
||||
).trim();
|
||||
|
||||
export const isFldngHstryNode = (node: any) => {
|
||||
const name = resolveFldngHstryNodeName(node);
|
||||
const layerType = resolveFldngHstryLayerType(node);
|
||||
return (
|
||||
name === FLOOD_HISTORY_LAYER_NAME ||
|
||||
name === FLOOD_HISTORY_DEPTH_LAYER_NAME ||
|
||||
layerType === FLOOD_HISTORY_LAYER_TYPE ||
|
||||
layerType === FLOOD_HISTORY_DEPTH_LAYER_TYPE ||
|
||||
layerType === FLOOD_HISTORY_PREVIEW_LAYER_TYPE
|
||||
);
|
||||
};
|
||||
|
||||
/** 침수흔적위선 체크 상태를 심선과 분리해 판별한다. */
|
||||
export const isFloodHistoryWaterLevelNode = (node: any) => {
|
||||
const name = resolveFldngHstryNodeName(node);
|
||||
const layerType = resolveFldngHstryLayerType(node);
|
||||
return (
|
||||
name === FLOOD_HISTORY_LAYER_NAME || layerType === FLOOD_HISTORY_LAYER_TYPE
|
||||
);
|
||||
};
|
||||
|
||||
/** 침수흔적심선 체크 상태를 위선과 분리해 판별한다. */
|
||||
export const isFloodHistoryDepthNode = (node: any) => {
|
||||
const name = resolveFldngHstryNodeName(node);
|
||||
const layerType = resolveFldngHstryLayerType(node);
|
||||
return (
|
||||
name === FLOOD_HISTORY_DEPTH_LAYER_NAME ||
|
||||
layerType === FLOOD_HISTORY_DEPTH_LAYER_TYPE
|
||||
);
|
||||
};
|
||||
|
||||
export const isFldngHstryRootNode = (node: any) =>
|
||||
resolveFldngHstryNodeName(node) === FLOOD_HISTORY_ROOT_NAME;
|
||||
|
||||
export const isFloodHistoryNode = isFldngHstryNode;
|
||||
export const FLOOD_HISTORY_ROW_SELECT_MESSAGE = FLDNG_HSTRY_ROW_SELECT_MESSAGE;
|
||||
export const FLOOD_HISTORY_PAGE_SIZE = FLDNG_HSTRY_PAGE_SIZE;
|
||||
export const FLOOD_HISTORY_DOWNLOAD_BATCH_SIZE =
|
||||
FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE;
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
/** 침수흔적 검색조건·표시값·geometry·레이어 판별 변환을 제공한다. */
|
||||
|
||||
import { appendApiSearchParams } from "@/shared/util/util";
|
||||
|
||||
import {
|
||||
FLDNG_HSTRY_PAGE_SIZE,
|
||||
FLDNG_HSTRY_DEPTH_LAYER_NAME,
|
||||
FLDNG_HSTRY_DEPTH_LAYER_TYPE,
|
||||
FLDNG_HSTRY_LAYER_NAME,
|
||||
FLDNG_HSTRY_LAYER_TYPE,
|
||||
FLDNG_HSTRY_MIN_YEAR,
|
||||
FLDNG_HSTRY_PREVIEW_LAYER_TYPE,
|
||||
FLDNG_HSTRY_ROOT_NAME,
|
||||
type FldngHstryApiItem,
|
||||
type FldngHstryDepthZone,
|
||||
type FldngHstryFilters,
|
||||
type FldngHstryItem,
|
||||
type FldngHstryTableRow,
|
||||
type FldngHstryYearRange,
|
||||
} from "./fldngHstryTypes";
|
||||
|
||||
export const normalizeFldngHstryWtlvDisplayId = (value: unknown) => {
|
||||
const normalized = String(value ?? "").trim();
|
||||
const prefix = `${FLDNG_HSTRY_LAYER_TYPE}.`;
|
||||
return normalized.startsWith(prefix)
|
||||
? normalized.slice(prefix.length)
|
||||
: normalized;
|
||||
};
|
||||
|
||||
export const normalizeFldngHstryWtlvApiFid = (value: unknown) => {
|
||||
const normalized = String(value ?? "").trim();
|
||||
return normalized ? normalizeFldngHstryWtlvDisplayId(normalized) : "";
|
||||
};
|
||||
|
||||
export const formatFldngHstryDateTime = (
|
||||
dateValue: unknown,
|
||||
timeValue: unknown,
|
||||
) => {
|
||||
const rawDate = String(dateValue ?? "").trim();
|
||||
const rawTime = String(timeValue ?? "").trim();
|
||||
const normalizedDate = /^\d{8}$/.test(rawDate)
|
||||
? `${rawDate.slice(0, 4)}-${rawDate.slice(4, 6)}-${rawDate.slice(6, 8)}`
|
||||
: rawDate;
|
||||
const timeDigits = rawTime.replace(/[^0-9]/g, "");
|
||||
const normalizedTime = !timeDigits
|
||||
? ""
|
||||
: timeDigits.length <= 2
|
||||
? `${timeDigits.padStart(2, "0")}:00`
|
||||
: `${timeDigits.padStart(4, "0").slice(0, 2)}:${timeDigits.padStart(4, "0").slice(2, 4)}`;
|
||||
return [normalizedDate, normalizedTime].filter(Boolean).join(" ");
|
||||
};
|
||||
|
||||
export const buildFldngHstryDefaultYearRange = (
|
||||
currentYear = new Date().getFullYear(),
|
||||
): FldngHstryYearRange => ({
|
||||
startYear: String(FLDNG_HSTRY_MIN_YEAR),
|
||||
endYear: String(currentYear),
|
||||
});
|
||||
|
||||
export const normalizeFldngHstryYearRange = (
|
||||
range?: Partial<FldngHstryYearRange> | null,
|
||||
fallback: FldngHstryYearRange = buildFldngHstryDefaultYearRange(),
|
||||
): FldngHstryYearRange => {
|
||||
const normalizeYear = (value: unknown) =>
|
||||
String(value ?? "")
|
||||
.replace(/[^0-9]/g, "")
|
||||
.slice(0, 4);
|
||||
const startYear = normalizeYear(range?.startYear);
|
||||
const endYear = normalizeYear(range?.endYear);
|
||||
return {
|
||||
startYear: /^\d{4}$/.test(startYear) ? startYear : fallback.startYear,
|
||||
endYear: /^\d{4}$/.test(endYear) ? endYear : fallback.endYear,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildFldngHstryYearCql = (
|
||||
range: FldngHstryYearRange,
|
||||
enabled: boolean,
|
||||
) => {
|
||||
if (!enabled) return "";
|
||||
const normalized = normalizeFldngHstryYearRange(range);
|
||||
return `(fldng_yr >= '${normalized.startYear}' AND fldng_yr <= '${normalized.endYear}')`;
|
||||
};
|
||||
|
||||
const toRadian = (degree: number) => (degree * Math.PI) / 180;
|
||||
|
||||
const createIrregularRing = ({
|
||||
centerX,
|
||||
centerY,
|
||||
radiusX,
|
||||
radiusY,
|
||||
scales,
|
||||
rotationDeg = 0,
|
||||
}: {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
radiusX: number;
|
||||
radiusY: number;
|
||||
scales: number[];
|
||||
rotationDeg?: number;
|
||||
}): [number, number][] => {
|
||||
const rotation = toRadian(rotationDeg);
|
||||
const points = scales.map((scale, index) => {
|
||||
const angle = rotation + (Math.PI * 2 * index) / scales.length;
|
||||
return [
|
||||
centerX + radiusX * scale * Math.cos(angle),
|
||||
centerY + radiusY * scale * Math.sin(angle),
|
||||
] as [number, number];
|
||||
});
|
||||
return points.length > 0 ? [...points, points[0]!] : [];
|
||||
};
|
||||
|
||||
const toNumericSeed = (value: string) =>
|
||||
Array.from(value).reduce(
|
||||
(sum, char, index) => sum + char.charCodeAt(0) * (index + 1),
|
||||
0,
|
||||
);
|
||||
|
||||
const buildGeometrySeed = (fid: string) => {
|
||||
const seed = toNumericSeed(fid);
|
||||
return {
|
||||
centerX: 1050000 + (seed % 120000),
|
||||
centerY: 1680000 + (seed % 150000),
|
||||
width: 14000 + (seed % 9000),
|
||||
height: 11000 + (seed % 7000),
|
||||
};
|
||||
};
|
||||
|
||||
const createFldngHstryDepthZones = (
|
||||
historyId: string,
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
): FldngHstryDepthZone[] => [
|
||||
{
|
||||
zoneId: `${historyId}-depth-1`,
|
||||
depthLabel: "0.0~0.5m",
|
||||
color: "#fff27a",
|
||||
ring: createIrregularRing({
|
||||
centerX: centerX - 2600,
|
||||
centerY: centerY - 1200,
|
||||
radiusX: 2900,
|
||||
radiusY: 2300,
|
||||
rotationDeg: -18,
|
||||
scales: [1.02, 0.78, 0.9, 1.08, 0.86, 0.72, 0.94, 1.04],
|
||||
}),
|
||||
},
|
||||
{
|
||||
zoneId: `${historyId}-depth-2`,
|
||||
depthLabel: "0.5~1.0m",
|
||||
color: "#c2f13d",
|
||||
ring: createIrregularRing({
|
||||
centerX: centerX + 1800,
|
||||
centerY: centerY - 600,
|
||||
radiusX: 3500,
|
||||
radiusY: 2800,
|
||||
rotationDeg: 12,
|
||||
scales: [1.04, 0.88, 1.02, 0.8, 0.92, 1.08, 0.84, 0.95],
|
||||
}),
|
||||
},
|
||||
{
|
||||
zoneId: `${historyId}-depth-3`,
|
||||
depthLabel: "1.0~2.0m",
|
||||
color: "#67d8ff",
|
||||
ring: createIrregularRing({
|
||||
centerX,
|
||||
centerY: centerY + 1800,
|
||||
radiusX: 4700,
|
||||
radiusY: 3900,
|
||||
rotationDeg: 8,
|
||||
scales: [1.12, 0.86, 0.94, 1.06, 0.82, 0.97, 1.04, 0.9, 1.08, 0.84],
|
||||
}),
|
||||
},
|
||||
{
|
||||
zoneId: `${historyId}-depth-4`,
|
||||
depthLabel: "2.0~5.0m",
|
||||
color: "#8f8bff",
|
||||
ring: createIrregularRing({
|
||||
centerX: centerX + 3200,
|
||||
centerY: centerY + 3200,
|
||||
radiusX: 2600,
|
||||
radiusY: 2000,
|
||||
rotationDeg: 26,
|
||||
scales: [1.05, 0.82, 1.08, 0.88, 0.96, 0.78, 1.02, 0.9],
|
||||
}),
|
||||
},
|
||||
{
|
||||
zoneId: `${historyId}-depth-5`,
|
||||
depthLabel: "5.0m 이상",
|
||||
color: "#f2528b",
|
||||
ring: createIrregularRing({
|
||||
centerX: centerX + 400,
|
||||
centerY: centerY - 3600,
|
||||
radiusX: 1200,
|
||||
radiusY: 1050,
|
||||
rotationDeg: -10,
|
||||
scales: [1, 0.8, 1.06, 0.84, 0.94, 0.76, 0.98],
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const normalizeDate = (value?: string | null) => {
|
||||
const source = String(value ?? "").trim();
|
||||
return /^\d{8}$/.test(source)
|
||||
? `${source.slice(0, 4)}-${source.slice(4, 6)}-${source.slice(6, 8)}`
|
||||
: "";
|
||||
};
|
||||
|
||||
const normalizeHm = (value?: string | null) => {
|
||||
const digits = String(value ?? "").replace(/[^0-9]/g, "");
|
||||
if (!digits) return "";
|
||||
if (digits.length === 1) return `0${digits}:00`;
|
||||
if (digits.length === 2) return `${digits}:00`;
|
||||
if (digits.length === 3)
|
||||
return `0${digits.slice(0, 1)}:${digits.slice(1, 3)}`;
|
||||
return `${digits.slice(0, 2)}:${digits.slice(2, 4)}`;
|
||||
};
|
||||
|
||||
const normalizeDateTime = (value?: string | null) => {
|
||||
const source = String(value ?? "").trim();
|
||||
if (!source) return "";
|
||||
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(source)) return source;
|
||||
const date = new Date(source);
|
||||
if (Number.isNaN(date.getTime()))
|
||||
return source.slice(0, 16).replace("T", " ");
|
||||
const yyyy = date.getFullYear();
|
||||
const mm = `${date.getMonth() + 1}`.padStart(2, "0");
|
||||
const dd = `${date.getDate()}`.padStart(2, "0");
|
||||
const hh = `${date.getHours()}`.padStart(2, "0");
|
||||
const mi = `${date.getMinutes()}`.padStart(2, "0");
|
||||
return `${yyyy}-${mm}-${dd} ${hh}:${mi}`;
|
||||
};
|
||||
|
||||
const formatNumber = (value?: number | null, digits = 1, suffix = "") => {
|
||||
const numeric = Number(value ?? 0);
|
||||
return Number.isFinite(numeric)
|
||||
? `${numeric.toFixed(digits)}${suffix}`
|
||||
: `0${suffix}`;
|
||||
};
|
||||
|
||||
const formatInteger = (value?: number | null, suffix = "") => {
|
||||
const numeric = Number(value ?? 0);
|
||||
return Number.isFinite(numeric)
|
||||
? `${numeric.toLocaleString("ko-KR")}${suffix}`
|
||||
: `0${suffix}`;
|
||||
};
|
||||
|
||||
const formatOptionalNumber = (
|
||||
value?: number | null,
|
||||
digits = 1,
|
||||
suffix = "",
|
||||
) => {
|
||||
if (value === null || value === undefined) return "-";
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? `${numeric.toFixed(digits)}${suffix}` : "-";
|
||||
};
|
||||
|
||||
const buildCauseDetailName = (item: FldngHstryApiItem) => {
|
||||
const direct = String(item.dtlOcrnCsCn ?? "").trim();
|
||||
if (direct) return direct;
|
||||
const code = String(item.fldngCsDtlCd ?? "").trim();
|
||||
return code ? `원인코드 ${code}` : "-";
|
||||
};
|
||||
|
||||
const buildGradeLabel = (avgFloodDepthM: number) => {
|
||||
if (avgFloodDepthM >= 2) return "3등급";
|
||||
if (avgFloodDepthM >= 1) return "2등급";
|
||||
return "1등급";
|
||||
};
|
||||
|
||||
export const normalizeFldngHstryFilters = (
|
||||
filters: FldngHstryFilters,
|
||||
): FldngHstryFilters => ({
|
||||
ctpvCd: String(filters.ctpvCd ?? "0") || "0",
|
||||
sggCd: String(filters.sggCd ?? "0") || "0",
|
||||
floodYear: String(filters.floodYear ?? "")
|
||||
.replace(/[^0-9]/g, "")
|
||||
.slice(0, 4),
|
||||
disasterName: String(filters.disasterName ?? "").trim(),
|
||||
rainStartDate: String(filters.rainStartDate ?? "").trim(),
|
||||
districtName: String(filters.districtName ?? "").trim(),
|
||||
});
|
||||
|
||||
export const buildFldngHstryYearOptions = () => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
return Array.from({ length: 30 }, (_, index) => String(currentYear - index));
|
||||
};
|
||||
|
||||
export const buildFldngHstryListQueryString = (
|
||||
filters: FldngHstryFilters,
|
||||
page: number,
|
||||
size: number,
|
||||
) => {
|
||||
const normalized = normalizeFldngHstryFilters(filters);
|
||||
const params = new URLSearchParams({
|
||||
page: String(page - 1),
|
||||
size: String(size),
|
||||
});
|
||||
appendApiSearchParams(params, {
|
||||
ctpvCd: normalized.ctpvCd === "0" ? "0" : normalized.ctpvCd.slice(0, 2),
|
||||
sggCd: normalized.sggCd === "0" ? "0" : normalized.sggCd.slice(0, 5),
|
||||
fldngYr: normalized.floodYear,
|
||||
fldngDstrNm: normalized.disasterName,
|
||||
rainBgngTm: normalized.rainStartDate,
|
||||
fldngDstrctNm: normalized.districtName,
|
||||
});
|
||||
return params.toString();
|
||||
};
|
||||
|
||||
export const formatFldngHstryArea = (value: number) =>
|
||||
Number(value ?? 0).toLocaleString("ko-KR", {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
export const formatFldngHstryDepth = (value: number) => value.toFixed(2);
|
||||
|
||||
export const attachFldngHstryGeometry = (
|
||||
item: FldngHstryApiItem,
|
||||
): FldngHstryItem => {
|
||||
const historyId = String(item.fid ?? "");
|
||||
const geometrySeed = buildGeometrySeed(historyId);
|
||||
const startDate = normalizeDate(item.bgngYmd);
|
||||
const endDate = normalizeDate(item.endYmd);
|
||||
const startTime = normalizeHm(item.fldngBgngHm);
|
||||
const endTime = normalizeHm(item.fldngEndHm);
|
||||
const avgFloodDepthM = Number(item.avgFldngWtlv ?? 0);
|
||||
const floodAreaHa = Number(item.fldngArea ?? 0);
|
||||
return {
|
||||
...item,
|
||||
historyId,
|
||||
ctpvNm: "",
|
||||
sggNm: "",
|
||||
floodYear: String(item.fldngYr ?? "").trim(),
|
||||
exmnYear: String(item.exmnYr ?? "").trim(),
|
||||
disasterName: String(item.fldngDstrNm ?? "").trim(),
|
||||
districtName: String(item.fldngDstrctNm ?? "").trim(),
|
||||
floodAreaHa,
|
||||
avgFloodDepthM,
|
||||
floodDepthM: avgFloodDepthM,
|
||||
gradeLabel: buildGradeLabel(avgFloodDepthM),
|
||||
startDate,
|
||||
endDate,
|
||||
startTime,
|
||||
endTime,
|
||||
floodHour: formatNumber(Number(item.fldngHr ?? 0), 1),
|
||||
causeDetailCode: String(item.fldngCsDtlCd ?? "").trim(),
|
||||
causeDetailName: buildCauseDetailName(item),
|
||||
damageAmount: formatInteger(Number(item.damAmt ?? 0)),
|
||||
rainStartDateTime: normalizeDateTime(item.rainBgngTm),
|
||||
rainEndDateTime: normalizeDateTime(item.rainEndTm),
|
||||
rainStartTime: normalizeDateTime(item.rainBgngTm).slice(11, 16),
|
||||
rainEndTime: normalizeDateTime(item.rainEndTm).slice(11, 16),
|
||||
totalRainfallMm: Number(item.rainTot ?? 0),
|
||||
rainfallIntensityMmHr: Number(item.rainStr ?? 0),
|
||||
areaRing: createIrregularRing({
|
||||
centerX: geometrySeed.centerX,
|
||||
centerY: geometrySeed.centerY,
|
||||
radiusX: geometrySeed.width / 2,
|
||||
radiusY: geometrySeed.height / 2,
|
||||
rotationDeg: 9,
|
||||
scales: [1.08, 0.9, 1.04, 0.82, 0.98, 1.12, 0.88, 0.96, 1.03, 0.86],
|
||||
}),
|
||||
depthZones: createFldngHstryDepthZones(
|
||||
historyId,
|
||||
geometrySeed.centerX,
|
||||
geometrySeed.centerY,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
export const toFldngHstryTableRows = (
|
||||
items: FldngHstryItem[],
|
||||
page: number,
|
||||
pageSize = FLDNG_HSTRY_PAGE_SIZE,
|
||||
): FldngHstryTableRow[] =>
|
||||
items.map((item, index) => ({
|
||||
...item,
|
||||
rowNo: (page - 1) * pageSize + index + 1,
|
||||
floodYearDisplay: item.floodYear || "-",
|
||||
floodHourDisplay: item.floodHour || "-",
|
||||
avgFloodDepthDisplay: formatFldngHstryDepth(item.avgFloodDepthM),
|
||||
floodAreaDisplay: formatFldngHstryArea(item.floodAreaHa),
|
||||
exmnYearDisplay: item.exmnYear || "-",
|
||||
damageAmountDisplay: item.damageAmount || "-",
|
||||
rainBgngTmDisplay: item.rainStartDateTime || "-",
|
||||
rainEndTmDisplay: item.rainEndDateTime || "-",
|
||||
rainTotDisplay: formatOptionalNumber(item.rainTot, 1),
|
||||
rainStrDisplay: formatOptionalNumber(item.rainStr, 1),
|
||||
}));
|
||||
|
||||
const resolveFldngHstryNodeName = (node: any) =>
|
||||
String(
|
||||
node?.text ??
|
||||
node?.data?.lyrNm ??
|
||||
node?.data?.name ??
|
||||
node?.lyrNm ??
|
||||
node?.name ??
|
||||
"",
|
||||
).trim();
|
||||
|
||||
const resolveFldngHstryLayerType = (node: any) =>
|
||||
String(
|
||||
node?.data?.lyrTyNm ??
|
||||
node?.data?.lyrTypeNm ??
|
||||
node?.lyrTyNm ??
|
||||
node?.lyrTypeNm ??
|
||||
"",
|
||||
).trim();
|
||||
|
||||
export const isFldngHstryNode = (node: any) => {
|
||||
const name = resolveFldngHstryNodeName(node);
|
||||
const layerType = resolveFldngHstryLayerType(node);
|
||||
return (
|
||||
name === FLDNG_HSTRY_LAYER_NAME ||
|
||||
name === FLDNG_HSTRY_DEPTH_LAYER_NAME ||
|
||||
layerType === FLDNG_HSTRY_LAYER_TYPE ||
|
||||
layerType === FLDNG_HSTRY_DEPTH_LAYER_TYPE ||
|
||||
layerType === FLDNG_HSTRY_PREVIEW_LAYER_TYPE
|
||||
);
|
||||
};
|
||||
|
||||
export const isFldngHstryWtlvNode = (node: any) => {
|
||||
const name = resolveFldngHstryNodeName(node);
|
||||
const layerType = resolveFldngHstryLayerType(node);
|
||||
return (
|
||||
name === FLDNG_HSTRY_LAYER_NAME || layerType === FLDNG_HSTRY_LAYER_TYPE
|
||||
);
|
||||
};
|
||||
|
||||
export const isFldngHstryDepthNode = (node: any) => {
|
||||
const name = resolveFldngHstryNodeName(node);
|
||||
const layerType = resolveFldngHstryLayerType(node);
|
||||
return (
|
||||
name === FLDNG_HSTRY_DEPTH_LAYER_NAME ||
|
||||
layerType === FLDNG_HSTRY_DEPTH_LAYER_TYPE
|
||||
);
|
||||
};
|
||||
|
||||
export const isFldngHstryRootNode = (node: any) =>
|
||||
resolveFldngHstryNodeName(node) === FLDNG_HSTRY_ROOT_NAME;
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 화면명: 도시침수지도 목록
|
||||
* 빈도별 도시침수지도를 검색하고 목록·엑셀·지도 보기 기능을 제공한다.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
@@ -101,7 +105,7 @@ export default function FloodDngrMapPopupPage() {
|
||||
availableSggCodes.has(String(item.val).trim().slice(0, 5)),
|
||||
);
|
||||
return filters.ctpvCd === "0"
|
||||
? scopedOptions
|
||||
? []
|
||||
: scopedOptions.filter((item) =>
|
||||
String(item.val).startsWith(filters.ctpvCd.slice(0, 2)),
|
||||
);
|
||||
@@ -390,8 +394,6 @@ export default function FloodDngrMapPopupPage() {
|
||||
onPageChange={(page) => void loadRows(page, appliedFilters)}
|
||||
onRowClick={(row) => void handleRowClick(row)}
|
||||
rowId={(row, index) => `${row.sggCd}-${row.sbmrgFrqExpln}-${index}`}
|
||||
scrollMode="horizontal"
|
||||
minTableWidth="120rem"
|
||||
/>
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/** 도시침수지도 화면의 검색 조건, 범례, 컬럼과 응답 변환을 정의한다. */
|
||||
"use client";
|
||||
|
||||
import type { UiHeadProps } from "@common/business_components/ui";
|
||||
import { appendApiSearchParams } from "@/shared/util/util";
|
||||
|
||||
export const FLOOD_DNGR_MAP_POPUP_KEY = "floodDngrMapPopup";
|
||||
export const FLOOD_DNGR_MAP_LAYER_TYPE = "tb_ms_flood_dngr_map";
|
||||
@@ -89,16 +91,12 @@ export const buildFloodDngrMapListQueryString = (
|
||||
page: String(page),
|
||||
size: String(size),
|
||||
});
|
||||
if (filters.ctpvCd && filters.ctpvCd !== "0")
|
||||
params.set("ctpvCd", filters.ctpvCd.slice(0, 2));
|
||||
if (filters.sggCd && filters.sggCd !== "0")
|
||||
params.set("sggCd", filters.sggCd.slice(0, 5));
|
||||
if (filters.sbmrgDpthlCd && filters.sbmrgDpthlCd !== "0") {
|
||||
params.set("sbmrgDpthlCd", filters.sbmrgDpthlCd);
|
||||
}
|
||||
if (filters.sbmrgFrqExpln && filters.sbmrgFrqExpln !== "0") {
|
||||
params.set("sbmrgFrqExpln", filters.sbmrgFrqExpln);
|
||||
}
|
||||
appendApiSearchParams(params, {
|
||||
ctpvCd: filters.ctpvCd === "0" ? "0" : filters.ctpvCd.slice(0, 2),
|
||||
sggCd: filters.sggCd === "0" ? "0" : filters.sggCd.slice(0, 5),
|
||||
sbmrgDpthlCd: filters.sbmrgDpthlCd,
|
||||
sbmrgFrqExpln: filters.sbmrgFrqExpln,
|
||||
});
|
||||
return params.toString();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 화면명: 하수관로 편집정보
|
||||
* 편집 중인 하수관로의 기본정보와 입력정보를 표시하고 수정한다.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 화면명: 하수관로 편집 세션
|
||||
* 현재 편집 대상과 변경 상태를 표시하고 저장·취소 동작을 제공한다.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { Button } from "@common/components";
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 화면명: 하수관로 편집 도구
|
||||
* 관로 추가·이동·꼭지점 편집·삭제 도구를 선택하고 제어한다.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { Check, Move, PenTool, Plus, Trash2, X } from "lucide-react";
|
||||
@@ -55,7 +59,7 @@ export default function GisEditToolbar({
|
||||
data-testid="gis-edit-toolbar"
|
||||
className="absolute bottom-4 left-1/2 z-[60] flex w-[min(980px,calc(100%-2rem))] -translate-x-1/2 flex-col"
|
||||
>
|
||||
<div className="card card-border !m-0 flex !bg-background !p-[0.8rem] shadow-[0_8px_24px_rgba(23,42,70,0.18)]">
|
||||
<div className="card !m-0 flex !border-0 !bg-background !p-[0.8rem] shadow-[0_1px_2px_rgba(60,64,67,0.3),0_1px_3px_1px_rgba(60,64,67,0.15)]">
|
||||
<div className="flex w-full items-center gap-3 overflow-hidden">
|
||||
<div className="flex w-[168px] shrink-0 flex-col justify-center border-r border-border pr-3">
|
||||
<span className="whitespace-nowrap text-[1.3rem] font-semibold leading-5 text-label">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/** 하수관로 GIS 편집에서 사용하는 도구·세션·변경 데이터 타입을 정의한다. */
|
||||
export type GisEditTool = "ADD" | "MOVE" | "VERTEX_EDIT" | "DELETE";
|
||||
|
||||
export type GisEditGeometryType = "Point" | "LineString" | "Polygon";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/** 하수관로 GIS 편집 도구의 선택 상태와 변경 내역을 관리하는 훅이다. */
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/** 하수관로 GIS 편집에 필요한 공통코드를 조회하는 훅이다. */
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 화면명: GIS 관리
|
||||
* GIS 자료 업로드 이력을 조회하고 등록·검증·반영 기능을 제공한다.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import type { ChangeEvent } from "react";
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 화면명: GIS 업로드 미리보기 지도
|
||||
* 업로드한 공간정보를 지도에 미리 표시하고 검증 결과를 확인한다.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/** GIS 관리 화면에 필요한 공통코드를 조회하고 선택 항목으로 제공한다. */
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 화면명: 침입수 분석결과 목록
|
||||
* 침입수 분석결과를 검색하고 목록·엑셀·지도 보기 기능을 제공한다.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/** 침입수 분석결과 화면의 타입·검색 조건·컬럼과 응답 변환을 정의한다. */
|
||||
"use client";
|
||||
|
||||
export const INFTWT_RESULT_ROW_SELECT_MESSAGE = "SN_INFTWT_RESULT_ROW_SELECT";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
/** 침입수 분석결과 선택 시 부모 지도에 결과 레이어를 표시하는 훅이다. */
|
||||
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react";
|
||||
import GeoJSON from "ol/format/GeoJSON.js";
|
||||
import VectorLayer from "ol/layer/Vector.js";
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 화면명: 선택 레이어 설정 카드
|
||||
* 레이어별 설정 제목과 활성화·닫기 영역을 공통 레이아웃으로 제공한다.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
@@ -22,8 +26,16 @@ export default function LayerSettingCard({
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="w-full overflow-visible rounded-[6px] border border-border bg-background shadow-blur">
|
||||
<div className="flex min-h-[28px] items-center justify-between border-b border-border px-2">
|
||||
<span className="text-[1.15rem] font-semibold text-label">{title}</span>
|
||||
<div className="flex min-h-[28px] items-center justify-between border-b border-border pl-[0.8rem] pr-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-[7px] w-[7px] shrink-0 rotate-45 bg-[#7CCE5C]"
|
||||
/>
|
||||
<span className="truncate text-[12px] font-semibold text-label">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{showToggle ? (
|
||||
<button
|
||||
@@ -52,7 +64,9 @@ export default function LayerSettingCard({
|
||||
</div>
|
||||
</div>
|
||||
{children ? (
|
||||
<div className="px-2 py-1 text-[1.15rem] text-label">{children}</div>
|
||||
<div className="flex flex-col gap-[6px] p-[0.8rem] text-[1.15rem] text-label">
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 화면명: 관로현황 목록
|
||||
* 관로분석 집계 항목에 해당하는 개별 관로 목록을 표시한다.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 화면명: 관로분석
|
||||
* 행정구역·처리구역과 분석구분별 관로 집계 결과를 조회한다.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -388,7 +392,7 @@ export default function PipeAnalysisPopupPage() {
|
||||
const sggOptions = useMemo(
|
||||
() =>
|
||||
draftFilters.ctpvCd === "0"
|
||||
? sgg
|
||||
? []
|
||||
: sgg.filter((item) =>
|
||||
String(item.val).startsWith(draftFilters.ctpvCd.slice(0, 2)),
|
||||
),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/** 관로분석 검색 조건, 집계 컬럼과 표시값 변환 유틸을 제공한다. */
|
||||
"use client";
|
||||
|
||||
import type { SelectDataType } from "@common/business_components";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/**
|
||||
* 화면명: 하수관로 정보
|
||||
* ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (하수관로)
|
||||
*
|
||||
* 📄 MsSwagePpl
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/**
|
||||
* 화면명: 우수토실 정보
|
||||
* ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (우수토실)
|
||||
*
|
||||
* 📄 Ssc
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/**
|
||||
* 화면명: 하수처리시설 정보
|
||||
* ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (하수처리시설)
|
||||
*
|
||||
* 📄 SwtrmtFclt
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user