Merge remote-tracking branch 'origin/main' into deploy
Deploy Frontend / deploy (push) Successful in 3m40s

This commit is contained in:
2026-08-28 14:34:02 +09:00
6 changed files with 1009 additions and 29 deletions
+800
View File
@@ -0,0 +1,800 @@
# 실무협의문서 개발 가이드
> **대상 앱**: `frontend/apps/as`
> **작성일**: 2026-08-27
> **버전**: 1.0
> **SoT (백엔드)**:
>
> - `PwrkCnsltnDocCrtRsltController` — `/api/v1/pwrk-cnsltn-doc-crt-rslt`
> - `PwrkCnsltnMngController` — `/api/v1/pwrk-cnsltn-mng`
> - `DcRagApiToController` — `/api/v1/dcrag-to`
> - 초안생성 데이터 설명 (`02.03.초안생성_데이터_설명.md`)
>
> **현재 프론트 상태**: `pwrk-cnsltn-`\* 엔티티·화면 **미구현**. 재원협의(`fnrs-cnsltn`)만 존재한다.
---
## 1. 문서 목적
재원협의 접수자가 **자료 검증 → 실무협의서 초안 생성 → 한글 웹기안기 열람/다운로드 → 편집본 버전 저장**까지 구현할 때 사용하는 프론트엔드 개발 가이드이다.
백엔드 컨트롤러·DTO를 **API source of truth**로 두고, 초안생성 데이터 설명의 업무 시퀀스를 화면 동작에 매핑한다.
---
## 2. 도메인 개요
실무협의서(PWRK)는 재원협의서(FNRS)의 **하위 문서**이다. 문서번호 규칙:
| 문서 | 번호 예시 | 필드 |
| -------- | ----------------- | ----------------------------- |
| 재원협의 | `F-20260824-0001` | `fnrsCnsltnDocNo` / `upDocNo` |
| 실무협의 | `P-20260827-0001` | `pwrkCnsltnDocNo` |
재원협의 1건당 실무협의서는 **1건만** 생성된다. `POST /pwrk-cnsltn-mng`는 `upDocNo`로 기존 문서를 재사용하고, 없으면 문서번호를 발급한다.
### 2-1. 핵심 식별자
| 필드 | 의미 | 비고 |
| ----------------- | ------------------------------ | --------------------------------------------------------- |
| `pwrkCnsltnDocNo` | 실무협의문서번호 | 문서 PK. `P-YYYYMMDD-NNNN` |
| `upDocNo` | 상위문서번호(재원협의) | `upDocSeComCd = "FNRS"` |
| `crtDmndUuid` | 생성요청 UUID | 초안 생성 작업·결과·다운로드의 키 |
| `verSn` | 버전일련번호 | 복합 PK `(crtDmndUuid, verSn)`. 저장 시 서버가 `+1` |
| `orgnlOtptDocCn` | 원본출력문서내용(HWP 바이너리) | **목록/상세 응답에서는 항상** `null`. 다운로드 API만 제공 |
### 2-2. IIPBS 약어
| 한글 | 약어 | 필드 예 |
| -------- | ------ | ----------------------------- |
| 실무 | PWRK | `pwrkCnsltnDocNo` |
| 협의 | CNSLTN | `pwrk-cnsltn-mng` |
| 관리 | MNG | `PwrkCnsltnMng` |
| 문서 | DOC | `pwrkCnsltnDocNo` |
| 생성 | CRT | `crtDmndUuid`, `crtSttsComCd` |
| 요청 | DMND | `crtDmndUuid` |
| 결과 | RSLT | `crtRslt` |
| 버전 | VER | `verSn` |
| 일련번호 | SN | `verSn` |
| 원본 | ORGNL | `orgnlOtptDocCn` |
| 출력 | OTPT | `orgnlOtptDocCn` |
| 내용 | CN | `orgnlOtptDocCn` |
| 완료 | CMPTN | `crtCmptnDt` |
| 일시 | DT | `crtCmptnDt` |
| 검증 | VRFC | `vrfcSttsComCd` |
| 전송 | TRSM | `trsmFileList` |
| 보완 | SPLMNT | `splmntDmndList` |
| 요청자 | RQSTR | `rqstrUuid`, `rqstrNm` |
---
## 3. 전체 업무 흐름
재원협의 **접수자**가 실무협의서 초안을 만드는 경로이다. 프론트는 DC RAG를 직접 호출하지 않고 **as-service**만 호출한다.
```mermaid
sequenceDiagram
autonumber
participant User as 재원협의 접수자
participant FE as AS Frontend
participant AS as as-service
participant RAG as DC RAG
User->>FE: [자료 검증 요청]
FE->>AS: POST /dcrag-to/documents/transfer<br/>{ fnrsCnsltnDocNo }
AS->>RAG: 자료전송 (IF-01)
AS-->>FE: { resultCode: SUCCESS, requestId }
Note over FE: 재원협의 상세 재조회
AS-->>FE: esntlFileList / etcFileList 에<br/>vrfcDt, vrfcRsltCn, vrfcSttsComCd
User->>FE: [초안 생성]
FE->>AS: POST /pwrk-cnsltn-mng<br/>{ upDocNo, upDocSeComCd: FNRS, prgrsSttsComCd: DV }
alt 실무협의서 번호 없음
AS->>AS: P-번호 발급 + 문서 등록
else 이미 존재
AS->>AS: 기존 pwrkCnsltnDocNo 재사용
end
AS->>RAG: 초안생성 비동기 요청 (IF-03-1)
AS-->>FE: 201 { pwrkCnsltnDocNo, crtDmndUuid, crtSttsComCd: R, estimatedMinutes: 10 }
loop 약 10분, status !== COMPLETED/FAILED
FE->>AS: GET /dcrag-to/documents/draft/status/{crtDmndUuid}
AS-->>FE: { status, events[], progressPercent }
end
Note over FE: events 에 COMPLETED 가 있으면<br/>웹기안기 열기 / 다운로드 가능
User->>FE: [문서 열기] 또는 [다운로드]
FE->>AS: GET /pwrk-cnsltn-doc-crt-rslt/{crtDmndUuid}/webhwp/open<br/>또는 /download
User->>FE: 웹기안기 편집 후 [저장]
FE->>AS: POST /pwrk-cnsltn-doc-crt-rslt (multipart)<br/>crtDmndUuid + file
AS-->>FE: 201 { verSn: N+1, ... }
```
### 3-1. 프론트가 호출하면 안 되는 API
| API | 이유 |
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `POST /dcrag-to/documents/draft/generate` | 초안 생성은 `POST /pwrk-cnsltn-mng`가 문서번호 발급 + DC RAG 호출을 **한 트랜잭션**으로 처리한다. 프론트는 관리 API만 호출한다. |
---
## 4. 화면 데이터 흐름
초안 생성은 **재원협의 상세**에서 시작하고, 실무협의 목록/상세는 별도 화면이다.
```
BEGIN
// ===== 재원협의 상세 (기존 fnrs-cnsltn view) =====
CALL findById(fnrsCnsltnDocNo)
DISPLAY 재원협의 상세 + 필수/기타 첨부
ON [자료 검증 요청] 버튼 CLICK DO
CALL requestDocumentTransfer({ fnrsCnsltnDocNo })
CALL findById(fnrsCnsltnDocNo) // 검증결과 필드 반영
DISPLAY 첨부 리스트 (vrfcSttsComCd: PASS | FAIL)
END ON
ON [초안 생성] 버튼 CLICK DO
CALL pwrkCnsltnMngService.save({
upDocNo: fnrsCnsltnDocNo,
upDocSeComCd: "FNRS",
prgrsSttsComCd: "DV"
})
// → pwrkCnsltnDocNo, crtDmndUuid, estimatedMinutes
START POLL getDraftGenerationStatus(crtDmndUuid)
DISPLAY 초안 생성 진행률 (약 10분)
END ON
ON POLL status = COMPLETED 또는 events.eventType 에 COMPLETED DO
ENABLE [웹기안기 열기] [다운로드]
END ON
ON [웹기안기 열기] 버튼 CLICK DO
CALL HwpCtrl.Open( webhwpOpenUrl(crtDmndUuid) ) // TBD: 컨트롤러 미구현
END ON
ON [다운로드] 버튼 CLICK DO
CALL downloadLatest(crtDmndUuid) // blob + Content-Disposition
SAVE 로컬 파일
END ON
// ===== 실무협의 목록 =====
CALL pwrkCnsltnMngService.findPage(searchDto + pageable)
DISPLAY 실무협의 문서 그리드
ON 행 CLICK DO
NAVIGATE 실무협의 상세 (pwrkCnsltnDocNo)
END ON
// ===== 실무협의 상세 / 재원협의에서 연계 조회 =====
CALL findById(pwrkCnsltnDocNo)
// 또는 재원협의 번호로: findByUpDocNo(fnrsCnsltnDocNo)
DISPLAY 마스터 + cnsltnList + pjtcoAjmtList + crtDmndList.crtRslt
// crtRslt.orgnlOtptDocCn 은 항상 null — 바이너리는 download API
ON 버전 이력 조회 DO
CALL pwrkCnsltnDocCrtRsltService.findPage({ crtDmndUuid, sort: "verSn,desc" })
DISPLAY 버전 목록
END ON
ON 웹기안기 편집 완료 [저장] DO
CALL saveMultipart({ crtDmndUuid, file }) // 서버가 verSn + 1
INVALIDATE 상세·버전 목록
END ON
END
```
---
## 5. API 명세
### 5-1. 공통
| 항목 | 값 |
| ----------- | ----------------------------------------------------------------- |
| Gateway | `/as-service` |
| Base prefix | `ENDPOINT.AS_SERVICE.*` (`apps/as/src/shared/config/api.ts`) |
| Auth 헤더 | `Authorization`, `X-Site-Id`, `token-id` (기존 `client` 인터셉터) |
| 날짜 포맷 | `yyyy-MM-dd HH:mm:ss` |
추가할 ENDPOINT:
```ts
AS_SERVICE: {
// 기존 …
PWRK_CNSLTN_MNG: "/as-service/api/v1/pwrk-cnsltn-mng",
PWRK_CNSLTN_DOC_CRT_RSLT: "/as-service/api/v1/pwrk-cnsltn-doc-crt-rslt",
DCRAG_TO: "/as-service/api/v1/dcrag-to",
}
```
---
### 5-2. DC RAG 연계 (`DcRagApiToController`)
프론트 엔티티 슬라이스: `entities/dcrag-to/`
| 기능 | Controller | Method | URL | Request | Response |
| ----------------- | -------------------------- | ------ | ------------------------------------------------ | --------------------------------- | ------------------------------------- |
| 자료전송요청 | `requestDocumentTransfer` | POST | `/dcrag-to/documents/transfer` | `DcRagDocumentTransferRequestDto` | `DcRagCommonResponseDto` |
| 초안생성 진행상태 | `getDraftGenerationStatus` | GET | `/dcrag-to/documents/draft/status/{crtDmndUuid}` | path | `DcRagDraftStatusResponseDto` |
| 금액검증요청 | `requestCostVerification` | POST | `/dcrag-to/documents/cost/verify` | `DcRagCostVerifyRequestDto` | `DcRagCommonResponseDto` (202) |
| 초안생성요청 | `requestDraftGeneration` | POST | `/dcrag-to/documents/draft/generate` | `DcRagDraftGenerateRequestDto` | `DcRagDraftGenerateResponseDto` (202) |
**화면에서 쓰는 것**: 자료전송 + 상태 폴링 (+ 추후 금액검증).
초안생성요청은 5-3의 `pwrkCnsltnMngService.save`로 대체한다.
#### 자료전송 Request / Response
```json
POST /as-service/api/v1/dcrag-to/documents/transfer
{ "fnrsCnsltnDocNo": "F-20260824-0001" }
```
```json
{
"resultCode": "SUCCESS",
"message": "자료전송요청 접수 완료",
"requestId": "F-20260824-0001"
}
```
자료 검증이 **완료된 뒤** 재원협의 상세를 다시 조회하면, 필수첨부(`esntlFileList`)·기타첨부(`etcFileList`) 각 파일에 검증 결과가 붙는다.
| 필드 | 예 | 의미 |
| --------------- | ----------------------------- | ------------ |
| `vrfcDt` | `2026-08-25 18:13:49` | 검증일시 |
| `vrfcRsltCn` | `정상 확인 (재원협의 신청서)` | 검증결과내용 |
| `vrfcSttsComCd` | `PASS` / `FAIL` | 검증상태 |
프론트 `FnrsCnsltnMngDetResponseDto` / `AtflMngClientResponseDto` / `EsntlAtchFileMngTreeResponseDto`에 위 3필드가 **아직 없다**. 재원협의 상세 첨부 영역에 배지를 그리려면 타입을 확장해야 한다.
#### 초안생성 상태 폴링
```http
GET /as-service/api/v1/dcrag-to/documents/draft/status/{crtDmndUuid}
```
| 필드 | 설명 |
| ----------------------- | --------------------------------------------------------------------------------- |
| `status` | `REQUESTED` / `DOWNLOADING` / `ANALYZING` / `GENERATING` / `COMPLETED` / `FAILED` |
| `statusName` | 표시명 (예: `생성 완료`) |
| `progressPercent` | 0~100 |
| `events[]` | `{ eventType, eventMessage, occurredDt }` |
| `startedDt` | 시작일시 |
| `estimatedCompletionDt` | 예상 완료일시 |
| `errorMessage` | 실패 시 메시지 |
폴링 규칙:
- 주기: **5~10초** (TBD, UX 확정).
- 예상 소요: **약 10분** (`estimatedMinutes`).
- 종료 조건: `status === "COMPLETED"` **또는** `events`에 `eventType === "COMPLETED"`.
- 실패: `status === "FAILED"` → 폴링 중단, `errorMessage` 표시.
- 완료 전에는 웹기안기 열기·다운로드 버튼을 **비활성**.
이벤트 순서 예시:
| eventType | eventMessage |
| ------------- | --------------------------- |
| `REQUESTED` | 요청을 접수했습니다. |
| `DOWNLOADING` | 신청서를 수신하는 중입니다. |
| `ANALYZING` | 자료 준비가 완료되었습니다. |
| `GENERATING` | 문서 저장이 완료되었습니다. |
| `COMPLETED` | 문서 생성이 완료되었습니다. |
---
### 5-3. 실무협의관리 (`PwrkCnsltnMngController`)
프론트 엔티티 슬라이스: `entities/pwrk-cnsltn-mng/`
| 기능 | Controller | Method | URL | Request | Response |
| ------------------- | ---------------------- | ------ | ----------------------------------------------------- | ----------------------------------- | ------------------------------------ |
| 페이징 조회 | `findPage` | GET | `/pwrk-cnsltn-mng` | `PwrkCnsltnMngSearchDto` + Pageable | `Page<PwrkCnsltnMngResponseDto>` |
| 상세 (문서번호) | `findById` | GET | `/pwrk-cnsltn-mng/{pwrkCnsltnDocNo}` | path | `PwrkCnsltnMngDetResponseDto` |
| 상세 (재원협의번호) | `findByUpDocNo` | GET | `/pwrk-cnsltn-mng/updocno/{upDocNo}` | path | `PwrkCnsltnMngDetResponseDto` |
| 등록 + 초안생성 | `save` | POST | `/pwrk-cnsltn-mng` | `PwrkCnsltnMngSaveRequestDto` | `PwrkCnsltnMngSaveResponseDto` (201) |
| 신청 통합 저장 | `saveApply` | POST | `/pwrk-cnsltn-mng/save-apply` | `PwrkCnsltnApplySaveRequestDto` | `string` (문서번호, 201) |
| 수정 | `update` | PUT | `/pwrk-cnsltn-mng/{pwrkCnsltnDocNo}` | `PwrkCnsltnMngUpdateRequestDto` | `string` |
| 삭제 | `delete` → `remove` | DELETE | `/pwrk-cnsltn-mng/{pwrkCnsltnDocNo}` | path | 204 |
| 보완완료 제출 | `submitSupplement` | POST | `/pwrk-cnsltn-mng/{pwrkCnsltnDocNo}/submitsupplement` | `PwrkCnsltnApplySaveRequestDto` | `string` |
| 보완요청 등록 | `submitReqSupplement` | POST | `/pwrk-cnsltn-mng/{pwrkCnsltnDocNo}/reqsupplement` | `PwrkCnsltnApplySaveRequestDto` | `string` |
| 진행상태 수정 | `changePrgrsSttsComCd` | POST | `/pwrk-cnsltn-mng/doc/updatePrgrsStts` | `DocPrgrsUpdateRequestDto` | 200 |
목록 기본 호출:
```http
GET /as-service/api/v1/pwrk-cnsltn-mng?page=0&size=10&sort=pwrkCnsltnDocNo,desc
```
#### 초안 생성 Request / Response (`save`)
```json
POST /as-service/api/v1/pwrk-cnsltn-mng
{
"upDocNo": "F-20260824-0001",
"upDocSeComCd": "FNRS",
"prgrsSttsComCd": "DV"
}
```
```json
{
"pwrkCnsltnDocNo": "P-20260826-0001",
"upDocNo": "F-20260824-0001",
"upDocSeComCd": "FNRS",
"prgrsSttsComCd": "DV",
"resultCode": "ACCEPTED",
"message": "초안 생성 작업이 접수되었습니다.",
"crtDmndUuid": "382f2821-9231-42e6-8309-4a1718a2ead8",
"crtSttsComCd": "R",
"requestId": "382f2821-9231-42e6-8309-4a1718a2ead8",
"estimatedMinutes": 10
}
```
백엔드 동작 (`PwrkCnsltnMngService.save`):
1. `upDocNo`로 기존 실무협의서가 있으면 **그 번호를 재사용** (재원협의 1 : 실무협의 1).
2. 없으면 `docNoMngService.issueDocNo(PWRK)`로 `P-` 번호 발급 후 insert.
3. DC RAG 초안생성을 비동기로 호출하고 `crtDmndUuid`를 응답에 실어 반환.
4. DC RAG 실패 시에도 **문서는 롤백하지 않고** 로그만 남긴다. 프론트는 `resultCode`가 `ACCEPTED`/`SUCCESS`가 아니면 토스트로 실패를 알린다.
#### 상세 응답 구조 (`PwrkCnsltnMngDetResponseDto`)
재원협의 상세에서 `GET .../updocno/{fnrsCnsltnDocNo}`로 연계 데이터를 한 번에 가져온다.
```json
{
"pwrkCnsltnDocNo": "P-20260827-0001",
"upDocNo": "F-20260824-0001",
"upDocSeComCd": "FNRS",
"prgrsSttsComCd": "DV",
"cnsltnList": [],
"pjtcoAjmtList": [],
"crtDmndList": [
{
"crtDmndUuid": "d0545bef-735f-4d7b-a781-88e96e03577a",
"pwrkCnsltnDocNo": "P-20260827-0001",
"rqstrUuid": "65a00f65-8460-49af-98ec-042977e56f4b",
"rqstrNm": "관리자",
"crtSttsComCd": "C",
"crtRslt": {
"crtDmndUuid": "d0545bef-735f-4d7b-a781-88e96e03577a",
"verSn": 1,
"rgtrUuid": "...",
"rgtrNm": "관리자",
"orgnlOtptDocCn": null,
"crtCmptnDt": "2026-08-27 10:47:01"
}
}
],
"splmntDmndList": [],
"trsmFileList": []
}
```
`crtRslt`는 최신 버전 메타만 포함한다. `orgnlOtptDocCn`은 절대 쓰지 말고 download API를 호출한다.
마스터에 재원협의 사업정보가 조인된다: `bizNm`, `admdstCd`, `envoSeComCd`, `taskSeComCd`, `aplcntNm` 등.
---
### 5-4. 문서생성결과 (`PwrkCnsltnDocCrtRsltController`)
프론트 엔티티 슬라이스: `entities/pwrk-cnsltn-doc-crt-rslt/`
복합 PK: `crtDmndUuid` + `verSn`
| 기능 | Controller | Method | URL | Request | Response |
| ------------------ | ------------------------- | ------ | ---------------------------------- | ----------------------------------------------- | --------------------------------------- |
| 버전 이력 페이징 | `findPage` | GET | `/pwrk-cnsltn-doc-crt-rslt` | `PwrkCnsltnDocCrtRsltSearchDto` + Pageable | `Page<PwrkCnsltnDocCrtRsltResponseDto>` |
| 특정 버전 상세 | `findById` | GET | `/{crtDmndUuid}/{verSn}` | path | `PwrkCnsltnDocCrtRsltResponseDto` |
| 최신 버전 상세 | `findLatestByCrtDmndUuid` | GET | `/{crtDmndUuid}/latest` | path | `PwrkCnsltnDocCrtRsltResponseDto` |
| 특정 버전 다운로드 | `download` | GET | `/{crtDmndUuid}/{verSn}/download` | path | `byte[]` (attachment) |
| 최신 버전 다운로드 | `downloadLatest` | GET | `/{crtDmndUuid}/download` | path | `byte[]` (attachment) |
| 등록 (multipart) | `saveMultipart` | POST | `/` `multipart/form-data` | `PwrkCnsltnDocCrtRsltSaveRequestDto` + `file` | `PwrkCnsltnDocCrtRsltResponseDto` (201) |
| 등록 (JSON) | `saveJson` | POST | `/` `application/json` | `PwrkCnsltnDocCrtRsltSaveRequestDto` | 201 |
| 수정 (multipart) | `updateMultipart` | PUT | `/{crtDmndUuid}/{verSn}` multipart | `PwrkCnsltnDocCrtRsltUpdateRequestDto` + `file` | ResponseDto |
| 수정 (JSON) | `updateJson` | PUT | `/{crtDmndUuid}/{verSn}` json | UpdateRequestDto | ResponseDto |
| 삭제 | `delete` → `remove` | DELETE | `/{crtDmndUuid}/{verSn}` | path | 204 |
Spring 매핑 주의: `/{crtDmndUuid}/latest` 와 `/{crtDmndUuid}/download` 는 `/{crtDmndUuid}/{verSn}` 보다 **구체 경로가 우선**한다. 프론트 path를 섞지 말 것.
버전 이력 조회 예:
```http
GET /as-service/api/v1/pwrk-cnsltn-doc-crt-rslt?page=0&size=10&crtDmndUuid={uuid}&sort=verSn,desc
```
#### 다운로드 (attachment)
화면 [다운로드] 버튼 → PC 저장.
```http
GET /as-service/api/v1/pwrk-cnsltn-doc-crt-rslt/{crtDmndUuid}/download
GET /as-service/api/v1/pwrk-cnsltn-doc-crt-rslt/{crtDmndUuid}/{verSn}/download
```
- axios: `responseType: "blob"`.
- 파일명: `Content-Disposition`의 `filename*=UTF-8''...` (`parseFilenameFromResponse` 재사용).
- 서버 파일명 규칙: `재원협의 실무협의서({bizNm})_v{verSn}.hwp`.
- **현재 컨트롤러 Content-Type**: `application/x-hwp`.
데이터 설명 예시는 `application/haansofthwpx`. 프론트는 Content-Type에 의존하지 말고 blob으로 저장한다.
기존 `attachService` 다운로드와 동일 패턴:
```ts
const response = await client.get<Blob>(url, { responseType: "blob" });
const filename = parseFilenameFromResponse(response);
const objectUrl = window.URL.createObjectURL(response.data);
```
#### 웹기안기 열기 (inline) — TBD
데이터 설명 기준 엔드포인트. **현재** `PwrkCnsltnDocCrtRsltController`**에는 없음.** 백엔드 추가 후 연결한다.
```http
GET /as-service/api/v1/pwrk-cnsltn-doc-crt-rslt/{crtDmndUuid}/webhwp/open
GET /as-service/api/v1/pwrk-cnsltn-doc-crt-rslt/{crtDmndUuid}/{verSn}/webhwp/open
```
의도:
- `Content-Disposition: inline`
- 한컴 웹기안기 `HwpCtrl.Open(url)` 이 이 URL을 직접 호출
- 브라우저 다운로드 창이 뜨지 않고 캔버스에 로드
프론트는 **blob fetch 후 Object URL을 넘기지 않는다.** 인증 헤더가 필요한 절대 URL을 기안기에 전달하는 방식이므로, 쿠키/게이트웨이 인증 가능 여부를 백엔드와 확인한다 (TBD).
#### 신규 버전 등록 (웹기안기 저장)
서버가 최신 `verSn + 1`을 부여한다. 클라이언트가 `verSn`을 보내지 않는다.
```http
POST /as-service/api/v1/pwrk-cnsltn-doc-crt-rslt
Content-Type: multipart/form-data
crtDmndUuid: {uuid}
file: 재원협의 실무협의서_v2.hwpx
```
화면에서는 **multipart만** 사용한다. JSON `saveJson`은 바이너리를 body에 넣는 경로라 UI에서 쓰지 않는다.
`file` part 이름: `"file"`. DTO 필드는 `@ModelAttribute` 로 평문 필드(`crtDmndUuid`) 또는 `saveDto` part.
---
## 6. DTO 매핑 (프론트 타입명 = 백엔드 클래스명)
### 6-1. `PwrkCnsltnDocCrtRslt*`
| 계층 | 파일 | 정의 |
| ------------------------------------ | -------------------------------------------- | ----------------------------------------------- |
| Save Request | `pwrk-cnsltn-doc-crt-rslt-payload-schema.ts` | zod SSOT → `PwrkCnsltnDocCrtRsltSaveRequestDto` |
| Response / Search / Update / Service | `pwrk-cnsltn-doc-crt-rslt-interface.ts` | `interface` |
**Response**
| 필드 | 타입 | 비고 |
| ---------------- | -------- | --------------------- |
| `crtDmndUuid` | `string` | UUID |
| `verSn` | `number` | BigDecimal |
| `rgtrUuid` | `string` | |
| `rgtrNm` | `string` | 서버 enrich |
| `orgnlOtptDocCn` | `null` | 목록/상세 미포함 |
| `crtCmptnDt` | `string` | `yyyy-MM-dd HH:mm:ss` |
**Search** (`PageableParams` 확장)
| 필드 | 비고 |
| ----------------- | ---------------------- |
| `crtDmndUuid` | 버전 이력 조회 시 필수 |
| `pwrkCnsltnDocNo` | |
| `verSn` | |
| `rgtrUuid` | |
**Save Request (zod)**
| 필드 | 필수 | 비고 |
| ------------- | ---- | ------------------------------------ |
| `crtDmndUuid` | Y | |
| `rgtrUuid` | N | 미전송 시 서버가 로그인 사용자 사용 |
| `file` | UI | multipart `file` part. DTO 필드 아님 |
**Update Request**
| 필드 | 비고 |
| ------------ | --------- |
| `rgtrUuid` | |
| `crtCmptnDt` | |
| `file` | multipart |
### 6-2. `PwrkCnsltnMng*`
**Search**
`pwrkCnsltnDocNo`, `upDocNo`, `upDocSeComCd`, `prgrsSttsComCd`, `admdstCd`, `envoSeComCd`, `taskSeComCd`, `bizNm`
**Save Request (초안 생성)** — 필드가 얇으므로 interface만으로도 가능. 권장은 zod.
| 필드 | 필수 | 값 |
| ----------------- | ---- | ----------------- |
| `upDocNo` | Y | 재원협의 문서번호 |
| `upDocSeComCd` | | `"FNRS"` |
| `prgrsSttsComCd` | | `"DV"` (초안생성) |
| `pwrkCnsltnDocNo` | N | 서버 발급 |
**Save Response** — `save`만 이 DTO. `fnrsCnsltnMngService.save`가 `string`을 반환하는 것과 **다름**.
| 필드 | 의미 |
| ------------------ | --------------------------------- |
| `pwrkCnsltnDocNo` | 발급/재사용 번호 |
| `resultCode` | `ACCEPTED` / `SUCCESS` / 실패코드 |
| `crtDmndUuid` | 폴링 키 |
| `crtSttsComCd` | `R` 생성요청 |
| `estimatedMinutes` | 기본 10 |
**DetResponse 중첩**
- `cnsltnList`: 협의내역 (`rvwOpnnSn`, `rvwOpnnClsfComCd`, `cnsltnCn`, `rfltYn`, …)
- `pjtcoAjmtList`: 사업비조정
- `crtDmndList[].crtRslt`: 생성요청 + 최신 결과
- `splmntDmndList`: 보완요청
- `sttsChgHstryList`: 상태변경이력
- `trsmFileList`: 전송파일 (데이터 설명 기준. Det DTO Java 필드 확인 후 타입 반영)
### 6-3. DC RAG
```ts
interface DcRagDocumentTransferRequestDto {
fnrsCnsltnDocNo: string;
taskSeComCd?: string;
taskSeCdNm?: string;
}
interface DcRagCommonResponseDto {
resultCode: string;
message?: string;
requestId?: string;
}
interface DcRagDraftStatusResponseDto {
crtDmndUuid: string;
status: DraftStatus;
statusName?: string;
progressPercent?: number;
events?: EventInfoDto[];
startedDt?: string;
estimatedCompletionDt?: string;
errorMessage?: string | null;
}
type DraftStatus =
| "REQUESTED"
| "DOWNLOADING"
| "ANALYZING"
| "GENERATING"
| "COMPLETED"
| "FAILED";
```
---
## 7. 공통코드
| 상위코드 | 필드 | 값 | 명칭 |
| ----------------------- | ---------------- | --------------- | ------------- |
| `ASDOCSE` | `upDocSeComCd` | `FNRS` | 재원협의 |
| `ASDOCSE` | 문서구분 | `PWRK` | 실무협의 |
| `PWRKSTTS` | `prgrsSttsComCd` | `DV` | 초안생성 |
| `PWRKSTTS` | | `AR` | 검토자지정 |
| `PWRKSTTS` | | `SV` | 작성중 |
| `PWRKSTTS` | | `RB` | 검토회신 |
| `PWRKSTTS` | | `RC` | 협의회신 |
| `PWRKSTTS` | | `TR` | 검토결과 회신 |
| `PWRKSTTS` | | `SL` | 보완요청 |
| `PWRKSTTS` | | `AA` | 금액조정중 |
| `AICRTSTTS` / `CRTSTTS` | `crtSttsComCd` | `R` | 생성요청 |
| | | `I` | 생성중 |
| | | `C` | 생성완료 |
| | | `F` | 생성실패 |
| (검증) | `vrfcSttsComCd` | `PASS` / `FAIL` | |
`CrtDmndResponseDto`는 `@CodeName(upCd = "CRTSTTS")`, 생성요청 단건 DTO는 `AICRTSTTS`를 쓴다. 코드값이 `R/I/C/F`로 같으므로 UI는 코드값으로 분기하고, 표시명은 응답의 `*Nm` 필드를 쓴다.
초안 생성 직후 `crtSttsComCd = R`, 폴링 `COMPLETED` 이후 상세의 `crtSttsComCd = C`.
---
## 8. 프론트엔드 구현 가이드
### 8-1. 기술 스택·원칙
- App Router + FSD (`app/` → `pages/` → `widgets/` → `entities/` → `shared/`)
- React Query. ad-hoc `useQuery` + service 직접 호출 금지
- `@common/components`, `useDataTable`, `@tanstack/react-table`
- `'use client'`는 Web API(다운로드, 폴링, 웹기안기)가 있는 페이지만
- 함수명 = Controller public 메서드명 (`delete` → `remove`)
- 타입명 = 백엔드 DTO명. 프론트 전용 축약 금지
- Save/Apply Request = zod SSOT. Response는 `interface`
참조 슬라이스: `entities/fnrs-cnsltn/`, `entities/sewer-bsc-plan/`
### 8-2. 모듈·파일 배치
| 구분 | 경로 |
| -------------------- | ------------------------------------------------------------------- |
| ENDPOINT | `apps/as/src/shared/config/api.ts` |
| 실무협의 관리 엔티티 | `entities/pwrk-cnsltn-mng/` |
| 생성결과 엔티티 | `entities/pwrk-cnsltn-doc-crt-rslt/` |
| DC RAG 엔티티 | `entities/dcrag-to/` |
| 목록 라우트 | `app/(main)/(with-base-layout)/pwrk-cnsltn/page.tsx` |
| 상세 라우트 | `app/(main)/(with-base-layout)/pwrk-cnsltn/[id]/page.tsx` |
| 페이지 UI | `pages/pwrk-cnsltn/` |
| 재원협의 상세 연계 | `pages/fnrs-cnsltn/ui/fnrs-view-page.tsx` (버튼·폴링·첨부 검증배지) |
엔티티 내부:
```
entities/pwrk-cnsltn-mng/
├── model/
│ ├── pwrk-cnsltn-mng-interface.ts
│ └── pwrk-cnsltn-mng-payload-schema.ts
├── api/
│ ├── pwrk-cnsltn-mng-service.ts
│ └── queries.ts
└── index.ts
entities/pwrk-cnsltn-doc-crt-rslt/
├── model/
│ ├── pwrk-cnsltn-doc-crt-rslt-interface.ts
│ └── pwrk-cnsltn-doc-crt-rslt-payload-schema.ts
├── api/
│ ├── pwrk-cnsltn-doc-crt-rslt-service.ts
│ └── queries.ts
└── index.ts
```
### 8-3. Service 시그니처 요지
`pwrkCnsltnMngService.save` 반환 타입이 `string`이 아니라 `PwrkCnsltnMngSaveResponseDto` 인 점을 `fnrs-cnsltn`과 혼동하지 말 것.
```ts
export interface PwrkCnsltnMngService {
findPage(searchDto: PwrkCnsltnMngSearchDto): Promise<PageResponse<PwrkCnsltnMngResponseDto>>;
findById(pwrkCnsltnDocNo: string): Promise<PwrkCnsltnMngDetResponseDto>;
findByUpDocNo(upDocNo: string): Promise<PwrkCnsltnMngDetResponseDto>;
save(body: PwrkCnsltnMngSaveRequestDto): Promise<PwrkCnsltnMngSaveResponseDto>;
saveApply(body: PwrkCnsltnApplySaveRequestDto): Promise<string>;
update(pwrkCnsltnDocNo: string, body: PwrkCnsltnMngUpdateRequestDto): Promise<string>;
submitSupplement(pwrkCnsltnDocNo: string, body: PwrkCnsltnApplySaveRequestDto): Promise<string>;
submitReqSupplement(
pwrkCnsltnDocNo: string,
body: PwrkCnsltnApplySaveRequestDto,
): Promise<string>;
remove(pwrkCnsltnDocNo: string): Promise<void>;
changePrgrsSttsComCd(body: DocPrgrsUpdateRequestDto): Promise<void>;
}
export interface PwrkCnsltnDocCrtRsltService {
findPage(
searchDto: PwrkCnsltnDocCrtRsltSearchDto,
): Promise<PageResponse<PwrkCnsltnDocCrtRsltResponseDto>>;
findById(crtDmndUuid: string, verSn: number): Promise<PwrkCnsltnDocCrtRsltResponseDto>;
findLatestByCrtDmndUuid(crtDmndUuid: string): Promise<PwrkCnsltnDocCrtRsltResponseDto>;
download(crtDmndUuid: string, verSn: number): Promise<AxiosResponse<Blob>>;
downloadLatest(crtDmndUuid: string): Promise<AxiosResponse<Blob>>;
saveMultipart(formData: FormData): Promise<PwrkCnsltnDocCrtRsltResponseDto>;
updateMultipart(
crtDmndUuid: string,
verSn: number,
formData: FormData,
): Promise<PwrkCnsltnDocCrtRsltResponseDto>;
remove(crtDmndUuid: string, verSn: number): Promise<void>;
}
```
multipart `Content-Type`은 axios가 boundary를 붙이도록 **헤더를 수동 지정하지 않는다.**
### 8-4. 폴링 훅
`useQuery` + `refetchInterval`:
```ts
useQuery({
queryKey: [...dcragToKeys.draftStatus(crtDmndUuid)],
queryFn: () => dcragToService.getDraftGenerationStatus(crtDmndUuid),
enabled: Boolean(crtDmndUuid) && isPolling,
refetchInterval: (query) => {
const status = query.state.data?.status;
if (status === "COMPLETED" || status === "FAILED") return false;
return 5_000;
},
});
```
`COMPLETED` 시 `pwrkCnsltnMng` 상세 쿼리를 invalidate 하여 `crtDmndList[].crtRslt`를 갱신한다.
### 8-5. 재원협의 상세에 붙일 UI
`pages/fnrs-cnsltn` 조회 모드:
1. **자료 검증 요청** — `requestDocumentTransfer` → 상세 refetch → 첨부 행에 PASS/FAIL 배지 (`vrfcSttsComCd`, `vrfcRsltCn`, `vrfcDt`).
2. **초안 생성** — 검증이 끝난 뒤 활성. `pwrkCnsltnMngService.save`.
3. **진행 표시** — `progressPercent` + 이벤트 타임라인. `estimatedMinutes`(10)를 안내.
4. **웹기안기 열기 / 다운로드** — `COMPLETED` 이후에만.
`findByUpDocNo(fnrsCnsltnDocNo)`로 이미 초안이 있으면 `crtDmndUuid`를 복원해 폴링·열기를 이어간다.
### 8-6. 실무협의 목록 컬럼 (권장)
마스터 응답 + 재원협의 조인 필드 기준. 화면설계 확정 전 TBD.
| 표시명 | Field |
| ------------ | ------------------ |
| 실무협의번호 | `pwrkCnsltnDocNo` |
| 재원협의번호 | `upDocNo` |
| 진행상태 | `prgrsSttsComCdNm` |
| 사업명 | `bizNm` |
| 시도/구군 | `sido` / `gugun` |
| 신청자 | `aplcntNm` |
| 등록일 | `regDt` |
행 클릭 → `/pwrk-cnsltn/{pwrkCnsltnDocNo}`.
---
## 9. 구현 순서
```
Task Progress:
- [ ] 1. ENDPOINT 3개 추가 (PWRK_CNSLTN_MNG, PWRK_CNSLTN_DOC_CRT_RSLT, DCRAG_TO)
- [ ] 2. entities/dcrag-to (transfer + draft status)
- [ ] 3. entities/pwrk-cnsltn-mng (findPage, findById, findByUpDocNo, save)
- [ ] 4. entities/pwrk-cnsltn-doc-crt-rslt (findPage, downloadLatest, saveMultipart)
- [ ] 5. 재원협의 상세: 검증결과 3필드 타입 확장 + 배지
- [ ] 6. 재원협의 상세: 자료검증 / 초안생성 / 폴링 UI
- [ ] 7. 실무협의 목록·상세 라우트
- [ ] 8. 다운로드 (blob)
- [ ] 9. 웹기안기 Open — 백엔드 webhwp/open 확인 후
- [ ] 10. 웹기안기 저장 → saveMultipart (verSn + 1)
```
1~4는 엔티티 레이어만으로 먼저 닫는다. 화면은 5부터.
---
## 10. TBD · 백엔드 확인
| 항목 | 내용 |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `webhwp/open` | 데이터 설명에는 있으나 **현재 Controller에 없음**. 추가 일정·인증 방식 확인 |
| Content-Type | Controller `application/x-hwp` vs 설명 `application/haansofthwpx` |
| 파일 확장자 | 다운로드 파일명이 `.hwp`로 고정. 실제 바이너리가 hwpx일 수 있음 |
| 폴링 주기 | 5초 vs 10초, 탭 비활성 시 중단 여부 |
| 검증 필드 타입 | `FnrsCnsltnMngDetResponseDto.esntlFileList` / `etcFileList` Java 타입이 아직 `FnrsCnsltnAtflResponseDto`가 아님. 런타임 JSON에 `vrfc*`가 오는지 확인 |
| `trsmFileList` | 데이터 설명 상세 예시에는 있으나 Det DTO Java 선언과 불일치 가능 |
| `crtSttsComCd` 상위코드 | `CRTSTTS` vs `AICRTSTTS` |
| 웹기안기 라이선스 URL | `HwpCtrl.Open`에 넘길 게이트웨이 절대 경로 (쿠키 인증 가능 여부) |
| 초안 생성 1회 제한 | `requestDraftGeneration` 주석에 “1회 제한”. 재클릭 UX (비활성 / 재요청 확인) |
| 자료전송 허용 확장자 | `.doc .docx .hwp .hwpx .md .pdf .txt .xls .xlsm .xlsx`. 이미지 첨부 시 DC RAG 실패 |
---
## 11. 참고
| 자료 | 위치 |
| ---------------------- | ------------------------------------------------------------------------------ |
| 생성결과 컨트롤러 | `backend/.../pwrkcnsltndoccrtrslt/PwrkCnsltnDocCrtRsltController.java` |
| 실무협의관리 컨트롤러 | `backend/.../pwrkcnsltnmng/PwrkCnsltnMngController.java` |
| DC RAG TO 컨트롤러 | `backend/.../dcrag/DcRagApiToController.java` |
| 초안생성 데이터 설명 | `02.03.초안생성_데이터_설명.md` |
| 엔티티 구현 스킬 | `frontend/.cursor/skills/backend-controller-to-entity/` |
| 재원협의 엔티티 (패턴) | `apps/as/src/entities/fnrs-cnsltn/` |
| IIPBS 단어목록 | `frontend/docs/IIPBS_단어목록.md` |
| 진행상태 코드 | `backend/IIPBS_CS/docs/2026/05.행정지원/01.문서상태별_사용자_상태변경_이력.md` |
@@ -1,18 +1,11 @@
import type { PageableParams, PageResponse } from "@/shared/model";
import type {
AtflMngClientResponseDto,
EsntlAtchFileMngTreeResponseDto,
PageableParams,
PageResponse,
} from "@/shared/model";
/** 필수첨부파일관리 트리 조회 응답 DTO */
export interface EsntlAtchFileMngTreeResponseDto {
/** 필수첨부파일아이디 */
esntlAtchFileId: string;
/** 문서구분공통코드 */
docSeComCd: string;
/** 필수첨부파일명 */
esntlAtchFileNm: string;
/** 필수첨부여부 ("Y" | "N") */
esntlAtchYn: "Y" | "N";
/** 필수첨부파일비고 (파일형식 등) */
esntlAtchFileRmrk: string;
}
export type { EsntlAtchFileMngTreeResponseDto };
/** 필수첨부파일관리 엔티티 (목록/상세 공용 응답 DTO) */
export interface EsntlAtchFileMng {
@@ -26,6 +19,10 @@ export interface EsntlAtchFileMng {
esntlAtchYn: "Y" | "N";
/** 필수첨부파일비고 */
esntlAtchFileRmrk?: string;
/** 첨부파일그룹아이디 (양식파일 그룹) */
atchFileGroupId?: string;
/** 첨부파일 목록 (양식파일) */
atchFileList?: AtflMngClientResponseDto[];
/** 등록자UUID */
rgtr?: string;
/** 등록자명 */
@@ -80,4 +77,6 @@ export interface EsntlAtchFileMngSaveRequest {
esntlAtchYn?: "Y" | "N";
/** 필수첨부파일비고 */
esntlAtchFileRmrk?: string;
/** 첨부파일그룹아이디 (양식파일 그룹) */
atchFileGroupId?: string;
}
@@ -1,6 +1,7 @@
"use client";
import { createColumnHelper } from "@tanstack/react-table";
import { Paperclip } from "lucide-react";
import type { Code } from "@/entities/code";
import { findCodeLabel } from "@/entities/code";
@@ -13,22 +14,24 @@ interface CreateEsntlAtchFileColumnsOptions {
docSeCodes: Code[] | undefined;
onEdit: (row: EsntlAtchFileMng) => void;
onDelete: (row: EsntlAtchFileMng) => void;
onDownloadTemplate: (atflId: string) => void;
}
function formatYn(value: string | undefined) {
if (value === "Y") return "예";
if (value === "N") return "아니오";
return "—";
return "-";
}
function formatOptional(value: string | undefined) {
return value?.trim() ? value : "—";
return value?.trim() ? value : "-";
}
export function createEsntlAtchFileColumns({
docSeCodes,
onEdit,
onDelete,
onDownloadTemplate,
}: CreateEsntlAtchFileColumnsOptions) {
return [
columnHelper.accessor("esntlAtchFileId", {
@@ -37,7 +40,7 @@ export function createEsntlAtchFileColumns({
}),
columnHelper.accessor("docSeComCd", {
header: "문서구분",
cell: ({ getValue }) => findCodeLabel(docSeCodes, getValue()) || getValue() || "—",
cell: ({ getValue }) => findCodeLabel(docSeCodes, getValue()) || getValue() || "-",
}),
columnHelper.accessor("esntlAtchFileNm", {
header: "필수첨부파일명",
@@ -50,18 +53,33 @@ export function createEsntlAtchFileColumns({
}),
columnHelper.accessor("esntlAtchFileRmrk", {
header: "비고",
meta: { align: "left" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("regDt", {
header: "등록일시",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("mdfcnDt", {
header: "수정일시",
columnHelper.display({
id: "templateDownload",
header: "양식 다운로드",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
cell: ({ row }) => {
const files = row.original.atchFileList ?? [];
if (files.length === 0) return "-";
return (
<div className="space-y-2" onClick={(e) => e.stopPropagation()}>
{files.map((file) => (
<a
key={file.atflId}
className="flex cursor-pointer items-start gap-0.5 text-left text-blue-600 hover:underline"
onClick={() => onDownloadTemplate(file.atflId)}
>
<Paperclip className="my-1 size-5 shrink-0" />
{file.orgnlFileNm || "다운로드"}
</a>
))}
</div>
);
},
}),
columnHelper.display({
id: "actions",
@@ -1,8 +1,13 @@
"use client";
import { useEffect } from "react";
import { useEffect, useId, 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 { attachService } from "@/entities/attach";
import { CodeSelect } from "@/entities/code";
import type { EsntlAtchFileMng } from "@/entities/esntl";
import {
@@ -24,6 +29,14 @@ export interface EsntlAtchFileFormValues {
esntlAtchFileNm: string;
esntlAtchYn: "Y" | "N";
esntlAtchFileRmrk: string;
atchFileGroupId: string;
}
interface UploadedTemplateFile {
fileName: string;
fileKey: string;
atflId?: string;
atflCd?: string;
}
interface EsntlAtchFileFormDialogProps {
@@ -35,6 +48,10 @@ interface EsntlAtchFileFormDialogProps {
onSubmit: (values: EsntlAtchFileFormValues) => void;
}
function createUploadKey(file: File) {
return `template::${getDefaultFileKey(file)}`;
}
function EsntlAtchFileFormDialog({
mode,
open,
@@ -43,39 +60,116 @@ function EsntlAtchFileFormDialog({
isSubmitting,
onSubmit,
}: EsntlAtchFileFormDialogProps) {
const formId = useId();
const [uploadedFiles, setUploadedFiles] = useState<UploadedTemplateFile[]>([]);
const { register, handleSubmit, reset, setValue, watch } = useForm<EsntlAtchFileFormValues>({
defaultValues: {
docSeComCd: "",
esntlAtchFileNm: "",
esntlAtchYn: "Y",
esntlAtchFileRmrk: "",
atchFileGroupId: "",
},
});
const docSeComCd = watch("docSeComCd");
const esntlAtchYn = watch("esntlAtchYn");
const atchFileGroupId = watch("atchFileGroupId");
const { uploadFileAsync, isUploading, uploads } = useTus({
onSuccess: (atflCd, atflId, _file, fileKey) => {
if (!atflCd || !atflId) return;
setValue("atchFileGroupId", atflCd, { shouldDirty: true });
setUploadedFiles((prev) =>
prev.map((item) => (item.fileKey === fileKey ? { ...item, atflCd, atflId } : item)),
);
},
onError: (_error, _file, fileKey) => {
setUploadedFiles((prev) => prev.filter((item) => item.fileKey !== fileKey));
},
});
const isAnyUploading = uploadedFiles.some((item) => isUploading[item.fileKey] ?? false);
useEffect(() => {
if (!open) return;
if (mode === "edit" && initial) {
const initialFiles = (initial.atchFileList ?? [])
.filter((file) => file.atflId)
.map((file) => ({
fileName: file.orgnlFileNm ?? "양식파일",
fileKey: `server::${file.atflId}`,
atflId: file.atflId,
atflCd: file.atflCd,
}));
setUploadedFiles(initialFiles);
reset({
docSeComCd: initial.docSeComCd ?? "",
esntlAtchFileNm: initial.esntlAtchFileNm ?? "",
esntlAtchYn: initial.esntlAtchYn ?? "Y",
esntlAtchFileRmrk: initial.esntlAtchFileRmrk ?? "",
atchFileGroupId: initial.atchFileGroupId ?? "",
});
return;
}
setUploadedFiles([]);
reset({
docSeComCd: "",
esntlAtchFileNm: "",
esntlAtchYn: "Y",
esntlAtchFileRmrk: "",
atchFileGroupId: "",
});
}, [open, mode, initial, reset]);
async function handleFileUpload(files: FileList | null) {
if (!files || files.length === 0) return;
for (const file of Array.from(files)) {
const fileKey = createUploadKey(file);
const existingAtflCd = atchFileGroupId || uploadedFiles.find((item) => item.atflCd)?.atflCd;
setUploadedFiles((prev) => [...prev, { fileName: file.name, fileKey }]);
try {
await uploadFileAsync(file, {
fileKey,
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
});
} catch {
// onError callback handles cleanup
}
}
}
async function handleFileRemove(fileIndex: number) {
const target = uploadedFiles[fileIndex];
if (!target) return;
uploads[target.fileKey]?.abort();
if (target.atflId) {
try {
await attachService.deleteAttachmentByAtflId(target.atflId);
} catch (error) {
console.error("양식파일 삭제 실패:", error);
}
}
const nextFiles = uploadedFiles.filter((_, index) => index !== fileIndex);
setUploadedFiles(nextFiles);
const nextGroupId = nextFiles.find((item) => item.atflCd)?.atflCd ?? "";
setValue("atchFileGroupId", nextGroupId, { shouldDirty: true });
}
const fileInputId = `${formId}-template-file`;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[52rem]">
@@ -109,6 +203,60 @@ function EsntlAtchFileFormDialog({
{...register("esntlAtchFileNm", { required: true })}
/>
</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>
</div>
<div className="space-y-2">
<Label>필수첨부여부</Label>
<RadioGroup
@@ -142,7 +290,7 @@ function EsntlAtchFileFormDialog({
<Button type="button" color="white" onClick={() => onOpenChange(false)}>
취소
</Button>
<Button type="submit" disabled={isSubmitting}>
<Button type="submit" disabled={isSubmitting || isAnyUploading}>
저장
</Button>
</DialogFooter>
@@ -6,6 +6,7 @@ import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import { useDataTable } from "@/shared/lib";
import { useDownloadAttachmentByAtflId } from "@/entities/attach";
import { useCodeList } from "@/entities/code";
import {
type EsntlAtchFileMng,
@@ -37,6 +38,7 @@ function EsntlAtchFileList({
}: EsntlAtchFileListProps) {
const { confirm } = useConfirm();
const { data: docSeCodes } = useCodeList("ASDOCSE");
const { mutate: downloadTemplate } = useDownloadAttachmentByAtflId();
const [dialogMode, setDialogMode] = useState<"create" | "edit" | null>(null);
const [editingRow, setEditingRow] = useState<EsntlAtchFileMng | null>(null);
@@ -64,6 +66,13 @@ function EsntlAtchFileList({
[confirm, deleteMutation],
);
const handleDownloadTemplate = useCallback(
(atflId: string) => {
downloadTemplate(atflId);
},
[downloadTemplate],
);
const columns = useMemo(
() =>
createEsntlAtchFileColumns({
@@ -73,8 +82,9 @@ function EsntlAtchFileList({
setDialogMode("edit");
},
onDelete: handleDelete,
onDownloadTemplate: handleDownloadTemplate,
}),
[docSeCodes, handleDelete],
[docSeCodes, handleDelete, handleDownloadTemplate],
);
const { tableComponent } = useDataTable({
@@ -102,6 +112,7 @@ function EsntlAtchFileList({
esntlAtchFileNm: values.esntlAtchFileNm,
esntlAtchYn: values.esntlAtchYn,
esntlAtchFileRmrk: values.esntlAtchFileRmrk || undefined,
atchFileGroupId: values.atchFileGroupId || undefined,
};
if (dialogMode === "create") {
+5 -1
View File
@@ -108,7 +108,11 @@ export interface EsntlAtchFileMngTreeResponseDto {
esntlAtchYn: string;
/** 필수첨부파일비고 */
esntlAtchFileRmrk: string | null;
/** 첨부파일 목록 (슬롯당 atchFileGroupId 1:N) */
/** 첨부파일그룹아이디 (양식파일 그룹) */
atchFileGroupId?: string;
/** 템플릿 샘플 파일 목록 (트리 조회 시 양식파일) */
templateAtchFileList?: AtflMngClientResponseDto[];
/** 첨부파일 목록 (문서 사용자 첨부) */
atchFileList?: AtflMngClientResponseDto[];
}