Merge remote-tracking branch 'origin/main' into deploy
Deploy Frontend / deploy (push) Successful in 3m40s
Deploy Frontend / deploy (push) Successful in 3m40s
This commit is contained in:
@@ -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") {
|
||||
|
||||
@@ -108,7 +108,11 @@ export interface EsntlAtchFileMngTreeResponseDto {
|
||||
esntlAtchYn: string;
|
||||
/** 필수첨부파일비고 */
|
||||
esntlAtchFileRmrk: string | null;
|
||||
/** 첨부파일 목록 (슬롯당 atchFileGroupId 1:N) */
|
||||
/** 첨부파일그룹아이디 (양식파일 그룹) */
|
||||
atchFileGroupId?: string;
|
||||
/** 템플릿 샘플 파일 목록 (트리 조회 시 양식파일) */
|
||||
templateAtchFileList?: AtflMngClientResponseDto[];
|
||||
/** 첨부파일 목록 (문서 사용자 첨부) */
|
||||
atchFileList?: AtflMngClientResponseDto[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user