Merge branch 'main' into feature/0612_sentry_fr_merge
This commit is contained in:
@@ -116,7 +116,7 @@ SV → ST → LE → FV → ER → AC → TE → TP → RM → RB → RC → RA
|
||||
| `TE` | 기술검토 | 웹기안기·검토완료·보완. 결재선 1차 |
|
||||
| `TP` | 기술검토 회신 | 환경청 → 광역 `RM` |
|
||||
| `RM` | 재원협의결과회신 | 광역 → 기초 `RB` |
|
||||
| `RB` | 지자체회신 | 기초 조치결과 저장/협의회신 |
|
||||
| `RB` | 지자체회신 | 기초 실무협의서 관리 / 조치완료 |
|
||||
| `RC` | 조치결과서 제출 | 광역 → 환경청 `RA` |
|
||||
| `RA` | 조치결과서제출(환경청) | 환경청 2차 의뢰 `AV` |
|
||||
| `AV` | 금액검증 | AI 진행 표시. **30분** 후 `RK` Fallback |
|
||||
@@ -167,8 +167,8 @@ BEGIN
|
||||
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 [실무협의서 관리] (RB) → GET webhwp/open (blob) → HwpCtrl.Open(File)
|
||||
ON [조치완료] (RB) → submit-actn-result { isSubmit: true }
|
||||
ON [결과회신] (TA) → API 없음 (조회)
|
||||
|
||||
// ----- 광역 ROLE_USER04 -----
|
||||
@@ -181,7 +181,8 @@ BEGIN
|
||||
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 [실무협의서 관리] (RA) → GET webhwp/open (blob) → HwpCtrl.Open(File) (저장 불가)
|
||||
ON [조치결과 검토의뢰] (RA) → request-actn-review → AV → START POLL AV
|
||||
ON [결과회신] (RR) → updatePrgrsStts(EP)
|
||||
|
||||
// ----- 공단 ROLE_USER01 -----
|
||||
@@ -230,9 +231,11 @@ END
|
||||
| 기초 | 파일보완완료 / 보완완료 | `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` |
|
||||
| 기초 | 실무협의서 관리 | `RB` 유지 | `GET .../pwrk-cnsltn-doc-crt-rslt/{uuid}/webhwp/open` |
|
||||
| 기초 | 조치완료 | `RB→RA` | **신규** `POST /pwrk-cnsltn-mng/{pwrkDocNo}/submit-actn-result` (`isSubmit: true`) |
|
||||
| 광역 | 조치결과서제출 | `RC→RA` | `updatePrgrsStts` |
|
||||
| 환경청 | 조치결과서 검토요청 | `RA→AV` | **신규** `POST /fnrs-cnsltn-mng/{docNo}/request-actn-review` |
|
||||
| 환경청 | 실무협의서 관리 | `RA` 유지 (조회) | `GET .../pwrk-cnsltn-doc-crt-rslt/{uuid}/webhwp/open` |
|
||||
| 환경청 | 조치결과 검토의뢰 | `RA→AV` | **신규** `POST /fnrs-cnsltn-mng/{docNo}/request-actn-review` |
|
||||
| 공단 검토 | 금액검증 재실행 | `RK` 유지 | `POST /dcrag-to/documents/cost/verify` |
|
||||
| 환경청 | 결과회신 | `RR→EP` | `updatePrgrsStts` |
|
||||
| 광역 | 결과회신 | `EP→TA` | `updatePrgrsStts` |
|
||||
|
||||
@@ -39,10 +39,10 @@
|
||||
| 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`) | **신규** |
|
||||
| 26 | 실무협의서 관리 | `RB` | as | `GET .../pwrk-cnsltn-doc-crt-rslt/{uuid}/webhwp/open` | 기존 |
|
||||
| 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` | **신규** |
|
||||
| 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` | 기존 |
|
||||
@@ -186,16 +186,16 @@ v1.0 가이드의 “이 API를 프론트가 치면 안 된다”는 **폐기**.
|
||||
|
||||
반려: `ATRZ_PRGRS_004`. 기존 `elaprPrgrsSttsService.process`. 최종 시 서버가 `TP`/`RR` 전이.
|
||||
|
||||
### 3-5. `TP → RM → RB → RC`
|
||||
### 3-5. `TP → RM → RB → RA`
|
||||
|
||||
**환경청 1차 회신** — `tech-review` (`TP→RM`).
|
||||
**광역 지자체회신** — `updatePrgrsStts` `RB`.
|
||||
|
||||
**조치결과 저장/제출 (신규)** — `POST /pwrk-cnsltn-mng/{pwrkDocNo}/submit-actn-result`
|
||||
**조치완료 (신규)** — `POST /pwrk-cnsltn-mng/{pwrkDocNo}/submit-actn-result`
|
||||
|
||||
```json
|
||||
{
|
||||
"isSubmit": false,
|
||||
"isSubmit": true,
|
||||
"actnList": [
|
||||
{
|
||||
"rvwOpnnSn": 1,
|
||||
@@ -203,29 +203,25 @@ v1.0 가이드의 “이 API를 프론트가 치면 안 된다”는 **폐기**.
|
||||
"atchFileGroupId": "optional"
|
||||
}
|
||||
],
|
||||
"totActnSummary": "제출 시 필수",
|
||||
"muniDocNo": "제출 시 필수"
|
||||
"totActnSummary": "제출 시 권장",
|
||||
"muniDocNo": "제출 시 권장"
|
||||
}
|
||||
```
|
||||
|
||||
- `isSubmit: false` → `RB` 유지
|
||||
- `isSubmit: true` → `RB→RC`
|
||||
- `isSubmit: false` → `RB` 유지 (API만 존재, **화면 버튼 없음**. HWP·첨부는 실무협의서 관리 다이얼로그에서 저장)
|
||||
- `isSubmit: true` → `RB→RA` (조치완료)
|
||||
응답: `string` (pwrkDocNo). DTO명 `PwrkActnResultSubmitDto`.
|
||||
|
||||
### 3-6. `RC → RA → AV → RK`
|
||||
|
||||
**광역 제출** — `updatePrgrsStts` `RA`.
|
||||
|
||||
### 3-6. `RA → AV → RK`
|
||||
**2차 의뢰 (신규)** — `POST /fnrs-cnsltn-mng/{docNo}/request-actn-review`
|
||||
|
||||
```json
|
||||
{
|
||||
"envoDocNo": "환경청 공문번호",
|
||||
"specialRqstCn": "중점 검증 요청 (선택)"
|
||||
}
|
||||
```
|
||||
|
||||
`RA→AV`. 응답: `string` (pwrkDocNo). DTO명 `EnvoActnReviewReqDto`.
|
||||
`RA→AV`. 응답: `string` (docNo). DTO명 `EnvoActnReviewReqDto`. `specialRqstCn`만 선택.
|
||||
|
||||
금액검증 콜백은 서버. 콜백 후 서버가 `copy-line` 호출.
|
||||
|
||||
|
||||
@@ -157,7 +157,6 @@ requestCostVerification(body: DcRagCostVerifyRequestDto): Promise<DcRagCommonRes
|
||||
|
||||
| 필드 | 필수 |
|
||||
| --- | --- |
|
||||
| `envoDocNo` | Y |
|
||||
| `specialRqstCn` | N |
|
||||
|
||||
**`FnrsCnsltnFileVrfcRsltResponseDto`**: [ref-endpoint.md](./ref-endpoint.md) §3-3.
|
||||
@@ -168,13 +167,14 @@ requestCostVerification(body: DcRagCostVerifyRequestDto): Promise<DcRagCommonRes
|
||||
|
||||
| 필드 | 필수 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| `isSubmit` | Y | `false` 저장, `true` 협의회신 |
|
||||
| `actnList` | Y | |
|
||||
| `actnList[].rvwOpnnSn` | Y | |
|
||||
| `actnList[].actnRsltCn` | Y | |
|
||||
| `isSubmit` | Y | `false` API만(화면 버튼 없음), `true` 조치완료(`RB→RA`) |
|
||||
| `actnList` | N | 조치완료 시 선택 |
|
||||
| `actnList[].rvwOpnnSn` | Y (항목 있을 때) | |
|
||||
| `actnList[].actnRsltCn` | N | |
|
||||
| `actnList[].atchFileGroupId` | N | |
|
||||
| `totActnSummary` | 제출 시 | |
|
||||
| `muniDocNo` | 제출 시 | |
|
||||
| `totActnSummary` | N | 제출 시 권장 |
|
||||
| `muniDocNo` | N | 제출 시 권장 |
|
||||
| `pwrkAtchFileGroupId` | N | |
|
||||
|
||||
### 4-3. `PwrkCnsltnMng*`
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
2-Pass:
|
||||
|
||||
- **Pass 1** 기술 타당성: `SV→ST→LE→FV→ER→AC→TE→TP→RM→RB`
|
||||
- **Pass 2** 조치결과·금액: `RB→RC→RA→AV→RK→RR→EP→TA`
|
||||
- **Pass 2** 조치결과·금액: `RB→RA→AV→RK→RR→EP→TA`
|
||||
|
||||
자체종결(기술검토 생략): `LE→EP→TA`
|
||||
|
||||
@@ -74,12 +74,12 @@ sequenceDiagram
|
||||
rect rgb(250, 240, 255)
|
||||
환경청->>광역: [재원협의 결과 회신] TP → RM
|
||||
광역->>기초: [지자체회신] RM → RB
|
||||
기초->>광역: [협의회신] RB → RC
|
||||
기초->>환경청: [조치완료] RB → RA
|
||||
end
|
||||
|
||||
rect rgb(240, 255, 255)
|
||||
광역->>환경청: [조치결과서제출] RC → RA
|
||||
환경청->>AI: [조치결과서 검토요청] RA → AV
|
||||
환경청->>AI: [조치결과 검토의뢰] RA → AV
|
||||
AI-->>공단: 금액검증 콜백 AV → RK
|
||||
Note over 공단: 30분 미응답 시 RK + 수동 [금액검증]
|
||||
Note over 공단: AV→RK 시 1차 결재선 → 2차 복사 (서버)
|
||||
@@ -123,8 +123,8 @@ sequenceDiagram
|
||||
| 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` |
|
||||
| 10 | 조치결과 작성 | 기초 | `RB→RA` | `submit-actn-result` `isSubmit:true` (조치완료) |
|
||||
| 11 | 조치 의뢰 | 환경청 | `RA→AV` | `request-actn-review` |
|
||||
| 12 | AI 금액검증 | AI·공단 | `AV→RK` | 결재선 복사(서버) |
|
||||
| 13 | 2차 검토·결재 | 공단 | `RK→RR` / `SL` | 2차 결재선 |
|
||||
| 14 | 최종 회신 | 환경청·광역·기초 | `RR→EP→TA` | 종결 |
|
||||
@@ -149,7 +149,7 @@ sequenceDiagram
|
||||
| `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` |
|
||||
| `RA` | 조치결과서 제출(환경청) | Result Ask to Basin | 기초→환경청 | `RB→RA` |
|
||||
| `AV` | 금액검증 | Amount Verification | AI | `RA→AV` |
|
||||
| `RK` | 조치결과서검토 | Result Check | 공단 2차 결재 | `AV→RK`, `SL→RK` |
|
||||
| `RR` | 조치결과서검토 회신 | Result Return to Basin | 공단→환경청 | `RK→RR` |
|
||||
@@ -200,7 +200,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→RK` | 환경청 [조치결과 검토의뢰] | 30분 → `RK` + [금액검증] | `POST /dcrag-to/documents/cost/verify` |
|
||||
|
||||
- 예상 소요 안내는 **30분** (v1.0의 10분은 폐기).
|
||||
- 콜백 수신·스케줄러 전이는 서버. FE는 상세 refetch / draft status 폴링만.
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
|
||||
| 화면 | 소유 | 라우트 (현행) | 비고 |
|
||||
| --- | --- | --- | --- |
|
||||
| 작성화면 | 기초 `ROLE_USER05` | `/fnrs-cnsltn/new`, `/fnrs-cnsltn/{id}/edit` | `SV`, `RB` |
|
||||
| 작성화면 | 기초 `ROLE_USER05` | `/fnrs-cnsltn/new`, `/fnrs-cnsltn/{id}/edit` | `SV` |
|
||||
| 수정화면 | 기초 | `/fnrs-cnsltn/{id}/edit` | `SL` 보완 |
|
||||
| 상세화면 | 광역·환경청·공단·기초(TA) | `/fnrs-cnsltn/{id}` | 조회 + 액션 버튼 |
|
||||
| 상세화면 | 광역·환경청·공단·기초(`RB`/`TA`) | `/fnrs-cnsltn/{id}` | 조회 + 액션 버튼 |
|
||||
| 자동화 | AI | 버튼 없음 | `FV`/`AC`/`AV` 진행 패널 |
|
||||
|
||||
공단 상세는 결재선 구분으로 같은 상태라도 버튼이 다르다.
|
||||
@@ -57,10 +57,11 @@ API 컬럼은 [ref-endpoint.md](./ref-endpoint.md)와 동일.
|
||||
| 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`** |
|
||||
| 25 | 상세 | USER05 | - | `RB` | 실무협의서 관리 | 유지 | — | 웹기안기 Open |
|
||||
| 26 | 상세 | USER05 | - | `RB` | 조치완료 | `RA` | 환경청 | **`submit-actn-result` `isSubmit:true`** |
|
||||
| 27 | 상세 | USER04 | - | `RC` | 조치결과서제출 | `RA` | 환경청 | `updatePrgrsStts` |
|
||||
| 28 | 상세 | USER03 | - | `RA` | 조치결과서 검토요청 | `AV` | 공단 검토 | **`request-actn-review`** |
|
||||
| 28 | 상세 | USER03 | - | `RA` | 실무협의서 관리 | 유지 | — | 웹기안기 Open. **저장 불가** |
|
||||
| 28a | 상세 | USER03 | - | `RA` | 조치결과 검토의뢰 | `AV` | 공단 검토 | **`request-actn-review`** |
|
||||
| 29 | 자동화 | AI | - | `AV` | (금액검증) | `RK` | 공단 검토 | FE 호출 없음. 진행 패널 |
|
||||
| 30 | 상세 | USER01 | 검토자 | `RK` | 실무협의서관리 | 유지 | — | 웹기안기 |
|
||||
| 31 | 상세 | USER01 | 검토자 | `RK` | 금액검증 | `RK` | — | `dcrag-to/cost/verify` |
|
||||
@@ -85,13 +86,12 @@ API 컬럼은 [ref-endpoint.md](./ref-endpoint.md)와 동일.
|
||||
| `fileSplmnt` | 파일보완 | `ER` 접수자. 현행에 없음 |
|
||||
| `fileVrfc` | 파일검증 | 유지. `ER` 재실행 |
|
||||
| `dsgntRvwr` | 검토자지정 | 유지. 전이를 `AC`로 |
|
||||
| `pwrkCnsltnDocMng` | 실무협의서관리 | 유지. `TE`·`RK` |
|
||||
| `pwrkCnsltnDocMng` | 실무협의서관리 | 유지. `TE`·`RK`·`RB`·`RA`(조회 전용) |
|
||||
| `pwrkCnsltnDocCrt` | 실무협의서생성 | 유지. 실패/타임아웃만 |
|
||||
| `rvwRsltInq` | 검토결과조회 | 신규 |
|
||||
| `actnStrg` | 저장(조치결과) | `RB` |
|
||||
| `cnsltnRply` | 협의회신 | `RB→RC` |
|
||||
| `actnCmptn` | 조치완료 | `RB→RA`. `isSubmit:true` |
|
||||
| `actnSbmsn` | 조치결과서제출 | 광역 `RC→RA` |
|
||||
| `rqstActnRvw` | 조치결과서 검토요청 | 환경청 `RA→AV` |
|
||||
| `rqstActnRvw` | 조치결과 검토의뢰 | 환경청 `RA→AV` |
|
||||
| `costVrfc` | 금액검증 | `RK` |
|
||||
| `lclRply` | 지자체회신 | 광역 `RM→RB` |
|
||||
| `fnrsRsltRply` | 재원협의 결과 회신 | 환경청 `TP→RM` |
|
||||
@@ -107,7 +107,7 @@ API 컬럼은 [ref-endpoint.md](./ref-endpoint.md)와 동일.
|
||||
ROLE_USER05
|
||||
"" | SV → 저장, 신청완료, 삭제
|
||||
SL → (직전 ER) 파일보완완료 | (직전 TE|RK) 보완완료
|
||||
RB → 저장(조치), 협의회신
|
||||
RB → 실무협의서 관리, 조치완료
|
||||
TA → 결과회신 (조회)
|
||||
|
||||
ROLE_USER04
|
||||
@@ -119,7 +119,7 @@ ROLE_USER04
|
||||
ROLE_USER03
|
||||
LE → 결과회신(자체종결), 기술검토 의뢰
|
||||
TP → 재원협의 결과 회신
|
||||
RA → 조치결과서 검토요청
|
||||
RA → 실무협의서 관리(조회), 조치결과 검토의뢰
|
||||
RR → 결과회신
|
||||
```
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ export const fnrsExamineRequestSchema = z.object({
|
||||
|
||||
/** 백엔드 EnvoActnReviewReqDto */
|
||||
export const envoActnReviewReqSchema = z.object({
|
||||
envoDocNo: z.string().min(1),
|
||||
specialRqstCn: z.string().optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -5,14 +5,37 @@ import type {
|
||||
PwrkCnsltnMngDetResponseDto,
|
||||
PwrkCnsltnMngService,
|
||||
} from "../model/pwrk-cnsltn-mng-interface";
|
||||
import {
|
||||
type PwrkActnResultSubmitDto,
|
||||
pwrkActnResultSubmitSchema,
|
||||
} from "../model/pwrk-cnsltn-mng-payload-schema";
|
||||
|
||||
const BASE = ENDPOINT.AS_SERVICE.PWRK_CNSLTN_MNG;
|
||||
|
||||
async function findById(pwrkCnsltnDocNo: string): Promise<PwrkCnsltnMngDetResponseDto> {
|
||||
const response = await client.get<PwrkCnsltnMngDetResponseDto>(`${BASE}/${pwrkCnsltnDocNo}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findByUpDocNo(upDocNo: string): Promise<PwrkCnsltnMngDetResponseDto> {
|
||||
const response = await client.get<PwrkCnsltnMngDetResponseDto>(`${BASE}/updocno/${upDocNo}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function submitActnResult(
|
||||
pwrkCnsltnDocNo: string,
|
||||
body: PwrkActnResultSubmitDto,
|
||||
): Promise<string> {
|
||||
const payload = pwrkActnResultSubmitSchema.parse(body);
|
||||
const response = await client.post<string>(
|
||||
`${BASE}/${pwrkCnsltnDocNo}/submit-actn-result`,
|
||||
payload,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const pwrkCnsltnMngService = {
|
||||
findById,
|
||||
findByUpDocNo,
|
||||
submitActnResult,
|
||||
} satisfies PwrkCnsltnMngService;
|
||||
|
||||
@@ -4,9 +4,21 @@ import { pwrkCnsltnMngService } from "./pwrk-cnsltn-mng-service";
|
||||
|
||||
export const pwrkCnsltnMngKeys = {
|
||||
all: ["pwrk-cnsltn-mng"] as const,
|
||||
byId: (pwrkCnsltnDocNo: string) => [...pwrkCnsltnMngKeys.all, "by-id", pwrkCnsltnDocNo] as const,
|
||||
byUpDocNo: (upDocNo: string) => [...pwrkCnsltnMngKeys.all, "by-up-doc-no", upDocNo] as const,
|
||||
};
|
||||
|
||||
export function usePwrkCnsltnMng(
|
||||
pwrkCnsltnDocNo: string | null | undefined,
|
||||
isEnabled = true,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: pwrkCnsltnMngKeys.byId(pwrkCnsltnDocNo!),
|
||||
queryFn: () => pwrkCnsltnMngService.findById(pwrkCnsltnDocNo!),
|
||||
enabled: Boolean(pwrkCnsltnDocNo) && isEnabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePwrkCnsltnMngByUpDocNo(upDocNo: string | null | undefined, isEnabled = true) {
|
||||
return useQuery({
|
||||
queryKey: pwrkCnsltnMngKeys.byUpDocNo(upDocNo!),
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
export { pwrkCnsltnMngService } from "./api/pwrk-cnsltn-mng-service";
|
||||
export { pwrkCnsltnMngKeys, usePwrkCnsltnMngByUpDocNo } from "./api/queries";
|
||||
export { pwrkCnsltnMngKeys, usePwrkCnsltnMng, usePwrkCnsltnMngByUpDocNo } from "./api/queries";
|
||||
export { findCompletedPwrkDraft } from "./lib/find-completed-draft";
|
||||
export type {
|
||||
PwrkCnsltnCnsltnResponseDto,
|
||||
PwrkCnsltnDocCrtDmndResponseDto,
|
||||
PwrkCnsltnDocCrtRsltResponseDto,
|
||||
PwrkCnsltnMngDetResponseDto,
|
||||
PwrkCnsltnMngService,
|
||||
PwrkCnsltnPjtcoAjmtResponseDto,
|
||||
} from "./model/pwrk-cnsltn-mng-interface";
|
||||
export {
|
||||
type PwrkActnResultSubmitDto,
|
||||
pwrkActnResultSubmitSchema,
|
||||
} from "./model/pwrk-cnsltn-mng-payload-schema";
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import type { AtflMngClientResponseDto } from "@/shared/model";
|
||||
|
||||
import type { PwrkActnResultSubmitDto } from "./pwrk-cnsltn-mng-payload-schema";
|
||||
|
||||
export interface PwrkCnsltnDocCrtRsltResponseDto {
|
||||
crtDmndUuid: string;
|
||||
verSn: number;
|
||||
@@ -10,25 +14,84 @@ export interface PwrkCnsltnDocCrtRsltResponseDto {
|
||||
export interface PwrkCnsltnDocCrtDmndResponseDto {
|
||||
crtDmndUuid: string;
|
||||
pwrkCnsltnDocNo: string;
|
||||
orgnlDocNo?: string;
|
||||
orgnlDocSeComCd?: string;
|
||||
orgnlDocSeComCdNm?: string;
|
||||
crtSysDmndId?: string;
|
||||
crtSttsComCd?: string;
|
||||
crtSttsComCdNm?: string;
|
||||
failRsn?: string;
|
||||
dmndDt?: string;
|
||||
cmptnDt?: string;
|
||||
rqstrUuid?: string;
|
||||
rqstrNm?: string;
|
||||
crtRslt?: PwrkCnsltnDocCrtRsltResponseDto | null;
|
||||
}
|
||||
|
||||
/** 백엔드 PwrkCnsltnMngDetResponseDto.CnsltnResponseDto */
|
||||
export interface PwrkCnsltnCnsltnResponseDto {
|
||||
pwrkCnsltnDocNo?: string;
|
||||
rvwOpnnSn?: number;
|
||||
rvwOpnnClsfComCd?: string;
|
||||
rvwOpnnClsfComCdNm?: string;
|
||||
rvwOpnn?: string;
|
||||
cnsltnCn?: string;
|
||||
rfltYn?: string;
|
||||
rmrk?: string;
|
||||
atchFileGroupId?: string;
|
||||
atchFileInfo?: AtflMngClientResponseDto;
|
||||
rvwrNm?: string;
|
||||
rvwrMblTelno?: string;
|
||||
rvwrOgdpNm?: string;
|
||||
rvwrEmlAddr?: string;
|
||||
lclgvPicNm?: string;
|
||||
lclgvPicTelno?: string;
|
||||
lclgvPicOgdpNm?: string;
|
||||
lclgvPicEmlAddr?: string;
|
||||
crtDmndUuid?: string;
|
||||
crtYn?: string;
|
||||
hwrtChgYn?: string;
|
||||
verSn?: number;
|
||||
dtlSn?: number;
|
||||
}
|
||||
|
||||
/** 백엔드 PwrkCnsltnMngDetResponseDto.PjtcoAjmtResponseDto */
|
||||
export interface PwrkCnsltnPjtcoAjmtResponseDto {
|
||||
pwrkCnsltnDocNo?: string;
|
||||
rvwOpnnSn?: number;
|
||||
cnstpSeCd?: string;
|
||||
rvwOpnnClsfComCd?: string;
|
||||
rvwOpnnClsfComCdNm?: string;
|
||||
ognTpcoAmt?: number;
|
||||
ognGvexpAmt?: number;
|
||||
ognLcexpAmt?: number;
|
||||
chgTpcoAmt?: number;
|
||||
chgGvexpAmt?: number;
|
||||
chgLcexpAmt?: number;
|
||||
}
|
||||
|
||||
export interface PwrkCnsltnMngDetResponseDto {
|
||||
pwrkCnsltnDocNo: string;
|
||||
upDocNo: string;
|
||||
upDocSeComCd?: string;
|
||||
upDocSeComCdNm?: string;
|
||||
prgrsSttsComCd?: string;
|
||||
prgrsSttsComCdNm?: string;
|
||||
bizNm?: string;
|
||||
atchFileGroupId?: string;
|
||||
regDt?: string;
|
||||
rgtrNm?: string;
|
||||
cnsltnList?: PwrkCnsltnCnsltnResponseDto[];
|
||||
pjtcoAjmtList?: PwrkCnsltnPjtcoAjmtResponseDto[];
|
||||
crtDmndList?: PwrkCnsltnDocCrtDmndResponseDto[];
|
||||
crtRsltList?: PwrkCnsltnDocCrtRsltResponseDto[];
|
||||
}
|
||||
|
||||
export interface PwrkCnsltnMngService {
|
||||
findById(pwrkCnsltnDocNo: string): Promise<PwrkCnsltnMngDetResponseDto>;
|
||||
findByUpDocNo(upDocNo: string): Promise<PwrkCnsltnMngDetResponseDto>;
|
||||
submitActnResult(
|
||||
pwrkCnsltnDocNo: string,
|
||||
body: PwrkActnResultSubmitDto,
|
||||
): Promise<string>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/** 백엔드 PwrkActnResultSubmitDto.ActnItemDto */
|
||||
export const pwrkActnResultActnItemSchema = z.object({
|
||||
rvwOpnnSn: z.number(),
|
||||
actnRsltCn: z.string().optional(),
|
||||
rfltYn: z.string().optional(),
|
||||
atchFileGroupId: z.string().optional(),
|
||||
});
|
||||
|
||||
/** 백엔드 PwrkActnResultSubmitDto */
|
||||
export const pwrkActnResultSubmitSchema = z.object({
|
||||
isSubmit: z.boolean(),
|
||||
actnList: z.array(pwrkActnResultActnItemSchema).optional(),
|
||||
totActnSummary: z.string().optional(),
|
||||
muniDocNo: z.string().optional(),
|
||||
pwrkAtchFileGroupId: z.string().optional(),
|
||||
});
|
||||
|
||||
export type PwrkActnResultSubmitDto = z.infer<typeof pwrkActnResultSubmitSchema>;
|
||||
@@ -64,7 +64,7 @@ export const sewerBscPlanFnrsCstItemSchema = z.object({
|
||||
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanSplmntDmndItem */
|
||||
export const sewerBscPlanSplmntDmndItemSchema = z.object({
|
||||
splmntDmndSn: z.number().nullish(),
|
||||
splmntArtclComCd: z.string().optional(),
|
||||
splmntArtclComCd: z.string().nullish(),
|
||||
splmntDmndCn: z.string().optional(),
|
||||
splmntDmndDt: z.string().optional(),
|
||||
splmntDmndAtchFileGroupId: z.string().optional(),
|
||||
|
||||
@@ -169,7 +169,9 @@ function DesignFormPage({ mode, initialData, createDefaults }: DesignFormPagePro
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSave)} className="mt-6 space-y-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?.designCnstnDocNo && (
|
||||
<ProgressStatusSection
|
||||
|
||||
@@ -3,10 +3,8 @@
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { Paperclip, X } from "lucide-react";
|
||||
|
||||
import { getDefaultFileKey, useTus } from "@/shared/lib";
|
||||
import { TextShimmer } from "@/shared/ui";
|
||||
import { AttachmentFileField } from "@/shared/ui";
|
||||
import { attachService } from "@/entities/attach";
|
||||
import { CodeSelect } from "@/entities/code";
|
||||
import type { EsntlAtchFileMng } from "@/entities/esntl";
|
||||
@@ -217,57 +215,16 @@ function EsntlAtchFileFormDialog({
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={fileInputId}>양식파일</Label>
|
||||
<div className="space-y-2">
|
||||
{uploadedFiles.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{uploadedFiles.map((item, index) => {
|
||||
const uploading = isUploading[item.fileKey] ?? false;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.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}>
|
||||
{item.fileName}
|
||||
</TextShimmer>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleFileRemove(index);
|
||||
}}
|
||||
disabled={uploading}
|
||||
>
|
||||
<X className="size-5 shrink-0 text-destructive" aria-label="삭제" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<label htmlFor={fileInputId} className="block">
|
||||
<input
|
||||
id={fileInputId}
|
||||
type="file"
|
||||
multiple
|
||||
className="peer sr-only"
|
||||
onChange={(e) => {
|
||||
void handleFileUpload(e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="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>
|
||||
</div>
|
||||
<AttachmentFileField
|
||||
files={uploadedFiles.map((item) => ({
|
||||
fileKey: item.fileKey,
|
||||
fileName: item.fileName,
|
||||
}))}
|
||||
inputId={fileInputId}
|
||||
isUploadingByKey={isUploading}
|
||||
onAdd={(fileList) => void handleFileUpload(fileList)}
|
||||
onRemove={(index) => void handleFileRemove(index)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>필수첨부여부</Label>
|
||||
|
||||
@@ -6,17 +6,21 @@ export const FNRS_CNSLTN_VIEW_BUTTON_ACTION = {
|
||||
envoSbmsn: "envoSbmsn",
|
||||
rjct: "rjct",
|
||||
sncnAprv: "sncnAprv",
|
||||
lclRply: "lclRply",
|
||||
rvwRply: "rvwRply",
|
||||
rqstTechRvw: "rqstTechRvw",
|
||||
fileSplmnt: "fileSplmnt",
|
||||
dsgntRvwr: "dsgntRvwr",
|
||||
pwrkCnsltnDocCrt: "pwrkCnsltnDocCrt",
|
||||
pwrkCnsltnDocMng: "pwrkCnsltnDocMng",
|
||||
rvwRsltInq: "rvwRsltInq",
|
||||
rvwStrg: "rvwStrg",
|
||||
rvwCmptn: "rvwCmptn",
|
||||
dmndSplmnt: "dmndSplmnt",
|
||||
atrzAprv: "atrzAprv",
|
||||
atrzRply: "atrzRply",
|
||||
actnCmptn: "actnCmptn",
|
||||
rqstActnRvw: "rqstActnRvw",
|
||||
} as const;
|
||||
|
||||
export type FnrsCnsltnViewButtonAction =
|
||||
@@ -34,15 +38,24 @@ 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],
|
||||
},
|
||||
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],
|
||||
},
|
||||
ROLE_USER03: {
|
||||
LE: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRply, FNRS_CNSLTN_VIEW_BUTTON_ACTION.rqstTechRvw],
|
||||
TP: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRply],
|
||||
RA: [
|
||||
FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng,
|
||||
FNRS_CNSLTN_VIEW_BUTTON_ACTION.rqstActnRvw,
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -59,11 +72,15 @@ const ROLE_USER01_ATRZ_STATUS_ACTIONS: Record<
|
||||
ATRZ02: [
|
||||
FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocCrt,
|
||||
FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng,
|
||||
FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRsltInq,
|
||||
FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwStrg,
|
||||
FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwCmptn,
|
||||
FNRS_CNSLTN_VIEW_BUTTON_ACTION.dmndSplmnt,
|
||||
],
|
||||
ATRZ03: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzAprv],
|
||||
ATRZ03: [
|
||||
FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng,
|
||||
FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzAprv,
|
||||
],
|
||||
ATRZ99: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzRply],
|
||||
},
|
||||
RK: {
|
||||
@@ -81,7 +98,7 @@ interface GetButtonConfigsOptions {
|
||||
}
|
||||
|
||||
const PENDING_APPROVAL_ACTIONS = new Set<FnrsCnsltnViewButtonAction>([
|
||||
FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwCmptn,
|
||||
...ROLE_USER01_ATRZ_STATUS_ACTIONS.TE.ATRZ02,
|
||||
FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzAprv,
|
||||
FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzRply,
|
||||
]);
|
||||
@@ -109,8 +126,14 @@ function resolveRoleUser01AtrzButtonActions(
|
||||
}
|
||||
|
||||
return actions.filter((action) => {
|
||||
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) {
|
||||
if (
|
||||
action === FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng ||
|
||||
action === FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRsltInq
|
||||
) {
|
||||
return options.hasCompletedDraft;
|
||||
}
|
||||
if (action === FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocCrt) {
|
||||
@@ -118,9 +141,7 @@ function resolveRoleUser01AtrzButtonActions(
|
||||
}
|
||||
}
|
||||
|
||||
const needsPendingApproval = PENDING_APPROVAL_ACTIONS.has(action);
|
||||
|
||||
return !needsPendingApproval || options?.hasPendingApprovalRequest !== false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ export const fnrsCnsltnRegistSchema = z.object({
|
||||
splmntDmndList: z.array(
|
||||
z.object({
|
||||
splmntDmndSn: z.number().nullish(),
|
||||
splmntArtclComCd: z.string().optional(),
|
||||
splmntArtclComCd: z.string().nullish(),
|
||||
splmntArtclCdNm: z.string().nullish(),
|
||||
splmntDmndCn: z.string().optional(),
|
||||
splmntDmndDt: z.string().optional(),
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
FnrsCnsltnMngResponseDto,
|
||||
FnrsCnsltnSplmntDmndItem,
|
||||
} from "@/entities/fnrs-cnsltn";
|
||||
import type { PwrkCnsltnCnsltnResponseDto } from "@/entities/pwrk-cnsltn-mng";
|
||||
import {
|
||||
mapFnrsSharDetToFormList,
|
||||
type SplmntDmndDialogConfirmPayload,
|
||||
@@ -129,6 +130,26 @@ export function buildFileSplmntReqPayload(
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRvwRsltSplmntReqPayload(
|
||||
data: FnrsCnsltnMngDetResponseDto,
|
||||
selected: PwrkCnsltnCnsltnResponseDto[],
|
||||
checklistFormState: ChecklistFormState,
|
||||
): FnrsCnsltnApplySaveRequestDto {
|
||||
const existingItems: FnrsCnsltnSplmntDmndItem[] = (data.splmntDmndList ?? []).map((item) =>
|
||||
toSplmntDmndSaveItem(item),
|
||||
);
|
||||
|
||||
const newItems: FnrsCnsltnSplmntDmndItem[] = selected.map((row) => ({
|
||||
splmntDmndCn: row.rvwOpnn,
|
||||
cmptnYn: "N",
|
||||
}));
|
||||
|
||||
return {
|
||||
...buildSavePayload(data, checklistFormState, "LOCAL", "SL"),
|
||||
splmntDmndList: [...existingItems, ...newItems],
|
||||
};
|
||||
}
|
||||
|
||||
export function toSplmntDmndSaveItem(item: FnrsCnsltnSplmntDmndItem): FnrsCnsltnSplmntDmndItem {
|
||||
return {
|
||||
splmntDmndSn: item.splmntDmndSn,
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "@/entities/elapr";
|
||||
import {
|
||||
type DocPrgrsUpdateRequestDto,
|
||||
type EnvoActnReviewReqDto,
|
||||
type FnrsCnsltnApplySaveRequestDto,
|
||||
type FnrsCnsltnFileVrfcRsltResponseDto,
|
||||
fnrsCnsltnKeys,
|
||||
@@ -28,7 +29,13 @@ import {
|
||||
fnrsCnsltnMngService,
|
||||
type FnrsExamineRequestDto,
|
||||
} from "@/entities/fnrs-cnsltn";
|
||||
import { findCompletedPwrkDraft, usePwrkCnsltnMngByUpDocNo } from "@/entities/pwrk-cnsltn-mng";
|
||||
import {
|
||||
findCompletedPwrkDraft,
|
||||
type PwrkActnResultSubmitDto,
|
||||
type PwrkCnsltnCnsltnResponseDto,
|
||||
pwrkCnsltnMngService,
|
||||
usePwrkCnsltnMngByUpDocNo,
|
||||
} from "@/entities/pwrk-cnsltn-mng";
|
||||
import { SplmntDmndDialog, type SplmntDmndDialogConfirmPayload } from "@/widgets/pbadms-sprt";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
@@ -41,6 +48,7 @@ import {
|
||||
import { computeReviewStepChecklistProgress } from "../../lib/checklist-utils";
|
||||
import {
|
||||
buildFileSplmntReqPayload,
|
||||
buildRvwRsltSplmntReqPayload,
|
||||
buildSavePayload,
|
||||
buildSupplementReqPayload,
|
||||
buildTechReviewReplyPayload,
|
||||
@@ -51,6 +59,7 @@ import type { TechReviewOpinionSectionHandle } from "../sections/tech-review-opi
|
||||
|
||||
import { FileVrfcSplmntDialog } from "./file-vrfc-splmnt-dialog";
|
||||
import { PwrkCnsltnDocMngDialog } from "./pwrk-cnsltn-doc-mng-dialog";
|
||||
import { PwrkCnsltnRvwRsltInqDialog } from "./pwrk-cnsltn-rvw-rslt-inq-dialog";
|
||||
|
||||
interface FloatingActionButtonsProps {
|
||||
data: FnrsCnsltnMngDetResponseDto;
|
||||
@@ -73,6 +82,7 @@ type ActiveDialog =
|
||||
| "fileSplmnt"
|
||||
| "elaprApproval"
|
||||
| "pwrkCnsltnDocMng"
|
||||
| "rvwRsltInq"
|
||||
| null;
|
||||
|
||||
function FloatingActionButtons({
|
||||
@@ -102,13 +112,16 @@ function FloatingActionButtons({
|
||||
const needsAtrzResolve = needsRoleUser01AtrzResolve(dtlInstCd, prgrsSttsComCd);
|
||||
const needsTeApprovalCheck = needsAtrzResolve && prgrsSttsComCd === "TE";
|
||||
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);
|
||||
const { data: pwrkCnsltnData, isLoading: isPwrkCnsltnLoading } = usePwrkCnsltnMngByUpDocNo(
|
||||
docNo,
|
||||
needsPwrkDraftCheck,
|
||||
needsPwrkLookup,
|
||||
);
|
||||
const hasCompletedDraft = Boolean(findCompletedPwrkDraft(pwrkCnsltnData));
|
||||
const pwrkCnsltnDocNo = pwrkCnsltnData?.pwrkCnsltnDocNo;
|
||||
|
||||
const hasPendingApprovalRequest = hasCurrentUserPendingApprovalRequest(
|
||||
elaprDocPrgrsList,
|
||||
@@ -265,6 +278,19 @@ 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,
|
||||
@@ -311,7 +337,7 @@ function FloatingActionButtons({
|
||||
data,
|
||||
checklistFormState,
|
||||
"ENV",
|
||||
"EP",
|
||||
"RM",
|
||||
"CP",
|
||||
getReviewOpinionFormData(),
|
||||
opnrNm,
|
||||
@@ -320,7 +346,7 @@ function FloatingActionButtons({
|
||||
await executeAction({
|
||||
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRply,
|
||||
request: payload,
|
||||
successMessage: "검토결과 회신이 완료되었습니다.",
|
||||
successMessage: "재원협의 결과 회신이 완료되었습니다.",
|
||||
});
|
||||
techReviewOpinionRef?.current?.resetForm();
|
||||
}
|
||||
@@ -336,6 +362,15 @@ function FloatingActionButtons({
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRqstActnRvwClick() {
|
||||
const payload: EnvoActnReviewReqDto = {};
|
||||
await executeAction({
|
||||
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.rqstActnRvw,
|
||||
request: payload,
|
||||
successMessage: "조치결과 검토의뢰가 완료되었습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
async function handleElaprTmpltConfirm({ aplnRows }: ElaprTmpltPickerConfirmPayload) {
|
||||
const payload: ElaprDmndSaveRequestDto = {
|
||||
elaprDocNo: docNo,
|
||||
@@ -435,6 +470,32 @@ function FloatingActionButtons({
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRvwRsltSplmntConfirm(selected: PwrkCnsltnCnsltnResponseDto[]) {
|
||||
setActiveDialog(null);
|
||||
const requestPayload = buildRvwRsltSplmntReqPayload(data, selected, checklistFormState);
|
||||
await executeAction({
|
||||
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.dmndSplmnt,
|
||||
request: requestPayload,
|
||||
successMessage: "보완요청이 처리되었습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
async function handleActnCmptnClick() {
|
||||
if (!pwrkCnsltnDocNo) {
|
||||
alert("실무협의 문서번호를 찾을 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
await executeAction({
|
||||
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.actnCmptn,
|
||||
request: {
|
||||
pwrkCnsltnDocNo,
|
||||
body: { isSubmit: true } satisfies PwrkActnResultSubmitDto,
|
||||
},
|
||||
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)]">
|
||||
@@ -444,6 +505,21 @@ function FloatingActionButtons({
|
||||
</Button>
|
||||
{!isAiPending && (
|
||||
<>
|
||||
{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>
|
||||
)}
|
||||
|
||||
{canEdit && (
|
||||
<Button color="gray" onClick={handleEdit} disabled={isProcessing}>
|
||||
수정
|
||||
@@ -456,7 +532,7 @@ function FloatingActionButtons({
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.aply) && (
|
||||
<Button color="green" onClick={handleAplyClick} disabled={isProcessing}>
|
||||
신청완료
|
||||
신청 완료
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.del) && (
|
||||
@@ -471,7 +547,12 @@ function FloatingActionButtons({
|
||||
)}
|
||||
{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) && (
|
||||
@@ -486,36 +567,41 @@ function FloatingActionButtons({
|
||||
)}
|
||||
{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.rqstActnRvw) && (
|
||||
<Button onClick={handleRqstActnRvwClick} 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}>
|
||||
실무협의서 초안생성
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.actnCmptn) && (
|
||||
<Button color="green" onClick={handleActnCmptnClick} disabled={isProcessing}>
|
||||
조치 완료
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng) && (
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRsltInq) && (
|
||||
<Button
|
||||
color="orange"
|
||||
onClick={() => setActiveDialog("pwrkCnsltnDocMng")}
|
||||
color="sky"
|
||||
onClick={() => setActiveDialog("rvwRsltInq")}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
실무협의서 관리
|
||||
검토 결과 조회
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwStrg) && (
|
||||
@@ -525,12 +611,12 @@ function FloatingActionButtons({
|
||||
)}
|
||||
{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) && (
|
||||
@@ -588,8 +674,18 @@ function FloatingActionButtons({
|
||||
<PwrkCnsltnDocMngDialog
|
||||
open={activeDialog === "pwrkCnsltnDocMng"}
|
||||
fnrsCnsltnDocNo={docNo}
|
||||
atrzSeComCdList={atrzSeComCdList}
|
||||
canSave={prgrsSttsComCd === "RA" ? false : undefined}
|
||||
onClose={() => setActiveDialog(null)}
|
||||
/>
|
||||
|
||||
<PwrkCnsltnRvwRsltInqDialog
|
||||
open={activeDialog === "rvwRsltInq"}
|
||||
fnrsCnsltnDocNo={docNo}
|
||||
isSubmitting={isProcessing}
|
||||
onClose={() => setActiveDialog(null)}
|
||||
onConfirm={handleRvwRsltSplmntConfirm}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
@@ -680,6 +776,36 @@ async function handleRequestExamine(
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRequestActnReview(
|
||||
context: ActionHandlerContext<EnvoActnReviewReqDto>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const docNo = context.data.detDto?.fnrsCnsltnDocNo;
|
||||
if (!docNo) throw new Error("문서번호가 없습니다.");
|
||||
await fnrsCnsltnMngService.requestActnReview(docNo, context.request);
|
||||
context.onSuccess?.();
|
||||
} catch (error) {
|
||||
context.onError?.(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleActnCmptn(
|
||||
context: ActionHandlerContext<{
|
||||
pwrkCnsltnDocNo: string;
|
||||
body: PwrkActnResultSubmitDto;
|
||||
}>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await pwrkCnsltnMngService.submitActnResult(
|
||||
context.request.pwrkCnsltnDocNo,
|
||||
context.request.body,
|
||||
);
|
||||
context.onSuccess?.();
|
||||
} catch (error) {
|
||||
context.onError?.(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAtrzAprv(context: ActionHandlerContext): Promise<void> {
|
||||
// TODO: 결재 승인 처리 API 호출
|
||||
context.onSuccess?.();
|
||||
@@ -698,17 +824,21 @@ const buttonActionHandlers: Record<FnrsCnsltnViewButtonAction, ActionHandler> =
|
||||
envoSbmsn: handleChangePrgrsSttsComCd,
|
||||
rjct: handleChangePrgrsSttsComCd,
|
||||
sncnAprv: handleChangePrgrsSttsComCd,
|
||||
lclRply: handleChangePrgrsSttsComCd,
|
||||
rvwRply: handleTechReview,
|
||||
rqstTechRvw: handleRequestExamine,
|
||||
fileSplmnt: handleDmndSplmnt,
|
||||
dsgntRvwr: handleDsgntRvwr,
|
||||
pwrkCnsltnDocCrt: () => Promise.resolve(),
|
||||
pwrkCnsltnDocMng: () => Promise.resolve(),
|
||||
rvwRsltInq: () => Promise.resolve(),
|
||||
rvwStrg: handleTechReview,
|
||||
rvwCmptn: handleTechReview,
|
||||
dmndSplmnt: handleDmndSplmnt,
|
||||
atrzAprv: handleAtrzAprv,
|
||||
atrzRply: handleAtrzRply,
|
||||
actnCmptn: handleActnCmptn,
|
||||
rqstActnRvw: handleRequestActnReview,
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
@@ -4,15 +4,10 @@ import { useEffect, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Paperclip, X } from "lucide-react";
|
||||
|
||||
import { getDefaultFileKey, useAlertStore, useTus } from "@/shared/lib";
|
||||
import { TextShimmer } from "@/shared/ui";
|
||||
import {
|
||||
attachService,
|
||||
parseFilenameFromResponse,
|
||||
useDownloadAttachmentByAtflId,
|
||||
} from "@/entities/attach";
|
||||
import { AttachmentFileField } from "@/shared/ui";
|
||||
import { attachService, parseFilenameFromResponse } from "@/entities/attach";
|
||||
import {
|
||||
pwrkCnsltnDocCrtRsltSaveRequestSchema,
|
||||
pwrkCnsltnDocCrtRsltService,
|
||||
@@ -42,9 +37,13 @@ const HangulGiangi = dynamic(
|
||||
|
||||
const PWRK_ATCH_FILE_KEY_PREFIX = "pwrk-cnsltn-doc::";
|
||||
|
||||
const APPROVER_ATRZ_SE_COM_CDS = new Set(["ATRZ03", "ATRZ99"]);
|
||||
|
||||
interface PwrkCnsltnDocMngDialogProps {
|
||||
open: boolean;
|
||||
fnrsCnsltnDocNo: string;
|
||||
atrzSeComCdList: string[];
|
||||
canSave?: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -59,10 +58,15 @@ function createPwrkAtchUploadKey(file: File) {
|
||||
return `${PWRK_ATCH_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
|
||||
}
|
||||
|
||||
function PwrkCnsltnDocMngDialog({ open, fnrsCnsltnDocNo, onClose }: PwrkCnsltnDocMngDialogProps) {
|
||||
function PwrkCnsltnDocMngDialog({
|
||||
open,
|
||||
fnrsCnsltnDocNo,
|
||||
atrzSeComCdList,
|
||||
canSave: canSaveProp,
|
||||
onClose,
|
||||
}: PwrkCnsltnDocMngDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const downloadByAtflId = useDownloadAttachmentByAtflId();
|
||||
const [atchFileGroupId, setAtchFileGroupId] = useState("");
|
||||
const [uploadedFiles, setUploadedFiles] = useState<UploadedAtchFile[]>([]);
|
||||
|
||||
@@ -229,12 +233,9 @@ function PwrkCnsltnDocMngDialog({ open, fnrsCnsltnDocNo, onClose }: PwrkCnsltnDo
|
||||
}
|
||||
}
|
||||
|
||||
function handleAtchFileDownload(atflId?: string) {
|
||||
if (!atflId) return;
|
||||
downloadByAtflId.mutate(atflId);
|
||||
}
|
||||
|
||||
const isAnyAtchUploading = uploadedFiles.some((item) => isUploading[item.fileKey]);
|
||||
const canSaveByAtrz = !atrzSeComCdList.some((code) => APPROVER_ATRZ_SE_COM_CDS.has(code));
|
||||
const canSave = canSaveProp ?? canSaveByAtrz;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
@@ -248,7 +249,7 @@ function PwrkCnsltnDocMngDialog({ open, fnrsCnsltnDocNo, onClose }: PwrkCnsltnDo
|
||||
|
||||
{isLoading ? <Skeleton className="h-[70vh] min-h-[40rem] w-full" /> : null}
|
||||
{isError ? (
|
||||
<p className="py-16 text-center text-lg text-destructive">
|
||||
<p className="py-16 text-center text-lg text-muted-foreground">
|
||||
실무협의서 정보를 불러오지 못했습니다.
|
||||
</p>
|
||||
) : null}
|
||||
@@ -263,6 +264,7 @@ function PwrkCnsltnDocMngDialog({ open, fnrsCnsltnDocNo, onClose }: PwrkCnsltnDo
|
||||
documentBlob={documentBlob}
|
||||
documentKey={completedDraft.crtDmndUuid}
|
||||
documentFileName={documentFileName}
|
||||
canSave={canSave}
|
||||
onSaveDocument={handleSaveDocument}
|
||||
onDownload={handleDownload}
|
||||
/>
|
||||
@@ -271,69 +273,22 @@ function PwrkCnsltnDocMngDialog({ open, fnrsCnsltnDocNo, onClose }: PwrkCnsltnDo
|
||||
|
||||
{completedDraft ? (
|
||||
<section className="space-y-2 rounded-md border border-border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xl font-semibold">첨부파일</h3>
|
||||
<label htmlFor="pwrk-cnsltn-doc-atch-file-input">
|
||||
<input
|
||||
type="file"
|
||||
id="pwrk-cnsltn-doc-atch-file-input"
|
||||
multiple
|
||||
className="peer sr-only"
|
||||
disabled={isAnyAtchUploading}
|
||||
onChange={(event) => {
|
||||
void handleAtchFileUpload(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={isAnyAtchUploading}
|
||||
asChild
|
||||
>
|
||||
<span>파일추가</span>
|
||||
</Button>
|
||||
</label>
|
||||
</div>
|
||||
{uploadedFiles.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{uploadedFiles.map((item, index) => {
|
||||
const uploading = isUploading[item.fileKey] ?? false;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.fileKey}
|
||||
className="inline-flex items-center justify-between gap-2 rounded-sm border bg-accent p-2"
|
||||
>
|
||||
<Paperclip className="size-5 shrink-0" />
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 text-left disabled:cursor-default"
|
||||
disabled={!item.atflId || downloadByAtflId.isPending}
|
||||
onClick={() => handleAtchFileDownload(item.atflId)}
|
||||
>
|
||||
<TextShimmer className="text-xl" active={uploading}>
|
||||
{item.fileName}
|
||||
</TextShimmer>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void handleAtchFileRemove(index);
|
||||
}}
|
||||
disabled={uploading}
|
||||
>
|
||||
<X className="size-5 shrink-0 text-destructive" aria-label="삭제" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<h3 className="text-xl font-semibold">첨부파일</h3>
|
||||
{uploadedFiles.length === 0 ? (
|
||||
<p className="text-lg text-muted-foreground">등록된 첨부파일이 없습니다.</p>
|
||||
)}
|
||||
) : null}
|
||||
<AttachmentFileField
|
||||
files={uploadedFiles.map((item) => ({
|
||||
fileKey: item.fileKey,
|
||||
fileName: item.fileName,
|
||||
}))}
|
||||
inputId="pwrk-cnsltn-doc-atch-file-input"
|
||||
isUploadingByKey={isUploading}
|
||||
addDisabled={!canSave || isAnyAtchUploading}
|
||||
showAddButton={canSave}
|
||||
onAdd={(files) => void handleAtchFileUpload(files)}
|
||||
onRemove={canSave ? (index) => void handleAtchFileRemove(index) : undefined}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
type PwrkCnsltnCnsltnResponseDto,
|
||||
usePwrkCnsltnMngByUpDocNo,
|
||||
} from "@/entities/pwrk-cnsltn-mng";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
interface PwrkCnsltnRvwRsltInqDialogProps {
|
||||
open: boolean;
|
||||
fnrsCnsltnDocNo: string;
|
||||
isSubmitting?: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (selected: PwrkCnsltnCnsltnResponseDto[]) => void;
|
||||
}
|
||||
|
||||
interface CnsltnListTableProps {
|
||||
rows: PwrkCnsltnCnsltnResponseDto[];
|
||||
selectedRowKeys: Set<string>;
|
||||
onToggleAll: (checked: boolean) => void;
|
||||
onToggleRow: (rowKey: string, checked: boolean) => void;
|
||||
}
|
||||
|
||||
function PwrkCnsltnRvwRsltInqDialog({
|
||||
open,
|
||||
fnrsCnsltnDocNo,
|
||||
isSubmitting = false,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: PwrkCnsltnRvwRsltInqDialogProps) {
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<Set<string>>(new Set());
|
||||
const {
|
||||
data: pwrkCnsltnData,
|
||||
isLoading,
|
||||
isError,
|
||||
} = usePwrkCnsltnMngByUpDocNo(fnrsCnsltnDocNo, open);
|
||||
|
||||
const cnsltnList = pwrkCnsltnData?.cnsltnList ?? [];
|
||||
const selectedRows = cnsltnList.filter((row, index) =>
|
||||
selectedRowKeys.has(getCnsltnRowKey(row, index)),
|
||||
);
|
||||
|
||||
function handleClose() {
|
||||
setSelectedRowKeys(new Set());
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
if (!nextOpen) handleClose();
|
||||
}
|
||||
|
||||
function handleToggleAll(checked: boolean) {
|
||||
if (!checked) {
|
||||
setSelectedRowKeys(new Set());
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedRowKeys(new Set(cnsltnList.map((row, index) => getCnsltnRowKey(row, index))));
|
||||
}
|
||||
|
||||
function handleToggleRow(rowKey: string, checked: boolean) {
|
||||
setSelectedRowKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) next.add(rowKey);
|
||||
else next.delete(rowKey);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (selectedRows.length === 0) return;
|
||||
onConfirm(selectedRows);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
className="max-h-[96vh] max-w-[120rem] overflow-y-auto"
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>검토결과조회</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isError ? (
|
||||
<p className="py-16 text-center text-lg text-destructive">
|
||||
실무협의 검토결과를 불러오지 못했습니다.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!isLoading && !isError && pwrkCnsltnData ? (
|
||||
<div className="h-[64rem] overflow-x-auto">
|
||||
{cnsltnList.length === 0 ? (
|
||||
<p className="py-10 text-center text-muted-foreground">조회된 검토의견이 없습니다.</p>
|
||||
) : (
|
||||
<CnsltnListTable
|
||||
rows={cnsltnList}
|
||||
selectedRowKeys={selectedRowKeys}
|
||||
onToggleAll={handleToggleAll}
|
||||
onToggleRow={handleToggleRow}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" color="white" onClick={handleClose} disabled={isSubmitting}>
|
||||
닫기
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
disabled={isSubmitting || selectedRows.length === 0}
|
||||
>
|
||||
보완요청
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function CnsltnListTable({
|
||||
rows,
|
||||
selectedRowKeys,
|
||||
onToggleAll,
|
||||
onToggleRow,
|
||||
}: CnsltnListTableProps) {
|
||||
const selectedCount = rows.filter((row, index) =>
|
||||
selectedRowKeys.has(getCnsltnRowKey(row, index)),
|
||||
).length;
|
||||
const isAllSelected = rows.length > 0 && selectedCount === rows.length;
|
||||
const isSomeSelected = selectedCount > 0 && !isAllSelected;
|
||||
|
||||
return (
|
||||
<Table variant="secondary">
|
||||
<colgroup>
|
||||
<col width="8%" />
|
||||
<col width="42%" />
|
||||
<col width="50%" />
|
||||
</colgroup>
|
||||
<TableHeader className="sticky top-0">
|
||||
<TableRow>
|
||||
<TableHead className="w-14" align="center">
|
||||
<Checkbox
|
||||
checked={isAllSelected ? true : isSomeSelected ? "indeterminate" : false}
|
||||
onCheckedChange={(checked) => onToggleAll(checked === true)}
|
||||
aria-label="전체 선택"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>분류</TableHead>
|
||||
<TableHead>검토의견</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row, index) => {
|
||||
const rowKey = getCnsltnRowKey(row, index);
|
||||
const isSelected = selectedRowKeys.has(rowKey);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={rowKey}
|
||||
data-state={isSelected ? "selected" : undefined}
|
||||
className={isSelected ? "!bg-primary-transparent" : undefined}
|
||||
>
|
||||
<TableCell align="center">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) => onToggleRow(rowKey, checked === true)}
|
||||
aria-label="행 선택"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<p className="whitespace-pre-wrap">{formatOptional(row.rmrk)}</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<p className="whitespace-pre-wrap">{formatOptional(row.rvwOpnn)}</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
function getCnsltnRowKey(row: PwrkCnsltnCnsltnResponseDto, index: number) {
|
||||
return [row.pwrkCnsltnDocNo, row.verSn, row.dtlSn, row.rvwOpnnSn, String(index)]
|
||||
.filter((value) => value !== undefined && value !== null && value !== "")
|
||||
.join("::");
|
||||
}
|
||||
|
||||
function formatOptional(value: string | number | undefined | null) {
|
||||
if (value === undefined || value === null) return "-";
|
||||
const text = String(value).trim();
|
||||
return text ? text : "-";
|
||||
}
|
||||
|
||||
export { PwrkCnsltnRvwRsltInqDialog };
|
||||
export type { PwrkCnsltnRvwRsltInqDialogProps };
|
||||
@@ -12,7 +12,7 @@ interface FnrsDetailGateProps {
|
||||
|
||||
function FnrsDetailGate({ id }: FnrsDetailGateProps) {
|
||||
const userInfo = useUserStore((state) => state.userInfo);
|
||||
const { data, isLoading, isFetching, isError } = useFnrsCnsltnMng(id);
|
||||
const { data, isLoading, isError } = useFnrsCnsltnMng(id);
|
||||
const canEdit = canEditPbadmsDoc({
|
||||
docSeComCd: "FNRS",
|
||||
prgrsSttsComCd: data?.detDto?.prgrsSttsComCd,
|
||||
@@ -22,9 +22,9 @@ function FnrsDetailGate({ id }: FnrsDetailGateProps) {
|
||||
|
||||
return (
|
||||
<PbadmsDetailGate
|
||||
isPending={!userInfo || isLoading || isFetching}
|
||||
isPending={!userInfo || isLoading}
|
||||
isError={isError}
|
||||
shouldRedirectToEdit={!isFetching && Boolean(userInfo) && canEdit}
|
||||
shouldRedirectToEdit={Boolean(userInfo) && canEdit}
|
||||
editHref={`/fnrs-cnsltn/${id}/edit`}
|
||||
>
|
||||
<FnrsViewPage id={id} />
|
||||
|
||||
@@ -32,7 +32,7 @@ function BusinessCostSection({ bizCstList }: BusinessCostSectionProps) {
|
||||
<TableHead colSpan={2}>증감</TableHead>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead className="w-[12rem]">(C-B)</TableHead>
|
||||
<TableHead className="w-[12rem]">(C-A)</TableHead>
|
||||
<TableHead className="w-[6rem]">%</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
@@ -6,13 +6,11 @@ import { Paperclip } from "lucide-react";
|
||||
|
||||
import { PROXY_HOST } from "@/shared/config";
|
||||
import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib";
|
||||
import { TextShimmer } from "@/shared/ui";
|
||||
import { SectionCollapsible } from "@/shared/ui";
|
||||
import { AttachmentFileField, SectionCollapsible } from "@/shared/ui";
|
||||
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
|
||||
import { attachService } from "@/entities/attach";
|
||||
import type { FnrsCnsltnRvwOpnnResponseDto } from "@/entities/fnrs-cnsltn";
|
||||
import {
|
||||
Button,
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
Table,
|
||||
@@ -246,38 +244,20 @@ const TechReviewOpinionSection = forwardRef<
|
||||
<TableRow>
|
||||
<TableHead>첨부파일</TableHead>
|
||||
<TableCell>
|
||||
{uploadedFile ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<TextShimmer className="text-xl" active={uploading}>
|
||||
{uploadedFile.fileName}
|
||||
</TextShimmer>
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void handleFileRemove();
|
||||
}}
|
||||
disabled={uploading}
|
||||
className="size-8 !bg-destructive"
|
||||
>
|
||||
<i className="diveicon di-x text-xs" aria-label="삭제" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<label>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void handleFileUpload(event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<Button size="sm" variant="outline" type="button" asChild>
|
||||
<span>파일 선택</span>
|
||||
</Button>
|
||||
</label>
|
||||
)}
|
||||
<AttachmentFileField
|
||||
files={
|
||||
uploadedFile
|
||||
? [{ fileKey: uploadedFile.fileKey, fileName: uploadedFile.fileName }]
|
||||
: []
|
||||
}
|
||||
inputId={`rvw-opnn-${reviewStep}-file-input`}
|
||||
isUploadingByKey={isUploading}
|
||||
multiple={false}
|
||||
showAddButton={!uploadedFile}
|
||||
addDisabled={uploading}
|
||||
onAdd={(fileList) => void handleFileUpload(fileList)}
|
||||
onRemove={() => void handleFileRemove()}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -300,7 +280,10 @@ const TechReviewOpinionSection = forwardRef<
|
||||
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
|
||||
<div className="space-y-6">
|
||||
{kecoRvwList.length > 0 ? <TechReviewOpinionTable items={kecoRvwList} /> : null}
|
||||
{/*
|
||||
재원협의에서 환경공단 검토의견은 자동 생성
|
||||
{renderOpinionForm("KECO")}
|
||||
*/}
|
||||
</div>
|
||||
</SectionCollapsible>
|
||||
) : null}
|
||||
|
||||
@@ -22,10 +22,10 @@ export function mapDetailToFormData(data: InstlSncnMngDetResponse): InstlSncnReg
|
||||
bizCn: detDto.bizCn,
|
||||
taskSeComCd: parseTaskSeComCdFromApi(detDto.taskSeComCd),
|
||||
sncnSeComCd: detDto.sncnSeComCd,
|
||||
dsgnrNm: detDto.dsgnrNm,
|
||||
dsgnrTelno: detDto.dsgnrTelno,
|
||||
dsgnrOgdpNm: detDto.dsgnrOgdpNm,
|
||||
dsgnrEmlAddr: detDto.dsgnrEmlAddr,
|
||||
dsgnrNm: detDto.dsgnrNm ?? "",
|
||||
dsgnrTelno: detDto.dsgnrTelno ?? "",
|
||||
dsgnrOgdpNm: detDto.dsgnrOgdpNm ?? "",
|
||||
dsgnrEmlAddr: detDto.dsgnrEmlAddr ?? "",
|
||||
aplcntNm: detDto.aplcntNm ?? "",
|
||||
aplcntMblTelno: detDto.aplcntMblTelno ?? "",
|
||||
aplcntOgdpNm: detDto.aplcntOgdpNm ?? "",
|
||||
|
||||
@@ -78,7 +78,7 @@ export const instlSncnRegistSchema = z.object({
|
||||
splmntDmndList: z.array(
|
||||
z.object({
|
||||
splmntDmndSn: z.number().nullish(),
|
||||
splmntArtclComCd: z.string().optional(),
|
||||
splmntArtclComCd: z.string().nullish(),
|
||||
splmntArtclCdNm: z.string().nullish(),
|
||||
splmntDmndCn: z.string().optional(),
|
||||
splmntDmndDt: z.string().optional(),
|
||||
|
||||
@@ -225,7 +225,9 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
|
||||
<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.instlSncnDocNo && (
|
||||
<ProgressStatusSection
|
||||
|
||||
@@ -32,7 +32,7 @@ function BusinessCostSection({ bizCstList }: BusinessCostSectionProps) {
|
||||
<TableHead colSpan={2}>증감</TableHead>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead className="w-[12rem]">(C-B)</TableHead>
|
||||
<TableHead className="w-[12rem]">(C-A)</TableHead>
|
||||
<TableHead className="w-[6rem]">%</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
@@ -242,7 +242,9 @@ function SewerBscPlanFormPage({
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSave)} className="mt-6 space-y-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?.sewerBscPlanDocNo && (
|
||||
<ProgressStatusSection
|
||||
|
||||
@@ -203,7 +203,7 @@ export interface EsntlAtchFileSaveBase {
|
||||
/** 보완요구 저장 공통 필드 */
|
||||
export interface SplmntDmndSaveBase {
|
||||
splmntDmndSn?: number | null;
|
||||
splmntArtclComCd?: string;
|
||||
splmntArtclComCd?: string | null;
|
||||
splmntDmndCn?: string;
|
||||
splmntDmndDt?: string;
|
||||
splmntDmndAtchFileGroupId?: string;
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { FileAddButton } from "./file-add-button";
|
||||
import { UploadedFileChip } from "./uploaded-file-chip";
|
||||
|
||||
interface AttachmentFileFieldItem {
|
||||
fileKey: string;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
interface AttachmentFileFieldProps {
|
||||
files: AttachmentFileFieldItem[];
|
||||
inputId: string;
|
||||
isUploadingByKey: Record<string, boolean>;
|
||||
onAdd: (files: FileList | null) => void;
|
||||
onRemove?: (index: number) => void;
|
||||
accept?: string;
|
||||
multiple?: boolean;
|
||||
addDisabled?: boolean;
|
||||
showAddButton?: boolean;
|
||||
}
|
||||
|
||||
function AttachmentFileField({
|
||||
files,
|
||||
inputId,
|
||||
isUploadingByKey,
|
||||
onAdd,
|
||||
onRemove,
|
||||
accept,
|
||||
multiple = true,
|
||||
addDisabled = false,
|
||||
showAddButton = true,
|
||||
}: AttachmentFileFieldProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{files.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{files.map((file, index) => (
|
||||
<UploadedFileChip
|
||||
key={file.fileKey}
|
||||
fileName={file.fileName}
|
||||
isUploading={isUploadingByKey[file.fileKey] ?? false}
|
||||
onRemove={onRemove ? () => onRemove(index) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{showAddButton ? (
|
||||
<FileAddButton
|
||||
inputId={inputId}
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
disabled={addDisabled}
|
||||
onFilesSelected={onAdd}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { AttachmentFileField };
|
||||
export type { AttachmentFileFieldItem, AttachmentFileFieldProps };
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { Button, cn } from "@common/components";
|
||||
|
||||
interface FileAddButtonProps {
|
||||
inputId: string;
|
||||
onFilesSelected: (files: FileList | null) => void;
|
||||
accept?: string;
|
||||
multiple?: boolean;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
function FileAddButton({
|
||||
inputId,
|
||||
onFilesSelected,
|
||||
accept,
|
||||
multiple = true,
|
||||
disabled = false,
|
||||
label = "파일추가",
|
||||
}: FileAddButtonProps) {
|
||||
return (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className={cn("block cursor-pointer", disabled && "pointer-events-none")}
|
||||
>
|
||||
<input
|
||||
id={inputId}
|
||||
type="file"
|
||||
multiple={multiple}
|
||||
accept={accept}
|
||||
disabled={disabled}
|
||||
className="peer sr-only"
|
||||
aria-label={label}
|
||||
onChange={(event) => {
|
||||
onFilesSelected(event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2",
|
||||
disabled && "pointer-events-none opacity-50",
|
||||
)}
|
||||
asChild
|
||||
>
|
||||
<span>{label}</span>
|
||||
</Button>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export { FileAddButton };
|
||||
export type { FileAddButtonProps };
|
||||
@@ -1,3 +1,7 @@
|
||||
export type { AttachmentFileFieldItem, AttachmentFileFieldProps } from "./attachment-file-field";
|
||||
export { AttachmentFileField } from "./attachment-file-field";
|
||||
export type { FileAddButtonProps } from "./file-add-button";
|
||||
export { FileAddButton } from "./file-add-button";
|
||||
export { NoData } from "./no-data";
|
||||
export { PageLayout } from "./page-layout";
|
||||
export { SectionCollapsible } from "./section-collapsible";
|
||||
@@ -7,3 +11,5 @@ export {
|
||||
} from "./section-collapsible-group";
|
||||
export type { TextShimmerProps } from "./text-shimmer";
|
||||
export { TEXT_SHIMMER_CLASS, TextShimmer } from "./text-shimmer";
|
||||
export type { UploadedFileChipProps } from "./uploaded-file-chip";
|
||||
export { UploadedFileChip } from "./uploaded-file-chip";
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { Paperclip, X } from "lucide-react";
|
||||
|
||||
import { TextShimmer } from "./text-shimmer";
|
||||
|
||||
interface UploadedFileChipProps {
|
||||
fileName: string;
|
||||
isUploading?: boolean;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
function UploadedFileChip({ fileName, isUploading = false, onRemove }: UploadedFileChipProps) {
|
||||
return (
|
||||
<div 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={isUploading}>
|
||||
{fileName}
|
||||
</TextShimmer>
|
||||
{onRemove ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="삭제"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
onRemove();
|
||||
}}
|
||||
disabled={isUploading}
|
||||
>
|
||||
<X className="size-5 shrink-0 text-destructive" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { UploadedFileChip };
|
||||
export type { UploadedFileChipProps };
|
||||
@@ -1,11 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { Download, Fullscreen, Save } from "lucide-react";
|
||||
import { useId } from "react";
|
||||
|
||||
import { Button } from "@common/components";
|
||||
import { Download, FolderOpen, Fullscreen, Save } from "lucide-react";
|
||||
|
||||
import { Button, cn } from "@common/components";
|
||||
|
||||
const WEBHYP_DEV_MODE = process.env.NEXT_PUBLIC_WEBHYP_DEV_MODE === "true";
|
||||
|
||||
interface HangulGiangiToolbarProps {
|
||||
isBusy?: boolean;
|
||||
canSave?: boolean;
|
||||
onOpen: (file: File) => void;
|
||||
onSave: () => void;
|
||||
onDownload: () => void;
|
||||
onFullscreen: () => void;
|
||||
@@ -13,16 +19,58 @@ interface HangulGiangiToolbarProps {
|
||||
|
||||
function HangulGiangiToolbar({
|
||||
isBusy = false,
|
||||
canSave = true,
|
||||
onOpen,
|
||||
onSave,
|
||||
onDownload,
|
||||
onFullscreen,
|
||||
}: HangulGiangiToolbarProps) {
|
||||
const openInputId = useId();
|
||||
|
||||
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>
|
||||
{WEBHYP_DEV_MODE && (
|
||||
<label
|
||||
htmlFor={openInputId}
|
||||
className={cn("cursor-pointer", isBusy && "pointer-events-none")}
|
||||
>
|
||||
<input
|
||||
id={openInputId}
|
||||
type="file"
|
||||
accept=".hwpx,application/haansofthwpx"
|
||||
disabled={isBusy}
|
||||
className="peer sr-only"
|
||||
aria-label="열기"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) onOpen(file);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
color="white"
|
||||
size="sm"
|
||||
disabled={isBusy}
|
||||
className={cn(
|
||||
"peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2",
|
||||
isBusy && "pointer-events-none opacity-50",
|
||||
)}
|
||||
asChild
|
||||
>
|
||||
<span>
|
||||
<FolderOpen className="!size-5" />
|
||||
열기
|
||||
</span>
|
||||
</Button>
|
||||
</label>
|
||||
)}
|
||||
{canSave && (
|
||||
<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" />
|
||||
다운로드
|
||||
|
||||
@@ -21,6 +21,7 @@ function HangulGiangi({
|
||||
documentFileName,
|
||||
onSaveDocument,
|
||||
onDownload,
|
||||
canSave = true,
|
||||
bridgeUrl = DEFAULT_BRIDGE_URL,
|
||||
ctrlBaseUrl = WEBHYP_CTRL_URL,
|
||||
className,
|
||||
@@ -69,43 +70,46 @@ function HangulGiangi({
|
||||
return () => window.clearInterval(timer);
|
||||
}, [isCtrlReady, onReady]);
|
||||
|
||||
const handleOpen = useCallback(() => {
|
||||
const ctrl = resolveHwpCtrl(iframeRef.current, isCtrlReady);
|
||||
if (!ctrl) return;
|
||||
const openHwpxBlob = useCallback(
|
||||
(blob: Blob, fileName: string) => {
|
||||
const ctrl = resolveHwpCtrl(iframeRef.current, isCtrlReady);
|
||||
if (!ctrl) return;
|
||||
|
||||
setIsBusy(true);
|
||||
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;
|
||||
setIsBusy(true);
|
||||
openingDocumentKeyRef.current = documentKey;
|
||||
attemptedDocumentKeyRef.current = documentKey;
|
||||
try {
|
||||
const documentFile = new File([blob], fileName, {
|
||||
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) return;
|
||||
openingDocumentKeyRef.current = null;
|
||||
setIsBusy(false);
|
||||
if (!result.result) return;
|
||||
|
||||
openedDocumentKeyRef.current = callbackDocumentKey;
|
||||
},
|
||||
{ documentKey } satisfies DocumentCallbackUserData,
|
||||
);
|
||||
openedDocumentKeyRef.current = callbackDocumentKey;
|
||||
},
|
||||
{ documentKey } satisfies DocumentCallbackUserData,
|
||||
);
|
||||
|
||||
if (isAccepted) return;
|
||||
if (isAccepted) return;
|
||||
|
||||
openingDocumentKeyRef.current = null;
|
||||
setIsBusy(false);
|
||||
} catch {
|
||||
openingDocumentKeyRef.current = null;
|
||||
setIsBusy(false);
|
||||
}
|
||||
}, [documentBlob, documentFileName, documentKey, isCtrlReady]);
|
||||
openingDocumentKeyRef.current = null;
|
||||
setIsBusy(false);
|
||||
} catch {
|
||||
openingDocumentKeyRef.current = null;
|
||||
setIsBusy(false);
|
||||
}
|
||||
},
|
||||
[documentKey, isCtrlReady],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -116,8 +120,19 @@ function HangulGiangi({
|
||||
) {
|
||||
return;
|
||||
}
|
||||
handleOpen();
|
||||
}, [documentKey, handleOpen, isCtrlReady]);
|
||||
openHwpxBlob(documentBlob, documentFileName);
|
||||
}, [documentBlob, documentFileName, documentKey, isCtrlReady, openHwpxBlob]);
|
||||
|
||||
const handleToolbarOpen = useCallback(
|
||||
(file: File) => {
|
||||
if (!file.name.toLowerCase().endsWith(".hwpx")) {
|
||||
alert("HWPX 파일만 열 수 있습니다.");
|
||||
return;
|
||||
}
|
||||
openHwpxBlob(file, file.name);
|
||||
},
|
||||
[openHwpxBlob],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const ctrl = resolveHwpCtrl(iframeRef.current, isCtrlReady);
|
||||
@@ -183,6 +198,8 @@ function HangulGiangi({
|
||||
>
|
||||
<HangulGiangiToolbar
|
||||
isBusy={isBusy || !isCtrlReady}
|
||||
canSave={canSave}
|
||||
onOpen={handleToolbarOpen}
|
||||
onSave={handleSave}
|
||||
onDownload={handleDownload}
|
||||
onFullscreen={handleFullscreen}
|
||||
@@ -296,6 +313,7 @@ interface HangulGiangiProps {
|
||||
documentFileName: string;
|
||||
onSaveDocument: (file: Blob) => Promise<void>;
|
||||
onDownload: () => Promise<void>;
|
||||
canSave?: boolean;
|
||||
bridgeUrl?: string;
|
||||
ctrlBaseUrl?: string;
|
||||
className?: string;
|
||||
|
||||
@@ -4,13 +4,17 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Download, Paperclip, X } from "lucide-react";
|
||||
import { Download } from "lucide-react";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useTus } from "@/shared/lib";
|
||||
import type { AtflMngClientResponseDto, EsntlAtchFileMngTreeResponseDto } from "@/shared/model";
|
||||
import { TextShimmer } from "@/shared/ui";
|
||||
import { SectionCollapsible } from "@/shared/ui";
|
||||
import {
|
||||
AttachmentFileField,
|
||||
FileAddButton,
|
||||
SectionCollapsible,
|
||||
UploadedFileChip,
|
||||
} from "@/shared/ui";
|
||||
import { attachService, useDownloadAttachmentByAtflCd } from "@/entities/attach";
|
||||
import { useEsntlAtchFileMngTree } from "@/entities/esntl";
|
||||
import {
|
||||
@@ -423,64 +427,21 @@ function AttachmentsFormSection({
|
||||
</TableHead>
|
||||
<TableCell align="center">{file.esntlAtchFileRmrk}</TableCell>
|
||||
<TableCell className="space-y-2">
|
||||
{uploadedFiles[file.esntlAtchFileId]?.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{uploadedFiles[file.esntlAtchFileId].map((item, idx) => {
|
||||
const uploading = isUploading[item.fileKey] ?? false;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.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}>
|
||||
{item.file?.name ?? item.fileName}
|
||||
</TextShimmer>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void removeEssentialFile(file.esntlAtchFileId, idx);
|
||||
}}
|
||||
disabled={uploading}
|
||||
>
|
||||
<X
|
||||
className="size-5 shrink-0 text-destructive"
|
||||
aria-label="삭제"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label htmlFor={`${file.esntlAtchFileId}-file-input`}>
|
||||
<input
|
||||
type="file"
|
||||
id={`${file.esntlAtchFileId}-file-input`}
|
||||
multiple
|
||||
className="peer sr-only"
|
||||
accept={toFileAccept(file.esntlAtchFileRmrk) || undefined}
|
||||
onChange={(e) => {
|
||||
void handleEssentialFileUpload(
|
||||
file.esntlAtchFileId,
|
||||
e.target.files,
|
||||
);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
<AttachmentFileField
|
||||
files={(uploadedFiles[file.esntlAtchFileId] ?? []).map((item) => ({
|
||||
fileKey: item.fileKey,
|
||||
fileName: item.file?.name ?? item.fileName,
|
||||
}))}
|
||||
inputId={`${file.esntlAtchFileId}-file-input`}
|
||||
isUploadingByKey={isUploading}
|
||||
accept={toFileAccept(file.esntlAtchFileRmrk) || undefined}
|
||||
onAdd={(fileList) =>
|
||||
void handleEssentialFileUpload(file.esntlAtchFileId, fileList)
|
||||
}
|
||||
onRemove={(index) =>
|
||||
void removeEssentialFile(file.esntlAtchFileId, index)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
@@ -495,55 +456,22 @@ function AttachmentsFormSection({
|
||||
<div className="px-2">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-xl font-semibold">기타 첨부파일</h3>
|
||||
<label htmlFor="etc-file-input">
|
||||
<input
|
||||
type="file"
|
||||
id="etc-file-input"
|
||||
multiple
|
||||
className="peer sr-only"
|
||||
onChange={(e) => {
|
||||
void handleEtcFileUpload(e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
<FileAddButton
|
||||
inputId="etc-file-input"
|
||||
onFilesSelected={(files) => void handleEtcFileUpload(files)}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded border p-4">
|
||||
{etcFiles.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{etcFiles.map((item, idx) => {
|
||||
const uploading = isUploading[item.fileKey] ?? false;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.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}>
|
||||
{item.file?.name ?? item.fileName}
|
||||
</TextShimmer>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void removeEtcFile(idx);
|
||||
}}
|
||||
disabled={uploading}
|
||||
>
|
||||
<X className="size-5 shrink-0 text-destructive" aria-label="삭제" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{etcFiles.map((item, idx) => (
|
||||
<UploadedFileChip
|
||||
key={item.fileKey}
|
||||
fileName={item.file?.name ?? item.fileName}
|
||||
isUploading={isUploading[item.fileKey] ?? false}
|
||||
onRemove={() => void removeEtcFile(idx)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-6 text-center text-xl text-muted-foreground">
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
} from "@common/components";
|
||||
|
||||
import { buildAcntgCostItems, recalculateBizCstTotals } from "../lib/business-cost-form-utils";
|
||||
import { calcDiffPercent } from "../lib/format-utils";
|
||||
import { calcDiffPercent } from "../lib/format-utils";
|
||||
|
||||
interface BusinessCostFormData {
|
||||
bizCstList: Array<{
|
||||
@@ -95,7 +95,7 @@ function BusinessCostFormSection() {
|
||||
<TableHead colSpan={2}>증감</TableHead>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead className="w-[12rem]">(C-B)</TableHead>
|
||||
<TableHead className="w-[12rem]">(C-A)</TableHead>
|
||||
<TableHead className="w-[6rem]">%</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
@@ -2,10 +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 { AttachmentFileField } from "@/shared/ui";
|
||||
import { attachService } from "@/entities/attach";
|
||||
import { CodeSelect } from "@/entities/code";
|
||||
import {
|
||||
@@ -287,57 +285,17 @@ function SplmntDmndDialog({
|
||||
/>
|
||||
</TableCell>
|
||||
<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>
|
||||
<AttachmentFileField
|
||||
files={row.uploadedFiles.map((file) => ({
|
||||
fileKey: file.fileKey,
|
||||
fileName: file.fileName,
|
||||
}))}
|
||||
inputId={fileInputId}
|
||||
isUploadingByKey={isUploading}
|
||||
addDisabled={isRowUploading}
|
||||
onAdd={(fileList) => void handleFileUpload(row.rowKey, fileList)}
|
||||
onRemove={(fileIndex) => void handleFileRemove(row.rowKey, fileIndex)}
|
||||
/>
|
||||
</TableCell>
|
||||
{rows.length > 1 ? (
|
||||
<TableCell align="center" className="align-top">
|
||||
|
||||
@@ -3,15 +3,13 @@
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
|
||||
import { Paperclip, X } from "lucide-react";
|
||||
import { Paperclip } from "lucide-react";
|
||||
|
||||
import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib";
|
||||
import { TextShimmer } from "@/shared/ui";
|
||||
import { SectionCollapsible } from "@/shared/ui";
|
||||
import { AttachmentFileField, SectionCollapsible } from "@/shared/ui";
|
||||
import { attachService } from "@/entities/attach";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
@@ -303,63 +301,26 @@ function SplmntDmndFormSection({ readOnly = false }: SplmntDmndFormSectionProps)
|
||||
)}
|
||||
</TableCell>
|
||||
<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>
|
||||
) : 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 ? (
|
||||
{isRowReadOnly && rowFiles.length === 0 ? (
|
||||
<span>-</span>
|
||||
) : null}
|
||||
) : (
|
||||
<AttachmentFileField
|
||||
files={rowFiles.map((file) => ({
|
||||
fileKey: file.fileKey,
|
||||
fileName: file.fileName,
|
||||
}))}
|
||||
inputId={fileInputId}
|
||||
isUploadingByKey={isUploading}
|
||||
addDisabled={isRowUploading}
|
||||
showAddButton={!isRowReadOnly}
|
||||
onAdd={(fileList) => void handleFileUpload(index, fileList)}
|
||||
onRemove={
|
||||
isRowReadOnly
|
||||
? undefined
|
||||
: (fileIndex) => void handleFileRemove(index, fileIndex)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center" className="align-top">
|
||||
<Badge size="lg" color={item.cmptnYn === "Y" ? "green" : "gray"}>
|
||||
|
||||
Reference in New Issue
Block a user