Merge branch 'iipbs/as/dev'

This commit is contained in:
2026-09-17 16:48:50 +09:00
41 changed files with 909 additions and 316 deletions
+11 -8
View File
@@ -10,7 +10,7 @@
>
> - 프로세스·상태·버튼: `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`)
> - 공통코드: `FNRSSTTS` (`SV, ST, LE, FV, ER, SL, AC, TE, TP, RM, RB, RC, RA, AV, EV, RK, RR, EP, TA`)
>
> **백엔드 서비스**:
>
@@ -98,7 +98,7 @@ v1.0은 공단 접수자의 **자료검증 → 초안생성 → 웹기안기**
정방향:
```
SV → ST → LE → FV → ER → AC → TE → TP → RM → RB → RC → RA → AV → RK → RR → EP → TA
SV → ST → LE → FV → ER → AC → TE → TP → RM → RB → RC → RA → AV → EV → RK → RR → EP → TA
```
자체종결: `LE → EP → TA`
@@ -119,7 +119,8 @@ SV → ST → LE → FV → ER → AC → TE → TP → RM → RB → RC → RA
| `RB` | 지자체회신 | 기초 실무협의서 관리 / 조치완료 |
| `RC` | 조치결과서 제출 | 광역 → 환경청 `RA` |
| `RA` | 조치결과서제출(환경청) | 환경청 2차 의뢰 `AV` |
| `AV` | 금액검증 | AI 진행 표시. **30분** 후 `RK` Fallback |
| `AV` | 금액검증 | AI 진행 표시. **30분** 후 `EV` Fallback |
| `EV` | 조치결과검토의뢰 | 공단 접수자: 2차 결재선 검토자지정 → `RK` |
| `RK` | 조치결과서검토 | 웹기안기·금액검증 재실행·보완. 결재선 2차 |
| `RR` | 조치결과서검토 회신 | 환경청 → 광역 `EP` |
| `EP` | 검토결과 회신 | 광역 → 기초 `TA` |
@@ -144,7 +145,7 @@ SV → ST → LE → FV → ER → AC → TE → TP → RM → RB → RC → RA
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차 결재선을 **조회·표시**만 한다.
9. **2차 결재선은 EV에서 수동 검토자지정**. `POST /elapr-dmnd` (`elaprCycl: 2`, `docPrgrsSttsComCd: RK`). 1차 결재선 자동 copy-line은 사용하지 않는다.
10. **웹기안기는 `webhwp/open` 응답을 axios `blob`으로 조회한 뒤 `File`로 감싸 `HwpCtrl.Open`에 전달한다.** `blob:` Object URL과 `orgnlOtptDocCn`은 쓰지 않는다.
---
@@ -189,6 +190,7 @@ BEGIN
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 [검토자지정] (EV, ATRZ01) → POST elapr-dmnd (elaprCycl: 2, docPrgrsSttsComCd: RK)
ON [실무협의서관리] (TE|RK) → GET webhwp/open (blob) → HwpCtrl.Open(File)
ON [실무협의서생성] (TE, 실패/타임아웃) → dcrag-to draft/generate
ON [검토결과조회] (TE) → pwrk-cnsltn-mng findById | findByUpDocNo
@@ -213,7 +215,7 @@ END
프론트가 치는 것만. 콜백·스케줄러는 제외.
| 주체 | 버튼 | 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` |
@@ -222,13 +224,14 @@ END
| 공단 접수 | 파일보완 팝업 | `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` |
| 공단 접수 | 검토자지정 | `ER→AC` | `POST /elapr-dmnd` (`elaprCycl: 1`) |
| 공단 접수 | 검토자지정 | `EV→RK` | `POST /elapr-dmnd` (`elaprCycl: 2`) |
| 공단 검토 | 실무협의서관리 | `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` |
| 기초 | 파일보완완료 / 보완완료 | `SL→ER` / `TE` / `RK` | `POST /fnrs-cnsltn-mng/{docNo}/submitsupplement` |
| 환경청 | 재원협의 결과 회신 | `TP→RM` | `POST /fnrs-cnsltn-mng/{docNo}/tech-review` |
| 광역 | 지자체회신 | `RM→RB` | `updatePrgrsStts` |
| 기초 | 실무협의서 관리 | `RB` 유지 | `GET .../pwrk-cnsltn-doc-crt-rslt/{uuid}/webhwp/open` |
@@ -242,6 +245,6 @@ END
| 웹기안기 | 저장 | 버전 +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`.
신규 4개: `request-examine`, `submit-actn-result`, `request-actn-review`, EV 2차 `elapr-dmnd`.
---
+6 -4
View File
@@ -43,7 +43,8 @@
| 27 | 조치완료 | `RB→RA` | as | `POST /api/v1/pwrk-cnsltn-mng/{pwrkDocNo}/submit-actn-result` (`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` | 서버 |
| 30 | 금액검증 콜백 | `AV→EV` | as | `POST /api/v1/dcrag/cost/verify-result` | 서버 |
| 30a | 검토자지정(2차) | `EV→RK` | elapr | `POST /api/v1/elapr-dmnd` (`elaprCycl=2`) | 기존 |
| 31 | 실무협의서관리 | `RK` | as | `GET /pwrk-cnsltn-mng/{pwrkDocNo}` | 기존 |
| 32 | 금액검증 재실행 | `RK` | as | `POST /api/v1/dcrag-to/documents/cost/verify` | 기존 |
| 33 | 보완요청(금액) | `RK→SL` | as | `reqsupplement` | 기존 |
@@ -212,7 +213,7 @@ v1.0 가이드의 “이 API를 프론트가 치면 안 된다”는 **폐기**.
- `isSubmit: true` → `RB→RA` (조치완료)
응답: `string` (pwrkDocNo). DTO명 `PwrkActnResultSubmitDto`.
### 3-6. `RA → AV → RK`
### 3-6. `RA → AV → EV → RK`
**2차 의뢰 (신규)** — `POST /fnrs-cnsltn-mng/{docNo}/request-actn-review`
```json
@@ -223,7 +224,8 @@ v1.0 가이드의 “이 API를 프론트가 치면 안 된다”는 **폐기**.
`RA→AV`. 응답: `string` (docNo). DTO명 `EnvoActnReviewReqDto`. `specialRqstCn`만 선택.
금액검증 콜백은 서버. 콜백 후 서버가 `copy-line` 호출.
금액검증 콜백은 서버 → `AV→EV`.
**2차 검토자지정** — `POST /elapr-dmnd` (`elaprCycl: 2`, `docPrgrsSttsComCd: RK`). 1차 결재선(`findDocPrgrsByDocNo`, cycl=1)을 시드로 편집.
**금액검증 재실행** — `POST /dcrag-to/documents/cost/verify`
@@ -292,7 +294,7 @@ multipart `Content-Type` 헤더를 수동 지정하지 않는다 (boundary). JSO
}
```
`RK`에서 2차 결재선은 기존 `findDocPrgrsByDocNo` / `elaprCycl=2` 조회로 표시.
`RK`에서 결재현황은 `findDocPrgrsByDocNo`(전체) 조회 후 `elaprCycl`로 1차/2차 탭 표시.
---
@@ -343,5 +343,8 @@ docPrgrsSttsComCd: data.detDto?.prgrsSttsComCd, // ER
To-Be:
- 검토자지정은 **`ER`에서만**. `elaprCycl: 1`, `docPrgrsSttsComCd: "AC"`
- 2차 결재선은 서버 `copy-line`. `RK`에서 검토자지정 팝업을 다시 열지 않음
- 검토자지정은 **`ER`(1차)** 과 **`EV`(2차)** 에서.
- `ER`: `elaprCycl: 1`, `docPrgrsSttsComCd: "AC"`, 서식 선택 다이얼로그
- `EV`: `elaprCycl: 2`, `docPrgrsSttsComCd: "RK"`, 1차 결재선 시드 다이얼로그(서식 없음)
`RK` / ATRZ02 FAB: `findDocPrgrsByDocNo` 전체 조회 후 pending은 `elaprCycl === 2`만 사용. 검토완료 후 `ElaprApprovalDialog`에 `elaprCycl={2}`. 보완요청은 TE와 동일 `SplmntDmndDialog`.
+13 -13
View File
@@ -16,7 +16,7 @@
2-Pass:
- **Pass 1** 기술 타당성: `SV→ST→LE→FV→ER→AC→TE→TP→RM→RB`
- **Pass 2** 조치결과·금액: `RB→RA→AV→RK→RR→EP→TA`
- **Pass 2** 조치결과·금액: `RB→RA→AV→EV→RK→RR→EP→TA`
자체종결(기술검토 생략): `LE→EP→TA`
@@ -80,9 +80,9 @@ sequenceDiagram
rect rgb(240, 255, 255)
광역->>환경청: [조치결과서제출] RC → RA
환경청->>AI: [조치결과 검토의뢰] RA → AV
AI-->>공단: 금액검증 콜백 AV → RK
Note over 공단: 30분 미응답 시 RK + 수동 [금액검증]
Note over 공단: AV→RK 시 1차 결재선 → 2차 복사 (서버)
AI-->>공단: 금액검증 콜백 AV → EV
공단->>공단: [검토자지정] EV → RK
Note over 공단: EV에서 1차 결재선 시드로 2차 결재선 수동 배정
end
rect rgb(255, 255, 230)
@@ -125,7 +125,8 @@ sequenceDiagram
| 9 | 검토결과 회신 | 환경청·광역 | `TP→RM→RB` | 1차 회신. `EP`와 혼동 금지 |
| 10 | 조치결과 작성 | 기초 | `RB→RA` | `submit-actn-result` `isSubmit:true` (조치완료) |
| 11 | 조치 의뢰 | 환경청 | `RA→AV` | `request-actn-review` |
| 12 | AI 금액검증 | AI·공단 | `AV→RK` | 결재선 복사(서버) |
| 12 | AI 금액검증 | AI·공단 | `AV→EV` | 콜백/Fallback 후 접수자 대기 |
| 12a | 2차 검토자지정 | 공단 접수 | `EV→RK` | `elapr-dmnd` cycl=2 |
| 13 | 2차 검토·결재 | 공단 | `RK→RR` / `SL` | 2차 결재선 |
| 14 | 최종 회신 | 환경청·광역·기초 | `RR→EP→TA` | 종결 |
@@ -151,7 +152,8 @@ sequenceDiagram
| `RC` | 조치결과서 제출 | Result Complete | 기초→광역 | `RB→RC` |
| `RA` | 조치결과서 제출(환경청) | Result Ask to Basin | 기초→환경청 | `RB→RA` |
| `AV` | 금액검증 | Amount Verification | AI | `RA→AV` |
| `RK` | 조치결과서검토 | Result Check | 공단 2차 결재 | `AV→RK`, `SL→RK` |
| `EV` | 조치결과검토의뢰 | Examine Verify | 공단 접수자 | `AV→EV` |
| `RK` | 조치결과서검토 | Result Check | 공단 2차 결재 | `EV→RK`, `SL→RK` |
| `RR` | 조치결과서검토 회신 | Result Return to Basin | 공단→환경청 | `RK→RR` |
| `EP` | 검토결과 회신 | Examine Pass to Province | 환경청→광역 | `LE→EP`(자체), `RR→EP`(최종) |
| `TA` | 검토결과 회신(종료) | Technical Approval | 광역→기초 | `EP→TA` |
@@ -200,7 +202,7 @@ stateDiagram-v2
| --- | --- | --- | --- | --- | --- |
| 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` |
| AI-3 | 금액 검증 | `AV→EV` | 환경청 [조치결과 검토의뢰] | 30분 → `EV` + 수동 [금액검증] | `POST /dcrag-to/documents/cost/verify` |
- 예상 소요 안내는 **30분** (v1.0의 10분은 폐기).
- 콜백 수신·스케줄러 전이는 서버. FE는 상세 refetch / draft status 폴링만.
@@ -221,14 +223,12 @@ stateDiagram-v2
---
## 7. 결재선 복사
규칙: `"금액검증후: 2차결재선 1차결재선 복사생성"`
## 7. 2차 결재선 (수동 검토자지정)
1. `TE`에서 1차 결재선(`elaprCycl = 1`) 완료.
2. `AV→RK` 콜백 시 서버가 `POST /elapr-apln/copy-line` (`source=1`, `target=2`).
3. `RK` 화면은 2차 결재선을 조회해 검토자/승인자/최종승인자를 표시.
4. 프론트는 copy-line을 **호출하지 않는다.**
2. 금액검증 콜백/Fallback → `AV→EV`.
3. 공단 접수자가 `EV`에서 [검토자지정]: 1차 결재선을 시드로 편집 후 `POST /elapr-dmnd` (`elaprCycl: 2`) → `RK`.
4. `copy-line` 자동 복사는 사용하지 않는다.
결재 `process`:
+20 -12
View File
@@ -62,16 +62,17 @@ API 컬럼은 [ref-endpoint.md](./ref-endpoint.md)와 동일.
| 27 | 상세 | USER04 | - | `RC` | 조치결과서제출 | `RA` | 환경청 | `updatePrgrsStts` |
| 28 | 상세 | USER03 | - | `RA` | 실무협의서 관리 | 유지 | — | 웹기안기 Open. **저장 불가** |
| 28a | 상세 | USER03 | - | `RA` | 조치결과 검토의뢰 | `AV` | 공단 검토 | **`request-actn-review`** |
| 29 | 자동화 | AI | - | `AV` | (금액검증) | `RK` | 공단 검토 | FE 호출 없음. 진행 패널 |
| 29 | 자동화 | AI | - | `AV` | (금액검증) | `EV` | 공단 접수 | FE 호출 없음. 진행 패널 |
| 29a | 상세 | USER01 | 접수자 | `EV` | 검토자지정 | `RK` | 공단 검토 | `POST /elapr-dmnd` cycl=2. 1차 결재선 시드 |
| 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 없음. 조회** |
| 36 | 상세 | USER03 | - | `RR` | 실무협의서 관리(조회), 검토결과 회신 | `EP` | 광역 | `tech-review` |
| 37 | 작성/상세 | USER04 | - | `EP` | 결과회신, 실무협의서 관리(조회) | `TA` | 기초 | `updatePrgrsStts` |
| 38 | 상세 | USER05 | - | `TA` | 결과회신, 실무협의서 관리(조회) | — | — | **API 없음. 조회** |
`SL` 20~22행: 버튼은 직전 상태에 따라 **하나만** 노출. 파일 루프면 [파일보완완료], 그 외 [보완완료].
@@ -85,7 +86,7 @@ API 컬럼은 [ref-endpoint.md](./ref-endpoint.md)와 동일.
| --- | --- | --- |
| `fileSplmnt` | 파일보완 | `ER` 접수자. 현행에 없음 |
| `fileVrfc` | 파일검증 | 유지. `ER` 재실행 |
| `dsgntRvwr` | 검토자지정 | 유지. 전이를 `AC`로 |
| `dsgntRvwr` | 검토자지정 | `ER→AC`(1차), `EV→RK`(2차) |
| `pwrkCnsltnDocMng` | 실무협의서관리 | 유지. `TE`·`RK`·`RB`·`RA`(조회 전용) |
| `pwrkCnsltnDocCrt` | 실무협의서생성 | 유지. 실패/타임아웃만 |
| `rvwRsltInq` | 검토결과조회 | 신규 |
@@ -108,19 +109,19 @@ ROLE_USER05
"" | SV → 저장, 신청완료, 삭제
SL → (직전 ER) 파일보완완료 | (직전 TE|RK) 보완완료
RB → 실무협의서 관리, 조치완료
TA → 결과회신 (조회)
TA → 결과회신 (조회), 실무협의서 관리(조회)
ROLE_USER04
ST → 환경청제출 // 현행 반려(rjct→SV)는 SoT 38행에 없음. 유지 시 별도 합의
RM → 지자체회신
RC → 조치결과서제출
EP → 결과회신
EP → 결과회신, 실무협의서 관리(조회)
ROLE_USER03
LE → 결과회신(자체종결), 기술검토 의뢰
TP → 재원협의 결과 회신
RA → 실무협의서 관리(조회), 조치결과 검토의뢰
RR → 결과회신
RR → 실무협의서 관리(조회), 검토결과 회신 (`tech-review` → `EP`)
```
### 3-2. 공단 결재선 × 상태
@@ -129,6 +130,9 @@ ROLE_USER03
ER / ATRZ01 접수자
→ 파일보완, 파일검증, 검토자지정
EV / ATRZ01 접수자
→ 검토자지정 (2차 결재선. 1차 결재선 시드, 서식 선택 없음)
TE / ATRZ02 검토자
→ (초안 성공) 실무협의서관리, 검토결과조회, 저장, 검토완료, 보완요청
→ (초안 실패·타임아웃) 실무협의서생성 (+ 위 항목 중 관리/조회는 초안 생긴 뒤)
@@ -137,14 +141,18 @@ TE / ATRZ03 승인자 → 결재(승인)
TE / ATRZ99 최종 → 결재(회신)
RK / ATRZ02 검토자
→ 실무협의서관리, 금액검증, 저장, 검토완료, 보완요청
RK / ATRZ03 → 결재(승인)
RK / ATRZ99 → 결재(회신)
→ (초안 완료) 실무협의서관리
→ (초안 완료 + 결재 대기 ATRZ_PRGRS_002, cycl2) 보완요청, 검토완료
· 보완요청 UI: TE와 동일 SplmntDmndDialog
RK / ATRZ03 → (초안 완료) 실무협의서관리, (cycl2 pending) 결재(승인)
RK / ATRZ99 → (초안 완료) 실무협의서관리, (cycl2 pending) 결재(회신)
· 결재 UI: ElaprApprovalDialog (elaprCycl=2) 승인/반려 → PUT process
```
`FV`, `AC`, `AV`: 액션 빈 배열 + 진행 패널.
현행 `TE`에서 `hasPendingApprovalRequest === false`이면 버튼을 숨긴다. v3.0에서는 검토자 버튼(저장·웹기안기·보완)은 결재 대기와 무관하게 두고, **검토완료/결재**만 결재건 존재 여부에 묶는다.
`PENDING_APPROVAL_ACTIONS`: `dmndSplmnt`, `rvwCmptn`, `atrzAprv`, `atrzRply`만.
`pwrkCnsltnDocMng`는 결재 대기와 무관(초안 완료만). ATRZ02·ATRZ03·ATRZ99라도 pending 없으면 관리만 노출.
---
+30 -9
View File
@@ -7,22 +7,44 @@
<!doctype html>
<html lang="en-us">
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<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
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>
<style>
#hcwoViewScroll::-webkit-scrollbar {
height: 10px;
width: 10px;
}
#hcwoViewScroll::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.35);
border-radius: 5px;
}
#hcwoViewScroll::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.08);
}
</style>
</head>
<body>
<div id="status" style="display:none;">
<div id="status" style="display: none">
<div class="sppiner-content">
<div class="spinner" id='spinner'></div>
<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">
@@ -34,8 +56,7 @@
</div>
</div>
<iframe id="download_iframe" style="width:0px;height:0px;border:0;display:none;"></iframe>
<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>
@@ -49,9 +49,13 @@ async function remove(elaprDmndId: string): Promise<void> {
await client.delete(`${BASE}/${elaprDmndId}`);
}
async function findDocPrgrsByDocNo(elaprDocNo: string): Promise<ElaprDocPrgrsResponseDto[]> {
async function findDocPrgrsByDocNo(
elaprDocNo: string,
elaprCycl?: number,
): Promise<ElaprDocPrgrsResponseDto[]> {
const response = await client.get<ElaprDocPrgrsResponseDto[]>(
`${BASE}/sanc/docprgrs/${elaprDocNo}`,
{ params: elaprCycl != null ? { elaprCycl } : undefined },
);
return response.data;
}
+11 -11
View File
@@ -185,11 +185,16 @@ export function useElaprDmnd(elaprDmndId: string | null | undefined) {
});
}
export function useElaprDocPrgrsByDocNo(elaprDocNo: string | null | undefined) {
export function useElaprDocPrgrsByDocNo(
elaprDocNo: string | null | undefined,
options?: { elaprCycl?: number; enabled?: boolean; structuralSharing?: boolean },
) {
const elaprCycl = options?.elaprCycl;
return useQuery({
queryKey: [...elaprDmndBaseKey, "findDocPrgrsByDocNo", elaprDocNo],
queryFn: () => elaprDmndService.findDocPrgrsByDocNo(elaprDocNo!),
enabled: Boolean(elaprDocNo),
queryKey: [...elaprDmndBaseKey, "findDocPrgrsByDocNo", elaprDocNo, elaprCycl],
queryFn: () => elaprDmndService.findDocPrgrsByDocNo(elaprDocNo!, elaprCycl),
enabled: Boolean(elaprDocNo) && (options?.enabled ?? true),
structuralSharing: options?.structuralSharing ?? true,
});
}
@@ -284,13 +289,8 @@ export function useProcessElaprPrgrsStts() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
elaprPrgrsId,
body,
}: {
elaprPrgrsId: string;
body: ElaprProcessRequestDto;
}) => elaprPrgrsSttsService.process(elaprPrgrsId, body),
mutationFn: ({ elaprPrgrsId, body }: { elaprPrgrsId: string; body: ElaprProcessRequestDto }) =>
elaprPrgrsSttsService.process(elaprPrgrsId, body),
onSuccess: () => {
invalidateElaprPrgrsSttsQueries(queryClient);
},
+5
View File
@@ -57,6 +57,7 @@ export {
buildElaprPrgrsSttsListFromApln,
buildElaprPrgrsSttsListFromAplnRows,
} from "./lib/dmnd-save-utils";
export { mapDocPrgrsToAplnRows } from "./lib/doc-prgrs-to-apln-rows";
export type { ElaprPrgrsSttsRow } from "./lib/prgrs-stts-save-utils";
export {
assignPrgrsSttsAtrzSeqNumbers,
@@ -113,6 +114,10 @@ export type { ReviewerOption } from "./model/reviewer-option";
export { AprvrPickerDialog } from "./ui/aprvr-picker-dialog";
export { ElaprAplnPickerGrid } from "./ui/elapr-apln-picker-grid";
export { ElaprApprovalDialog } from "./ui/elapr-approval-dialog";
export {
type ElaprDocPrgrsPickerConfirmPayload,
ElaprDocPrgrsPickerDialog,
} from "./ui/elapr-doc-prgrs-picker-dialog";
export { ElaprDocPrgrsStatus } from "./ui/elapr-doc-prgrs-status";
export {
type ElaprTmpltPickerConfirmPayload,
@@ -0,0 +1,21 @@
import type { ElaprDocPrgrsClientResponseDto } from "@/shared/model";
import type { ElaprApln } from "../model/elapr-apln-interface";
/** 문서 결재진행 → 결재선 그리드 시드 (ElaprApln 호환) */
export function mapDocPrgrsToAplnRows(
list: ElaprDocPrgrsClientResponseDto[] | undefined,
): ElaprApln[] {
if (!list?.length) return [];
return list.map((item, index) => ({
aplnId: item.elaprPrgrsId ?? `prgrs-${item.atrzSeqNo ?? index}`,
elaprTmpltId: "",
atrzSeqNo: item.atrzSeqNo,
atrzSeComCd: item.atrzSeComCd,
atrzSeComNm: item.atrzSeComNm,
aprvrUuid: item.aprvrUuid,
aprvrNm: item.aprvrNm,
aprvrUserId: item.aprvrUserId,
}));
}
@@ -22,33 +22,35 @@ function toRow(item: ElaprApln): ElaprAplnRow {
}
interface UseAplnRowsOptions {
elaprTmpltId: string | null;
seedKey: string | null;
serverRows: ElaprApln[] | undefined;
elaprTmpltId?: string;
}
export function createDraftRowKey(): string {
return `draft-${Date.now()}`;
}
export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) {
export function useAplnRows({ seedKey, serverRows, elaprTmpltId = "" }: UseAplnRowsOptions) {
const [rows, setRows] = useState<ElaprAplnRow[]>([]);
const [editingRowKey, setEditingRowKey] = useState<string | null>(null);
useEffect(() => {
if (!elaprTmpltId) {
if (!seedKey) {
setRows([]);
setEditingRowKey(null);
return;
}
setRows((serverRows ?? []).map(toRow));
setEditingRowKey(null);
}, [elaprTmpltId, serverRows]);
}, [seedKey, serverRows]);
const isEditingLocked = editingRowKey != null;
const visibleRows = getVisibleRows(rows);
const canAdd = Boolean(seedKey) && !isEditingLocked;
const addRow = useCallback(() => {
if (!elaprTmpltId || isEditingLocked) return;
if (!canAdd) return;
const rowKey = createDraftRowKey();
@@ -66,7 +68,7 @@ export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) {
},
]);
setEditingRowKey(rowKey);
}, [elaprTmpltId, isEditingLocked]);
}, [canAdd, elaprTmpltId]);
const startEdit = useCallback(
(rowKey: string) => {
@@ -45,5 +45,5 @@ export interface ElaprDmndService {
update(elaprDmndId: string, body: ElaprDmndSaveRequestDto): Promise<ElaprDmndResponseDto>;
updateStatus(elaprDmndId: string, atrzSttsComCd: string): Promise<ElaprDmndResponseDto>;
remove(elaprDmndId: string): Promise<void>;
findDocPrgrsByDocNo(elaprDocNo: string): Promise<ElaprDocPrgrsResponseDto[]>;
findDocPrgrsByDocNo(elaprDocNo: string, elaprCycl?: number): Promise<ElaprDocPrgrsResponseDto[]>;
}
@@ -20,6 +20,7 @@ import { useElaprAplnPage } from "../api/queries";
import { validateAplnRow } from "../lib/apln-validators";
import { useAplnRows } from "../lib/use-apln-rows";
import { useDndSensors } from "../lib/use-dnd-sensors";
import type { ElaprApln } from "../model/elapr-apln-interface";
import type { ElaprAplnRow } from "../model/elapr-apln-row-types";
import type { ReviewerOption } from "../model/reviewer-option";
@@ -27,13 +28,17 @@ import { AprvrPickerDialog } from "./aprvr-picker-dialog";
import { SortableAplnPickerRow } from "./sortable-apln-picker-row";
interface ElaprAplnPickerGridProps {
elaprTmpltId: string | null;
elaprTmpltId?: string | null;
externalServerRows?: ElaprApln[];
isExternalLoading?: boolean;
onRowsChange?: (rows: ElaprAplnRow[]) => void;
onEditingLockedChange?: (locked: boolean) => void;
}
function ElaprAplnPickerGrid({
elaprTmpltId,
elaprTmpltId = null,
externalServerRows,
isExternalLoading = false,
onRowsChange,
onEditingLockedChange,
}: ElaprAplnPickerGridProps) {
@@ -41,14 +46,18 @@ function ElaprAplnPickerGrid({
const [pickingRowKey, setPickingRowKey] = useState<string | null>(null);
const alert = useAlertStore((state) => state.setMessage);
const isExternalMode = externalServerRows != null;
const aplnQuery = useElaprAplnPage({
elaprTmpltId: elaprTmpltId ?? undefined,
elaprTmpltId: !isExternalMode ? (elaprTmpltId ?? undefined) : undefined,
page: 0,
size: 200,
sort: "atrzSeqNo,asc",
});
const seedKey = isExternalMode ? "external-doc-prgrs" : elaprTmpltId;
const serverRows = isExternalMode ? externalServerRows : aplnQuery.data?.content;
const {
rows,
visibleRows,
@@ -62,8 +71,9 @@ function ElaprAplnPickerGrid({
markDeleted,
reorderRows,
} = useAplnRows({
elaprTmpltId,
serverRows: aplnQuery.data?.content,
seedKey,
serverRows,
elaprTmpltId: elaprTmpltId ?? "",
});
useEffect(() => {
@@ -75,7 +85,10 @@ function ElaprAplnPickerGrid({
}, [isEditingLocked, onEditingLockedChange]);
const sensors = useDndSensors();
const isDisabled = !elaprTmpltId;
const isReady = isExternalMode || Boolean(elaprTmpltId);
const isLoading = isExternalMode
? isExternalLoading
: aplnQuery.isPending && Boolean(elaprTmpltId);
const pickingRow = pickingRowKey ? rows.find((r) => r.rowKey === pickingRowKey) : undefined;
function handleDragEnd(event: DragEndEvent) {
@@ -121,7 +134,7 @@ function ElaprAplnPickerGrid({
size="sm"
type="button"
color="green"
disabled={isDisabled || isEditingLocked}
disabled={!isReady || isEditingLocked}
onClick={addRow}
>
결재선 추가
@@ -129,15 +142,15 @@ function ElaprAplnPickerGrid({
</div>
</div>
{aplnQuery.isPending && elaprTmpltId ? (
{isLoading ? (
<div className="flex flex-col gap-2">
<Skeleton className="h-[4rem]" />
<Skeleton className="h-[16rem]" />
</div>
) : !elaprTmpltId ? (
) : !isReady ? (
<p className="py-8 text-center text-lg text-muted-foreground">결재서식을 선택하세요.</p>
) : visibleRows.length === 0 ? (
<p className="py-8 text-center text-lg text-muted-foreground">
<p className="py-16 text-center text-lg text-muted-foreground">
등록된 결재선이 없습니다. [결재선 추가]로 결재자를 등록하세요.
</p>
) : (
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useMemo, useState } from "react";
import { closestCenter, DndContext, type DragEndEvent } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
@@ -48,6 +48,7 @@ interface ElaprApprovalDialogProps {
onOpenChange: (open: boolean) => void;
elaprDocNo: string;
currentUserUuid: string;
elaprCycl?: number;
onSuccess: () => void;
}
@@ -58,6 +59,7 @@ function ElaprApprovalDialog({
onOpenChange,
elaprDocNo,
currentUserUuid,
elaprCycl,
onSuccess,
}: ElaprApprovalDialogProps) {
const queryClient = useQueryClient();
@@ -67,9 +69,16 @@ function ElaprApprovalDialog({
const [pickerOpen, setPickerOpen] = useState(false);
const [pickingRowKey, setPickingRowKey] = useState<string | null>(null);
const { data: elaprDocPrgrsList, isLoading } = useElaprDocPrgrsByDocNo(open ? elaprDocNo : null);
const { data: elaprDocPrgrsList, isLoading } = useElaprDocPrgrsByDocNo(open ? elaprDocNo : null, {
elaprCycl,
structuralSharing: false,
});
const cyclDocPrgrsList = useMemo(() => {
if (elaprCycl == null) return elaprDocPrgrsList;
return elaprDocPrgrsList?.filter((row) => Number(row.elaprCycl) === elaprCycl);
}, [elaprCycl, elaprDocPrgrsList]);
const elaprDmndId = elaprDocPrgrsList?.[0]?.elaprDmndId;
const elaprDmndId = cyclDocPrgrsList?.[0]?.elaprDmndId;
const {
rows,
@@ -85,22 +94,19 @@ function ElaprApprovalDialog({
markDeleted,
reorderRows,
} = usePrgrsSttsRows({
serverRows: elaprDocPrgrsList,
serverRows: cyclDocPrgrsList,
elaprDmndId,
});
const savePrgrsSttsMutation = useSaveElaprPrgrsStts();
const sensors = useDndSensors();
const currentUserPrgrs = findCurrentUserPendingApprovalRequest(
elaprDocPrgrsList,
currentUserUuid,
);
const currentUserPrgrs = findCurrentUserPendingApprovalRequest(cyclDocPrgrsList, currentUserUuid);
const canApprove = Boolean(currentUserPrgrs?.elaprPrgrsId);
const pickingRow = pickingRowKey ? rows.find((r) => r.rowKey === pickingRowKey) : undefined;
const isBusy = isProcessing || savePrgrsSttsMutation.isPending;
const isPrgrsSttsUnsaved = hasUnsavedPrgrsSttsChanges(rows, elaprDocPrgrsList ?? []);
const isPrgrsSttsUnsaved = hasUnsavedPrgrsSttsChanges(rows, cyclDocPrgrsList ?? []);
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
@@ -164,7 +170,7 @@ function ElaprApprovalDialog({
return;
}
const payload = buildPrgrsSttsSavePayload(rows, elaprDmndId, elaprDocPrgrsList ?? []);
const payload = buildPrgrsSttsSavePayload(rows, elaprDmndId, cyclDocPrgrsList ?? []);
if (payload.length === 0) {
alert("변경된 결재선이 없습니다.");
return;
@@ -0,0 +1,129 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { useAlertStore } from "@/shared/lib";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@common/components";
import { useElaprDocPrgrsByDocNo } from "../api/queries";
import {
getVisibleRows,
validateAllRowsForSave,
validateAtrzOrder,
validateUniqueSeqNo,
} from "../lib/apln-validators";
import { mapDocPrgrsToAplnRows } from "../lib/doc-prgrs-to-apln-rows";
import type { ElaprAplnRow } from "../model/elapr-apln-row-types";
import { ElaprAplnPickerGrid } from "./elapr-apln-picker-grid";
interface ElaprDocPrgrsPickerConfirmPayload {
aplnRows: ElaprAplnRow[];
}
interface ElaprDocPrgrsPickerDialogProps {
open: boolean;
elaprDocNo: string;
onClose: () => void;
onConfirm?: (payload: ElaprDocPrgrsPickerConfirmPayload) => void;
}
function ElaprDocPrgrsPickerDialog({
open,
elaprDocNo,
onClose,
onConfirm,
}: ElaprDocPrgrsPickerDialogProps) {
const alert = useAlertStore((state) => state.setMessage);
const [aplnRows, setAplnRows] = useState<ElaprAplnRow[]>([]);
const [isEditingLocked, setIsEditingLocked] = useState(false);
const docPrgrsQuery = useElaprDocPrgrsByDocNo(elaprDocNo, {
elaprCycl: 1,
enabled: open && Boolean(elaprDocNo),
});
const externalServerRows = useMemo(
() => mapDocPrgrsToAplnRows(docPrgrsQuery.data),
[docPrgrsQuery.data],
);
const handleRowsChange = useCallback((rows: ElaprAplnRow[]) => {
setAplnRows(rows);
}, []);
const handleEditingLockedChange = useCallback((locked: boolean) => {
setIsEditingLocked(locked);
}, []);
function handleOpenChange(isOpen: boolean) {
if (!isOpen) {
setAplnRows([]);
setIsEditingLocked(false);
onClose();
}
}
function handleConfirm() {
const rowsError = validateAllRowsForSave(aplnRows, isEditingLocked);
if (rowsError) {
alert(rowsError);
return;
}
const orderError = validateAtrzOrder(aplnRows);
if (orderError) {
alert(orderError);
return;
}
const seqError = validateUniqueSeqNo(aplnRows);
if (seqError) {
alert(seqError);
return;
}
const visibleRows = getVisibleRows(aplnRows);
onConfirm?.({ aplnRows: visibleRows });
setAplnRows([]);
setIsEditingLocked(false);
onClose();
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-[80rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>결재선 선택</DialogTitle>
</DialogHeader>
<ElaprAplnPickerGrid
externalServerRows={externalServerRows}
isExternalLoading={docPrgrsQuery.isPending}
onRowsChange={handleRowsChange}
onEditingLockedChange={handleEditingLockedChange}
/>
<DialogFooter>
<Button type="button" color="white" onClick={() => handleOpenChange(false)}>
닫기
</Button>
<Button type="button" disabled={isEditingLocked} onClick={handleConfirm}>
확인
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { ElaprDocPrgrsPickerDialog };
export type { ElaprDocPrgrsPickerConfirmPayload };
@@ -1,6 +1,6 @@
"use client";
import { Fragment } from "react";
import { Fragment, useEffect, useMemo, useState } from "react";
import { ChevronRight } from "lucide-react";
@@ -8,7 +8,7 @@ import { dayjs, useUserStore } from "@/shared/lib";
import type { ElaprDocPrgrsClientResponseDto } from "@/shared/model";
import { SectionCollapsible } from "@/shared/ui";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { cn, Skeleton } from "@common/components";
import { cn, Skeleton, Tabs, TabsContent, TabsList, TabsTrigger } from "@common/components";
import { useElaprDocPrgrsByDocNo } from "../api/queries";
import { getApproverStatus, sortByAtrzSeqNo } from "../lib/approval-utils";
@@ -67,10 +67,46 @@ function ElaprDocPrgrsStep({ item }: { item: ElaprDocPrgrsClientResponseDto }) {
);
}
function ElaprDocPrgrsStepList({ steps }: { steps: ElaprDocPrgrsClientResponseDto[] }) {
if (steps.length === 0) {
return <p className="px-4 py-6 text-xl text-muted-foreground">결재선이 없습니다.</p>;
}
return (
<div className="flex flex-wrap items-stretch justify-start gap-4 overflow-x-auto">
{steps.map((item, index) => (
<Fragment key={item.elaprPrgrsId ?? `${item.atrzSeqNo}-${item.aprvrUuid ?? index}`}>
{index > 0 && (
<div className="flex items-center">
<ChevronRight className="size-8 text-muted-foreground" />
</div>
)}
<ElaprDocPrgrsStep item={item} />
</Fragment>
))}
</div>
);
}
function ElaprDocPrgrsStatus({ elaprDocNo }: ElaprDocPrgrsStatusProps) {
const userInfo = useUserStore((state) => state.userInfo);
const { data, isLoading } = useElaprDocPrgrsByDocNo(elaprDocNo);
const steps = sortByAtrzSeqNo(data ?? []);
const [activeCycl, setActiveCycl] = useState<"1" | "2">("1");
const { cycl1Steps, cycl2Steps, hasCycl2 } = useMemo(() => {
const rows = data ?? [];
const cycl1 = sortByAtrzSeqNo(rows.filter((item) => Number(item.elaprCycl ?? 1) !== 2));
const cycl2 = sortByAtrzSeqNo(rows.filter((item) => Number(item.elaprCycl) === 2));
return {
cycl1Steps: cycl1,
cycl2Steps: cycl2,
hasCycl2: cycl2.length > 0,
};
}, [data]);
useEffect(() => {
setActiveCycl(hasCycl2 ? "2" : "1");
}, [hasCycl2]);
if (userInfo?.dtlInstCd !== "ROLE_USER01") {
return null;
@@ -88,24 +124,40 @@ function ElaprDocPrgrsStatus({ elaprDocNo }: ElaprDocPrgrsStatusProps) {
);
}
if (steps.length === 0) {
if (cycl1Steps.length === 0 && cycl2Steps.length === 0) {
return null;
}
return (
<SectionCollapsible title="결재현황">
<div className="flex flex-wrap items-stretch justify-start gap-4 overflow-x-auto p-4">
{steps.map((item, index) => (
<Fragment key={item.elaprPrgrsId ?? `${item.atrzSeqNo}-${item.aprvrUuid ?? index}`}>
{index > 0 && (
<div className="flex items-center">
{<ChevronRight className="size-8 text-muted-foreground" />}
{hasCycl2 ? (
<Tabs
value={activeCycl}
onValueChange={(value) => setActiveCycl(value as "1" | "2")}
className="p-2"
>
<TabsList>
<TabsTrigger value="1" className="!px-6 !py-3 text-xl">
재원협의
</TabsTrigger>
<TabsTrigger value="2" className="!px-6 !py-3 text-xl">
실무협의서
</TabsTrigger>
</TabsList>
<TabsContent value="1" className="mt-2 border-none p-0 shadow-none">
<div className="-mx-2">
<ElaprDocPrgrsStepList steps={cycl1Steps} />
</div>
</TabsContent>
<TabsContent value="2" className="mt-2 border-none p-0 shadow-none">
<div className="-mx-2">
<ElaprDocPrgrsStepList steps={cycl2Steps} />
</div>
</TabsContent>
</Tabs>
) : (
<ElaprDocPrgrsStepList steps={cycl1Steps} />
)}
<ElaprDocPrgrsStep item={item} />
</Fragment>
))}
</div>
</SectionCollapsible>
);
}
@@ -0,0 +1,24 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { PageResponse } from "@/shared/model";
import type {
PwrkCnsltnDocPjtcoAjmtMngResponseDto,
PwrkCnsltnDocPjtcoAjmtMngSearchDto,
PwrkCnsltnDocPjtcoAjmtMngService,
} from "../model/pwrk-cnsltn-doc-pjtco-ajmt-mng-interface";
const BASE = ENDPOINT.AS_SERVICE.PWRK_CNSLTN_DOC_PJTCO_AJMT_MNG;
async function findPage(
searchDto: PwrkCnsltnDocPjtcoAjmtMngSearchDto,
): Promise<PageResponse<PwrkCnsltnDocPjtcoAjmtMngResponseDto>> {
const response = await client.get<PageResponse<PwrkCnsltnDocPjtcoAjmtMngResponseDto>>(BASE, {
params: searchDto,
});
return response.data;
}
export const pwrkCnsltnDocPjtcoAjmtMngService = {
findPage,
} satisfies PwrkCnsltnDocPjtcoAjmtMngService;
@@ -0,0 +1,22 @@
import { useQuery } from "@tanstack/react-query";
import type { PwrkCnsltnDocPjtcoAjmtMngSearchDto } from "../model/pwrk-cnsltn-doc-pjtco-ajmt-mng-interface";
import { pwrkCnsltnDocPjtcoAjmtMngService } from "./pwrk-cnsltn-doc-pjtco-ajmt-mng-service";
export const pwrkCnsltnDocPjtcoAjmtMngKeys = {
all: ["pwrk-cnsltn-doc-pjtco-ajmt-mng"] as const,
page: (params: PwrkCnsltnDocPjtcoAjmtMngSearchDto) =>
[...pwrkCnsltnDocPjtcoAjmtMngKeys.all, "findPage", params] as const,
};
export function usePwrkCnsltnDocPjtcoAjmtMngPage(
params: PwrkCnsltnDocPjtcoAjmtMngSearchDto,
isEnabled = true,
) {
return useQuery({
queryKey: pwrkCnsltnDocPjtcoAjmtMngKeys.page(params),
queryFn: () => pwrkCnsltnDocPjtcoAjmtMngService.findPage(params),
enabled: Boolean(params.pwrkCnsltnDocNo) && isEnabled,
});
}
@@ -0,0 +1,10 @@
export { pwrkCnsltnDocPjtcoAjmtMngService } from "./api/pwrk-cnsltn-doc-pjtco-ajmt-mng-service";
export {
pwrkCnsltnDocPjtcoAjmtMngKeys,
usePwrkCnsltnDocPjtcoAjmtMngPage,
} from "./api/queries";
export type {
PwrkCnsltnDocPjtcoAjmtMngResponseDto,
PwrkCnsltnDocPjtcoAjmtMngSearchDto,
PwrkCnsltnDocPjtcoAjmtMngService,
} from "./model/pwrk-cnsltn-doc-pjtco-ajmt-mng-interface";
@@ -0,0 +1,44 @@
import type { PageableParams, PageResponse } from "@/shared/model";
/** 백엔드 PwrkCnsltnDocPjtcoAjmtMngResponseDto */
export interface PwrkCnsltnDocPjtcoAjmtMngResponseDto {
pwrkCnsltnDocNo: string;
rvwOpnnSn: number;
acntgSeCd: string;
acntgSeCdNm?: string;
rvwOpnnClsfComCd?: string;
rvwOpnnClsfComCdNm?: string;
ognTpcoAmt?: number;
ognGvexpAmt?: number;
ognLcexpAmt?: number;
ognOrgprBrdnAmt?: number;
chgTpcoAmt?: number;
chgGvexpAmt?: number;
chgLcexpAmt?: number;
chgOrgprBrdnAmt?: number;
ajmtRsn?: string;
vrfcTpcoAmt?: number;
vrfcGvexpAmt?: number;
vrfcLcexpAmt?: number;
vrfcOrgprBrdnAmt?: number;
rsbnYn?: string;
vrfcCn?: string;
regDt?: string;
rgtr?: string;
rgtrNm?: string;
mdfcnDt?: string;
mdfr?: string;
}
/** 백엔드 PwrkCnsltnDocPjtcoAjmtMngSearchDto + Pageable */
export interface PwrkCnsltnDocPjtcoAjmtMngSearchDto extends PageableParams {
pwrkCnsltnDocNo?: string;
rvwOpnnSn?: number;
acntgSeCd?: string;
}
export interface PwrkCnsltnDocPjtcoAjmtMngService {
findPage(
searchDto: PwrkCnsltnDocPjtcoAjmtMngSearchDto,
): Promise<PageResponse<PwrkCnsltnDocPjtcoAjmtMngResponseDto>>;
}
@@ -2,11 +2,9 @@ export const FNRS_CNSLTN_VIEW_BUTTON_ACTION = {
strg: "strg",
aply: "aply",
del: "del",
fnrsCnsltnAncmnt: "fnrsCnsltnAncmnt",
envoSbmsn: "envoSbmsn",
rjct: "rjct",
sncnAprv: "sncnAprv",
lclRply: "lclRply",
rvwRply: "rvwRply",
rqstTechRvw: "rqstTechRvw",
fileSplmnt: "fileSplmnt",
@@ -38,16 +36,12 @@ const ROLE_STATUS_BUTTON_ACTIONS: Record<string, Record<string, FnrsCnsltnViewBu
FNRS_CNSLTN_VIEW_BUTTON_ACTION.aply,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.del,
],
RB: [
FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.actnCmptn,
],
TA: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.fnrsCnsltnAncmnt],
RB: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng, FNRS_CNSLTN_VIEW_BUTTON_ACTION.actnCmptn],
TA: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
},
ROLE_USER04: {
ST: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.envoSbmsn, FNRS_CNSLTN_VIEW_BUTTON_ACTION.rjct],
RM: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.lclRply],
EP: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.sncnAprv],
EP: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.sncnAprv, FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
},
ROLE_USER03: {
LE: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRply, FNRS_CNSLTN_VIEW_BUTTON_ACTION.rqstTechRvw],
@@ -56,10 +50,11 @@ const ROLE_STATUS_BUTTON_ACTIONS: Record<string, Record<string, FnrsCnsltnViewBu
FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.rqstActnRvw,
],
RR: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng, FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRply],
},
};
const ROLE_USER01_ATRZ_STATUS_CODES = ["ER", "TE", "RK"] as const;
const ROLE_USER01_ATRZ_STATUS_CODES = ["ER", "EV", "TE", "RK"] as const;
const ROLE_USER01_ATRZ_STATUS_ACTIONS: Record<
string,
@@ -68,6 +63,9 @@ const ROLE_USER01_ATRZ_STATUS_ACTIONS: Record<
ER: {
ATRZ01: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.fileSplmnt, FNRS_CNSLTN_VIEW_BUTTON_ACTION.dsgntRvwr],
},
EV: {
ATRZ01: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.dsgntRvwr],
},
TE: {
ATRZ02: [
FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocCrt,
@@ -84,7 +82,19 @@ const ROLE_USER01_ATRZ_STATUS_ACTIONS: Record<
ATRZ99: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzRply],
},
RK: {
ATRZ02: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
ATRZ02: [
FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwCmptn,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.dmndSplmnt,
],
ATRZ03: [
FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzAprv,
],
ATRZ99: [
FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzRply,
],
},
};
@@ -98,7 +108,10 @@ interface GetButtonConfigsOptions {
}
const PENDING_APPROVAL_ACTIONS = new Set<FnrsCnsltnViewButtonAction>([
...ROLE_USER01_ATRZ_STATUS_ACTIONS.TE.ATRZ02,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRsltInq,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwStrg,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.dmndSplmnt,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwCmptn,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzAprv,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzRply,
]);
@@ -129,17 +142,21 @@ function resolveRoleUser01AtrzButtonActions(
const needsPendingApproval = PENDING_APPROVAL_ACTIONS.has(action);
if (needsPendingApproval && options?.hasPendingApprovalRequest === false) return false;
if (prgrsSttsComCd === "TE" && options?.hasCompletedDraft != null) {
if (
action === FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng ||
action === FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRsltInq
) {
if (options?.hasCompletedDraft != null) {
if (prgrsSttsComCd === "TE" || prgrsSttsComCd === "RK") {
if (action === FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng) {
return options.hasCompletedDraft;
}
}
if (prgrsSttsComCd === "TE") {
if (action === FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRsltInq) {
return options.hasCompletedDraft;
}
if (action === FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocCrt) {
return !options.hasCompletedDraft;
}
}
}
return true;
});
@@ -72,9 +72,9 @@ export const fnrsCnsltnRegistSchema = z.object({
cklstSn: z.number(),
cklstArtclSn: z.number(),
cklstRvwSn: z.number(),
rvwStpComCd: z.string(),
rvwStpComCd: z.string().nullish(),
selected: z.enum(["Y", "N"]).nullable(),
picRvwCn: z.string().nullable().optional(),
picRvwCn: z.string().nullish(),
}),
),
esntlAtchFileList: z.array(
@@ -32,7 +32,10 @@ export function getEditableReviewStep(
dtlInstCd?: string,
prgrsSttsComCd?: string,
): ReviewStepCode | undefined {
if (dtlInstCd === "ROLE_USER03" && (prgrsSttsComCd === "LE" || prgrsSttsComCd === "TP")) {
if (
dtlInstCd === "ROLE_USER03" &&
(prgrsSttsComCd === "LE" || prgrsSttsComCd === "TP" || prgrsSttsComCd === "RR")
) {
return "ENV";
}
@@ -12,7 +12,7 @@ const columnHelper = createColumnHelper<FnrsCnsltnMngResponseDto>();
const MAIN_STEP_LABELS = {
apply: "재원협의 신청",
envoReview: "환경청 검토",
envoReview: "재원협의 검토",
techReview: "기술검토",
reply: "검토결과 회신",
} as const;
@@ -20,7 +20,7 @@ const MAIN_STEP_LABELS = {
export const PROGRESS_STATUS_COLUMNS = [
{ label: "재원협의 신청 (시군)", sttsComCd: "ST" },
{ label: "재원협의 신청 (시도)", sttsComCd: "LE" },
{ label: "환경청 검토", sttsComCd: "ER" },
{ label: "재원협의 검토", sttsComCd: "ER" },
{ label: "기술검토", sttsComCd: "TE" },
{ label: "기술검토 회신", sttsComCd: "TP" },
{ label: "환경청 검토 결과 회신", sttsComCd: "EP" },
@@ -61,7 +61,7 @@ function buildProgressBadges(prgrsSttsComCd: string, techRplyHstryYn: "Y" | "N")
];
}
if (prgrsSttsComCd === "ER" || prgrsSttsComCd === "TE" || prgrsSttsComCd === "SL") {
if (["FV", "ER", "AC", "TE", "RB", "RA", "AV", "EV", "RK", "RR", "SL"].includes(prgrsSttsComCd)) {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
@@ -82,7 +82,7 @@ function buildProgressBadges(prgrsSttsComCd: string, techRplyHstryYn: "Y" | "N")
];
}
if (prgrsSttsComCd === "EP") {
if (prgrsSttsComCd === "EP" || prgrsSttsComCd === "TA") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
@@ -61,16 +61,25 @@ export const TASK_CODE_LABELS: Record<string, string> = {
};
export const PBADMS_PROGRESS_ORDER = [
// LOCAL
"SV",
"SL",
"TA",
"ST",
"SL",
"RB",
"EP",
"TA",
// ENV
"LE",
"TP",
"RA",
"RR",
// KECO
"ER",
"TE",
"TB",
"EV",
"RK",
] as const;
export const ROLE_TO_REVIEW_STEP: Record<string, ReviewStepCode> = {
@@ -14,6 +14,8 @@ import {
ElaprApprovalDialog,
type ElaprDmndSaveRequestDto,
elaprDmndService,
type ElaprDocPrgrsPickerConfirmPayload,
ElaprDocPrgrsPickerDialog,
type ElaprTmpltPickerConfirmPayload,
ElaprTmpltPickerDialog,
hasCurrentUserPendingApprovalRequest,
@@ -77,6 +79,7 @@ interface FloatingActionButtonsProps {
type ActiveDialog =
| "elaprTmplt"
| "elaprDocPrgrs"
| "rjct"
| "dmndSplmnt"
| "fileSplmnt"
@@ -110,30 +113,37 @@ function FloatingActionButtons({
const isRegister = data.detDto?.rgtr === userInfo?.userUnqId;
const needsAtrzResolve = needsRoleUser01AtrzResolve(dtlInstCd, prgrsSttsComCd);
const needsTeApprovalCheck = needsAtrzResolve && prgrsSttsComCd === "TE";
const needsElaprPrgrsCheck = needsAtrzResolve && ["TE", "RK"].includes(prgrsSttsComCd);
const needsPwrkDraftCheck = dtlInstCd === "ROLE_USER01" && ["TE", "RK"].includes(prgrsSttsComCd);
const needsPwrkForRb = dtlInstCd === "ROLE_USER05" && prgrsSttsComCd === "RB";
const needsPwrkLookup = needsPwrkDraftCheck || needsPwrkForRb;
const { data: elaprDocPrgrsList = [], isLoading: isElaprDocPrgrsLoading } =
useElaprDocPrgrsByDocNo(needsTeApprovalCheck ? docNo : null);
useElaprDocPrgrsByDocNo(needsElaprPrgrsCheck ? docNo : null);
const { data: pwrkCnsltnData, isLoading: isPwrkCnsltnLoading } = usePwrkCnsltnMngByUpDocNo(
docNo,
needsPwrkLookup,
);
const hasCompletedDraft = Boolean(findCompletedPwrkDraft(pwrkCnsltnData));
const pwrkCnsltnDocNo = pwrkCnsltnData?.pwrkCnsltnDocNo;
const elaprPrgrsForPending =
prgrsSttsComCd === "RK"
? elaprDocPrgrsList.filter((row) => Number(row.elaprCycl) === 2)
: elaprDocPrgrsList;
const hasPendingApprovalRequest = hasCurrentUserPendingApprovalRequest(
elaprDocPrgrsList,
elaprPrgrsForPending,
userInfo?.userUnqId,
);
const actionButtons = useMemo(() => {
if (needsAtrzResolve && isRvwLoginUserLoading) return [];
if (needsTeApprovalCheck && isElaprDocPrgrsLoading) return [];
if (needsElaprPrgrsCheck && isElaprDocPrgrsLoading) return [];
if (needsPwrkDraftCheck && isPwrkCnsltnLoading) return [];
return getButtonConfigs(dtlInstCd, prgrsSttsComCd, atrzSeComCdList, {
hasPendingApprovalRequest: needsTeApprovalCheck ? hasPendingApprovalRequest : undefined,
hasPendingApprovalRequest: needsElaprPrgrsCheck ? hasPendingApprovalRequest : undefined,
hasCompletedDraft: needsPwrkDraftCheck ? hasCompletedDraft : undefined,
});
}, [
@@ -145,7 +155,7 @@ function FloatingActionButtons({
isPwrkCnsltnLoading,
isRvwLoginUserLoading,
needsAtrzResolve,
needsTeApprovalCheck,
needsElaprPrgrsCheck,
needsPwrkDraftCheck,
prgrsSttsComCd,
]);
@@ -252,19 +262,6 @@ function FloatingActionButtons({
router.push("/fnrs-cnsltn");
}
async function handleFnrsCnsltnAncmntClick() {
const payload: DocPrgrsUpdateRequestDto = {
docNo,
docSeComCd,
prgrsSttsComCd: "TB",
};
await executeAction({
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.fnrsCnsltnAncmnt,
request: payload,
successMessage: "고시되었습니다.",
});
}
async function handleEnvoSbmsnClick() {
const payload: DocPrgrsUpdateRequestDto = {
docNo,
@@ -278,19 +275,6 @@ function FloatingActionButtons({
});
}
async function handleLclRplyClick() {
const payload: DocPrgrsUpdateRequestDto = {
docNo,
docSeComCd,
prgrsSttsComCd: "RB",
};
await executeAction({
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.lclRply,
request: payload,
successMessage: "지자체 회신이 완료되었습니다.",
});
}
async function handleRjctClick() {
const payload: DocPrgrsUpdateRequestDto = {
docNo,
@@ -313,7 +297,7 @@ function FloatingActionButtons({
await executeAction({
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.sncnAprv,
request: payload,
successMessage: "인가승인되었습니다.",
successMessage: "결과 회신이 완료되었습니다.",
});
}
@@ -333,11 +317,12 @@ function FloatingActionButtons({
return;
}
const isResultReply = prgrsSttsComCd === "RR";
const payload = buildTechReviewReplyPayload(
data,
checklistFormState,
"ENV",
"RM",
isResultReply ? "EP" : "RB",
"CP",
getReviewOpinionFormData(),
opnrNm,
@@ -346,12 +331,25 @@ function FloatingActionButtons({
await executeAction({
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRply,
request: payload,
successMessage: "재원협의 결과 회신이 완료되었습니다.",
successMessage: "검토 결과 회신이 완료되었습니다.",
});
techReviewOpinionRef?.current?.resetForm();
}
async function handleRqstTechRvwClick() {
if (prgrsSttsComCd === "LE") {
const { percent } = computeReviewStepChecklistProgress(
data.cklstResultList ?? [],
checklistFormState.results,
"ENV",
);
if (percent !== 100) {
alert("모든 체크리스트 항목을 체크해야 기술 검토 의뢰가 가능합니다.");
return;
}
}
const payload: FnrsExamineRequestDto = {
prgrsSttsComCd: "FV",
};
@@ -376,7 +374,7 @@ function FloatingActionButtons({
elaprDocNo: docNo,
dmndTtl: data.detDto?.bizNm ?? "",
dmndCn: data.detDto?.bizCn ?? "",
elaprCycl: data.detDto?.prgrsSttsComCd === "RK" ? 2 : 1,
elaprCycl: 1,
docPrgrsSttsComCd: "AC",
elaprPrgrsSttsList: buildElaprPrgrsSttsListFromAplnRows(aplnRows),
};
@@ -390,6 +388,25 @@ function FloatingActionButtons({
});
}
async function handleElaprDocPrgrsConfirm({ aplnRows }: ElaprDocPrgrsPickerConfirmPayload) {
const payload: ElaprDmndSaveRequestDto = {
elaprDocNo: docNo,
dmndTtl: data.detDto?.bizNm ?? "",
dmndCn: data.detDto?.bizCn ?? "",
elaprCycl: 2,
docPrgrsSttsComCd: "RK",
elaprPrgrsSttsList: buildElaprPrgrsSttsListFromAplnRows(aplnRows),
};
await executeAction({
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.dsgntRvwr,
request: payload,
successMessage: "검토자가 지정되었습니다.",
});
await queryClient.invalidateQueries({
queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, "findDocPrgrsByDocNo", docNo],
});
}
async function handleRvwStrgClick() {
const payload = buildTechReviewReplyPayload(
data,
@@ -410,6 +427,7 @@ function FloatingActionButtons({
}
async function handleRvwCmptnClick() {
if (prgrsSttsComCd === "TE") {
const { percent } = computeReviewStepChecklistProgress(
data.cklstResultList ?? [],
checklistFormState.results,
@@ -420,13 +438,14 @@ function FloatingActionButtons({
alert("모든 체크리스트 항목을 체크해야 검토완료가 가능합니다.");
return;
}
}
const payload = {
...buildTechReviewReplyPayload(
data,
checklistFormState,
"KECO",
"TE",
prgrsSttsComCd,
"SV",
getReviewOpinionFormData(),
opnrNm,
@@ -442,10 +461,6 @@ function FloatingActionButtons({
setActiveDialog("elaprApproval");
}
function handleDmndSplmntClick() {
setActiveDialog("dmndSplmnt");
}
async function handleDmndSplmntConfirm(payload: SplmntDmndDialogConfirmPayload) {
setActiveDialog(null);
const requestPayload = buildSupplementReqPayload(data, payload, checklistFormState);
@@ -540,21 +555,11 @@ function FloatingActionButtons({
삭제
</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.lclRply) && (
<Button onClick={handleLclRplyClick} disabled={isProcessing}>
지자체 회신
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rjct) && (
<Button color="red" onClick={handleRjctClick} disabled={isProcessing}>
반려
@@ -562,12 +567,12 @@ function FloatingActionButtons({
)}
{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) && (
@@ -586,7 +591,12 @@ function FloatingActionButtons({
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.dsgntRvwr) && (
<Button onClick={() => setActiveDialog("elaprTmplt")} disabled={isProcessing}>
<Button
onClick={() =>
setActiveDialog(prgrsSttsComCd === "EV" ? "elaprDocPrgrs" : "elaprTmplt")
}
disabled={isProcessing}
>
검토자 지정
</Button>
)}
@@ -615,7 +625,11 @@ function FloatingActionButtons({
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.dmndSplmnt) && (
<Button color="gray" onClick={handleDmndSplmntClick} disabled={isProcessing}>
<Button
color="gray"
onClick={() => setActiveDialog("dmndSplmnt")}
disabled={isProcessing}
>
보완 요청
</Button>
)}
@@ -643,6 +657,13 @@ function FloatingActionButtons({
onConfirm={handleElaprTmpltConfirm}
/>
<ElaprDocPrgrsPickerDialog
open={activeDialog === "elaprDocPrgrs"}
elaprDocNo={docNo}
onClose={() => setActiveDialog(null)}
onConfirm={handleElaprDocPrgrsConfirm}
/>
<SplmntDmndDialog
splmntArtclUpCd="SPLMNTARTCL"
open={activeDialog === "dmndSplmnt"}
@@ -664,6 +685,7 @@ function FloatingActionButtons({
onOpenChange={(open) => setActiveDialog(open ? "elaprApproval" : null)}
elaprDocNo={data.detDto?.fnrsCnsltnDocNo ?? fnrsCnsltnDocNo}
currentUserUuid={userInfo?.userUnqId ?? ""}
elaprCycl={prgrsSttsComCd === "RK" ? 2 : undefined}
onSuccess={() => {
queryClient.invalidateQueries({
queryKey: fnrsCnsltnKeys.detail(fnrsCnsltnDocNo),
@@ -675,7 +697,8 @@ function FloatingActionButtons({
open={activeDialog === "pwrkCnsltnDocMng"}
fnrsCnsltnDocNo={docNo}
atrzSeComCdList={atrzSeComCdList}
canSave={prgrsSttsComCd === "RA" ? false : undefined}
prgrsSttsComCd={prgrsSttsComCd}
canSave={needsElaprPrgrsCheck && !hasPendingApprovalRequest ? false : undefined}
onClose={() => setActiveDialog(null)}
/>
@@ -806,25 +829,13 @@ async function handleActnCmptn(
}
}
async function handleAtrzAprv(context: ActionHandlerContext): Promise<void> {
// TODO: 결재 승인 처리 API 호출
context.onSuccess?.();
}
async function handleAtrzRply(context: ActionHandlerContext): Promise<void> {
// TODO: 결재 회신 처리 API 호출
context.onSuccess?.();
}
const buttonActionHandlers: Record<FnrsCnsltnViewButtonAction, ActionHandler> = {
strg: handleSaveApply,
aply: handleSaveApply,
del: handleDel,
fnrsCnsltnAncmnt: handleChangePrgrsSttsComCd,
envoSbmsn: handleChangePrgrsSttsComCd,
rjct: handleChangePrgrsSttsComCd,
sncnAprv: handleChangePrgrsSttsComCd,
lclRply: handleChangePrgrsSttsComCd,
rvwRply: handleTechReview,
rqstTechRvw: handleRequestExamine,
fileSplmnt: handleDmndSplmnt,
@@ -835,8 +846,8 @@ const buttonActionHandlers: Record<FnrsCnsltnViewButtonAction, ActionHandler> =
rvwStrg: handleTechReview,
rvwCmptn: handleTechReview,
dmndSplmnt: handleDmndSplmnt,
atrzAprv: handleAtrzAprv,
atrzRply: handleAtrzRply,
atrzAprv: () => Promise.resolve(),
atrzRply: () => Promise.resolve(),
actnCmptn: handleActnCmptn,
rqstActnRvw: handleRequestActnReview,
};
@@ -1,9 +1,10 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import dynamic from "next/dynamic";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { PanelRightClose, PanelRightOpen } from "lucide-react";
import { getDefaultFileKey, useAlertStore, useTus } from "@/shared/lib";
import { AttachmentFileField } from "@/shared/ui";
@@ -12,6 +13,7 @@ import {
pwrkCnsltnDocCrtRsltSaveRequestSchema,
pwrkCnsltnDocCrtRsltService,
} from "@/entities/pwrk-cnsltn-doc-crt-rslt";
import { usePwrkCnsltnDocPjtcoAjmtMngPage } from "@/entities/pwrk-cnsltn-doc-pjtco-ajmt-mng";
import {
findCompletedPwrkDraft,
pwrkCnsltnMngKeys,
@@ -27,6 +29,8 @@ import {
Skeleton,
} from "@common/components";
import { PwrkCnsltnDocPjtcoAjmtTable } from "./pwrk-cnsltn-doc-pjtco-ajmt-table";
const HangulGiangi = dynamic(
() => import("@/widgets/hangulgiangi").then((module) => module.HangulGiangi),
{
@@ -39,10 +43,13 @@ const PWRK_ATCH_FILE_KEY_PREFIX = "pwrk-cnsltn-doc::";
const APPROVER_ATRZ_SE_COM_CDS = new Set(["ATRZ03", "ATRZ99"]);
const VIEW_ONLY_PRGRS_STTS_COM_CDS = new Set(["RA", "RR", "EP", "TA"]);
interface PwrkCnsltnDocMngDialogProps {
open: boolean;
fnrsCnsltnDocNo: string;
atrzSeComCdList: string[];
atrzSeComCdList?: string[];
prgrsSttsComCd?: string;
canSave?: boolean;
onClose: () => void;
}
@@ -62,6 +69,7 @@ function PwrkCnsltnDocMngDialog({
open,
fnrsCnsltnDocNo,
atrzSeComCdList,
prgrsSttsComCd,
canSave: canSaveProp,
onClose,
}: PwrkCnsltnDocMngDialogProps) {
@@ -69,6 +77,7 @@ function PwrkCnsltnDocMngDialog({
const alert = useAlertStore((state) => state.setMessage);
const [atchFileGroupId, setAtchFileGroupId] = useState("");
const [uploadedFiles, setUploadedFiles] = useState<UploadedAtchFile[]>([]);
const [isPjtcoAjmtTableOpen, setIsPjtcoAjmtTableOpen] = useState(false);
const {
data: pwrkCnsltnData,
@@ -92,6 +101,20 @@ function PwrkCnsltnDocMngDialog({
const documentFileName = `실무협의서_${pwrkCnsltnData?.pwrkCnsltnDocNo ?? crtDmndUuid}.hwpx`;
const isLoading = isPwrkCnsltnLoading || isDocumentLoading;
const isError = isPwrkCnsltnError || isDocumentError;
const pjtcoAjmtSearchParams = useMemo(
() => ({
pwrkCnsltnDocNo: pwrkCnsltnData?.pwrkCnsltnDocNo,
page: 0,
size: 1000,
sort: "rvwOpnnSn,asc",
}),
[pwrkCnsltnData?.pwrkCnsltnDocNo],
);
const {
data: pjtcoAjmtPage,
isLoading: isPjtcoAjmtLoading,
isError: isPjtcoAjmtError,
} = usePwrkCnsltnDocPjtcoAjmtMngPage(pjtcoAjmtSearchParams, open);
const { uploadFileAsync, isUploading, uploads } = useTus({
onSuccess: (atflCd, atflId, _file, fileKey) => {
@@ -234,8 +257,11 @@ function PwrkCnsltnDocMngDialog({
}
const isAnyAtchUploading = uploadedFiles.some((item) => isUploading[item.fileKey]);
const canSaveByAtrz = !atrzSeComCdList.some((code) => APPROVER_ATRZ_SE_COM_CDS.has(code));
const canSave = canSaveProp ?? canSaveByAtrz;
const viewOnly = prgrsSttsComCd ? VIEW_ONLY_PRGRS_STTS_COM_CDS.has(prgrsSttsComCd) : false;
const canSaveByAtrz = atrzSeComCdList
? !atrzSeComCdList.some((code) => APPROVER_ATRZ_SE_COM_CDS.has(code))
: true;
const canSave = !viewOnly && (canSaveProp ?? canSaveByAtrz);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
@@ -258,8 +284,9 @@ function PwrkCnsltnDocMngDialog({
생성 완료된 실무협의서 초안이 없습니다.
</p>
) : null}
{completedDraft ? (
documentBlob ? (
{completedDraft && documentBlob ? (
<div className="flex min-h-[40rem] items-stretch gap-2">
<div className="min-w-0 flex-1">
<HangulGiangi
documentBlob={documentBlob}
documentKey={completedDraft.crtDmndUuid}
@@ -268,7 +295,43 @@ function PwrkCnsltnDocMngDialog({
onSaveDocument={handleSaveDocument}
onDownload={handleDownload}
/>
) : null
</div>
{pjtcoAjmtPage?.content?.length ? (
isPjtcoAjmtTableOpen ? (
<section className="flex h-[calc(64vh+4rem)] w-0 flex-[0.4] flex-col overflow-hidden rounded-md border border-border">
<header className="flex items-center justify-between gap-2 border-b border-border px-3 py-2">
<h3 className="text-xl font-semibold">금액검증결과</h3>
<button
type="button"
className="rounded-sm border border-border p-2 hover:opacity-90"
onClick={() => setIsPjtcoAjmtTableOpen(false)}
aria-label="접기"
>
<PanelRightClose className="size-6" />
</button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto">
<PwrkCnsltnDocPjtcoAjmtTable
rows={pjtcoAjmtPage?.content ?? []}
isLoading={isPjtcoAjmtLoading}
isError={isPjtcoAjmtError}
/>
</div>
</section>
) : (
<button
type="button"
className="flex items-center gap-1 self-start rounded-sm border border-border px-1 py-2 hover:opacity-90"
style={{ writingMode: "vertical-rl" }}
onClick={() => setIsPjtcoAjmtTableOpen(true)}
aria-label="펼치기"
>
<PanelRightOpen className="size-5" />
<span className="text-xl tracking-wide">금액검증결과</span>
</button>
)
) : null}
</div>
) : null}
{completedDraft ? (
@@ -0,0 +1,97 @@
"use client";
import type { PwrkCnsltnDocPjtcoAjmtMngResponseDto } from "@/entities/pwrk-cnsltn-doc-pjtco-ajmt-mng";
import { fmtNumber } from "@/widgets/pbadms-sprt";
import {
Skeleton,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@common/components";
interface PwrkCnsltnDocPjtcoAjmtTableProps {
rows: PwrkCnsltnDocPjtcoAjmtMngResponseDto[];
isLoading: boolean;
isError: boolean;
}
function PwrkCnsltnDocPjtcoAjmtTable({
rows,
isLoading,
isError,
}: PwrkCnsltnDocPjtcoAjmtTableProps) {
if (isLoading) {
return (
<div className="space-y-2 p-3">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</div>
);
}
if (isError) {
return (
<p className="px-3 py-10 text-center text-muted-foreground">
사업비조정 정보를 불러오지 못했습니다.
</p>
);
}
return (
<Table variant="secondary">
<TableHeader className="sticky top-0">
<TableRow>
<TableHead>구분</TableHead>
<TableHead>검증결과</TableHead>
<TableHead className="text-right">검증금액</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="py-10 text-center text-muted-foreground">
조회된 사업비조정 항목이 없습니다.
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<TableRow key={getPjtcoAjmtRowKey(row)}>
<TableCell>{formatDivision(row)}</TableCell>
<TableCell>
<p className="whitespace-pre-wrap">{formatVrfcCn(row.vrfcCn)}</p>
</TableCell>
<TableCell className="text-right">{formatVrfcAmt(row.vrfcTpcoAmt)}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
);
}
function getPjtcoAjmtRowKey(row: PwrkCnsltnDocPjtcoAjmtMngResponseDto) {
return [row.pwrkCnsltnDocNo, row.rvwOpnnSn, row.acntgSeCd].join("::");
}
function formatDivision(row: PwrkCnsltnDocPjtcoAjmtMngResponseDto) {
const label = row.acntgSeCdNm?.trim() || row.acntgSeCd?.trim();
return label || "-";
}
function formatVrfcCn(value: string | undefined) {
const text = value?.trim();
return text || "-";
}
function formatVrfcAmt(value: number | undefined) {
if (value === undefined || value === null) return "-";
const formatted = fmtNumber(value);
return formatted || "-";
}
export { PwrkCnsltnDocPjtcoAjmtTable };
export type { PwrkCnsltnDocPjtcoAjmtTableProps };
@@ -159,9 +159,9 @@ function CnsltnListTable({
return (
<Table variant="secondary">
<colgroup>
<col width="8%" />
<col width="42%" />
<col width="50%" />
<col width="4%" />
<col width="4%" />
<col width="92%" />
</colgroup>
<TableHeader className="sticky top-0">
<TableRow>
@@ -172,7 +172,7 @@ function CnsltnListTable({
aria-label="전체 선택"
/>
</TableHead>
<TableHead>분류</TableHead>
<TableHead>구분</TableHead>
<TableHead>검토의견</TableHead>
</TableRow>
</TableHeader>
@@ -194,8 +194,8 @@ function CnsltnListTable({
aria-label="행 선택"
/>
</TableCell>
<TableCell>
<p className="whitespace-pre-wrap">{formatOptional(row.rmrk)}</p>
<TableCell align="center">
<p className="whitespace-pre-wrap">{formatOptional(row.rvwOpnnSn)}</p>
</TableCell>
<TableCell>
<p className="whitespace-pre-wrap">{formatOptional(row.rvwOpnn)}</p>
@@ -47,6 +47,7 @@ import {
import { getCreateDefaultValues, mapDetailToFormData } from "../lib/form-data-mapper";
import { PROGRESS_STATUS_COLUMNS } from "../model/columns";
import { PwrkCnsltnDocMngDialog } from "./components/pwrk-cnsltn-doc-mng-dialog";
import { BasicInfoFormSection } from "./sections/basic-info-form-section";
interface FnrsFormPageProps {
mode: "create" | "edit";
@@ -66,8 +67,10 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
const userInfo = useUserStore((state) => state.userInfo);
const alert = useAlertStore((state) => state.setMessage);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isPwrkCnsltnDocMngOpen, setIsPwrkCnsltnDocMngOpen] = useState(false);
const isEditMode = mode === "edit";
const isSplmntPrgrsStts = initialData?.detDto?.prgrsSttsComCd === "SL";
const fnrsCnsltnDocNo = initialData?.detDto?.fnrsCnsltnDocNo;
const { data: masterSharTree = [] } = useFnrsSharSeMngTree();
const { data: acntgTree = [] } = useAcntgSeMngTree();
@@ -337,11 +340,29 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
보완 완료
</Button>
)}
{isSplmntPrgrsStts && fnrsCnsltnDocNo ? (
<Button
color="orange"
type="button"
onClick={() => setIsPwrkCnsltnDocMngOpen(true)}
disabled={isSubmitting}
>
실무협의서 관리
</Button>
) : null}
</div>
</div>
</div>
</SectionCollapsibleGroup>
</form>
{fnrsCnsltnDocNo ? (
<PwrkCnsltnDocMngDialog
open={isPwrkCnsltnDocMngOpen}
fnrsCnsltnDocNo={fnrsCnsltnDocNo}
canSave
onClose={() => setIsPwrkCnsltnDocMngOpen(false)}
/>
) : null}
</FormProvider>
);
}
@@ -136,8 +136,7 @@ const TechReviewOpinionSection = forwardRef<
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
const hasEnvSection = envRvwList.length > 0 || editableReviewStep === "ENV";
const hasKecoSection =
kecoRvwList.length > 0 || (editableReviewStep === "KECO" && canWriteKecoOpinion);
const hasKecoSection = kecoRvwList.length > 0 || editableReviewStep === "KECO";
if (!hasEnvSection && !hasKecoSection) {
return null;
@@ -12,7 +12,7 @@ const columnHelper = createColumnHelper<InstlSncnMngResponseDto>();
const MAIN_STEP_LABELS = {
apply: "설치인가 신청",
envoReview: "환경청 검토",
envoReview: "설치인가 검토",
techReview: "기술검토",
reply: "검토결과 회신",
} as const;
@@ -20,7 +20,7 @@ const MAIN_STEP_LABELS = {
export const PROGRESS_STATUS_COLUMNS = [
{ label: "설치인가 신청 (시군)", sttsComCd: "ST" },
{ label: "설치인가 신청 (시도)", sttsComCd: "LE" },
{ label: "환경청 검토", sttsComCd: "ER" },
{ label: "설치인가 검토", sttsComCd: "ER" },
{ label: "기술검토", sttsComCd: "TE" },
{ label: "기술검토 회신", sttsComCd: "TP" },
{ label: "검토결과 회신", sttsComCd: "EP" },
@@ -12,14 +12,14 @@ const columnHelper = createColumnHelper<SewerBscPlanResponseDto>();
const MAIN_STEP_LABELS = {
apply: "기본계획 신청",
envoReview: "환경청 검토",
envoReview: "기본계획 검토",
techReview: "기술검토",
reply: "검토결과 회신",
} as const;
export const PROGRESS_STATUS_COLUMNS = [
{ label: "기본계획 신청 (시군)", sttsComCd: "LE" },
{ label: "환경청 검토", sttsComCd: "ER" },
{ label: "기본계획 검토", sttsComCd: "ER" },
{ label: "기술검토", sttsComCd: "TE" },
{ label: "기술검토 회신", sttsComCd: "TP" },
{ label: "횐경청 검토 결과 회신", sttsComCd: "EP" },
+1
View File
@@ -28,6 +28,7 @@ export const ENDPOINT = {
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",
PWRK_CNSLTN_DOC_PJTCO_AJMT_MNG: "/as-service/api/v1/pwrk-cnsltn-doc-pjtco-ajmt-mng",
},
USER_SERVICE: {
LOGIN: "/user-service/login",
+4 -3
View File
@@ -54,9 +54,9 @@ export interface BizCstTreeDto {
bscPlanAmt: number;
/** 금회설계금액(C) */
tstmDesignAmt: number;
/** 증감금액(금회설계금액 - 표준사업비, C-B) */
/** 증감금액(금회설계금액 - 예산반영금액, C-A) */
chgAmt: number;
/** 증감율 % ((증감금액 / 표준사업비) * 100) */
/** 증감율 % ((증감금액 / 예산반영금액(A)) * 100) */
chgRt: number;
}
@@ -151,6 +151,7 @@ export interface ElaprDocPrgrsClientResponseDto {
jbgdSeNm?: string;
ognzNm?: string;
dcarbYn?: string;
elaprCycl?: number;
}
/** 문서 상태 변경 이력 응답 DTO */
@@ -190,7 +191,7 @@ export interface CklstResultSaveBase {
cklstRvwSn?: number;
cklstArtclSn?: number;
cklstSn?: number;
rvwStpComCd: string;
rvwStpComCd?: string | null;
picRvwCn?: string | null;
}
@@ -58,6 +58,7 @@ export interface HwpCtrl {
callback?: HwpCtrlSaveAsCallback,
callbackUserData?: unknown,
) => boolean;
ShowHorizontalScroll: (show: boolean) => boolean;
}
export interface HwpCtrlFrameWindow {
@@ -87,7 +87,10 @@ function HangulGiangi({
"HWPX",
"",
(result) => {
const callbackDocumentKey = getCallbackDocumentKey(result.CallbackUserData, documentKey);
const callbackDocumentKey = getCallbackDocumentKey(
result.CallbackUserData,
documentKey,
);
if (openingDocumentKeyRef.current !== callbackDocumentKey) return;
openingDocumentKeyRef.current = null;
@@ -21,14 +21,12 @@ interface CklstResultListItem {
cklstSn: number;
cklstArtclSn: number;
cklstRvwSn: number;
rvwStpComCd: string;
rvwStpComCd?: string | null;
selected: "Y" | "N" | null;
picRvwCn?: string | null;
}
export function normalizeTaskSeComCdArray(
value: string | string[] | undefined | null,
): string[] {
export function normalizeTaskSeComCdArray(value: string | string[] | undefined | null): string[] {
if (!value) return [];
if (Array.isArray(value)) return value.filter(Boolean);
return value
@@ -122,7 +122,7 @@ function findBizCstSyncItem(costItems: CostItemDef[]): CostItemDef | undefined {
}
export interface BizCstTotalAmounts {
bscPlanAmt: number;
bgtRfltAmt: number;
tstmDesignAmt: number;
}
@@ -136,13 +136,13 @@ export function getBizCstTotalRow(
costItems: CostItemDef[],
): BizCstTotalAmounts {
const syncCostItem = findBizCstSyncItem(costItems);
if (!syncCostItem) return { bscPlanAmt: 0, tstmDesignAmt: 0 };
if (!syncCostItem) return { bgtRfltAmt: 0, tstmDesignAmt: 0 };
const costRow = bizCstList.find((item) => item.acntgSeCd === syncCostItem.code);
if (!costRow) return { bscPlanAmt: 0, tstmDesignAmt: 0 };
if (!costRow) return { bgtRfltAmt: 0, tstmDesignAmt: 0 };
return {
bscPlanAmt: costRow.bscPlanAmt || 0,
bgtRfltAmt: costRow.bgtRfltAmt || 0,
tstmDesignAmt: costRow.tstmDesignAmt || 0,
};
}
@@ -178,7 +178,7 @@ export function calcBizCstSharRemaining(
const leafSum = sumFnrsSharLeafAmounts(sharList, sharItems);
return {
exstnRemaining: roundAmount(bizCstTotal.bscPlanAmt - leafSum.exstnAmt),
exstnRemaining: roundAmount(bizCstTotal.bgtRfltAmt - leafSum.exstnAmt),
chgRqtRemaining: roundAmount(bizCstTotal.tstmDesignAmt - leafSum.chgRqtAmt),
};
}
@@ -120,14 +120,14 @@ function BusinessCostFormSection() {
</TableHead>
<TableCell>
<Controller
name={`bizCstList.${idx}.bscPlanAmt`}
name={`bizCstList.${idx}.bgtRfltAmt`}
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Input
type="number"
value={field.value || ""}
onChange={(e) => handleCostChange(idx, "bscPlanAmt", e.target.value)}
onChange={(e) => handleCostChange(idx, "bgtRfltAmt", e.target.value)}
readOnly={item.readonly}
aria-invalid={fieldState.invalid}
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
@@ -138,14 +138,14 @@ function BusinessCostFormSection() {
</TableCell>
<TableCell>
<Controller
name={`bizCstList.${idx}.bgtRfltAmt`}
name={`bizCstList.${idx}.bscPlanAmt`}
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Input
type="number"
value={field.value || ""}
onChange={(e) => handleCostChange(idx, "bgtRfltAmt", e.target.value)}
onChange={(e) => handleCostChange(idx, "bscPlanAmt", e.target.value)}
readOnly={item.readonly}
aria-invalid={fieldState.invalid}
className={`text-right ${item.readonly ? "bg-muted" : ""}`}