Merge branch 'main' into feature/0612_sentry_fr_merge

This commit is contained in:
2026-09-16 09:06:37 +09:00
39 changed files with 1014 additions and 518 deletions
@@ -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` |
+12 -16
View File
@@ -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 -11
View File
@@ -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
+1 -1
View File
@@ -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 };
+58
View File
@@ -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 };
+6
View File
@@ -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"}>