Compare commits

...
126 Commits
Author SHA1 Message Date
chanii8 60ee194cc9 [common/components] design: NoticePopover 스타일 수정 2026-09-23 13:55:16 +09:00
chanii8 3d027ed374 [common/features] chore: 게시판 댓글 사용 여부 적용 2026-09-23 11:37:16 +09:00
chanii8 d70eb17a72 Merge branch 'iipbs/as/dev' 2026-09-23 09:50:30 +09:00
chanii8 ddd619babc [as] feat: 행정지원 문서 체크리스트 레이블 변경 2026-09-23 09:48:08 +09:00
chanii8 5d27ee830e [as] feat: 행정지원 문서 체크리스트 탭 노출 알고리즘 수정 2026-09-23 09:35:35 +09:00
mind-one005 212b9a43a9 Merge branch 'iipbs/wr/dev' 2026-09-22 20:49:40 +09:00
mind-one005 f092a23ee4 wr: font 크기 조정 2026-09-22 20:45:22 +09:00
chanii8 25c357a423 [as] chore: 보완요청 섹션 첨부파일 관련 코드 수정 2026-09-22 16:21:13 +09:00
chanii8 a77246a1db [as] fix: 타입 에러 2026-09-22 15:39:06 +09:00
chanii8 f00ce31648 [as] refactor: zod 스키마 .optional()-> .nullish() 변경 2026-09-22 15:12:28 +09:00
chanii8 dc57a491dd [as] chore: 재원협의 코드 수정 2026-09-22 15:02:36 +09:00
mind-one005 c9b7266959 wr: 재이용실적 수정 2026-09-22 11:24:32 +09:00
chanii8 0dd2111cb9 [as] refactor: 재원협의 폼 useWatch + Controller 방식 제거 2026-09-22 10:57:41 +09:00
mind-one005 c39b048398 Merge branch 'iipbs/wr/dev' 2026-09-22 10:25:49 +09:00
chanii8 87fbf15e2b [as] feat: 재원협의 수정 2026-09-22 10:23:10 +09:00
mind-one005 db1cfaa1a6 wr: label 위치 조정 2026-09-22 10:05:49 +09:00
mind-one005 905b750877 Merge branch 'iipbs/sn/dev' 2026-09-22 09:28:50 +09:00
mind-one005 be14fe2603 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-09-22 09:06:39 +09:00
mind-one005 d7f101ee30 wr: 시도,시군구 전체"0" 2건 조회현상 수정 2026-09-21 17:51:09 +09:00
chanii8 20f7d595a1 Merge branch 'iipbs/as/dev' 2026-09-21 17:50:59 +09:00
chanii8 e909dc81ad [as] chore: 재원협의 버튼 노출 조건 수정 2026-09-21 17:50:23 +09:00
chanii8 a510779ea1 [as] feat: AI 만족도 평가 기능 구현 2026-09-21 17:24:01 +09:00
chanii8 0818e9ade2 Merge branch 'iipbs/as/dev' 2026-09-21 16:57:46 +09:00
chanii8 e60a8bed8b fix: 빌드 에러 2026-09-21 16:57:16 +09:00
chanii8 17fdcd7d1f fix: 빌드 에러 2026-09-21 16:56:46 +09:00
chanii8 6412e1a997 Merge branch 'iipbs/as/dev' 2026-09-21 16:36:58 +09:00
chanii8 1674598480 [as] design: RHFDevtool 스타일 수정 2026-09-21 16:36:28 +09:00
chanii8 23ebf36244 [as] feat: "@hookform/devtools" 추가 2026-09-21 16:04:19 +09:00
chanii8 342db06685 [as] refactor: 행정지원 문서 저장 payload 스키마 수정 2026-09-21 15:18:42 +09:00
mind-one001 35b9f813c4 로그삭제 2026-09-21 15:08:07 +09:00
mind-one005 aafb326662 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-09-21 14:46:02 +09:00
mind-one005 aa1b309b26 wr: 중수도 단위, 모달 조회 순서, 시설구분 조회 2026-09-21 14:45:50 +09:00
100JM 2c355525df [il] - DB 표준화로 인한 소스 수정 2026-09-21 13:55:37 +09:00
chanii8 bd20a499d3 [as] feat: AI 수동 검증 버튼 추가 2026-09-21 13:34:26 +09:00
chanii8 5fec5d5239 [as] feat: RAG 인덱싱 상태일 때 검토자 지정 버튼 분기 처리 2026-09-21 11:33:59 +09:00
chanii8 a14b22b1ef Merge branch 'iipbs/as/dev' 2026-09-21 11:02:55 +09:00
chanii8 50aa549311 [as] feat: 공단 실무협의서 관리 버튼 노출 조건 수정 2026-09-21 11:02:22 +09:00
kiminchan bf0aef59cd Merge remote-tracking branch 'origin/iipbs/sn/dev' into iipbs/sn/dev_kic 2026-09-21 10:06:26 +09:00
kiminchan b2ddcd0cf3 sn : 통수위험 결과 안내문에 위험 판정기준 및 상태 색상 표시 2026-09-21 09:55:55 +09:00
chanii8 572ef04bb4 Merge branch 'iipbs/as/dev' 2026-09-18 17:37:31 +09:00
chanii8 110d6c689c [as] feat: UploadedFileChip 컴포넌트 props 추가(onDownload) 2026-09-18 17:33:45 +09:00
chanii8 f40d6fd5e8 [as] feat: AttachmentDownloadLink 컴포넌트 구현 및 적용 2026-09-18 16:51:00 +09:00
mind-one001 49ace44c9b 테스트 파일 삭제 2026-09-18 16:35:21 +09:00
chanii8 cc3bc548c9 [as] feat: 재원협의 코드 수정 2026-09-18 16:27:51 +09:00
chanii8 cc3d7ccd7b Merge remote-tracking branch 'origin/iipbs/as/dev' 2026-09-17 17:05:08 +09:00
chanii8 43bc7db7d5 [as] chore: useAplnRows 수정 2026-09-17 17:04:31 +09:00
chanii8 b0672aead5 Merge branch 'iipbs/as/dev' 2026-09-17 16:48:50 +09:00
chanii8 086d5a18e8 [as] chore: 재원협의 코드 수정 2026-09-17 16:48:13 +09:00
mind-one005 91576f401b sn: 침수예측오버레이 수정 2026-09-17 15:41:08 +09:00
mind-one005 febcd18eda Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-09-17 14:05:11 +09:00
mind-one005 8cc9f0d291 sn: 침수예측현황 침수위험도 수정 2026-09-17 14:04:51 +09:00
chanii8 41643563f8 [as] chore: 수정사항 반영 2026-09-17 10:55:51 +09:00
chanii8 b6b58eaeba feat: 재원협의&실무협의서 로직 사이클 2026-09-17 10:00:00 +09:00
chanii8 e8a5701e73 [as] feat: 웹기안기 다이얼로그 내 금액검증결과 섹션 구현 2026-09-16 17:37:44 +09:00
kiminchan a8f2c8f41a GIS 편집 신규 관로 미리보기 표시 보완 2026-09-16 17:32:02 +09:00
100JM 241222dcd1 [il] - 연계 db 표준화로 인한 소스 수정 2026-09-16 14:52:38 +09:00
chanii8 380575dae0 [as] feat: 실무협의서 검토 로직(RR, RK) 작업 2026-09-16 14:19:39 +09:00
mind-one001 4d685d5a67 Merge remote-tracking branch 'origin/iipbs/sn/dev' into iipbs/sn/dev 2026-09-16 11:15:30 +09:00
mind-one001 96196229f4 fix(sn): align business form limits with standardized database 2026-09-16 11:15:18 +09:00
chanii8 5e54039028 [as] feat: 실무협의서 검토자 지정(EV) 로직 작업 2026-09-15 16:42:21 +09:00
mind-one005 94b2e84c10 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-09-15 16:28:23 +09:00
mind-one001 80d2230c9a 불필요 파일 삭제 2026-09-15 10:00:42 +09:00
chanii8 4d20a68f93 Merge branch 'iipbs/as/dev' 2026-09-14 17:52:41 +09:00
chanii8 90a1d6fdff [as] feat: 웹기안기 '열기' 기능 추가(개발용) 2026-09-14 17:52:16 +09:00
chanii8 00e1cb707e [as] feat: 재원협의 환경청 조치결과 검토 의뢰(RA) 로직 작업 2026-09-14 17:35:00 +09:00
chanii8 6eeb774a21 [as] feat: 재원협의 지자체 조치완료(RB) 로직 작업 2026-09-14 13:36:23 +09:00
chanii8 6c4b8a52dc [as] chore: 보완요청 사항 적용 2026-09-14 10:16:16 +09:00
chanii8 a7e0d8f826 [as] feat: 첨부파일 컴포넌트 생성 및 적용 2026-09-11 17:23:45 +09:00
chanii8 d1ccddc1d3 [as] feat: 재원협의 기술검토 로직 작업 2026-09-11 17:02:09 +09:00
mind-one005 d5f0c3bc95 sn: 침수발생이력, 침수예측현황 팝업크기 변경 외 2026-09-11 11:01:55 +09:00
chanii8 056643bd96 Merge branch 'iipbs/as/dev' 2026-09-11 10:36:03 +09:00
chanii8 01d5ed297e [as] chore: 재원협의 상세화면 게이트 수정 2026-09-11 10:35:33 +09:00
chanii8 dae5d0a9f5 Merge remote-tracking branch 'origin/iipbs/as/dev' 2026-09-10 17:52:46 +09:00
chanii8 41ad33310a [as] feat: 실무협의서 관리 다이얼로그 내 첨부파일 섹션 추가 2026-09-10 17:50:56 +09:00
chanii8 b38ee9c7c7 Merge branch 'iipbs/as/dev' 2026-09-10 15:43:30 +09:00
chanii8 9f7f1db9b8 [as] feat: 기안기 저장 코드 수정 2026-09-10 14:50:09 +09:00
mind-one001 b7134cb3e4 노후관로현황 그리드 수정 2026-09-10 14:06:24 +09:00
mind-one001 ec78658e46 시도별 노후관로현황 그리드 수정 2026-09-10 14:01:58 +09:00
mind-one001 44361af392 그리드 css 통합 2026-09-10 13:51:42 +09:00
mind-one001 76130caa6d Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-09-10 11:30:58 +09:00
mind-one001 352a78a0c5 gis 업로드 오류 기능 수정 2026-09-10 11:30:52 +09:00
chanii8 a2a165a42b Merge branch 'iipbs/as/dev' 2026-09-09 18:08:35 +09:00
chanii8 0f27c1e5ce [as] chore: 웹기안기 다이얼로그 수정 2026-09-09 18:08:08 +09:00
chanii8 e944ad4369 [as] feat: 실무협의서 웹기안기에 렌더링 2026-09-09 17:38:40 +09:00
kiminchan 65bee9586d 도시침수지도 목록 이동 운영 보완 기록 2026-09-09 16:45:12 +09:00
mind-one001 31cda8579a Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-09-09 15:42:53 +09:00
mind-one001 1dc40b9efc 빈 레이어 트리로 지도만 재사용하는 화면에서는 패널 외곽과 제목도 표시하지 않는다. 2026-09-09 15:42:31 +09:00
mind-one005 6cb3d8ad65 Merge branch 'iipbs/wr/dev' into iipbs/sn/dev 2026-09-09 14:06:31 +09:00
LEEKYUHYUN 225e2732d1 사이드패널 패딩 조정 및 스크롤바 수정 2026-09-09 11:35:59 +09:00
chanii8 768ad001d2 [as] feat: 재원협의 쿼리 무효화 로직 수정 2026-09-09 10:30:29 +09:00
mind-one005 866d11988d Merge branch 'iipbs/sn/dev' 2026-09-09 09:27:53 +09:00
mind-one005 d82ae99b6a sn: 오류 수정 2026-09-09 09:09:09 +09:00
mind-one005 eb4f6f1b09 Merge branch 'iipbs/sn/dev' 2026-09-08 22:29:39 +09:00
mind-one005 23341b93c7 sn: 침수흔적심선/위선 화살표 연속 scroll 추가 2026-09-08 18:15:40 +09:00
mind-one005 b99f4904b0 sn: featureClickEventPopup에서 침수흔적도 관련 분리 2026-09-08 17:13:36 +09:00
chanii8 17b1c12286 Merge branch 'iipbs/st/dev' 2026-09-08 17:01:32 +09:00
chanii8 c79824e358 [as] feat: 보완요청 파일 첨부 로직 수정 2026-09-08 17:01:02 +09:00
mind-one005 f742feca4b sn: 상단 주석 외 침수흔적도 관련 수정 2026-09-08 15:12:40 +09:00
rsone001 de8971bea6 [ST] fix: 대시보드 통계 처리 현황 panel css 수정 2026-09-08 13:43:26 +09:00
mind-one005 569e003827 sn: 팝업 화면 여백 조정 2026-09-08 11:01:53 +09:00
chanii8 c47c130fc3 [as] feat: 파일보완 요청 기능 구현 2026-09-08 10:29:27 +09:00
mind-one005 2db5dd59af Merge branch 'iipbs/wr/dev' into iipbs/sn/dev 2026-09-07 18:15:04 +09:00
mind-one005 8a8504dca9 sn: 운영정보 버튼 위치 수정 2026-09-07 17:51:36 +09:00
LEEKYUHYUN 7e82c6a802 "디지털하수관로" UI 수정 2026-09-07 17:44:46 +09:00
chanii8 01377b6013 Merge remote-tracking branch 'origin/iipbs/st/dev' 2026-09-07 17:26:39 +09:00
chanii8 b38be38169 [as] feat: AI 자동 진행 단계 대기 상태 텍스트 추가 2026-09-07 17:25:40 +09:00
chanii8 b9ac4a1c18 [as] feat: 재원협의 서비스(fnrsCnsltnService) 수정 2026-09-07 17:05:59 +09:00
mind-one005 bdc6251474 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-09-07 17:01:46 +09:00
mind-one005 479cf5c4a0 sn: 다단header 묶음 처리 외 2026-09-07 16:56:35 +09:00
chanii8 dbea497cc5 [as] feat: 웹한글 기안기 데모 페이지 생성 2026-09-07 15:24:06 +09:00
chanii8 94af73a810 [as] docs: 실무협의 로직 개발 가이드 추가 2026-09-07 15:22:21 +09:00
rsone001 a7fd6a8e16 [ST] - fix: 취약점 점검 관련 조치 2026-09-07 14:26:00 +09:00
mind-one001 2b3502161c 시설물 상세창 깨짐 수정 2026-09-04 17:16:32 +09:00
rsone001 8039ab8281 [ST] - refactor: 2-5 맨홀 지표 변경된 테이블에 맞게 수정, 일반검증 항목 입력기간일 시 등록/수정 불가 2026-09-04 16:05:26 +09:00
mind-one001 0544a72011 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-09-04 15:27:18 +09:00
mind-one001 34ba09ce1b 준설관리 기능 보완 2026-09-04 15:24:17 +09:00
chanii8 2bfe653ac0 [as] feat: 'dcrag-to' API 구현 2026-09-04 15:18:32 +09:00
mind-one005 aa5a71ae68 sn: 침입수, RDII 제목란 조금 넓게 외 2026-09-04 14:52:11 +09:00
mind-one005 9067e645b3 sn: 정보검색 기본정보 구분란 화면이 줄었을 때, 디자인 외 2026-09-04 14:33:25 +09:00
mind-one005 10d3fc096a sn: 시도 전체일 때, 시군구 목록 비우기 2026-09-04 11:00:38 +09:00
mind-one005 7fd95ba6f9 sn: src/shared/util/util.ts 적용 외 2026-09-04 10:32:36 +09:00
kiminchan b4982e07b8 Merge branch 'iipbs/sn/dev' 2026-09-04 10:22:18 +09:00
mind-one005 e707637c88 수정 2026-09-03 17:10:11 +09:00
mind-one005 5da89e8ee7 sn: appendApiSearchParams() 함수 적용 2026-09-03 17:09:58 +09:00
mind-one005 f9ff279895 Merge branch 'iipbs/sn/dev' into iipbs/wr/dev 2026-09-02 18:18:01 +09:00
mind-one005 4cde9b67c1 sn: util.ts 추가 정리 2026-09-02 18:10:50 +09:00
513 changed files with 26631 additions and 23616 deletions
@@ -0,0 +1,250 @@
# 재원협의·실무협의 프론트엔드 개발 가이드
> **대상 앱**: `frontend/apps/as`
> **작성일**: 2026-08-27
> **개정일**: 2026-09-07
> **버전**: 2.0
> **Process**: PR-AS-003 (재원협의 관리 — 실무협의 및 AI 검증 통합)
>
> **SoT (2026-09-03 완결)**:
>
> - 프로세스·상태·버튼: `03.재원협의_실무협의_프로세스_통합_v완결.md`
> - REST 설계: `05.재원협의_상태별_endpoint_설계.md`
> - 공통코드: `FNRSSTTS` (`SV, ST, LE, FV, ER, SL, AC, TE, TP, RM, RB, RC, RA, AV, EV, RK, RR, EP, TA`)
>
> **백엔드 서비스**:
>
> - `as-service` — `/api/v1/fnrs-cnsltn-mng`, `/api/v1/pwrk-cnsltn-mng`, `/api/v1/pwrk-cnsltn-doc-crt-rslt`, `/api/v1/dcrag-to`
> - `as-elapr-service` — `/api/v1/elapr-dmnd`, `/api/v1/elapr-prgrs-stts`, `/api/v1/elapr-apln`
>
> **현재 프론트 상태**: 재원협의(`fnrs-cnsltn`)·전자결재(`elapr`)·DC RAG TO(`dcrag-to`)는 존재한다. 실무협의(`pwrk-cnsltn-`\*) 엔티티·화면은 **미구현**. 버튼 매트릭스·상태코드는 **v2.1 잔존**이며 v3.0(`FNRSSTTS`)과 불일치한다. [9절](#9-현재-코드-vs-to-be-갭) 참고.
---
## 1. 문서 목적
재원협의 4대 주체(기초·광역·환경청·공단)의 **상태 전이·버튼·API**를 `apps/as`에 구현할 때 쓰는 프론트엔드 가이드이다.
v1.0은 공단 접수자의 **자료검증 → 초안생성 → 웹기안기**만 다뤘다. v2.0은 2026-09-03 완결 프로세스 기준으로 **신청부터 최종종결(**`TA`**)까지**, 실무협의·AI 3파이프라인·결재선·보완 루프를 포함한다.
상세 표·시퀀스·DTO는 본문에 넣지 않고 레퍼런스로 분리한다. 구현 중에는 본문의 규칙만 보고, 표가 필요하면 해당 파일을 연다.
---
## 2. 문서 맵
| 파일 | 내용 | 언제 보는가 |
| -------------------------------------------------------- | ------------------------------------------------------------------------- | ----------------------- |
| [development-guide.md](./development-guide.md) (본 문서) | 도메인, 핵심 규칙, 화면 흐름, 갭, 구현 순서 | 일상 구현 |
| [ref-process-status.md](./ref-process-status.md) | 4-Lane 시퀀스, Activity, `FNRSSTTS`, 보완 3루프, AI 타임아웃, 결재선 복사 | 상태 전이·폴링 UX |
| [ref-screen-buttons.md](./ref-screen-buttons.md) | 38행 화면·역할·버튼 매트릭스, 버튼 가시성 | `button-config.ts` 개편 |
| [ref-endpoint.md](./ref-endpoint.md) | 버튼→API 총괄, 단계별 입출력, FE 호출 금지 API, Gap Analysis | 엔티티·service 매핑 |
| [ref-frontend-entities.md](./ref-frontend-entities.md) | ENDPOINT, 슬라이스, Service 시그니처, 폴링, 웹기안기, DTO | 엔티티 레이어 구현 |
| [ref-webhwp-api.md](./ref-webhwp-api.md) | 웹한글기안기 v3 `HwpCtrl` Properties/Methods, Open/SaveAs | 기안기 위젯 연동 |
---
## 3. 도메인 개요
실무협의서(PWRK)는 재원협의서(FNRS)의 **하위 문서**이다. 재원협의 1건당 실무협의서는 **1건만** 생성된다.
| 문서 | 번호 예시 | 필드 |
| -------- | ----------------- | ----------------------------- |
| 재원협의 | `F-20260824-0001` | `fnrsCnsltnDocNo` / `upDocNo` |
| 실무협의 | `P-20260827-0001` | `pwrkCnsltnDocNo` |
진행상태는 **재원협의 마스터** `tb_as_fnrs_cnsltn_mng.prgrs_stts_com_cd` (`FNRSSTTS`)가 SSOT이다. 실무협의 문서의 `prgrsSttsComCd`는 연계 표시용이며, 화면 버튼은 재원협의 상태로 분기한다.
### 3-1. 4대 주체
| 역할 | 코드 | 화면 소유 | 핵심 구간 |
| --------------- | ------------- | -------------- | ------------------------------------ |
| 기초자치단체 | `ROLE_USER05` | 작성/수정/상세 | 신청, 3종 보완, 조치결과, 최종확인 |
| 광역자치단체 | `ROLE_USER04` | 상세 | 경유 제출, 결과 전달 |
| 유역·지방환경청 | `ROLE_USER03` | 상세 | 의뢰/자체종결, 1·2차 회신 |
| 한국환경공단 | `ROLE_USER01` | 상세 + 결재선 | 파일검증, 검토자지정, 1·2차 기술검토 |
### 3-2. 핵심 식별자
| 필드 | 의미 | 비고 |
| ----------------- | ---------------- | ----------------------------------------------------------- |
| `fnrsCnsltnDocNo` | 재원협의문서번호 | 상태 전이·목록 PK |
| `pwrkCnsltnDocNo` | 실무협의문서번호 | `P-YYYYMMDD-NNNN`. 검토자지정(`ER→AC`) 시 발급 |
| `upDocNo` | 상위문서번호 | 재원협의 번호. `upDocSeComCd = "FNRS"` |
| `crtDmndUuid` | 생성요청 UUID | 초안 폴링·웹기안기·다운로드 키 |
| `verSn` | 버전일련번호 | 복합 PK `(crtDmndUuid, verSn)`. 저장 시 서버가 `+1` |
| `orgnlOtptDocCn` | HWP 바이너리 | 목록/상세 응답은 항상 `null`. 다운로드·`webhwp/open`만 사용 |
| `elaprCycl` | 결재 차수 | 1차 `TE`, 2차 `RK`. 금액검증 완료 후 1차 결재선 복사 |
### 3-3. IIPBS 약어
| 한글 | 약어 | 필드 예 |
| ---- | ------------ | --------------------------- |
| 재원 | FNRS | `fnrsCnsltnDocNo` |
| 실무 | PWRK | `pwrkCnsltnDocNo` |
| 협의 | CNSLTN | `fnrs-cnsltn-mng` |
| 생성 | CRT | `crtDmndUuid` |
| 요청 | DMND | `crtDmndUuid`, `elapr-dmnd` |
| 검증 | VRFC | `vrfcSttsComCd` |
| 보완 | SPLMNT | `splmntDmndList` |
| 결재 | ELAPR / ATRZ | `elaprCycl`, `atrzSeComCd` |
---
## 4. 상태코드 한눈에 보기
상위코드 `FNRSSTTS`. v1.0의 `PWRKSTTS`(`DV`, `AR`, `TR` 등)는 **폐기**한다.
정방향:
```
SV → ST → LE → FV → ER → AC → TE → TP → RM → RB → RC → RA → AV → EV → RK → RR → EP → TA
```
자체종결: `LE → EP → TA`
보완: `ER|TE|RK → SL` 후 직전 상태로 복귀.
| 코드 | 명칭 | FE에서 할 일 |
| ---- | ---------------------- | ------------------------------------------------- |
| `SV` | 저장 | 기초 작성화면 저장/신청/삭제 |
| `ST` | 신청완료 | 광역 환경청제출 |
| `LE` | 환경청 검토 | 자체종결(`EP`) 또는 기술검토 의뢰(`FV`) |
| `FV` | 파일검증 | AI 진행 표시. **30분** 후 `ER` Fallback |
| `ER` | 기술검토 의뢰 | 공단 접수자: 파일보완 / 수동파일검증 / 검토자지정 |
| `SL` | 보완요청 | 기초 수정화면. 복귀 대상은 이력의 직전 상태 |
| `AC` | 초안생성 | AI 진행 표시. **30분** 후 `TE` Fallback |
| `TE` | 기술검토 | 웹기안기·검토완료·보완. 결재선 1차 |
| `TP` | 기술검토 회신 | 환경청 → 광역 `RM` |
| `RM` | 재원협의결과회신 | 광역 → 기초 `RB` |
| `RB` | 지자체회신 | 기초 실무협의서 관리 / 조치완료 |
| `RC` | 조치결과서 제출 | 광역 → 환경청 `RA` |
| `RA` | 조치결과서제출(환경청) | 환경청 2차 의뢰 `AV` |
| `AV` | 금액검증 | AI 진행 표시. **30분** 후 `EV` Fallback |
| `EV` | 조치결과검토의뢰 | 공단 접수자: 2차 결재선 검토자지정 → `RK` |
| `RK` | 조치결과서검토 | 웹기안기·금액검증 재실행·보완. 결재선 2차 |
| `RR` | 조치결과서검토 회신 | 환경청 → 광역 `EP` |
| `EP` | 검토결과 회신 | 광역 → 기초 `TA` |
| `TA` | 검토결과 회신(종료) | 기초 조회만. **고시(**`TB`**) 아님** |
코드 의미·구버전 대조·보완 루프·AI Fallback은 [ref-process-status.md](./ref-process-status.md).
---
## 5. 프론트가 지켜야 할 핵심 규칙
1. **상태 SSOT는** `FNRSSTTS`. 버튼 가시성은 `(dtlInstCd, prgrsSttsComCd, atrzSeComCd)`로만 계산한다. 공단은 결재선 구분(`ATRZ01` 접수자, `ATRZ02` 검토자, `ATRZ03` 승인자, `ATRZ99` 최종승인자)을 추가로 본다.
2. **AI 콜백 API는 화면에서 호출하지 않는다.** `POST /dcrag/documents/verify-result`, `POST /dcrag/draft/complete`, `POST /dcrag/cost/verify-result`는 서버 전용이다.
3. **초안생성 1차 트리거는 검토자지정이다.** `POST /elapr-dmnd` (`ER → AC`). 예전처럼 `POST /pwrk-cnsltn-mng`로 초안을 시작하지 않는다.
4. **기술검토 의뢰는** `LE → FV` **전용 API** `POST /fnrs-cnsltn-mng/{docNo}/request-examine`. 현재 코드의 `updatePrgrsStts(ER)` 직행은 폐기.
5. **수동 AI 재실행은 상태를 바꾸지 않는다.**
- `ER` 파일검증 → `POST /dcrag-to/documents/transfer`
- `TE` 초안생성 → `POST /dcrag-to/documents/draft/generate` (타임아웃·실패 시에만)
- `RK` 금액검증 → `POST /dcrag-to/documents/cost/verify`
6. **진행상태만 바꾸는 버튼은** `POST /fnrs-cnsltn-mng/doc/updatePrgrsStts`**를 재사용한다.** (`ST→LE`, `RM→RB`, `RC→RA`, `RR→EP`, `EP→TA`)
7. **보완요청/보완완료는 공통 API** `reqsupplement` / `submitsupplement`. 복귀 상태(`ER`/`TE`/`RK`)는 서버가 이력에서 결정한다. 프론트는 직전 상태를 추측해 보내지 않는다.
8. **결재 승인/회신은** `PUT /elapr-prgrs-stts/{id}/process` **하나**. 1차 최종승인자 → `TP`, 2차 → `RR`은 서버가 Feign으로 처리한다.
9. **2차 결재선은 EV에서 수동 검토자지정**. `POST /elapr-dmnd` (`elaprCycl: 2`, `docPrgrsSttsComCd: RK`). 1차 결재선 자동 copy-line은 사용하지 않는다.
10. **웹기안기는 `webhwp/open` 응답을 axios `blob`으로 조회한 뒤 `File`로 감싸 `HwpCtrl.Open`에 전달한다.** `blob:` Object URL과 `orgnlOtptDocCn`은 쓰지 않는다.
---
## 6. 화면 데이터 흐름
재원협의 **목록 → 상세/작성**이 본체다. 실무협의 목록은 공단 검토 보조 화면이다.
```
BEGIN
CALL fnrsCnsltnMngService.findPage(...)
ON 행 CLICK → /fnrs-cnsltn/{fnrsCnsltnDocNo}
CALL findById(fnrsCnsltnDocNo)
COMPUTE buttons = getButtonConfigs(dtlInstCd, prgrsSttsComCd, atrzSeComCdList)
// ----- 기초 ROLE_USER05 -----
ON [저장] (빈값/SV) → saveApply(prgrsSttsComCd: SV)
ON [신청완료] (SV) → saveApply(prgrsSttsComCd: ST)
ON [삭제] (SV) → remove
ON [파일보완완료] (SL←ER) → submitsupplement
ON [보완완료] (SL←TE|RK) → submitsupplement
ON [실무협의서 관리] (RB) → GET webhwp/open (blob) → HwpCtrl.Open(File)
ON [조치완료] (RB) → submit-actn-result { isSubmit: true }
ON [결과회신] (TA) → API 없음 (조회)
// ----- 광역 ROLE_USER04 -----
ON [환경청제출] (ST) → updatePrgrsStts(LE)
ON [지자체회신] (RM) → updatePrgrsStts(RB)
ON [조치결과서제출] (RC) → updatePrgrsStts(RA)
ON [결과회신] (EP) → updatePrgrsStts(TA)
// ----- 환경청 ROLE_USER03 -----
ON [결과회신] (LE 자체종결) → tech-review → EP
ON [기술검토 의뢰] (LE) → request-examine → FV → START POLL FV
ON [재원협의 결과 회신] (TP) → tech-review → RM
ON [실무협의서 관리] (RA) → GET webhwp/open (blob) → HwpCtrl.Open(File) (저장 불가)
ON [조치결과 검토의뢰] (RA) → request-actn-review → AV → START POLL AV
ON [결과회신] (RR) → updatePrgrsStts(EP)
// ----- 공단 ROLE_USER01 -----
ON [파일보완] (ER, ATRZ01) → GET file-vrfc-results → 팝업 → reqsupplement
ON [파일검증] (ER, ATRZ01) → dcrag-to transfer (상태 유지)
ON [검토자지정] (ER, ATRZ01) → POST elapr-dmnd (docPrgrsSttsComCd: AC) → START POLL AC
ON [검토자지정] (EV, ATRZ01) → POST elapr-dmnd (elaprCycl: 2, docPrgrsSttsComCd: RK)
ON [실무협의서관리] (TE|RK) → GET webhwp/open (blob) → HwpCtrl.Open(File)
ON [실무협의서생성] (TE, 실패/타임아웃) → dcrag-to draft/generate
ON [검토결과조회] (TE) → pwrk-cnsltn-mng findById | findByUpDocNo
ON [저장] (TE|RK) → pwrk-cnsltn-mng save-apply
ON [검토완료] → elapr-prgrs-stts process (상신)
ON [보완요청] (TE|RK) → 보완 팝업 → reqsupplement
ON [금액검증] (RK) → dcrag-to cost/verify
ON [결재(승인)] → process (중간)
ON [결재(회신)] → process (최종 → TP 또는 RR)
// ----- AI 진행 중 (FV / AC / AV) -----
POLL 상태 또는 draft status. 30분 안내. 종료 후 상세 invalidate
END
```
38행 버튼 표는 [ref-screen-buttons.md](./ref-screen-buttons.md). API 상세는 [ref-endpoint.md](./ref-endpoint.md).
---
## 7. 버튼 → API 축약
프론트가 치는 것만. 콜백·스케줄러는 제외.
| 주체 | 버튼 | From → To | Method & URI |
| --------- | -------------------------- | ----------------------------- | ---------------------------------------------------------------------------------- |
| 기초 | 저장 / 신청완료 | →`SV` / `SV→ST` | `POST /fnrs-cnsltn-mng/save-apply` |
| 기초 | 삭제 | `SV` | `DELETE /fnrs-cnsltn-mng/{docNo}` |
| 광역 | 환경청제출 | `ST→LE` | `POST /fnrs-cnsltn-mng/doc/updatePrgrsStts` |
| 환경청 | 결과회신(자체종결) | `LE→EP` | `POST /fnrs-cnsltn-mng/{docNo}/tech-review` |
| 환경청 | 기술검토 의뢰 | `LE→FV` | **신규** `POST /fnrs-cnsltn-mng/{docNo}/request-examine` |
| 공단 접수 | 파일보완 팝업 | `ER` | `GET /fnrs-cnsltn-mng/{docNo}/file-vrfc-results` |
| 공단 접수 | 파일보완요청 | `ER→SL` | `POST /fnrs-cnsltn-mng/{docNo}/reqsupplement` |
| 공단 접수 | 파일검증 재실행 | `ER` 유지 | `POST /dcrag-to/documents/transfer` |
| 공단 접수 | 검토자지정 | `ER→AC` | `POST /elapr-dmnd` (`elaprCycl: 1`) |
| 공단 접수 | 검토자지정 | `EV→RK` | `POST /elapr-dmnd` (`elaprCycl: 2`) |
| 공단 검토 | 실무협의서관리 | `TE`/`RK` | `GET .../pwrk-cnsltn-doc-crt-rslt/{uuid}/webhwp/open` |
| 공단 검토 | 실무협의서생성 | `TE` 유지 | `POST /dcrag-to/documents/draft/generate` |
| 공단 검토 | 저장 | `TE`/`RK` | `POST /pwrk-cnsltn-mng/save-apply` |
| 공단 | 검토완료 / 결재(승인·회신) | `TE` 유지 / `TE→TP` / `RK→RR` | `PUT /elapr-prgrs-stts/{id}/process` |
| 공단 검토 | 보완요청 | `TE→SL` / `RK→SL` | `POST /fnrs-cnsltn-mng/{docNo}/reqsupplement` |
| 기초 | 파일보완완료 / 보완완료 | `SL→ER` / `TE` / `RK` | `POST /fnrs-cnsltn-mng/{docNo}/submitsupplement` |
| 환경청 | 재원협의 결과 회신 | `TP→RM` | `POST /fnrs-cnsltn-mng/{docNo}/tech-review` |
| 광역 | 지자체회신 | `RM→RB` | `updatePrgrsStts` |
| 기초 | 실무협의서 관리 | `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` 유지 (조회) | `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` |
| 웹기안기 | 저장 | 버전 +1 | `POST /pwrk-cnsltn-doc-crt-rslt` multipart |
| 공통 | 다운로드 | — | `GET /pwrk-cnsltn-doc-crt-rslt/{uuid}/download` |
신규 4개: `request-examine`, `submit-actn-result`, `request-actn-review`, EV 2차 `elapr-dmnd`.
---
+334
View File
@@ -0,0 +1,334 @@
# 레퍼런스 — 상태별 Endpoint
> 상위: [개발가이드.md](./개발가이드.md)
> SoT: `05.재원협의_상태별_endpoint_설계.md` (v1.0, 2026-09-03)
> Gateway: `/as-service`, `/as-elapr-service`
> Auth: `Authorization`, `X-Site-Id`, `token-id` (기존 `client` 인터셉터)
> 날짜: `yyyy-MM-dd HH:mm:ss`
프론트가 치는 API만 상세히 적는다. AI 콜백·스케줄러·Feign은 [§6 호출 금지](#6-프론트-호출-금지).
---
## 1. 총괄 매트릭스 (38 + WebHWP)
| 순번 | 버튼 | From → To | 서비스 | Method & URI | 구분 |
| :---: | --- | --- | --- | --- | --- |
| 1 | 저장 | →`SV` | as | `POST /api/v1/fnrs-cnsltn-mng/save-apply` | 기존 |
| 2 | 신청완료 | `SV→ST` | as | 동일 (`prgrsSttsComCd=ST`) | 기존 |
| 3 | 삭제 | `SV` | as | `DELETE /api/v1/fnrs-cnsltn-mng/{docNo}` | 기존 |
| 4 | 환경청제출 | `ST→LE` | as | `POST /api/v1/fnrs-cnsltn-mng/doc/updatePrgrsStts` | 기존 |
| 5 | 결과회신(자체종결) | `LE→EP` | as | `POST /api/v1/fnrs-cnsltn-mng/{docNo}/tech-review` | 기존 |
| 6 | 기술검토 의뢰 | `LE→FV` | as | `POST /api/v1/fnrs-cnsltn-mng/{docNo}/request-examine` | **신규** |
| 7 | 파일검증 콜백 | `FV→ER` | as | `POST /api/v1/dcrag/documents/verify-result` | 서버 |
| 8 | 파일보완 팝업 | `ER` | as | `GET /api/v1/fnrs-cnsltn-mng/{docNo}/file-vrfc-results` | 기존 |
| 9 | 파일보완요청 | `ER→SL` | as | `POST /api/v1/fnrs-cnsltn-mng/{docNo}/reqsupplement` | 기존 |
| 10 | 파일검증 재실행 | `ER` | as | `POST /api/v1/dcrag-to/documents/transfer` | 기존 |
| 11 | 검토자지정 | `ER→AC` | elapr | `POST /api/v1/elapr-dmnd` | 기존 |
| 12 | 초안 콜백 | `AC→TE` | as | `POST /api/v1/dcrag/draft/complete` | 서버 |
| 13 | 실무협의서관리 | `TE` | as | `GET /api/v1/pwrk-cnsltn-mng/updocno/{upDocNo}` | 기존 |
| 14 | 검토결과조회 | `TE` | as | `GET /api/v1/pwrk-cnsltn-mng/{pwrkDocNo}` | 기존 |
| 15 | 실무협의서생성 | `TE` | as | `POST /api/v1/dcrag-to/documents/draft/generate` | 기존 |
| 16 | 저장(실무) | `TE` | as | `POST /api/v1/pwrk-cnsltn-mng/save-apply` | 기존 |
| 17 | 검토완료 | `TE` | elapr | `PUT /api/v1/elapr-prgrs-stts/{id}/process` | 기존 |
| 18 | 보완요청 | `TE→SL` | as | `POST .../reqsupplement` | 기존 |
| 19 | 결재(승인) | `TE` | elapr | `PUT .../process` | 기존 |
| 20 | 결재(회신) | `TE→TP` | elapr | `PUT .../process` | 기존 |
| 21 | 파일보완완료 | `SL→ER` | as | `POST .../submitsupplement` | 기존 |
| 22 | 보완완료(기술) | `SL→TE` | as | 동일 | 기존 |
| 23 | 보완완료(조치) | `SL→RK` | as | 동일 | 기존 |
| 24 | 재원협의 결과 회신 | `TP→RM` | as | `POST .../{docNo}/tech-review` | 기존 |
| 25 | 지자체회신 | `RM→RB` | as | `POST .../doc/updatePrgrsStts` | 기존 |
| 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` | **신규** |
| 30 | 금액검증 콜백 | `AV→EV` | as | `POST /api/v1/dcrag/cost/verify-result` | 서버 |
| 30a | 검토자지정(2차) | `EV→RK` | elapr | `POST /api/v1/elapr-dmnd` (`elaprCycl=2`) | 기존 |
| 31 | 실무협의서관리 | `RK` | as | `GET /pwrk-cnsltn-mng/{pwrkDocNo}` | 기존 |
| 32 | 금액검증 재실행 | `RK` | as | `POST /api/v1/dcrag-to/documents/cost/verify` | 기존 |
| 33 | 보완요청(금액) | `RK→SL` | as | `reqsupplement` | 기존 |
| 34 | 결재(승인) | `RK` | elapr | `process` | 기존 |
| 35 | 결재(회신) | `RK→RR` | elapr | `process` | 기존 |
| 36 | 결과회신 | `RR→EP` | as | `updatePrgrsStts` | 기존 |
| 37 | 결과회신 | `EP→TA` | as | `updatePrgrsStts` | 기존 |
| 38 | 결과회신(확인) | `TA` | — | 없음 | — |
| W1 | 웹기안기 Open | — | as | `GET /api/v1/pwrk-cnsltn-doc-crt-rslt/{uuid}/webhwp/open` | 기존 |
| W2 | HWPX 저장 | — | as | `POST /api/v1/pwrk-cnsltn-doc-crt-rslt` multipart | 기존 |
| W3 | 다운로드 | — | as | `GET /api/v1/pwrk-cnsltn-doc-crt-rslt/{uuid}/download` | 기존 |
| E-N | 결재선 복사 | `AV→RK` 부수 | elapr | `POST /api/v1/elapr-apln/copy-line` | **신규·서버** |
조회 공통: `GET /fnrs-cnsltn-mng`, `GET /fnrs-cnsltn-mng/{docNo}`, `GET /pwrk-cnsltn-mng`, `GET /dcrag-to/documents/draft/status/{crtDmndUuid}`.
---
## 2. 공통 요청
### `DocPrgrsUpdateRequestDto`
`POST /fnrs-cnsltn-mng/doc/updatePrgrsStts` — `ST→LE`, `RM→RB`, `RC→RA`, `RR→EP`, `EP→TA`.
```json
{
"docNo": "F-20260824-0001",
"docSeComCd": "FNRS",
"prgrsSttsComCd": "LE",
"rgtrUuid": "optional-uuid",
"rmrk": "optional"
}
```
응답: `204/200` void. 프론트 엔티티: 기존 `changePrgrsSttsComCd`.
### `FnrsCnsltnApplySaveRequestDto`
`save-apply`, `tech-review`, `reqsupplement`, `submitsupplement`가 공유. 핵심은 `fnrsCnsltnMng.prgrsSttsComCd`와 중첩 리스트(`bzScaleList`, `bizCstList`, `cklstResultList`, `esntlAtchFileList`, `splmntDmndList`).
---
## 3. 단계별 FE API
### 3-1. `SV → ST → LE`
**저장 / 신청완료** — `POST /fnrs-cnsltn-mng/save-apply`
권한: `ROLE_USER05`. 임시저장은 `prgrsSttsComCd=SV`, 신청은 `ST`.
응답: `string` (문서번호). 기존 `fnrsCnsltnMngService.saveApply`.
**삭제** — `DELETE /fnrs-cnsltn-mng/{docNo}` (`SV`만).
**환경청제출** — `updatePrgrsStts` `LE`. 권한: `ROLE_USER04`.
### 3-2. `LE → FV | EP`
**자체종결** — `POST /fnrs-cnsltn-mng/{docNo}/tech-review`
Body: `FnrsCnsltnApplySaveRequestDto` (환경청 의견·체크리스트). `LE→EP`.
기존 `techReview`. 현재 화면은 `TP`에서도 이 API를 쓰며 목표 상태를 `EP`로 넣고 있다 → **`TP`는 `RM`으로 바꿔야 함**.
**기술검토 의뢰 (신규)** — `POST /fnrs-cnsltn-mng/{docNo}/request-examine`
```json
{ "prgrsSttsComCd": "FV" }
```
응답: `string` (docNo). `LE→FV` + AI 파일검증 + 30분 타이머(서버).
프론트: `fnrsCnsltnMngService.requestExamine(docNo, body)` 추가. **`updatePrgrsStts("ER")` 금지.**
### 3-3. `FV → ER → SL → ER`
파일검증 콜백은 서버. FE는 `FV`에서 상세 refetch.
**검증결과 목록** — `GET /fnrs-cnsltn-mng/{fnrsCnsltnDocNo}/file-vrfc-results`
응답: `FnrsCnsltnFileVrfcRsltResponseDto[]`
| 필드 | 설명 |
| --- | --- |
| `fileSeComCd` | `ESSENTIAL` / `ADDITIONAL` |
| `fileSeNm` | 구분명 |
| `esntlAtchFileId` | 필수첨부 ID |
| `atchFileNm` | 파일명 |
| `vrfcRsltComCd` | `PASS` / `FAIL` / `WARN` |
| `vrfcOpnn` | 검증의견 |
엔티티에 `findFileVrfcResults` 추가. 첨부 행 배지는 상세 `esntlFileList`/`etcFileList`의 `vrfcSttsComCd`, `vrfcRsltCn`, `vrfcDt`도 사용 (타입 확장 필요).
**파일보완요청** — `POST .../{docNo}/reqsupplement` (`ER→SL`).
**파일보완완료** — `POST .../{docNo}/submitsupplement` (`SL→ER`). 복귀는 서버.
**파일검증 재실행** — `POST /dcrag-to/documents/transfer`
```json
{ "fnrsCnsltnDocNo": "F-20260824-0001", "taskSeComCd": "SF", "taskSeCdNm": "하수처리시설" }
```
응답: `{ resultCode, message, requestId }`. 상태 유지. 기존 `dcRagApiToService.requestDocumentTransfer`.
### 3-4. `ER → AC → TE → TP`
**검토자지정** — `POST /elapr-dmnd`
기존 `elaprDmndService.save`.
| 필드 | 값 |
| --- | --- |
| `elaprDocNo` | 재원협의 번호 |
| `elaprCycl` | `1` |
| `docPrgrsSttsComCd` | **`AC`** (현행은 현재 상태 `ER`를 넣고 있음 → 수정) |
| `elaprPrgrsSttsList[].atrzOrdr` | 1 검토자, 2 승인자, 3 최종 |
서버가 실무협의 문서 생성 + RAG 초안 트리거 + 30분 타이머. 응답의 문서번호/`crtDmndUuid`가 있으면 폴링 키로 쓴다. 없으면 `findByUpDocNo`로 복원.
**초안 상태 폴링** — `GET /dcrag-to/documents/draft/status/{crtDmndUuid}`
기존 `getDraftGenerationStatus`. 주기 5~10초. 종료 `COMPLETED`/`FAILED`.
**수동 초안생성** — `POST /dcrag-to/documents/draft/generate` (`TE` 유지)
```json
{
"fnrsCnsltnDocNo": "F-...",
"pwrkCnsltnDocNo": "P-...",
"crtDmndUuid": "optional"
}
```
202 + `DcRagDraftGenerateResponseDto` (`crtDmndUuid`, `crtSttsComCd`, `estimatedMinutes`).
v1.0 가이드의 “이 API를 프론트가 치면 안 된다”는 **폐기**. 1차 트리거는 검토자지정이고, 이 API는 Fallback 전용.
**실무 저장** — `POST /pwrk-cnsltn-mng/save-apply` → `string`.
**보완요청/완료** — fnrs `reqsupplement` / `submitsupplement`.
**결재** — `PUT /elapr-prgrs-stts/{elaprPrgrsId}/process`
```json
{
"atrzPrgrsSttsComCd": "ATRZ_PRGRS_003",
"atrzOpnnCn": "의견",
"finalApprovalYn": false
}
```
반려: `ATRZ_PRGRS_004`. 기존 `elaprPrgrsSttsService.process`. 최종 시 서버가 `TP`/`RR` 전이.
### 3-5. `TP → RM → RB → RA`
**환경청 1차 회신** — `tech-review` (`TP→RM`).
**광역 지자체회신** — `updatePrgrsStts` `RB`.
**조치완료 (신규)** — `POST /pwrk-cnsltn-mng/{pwrkDocNo}/submit-actn-result`
```json
{
"isSubmit": true,
"actnList": [
{
"rvwOpnnSn": 1,
"actnRsltCn": "수용 — 관로 연장 재산정",
"atchFileGroupId": "optional"
}
],
"totActnSummary": "제출 시 권장",
"muniDocNo": "제출 시 권장"
}
```
- `isSubmit: false` → `RB` 유지 (API만 존재, **화면 버튼 없음**. HWP·첨부는 실무협의서 관리 다이얼로그에서 저장)
- `isSubmit: true` → `RB→RA` (조치완료)
응답: `string` (pwrkDocNo). DTO명 `PwrkActnResultSubmitDto`.
### 3-6. `RA → AV → EV → RK`
**2차 의뢰 (신규)** — `POST /fnrs-cnsltn-mng/{docNo}/request-actn-review`
```json
{
"specialRqstCn": "중점 검증 요청 (선택)"
}
```
`RA→AV`. 응답: `string` (docNo). DTO명 `EnvoActnReviewReqDto`. `specialRqstCn`만 선택.
금액검증 콜백은 서버 → `AV→EV`.
**2차 검토자지정** — `POST /elapr-dmnd` (`elaprCycl: 2`, `docPrgrsSttsComCd: RK`). 1차 결재선(`findDocPrgrsByDocNo`, cycl=1)을 시드로 편집.
**금액검증 재실행** — `POST /dcrag-to/documents/cost/verify`
```json
{
"fnrsCnsltnDocNo": "F-...",
"pwrkCnsltnDocNo": "P-..."
}
```
202. 기존 `requestCostVerification`. `RK` 유지.
### 3-7. `RK → RR | SL`
보완은 3-4와 동일 API. 팝업만 금액 체크.
결재 `process` 2차 최종 → 서버가 `RR`.
### 3-8. `RR → EP → TA`
둘 다 `updatePrgrsStts`. `TA` 확인은 API 없음.
---
## 4. 웹기안기
설계서 기준 **기존**. 구현 시 컨트롤러 존재 여부를 확인한다.
| ID | 용도 | URI | 응답 |
| --- | --- | --- | --- |
| W1 | 웹기안기 Open | `GET .../pwrk-cnsltn-doc-crt-rslt/{crtDmndUuid}/webhwp/open` | `inline` HWPX blob → `HwpCtrl.Open(File)` |
| W2 | 편집 저장 | `POST .../pwrk-cnsltn-doc-crt-rslt` multipart `file` + `crtDmndUuid` | 201, `verSn+1` |
| W3 | PC 저장 | `GET .../{crtDmndUuid}/download` | `attachment` blob |
버전 지정: `GET .../{crtDmndUuid}/{verSn}/webhwp/open`, `.../{verSn}/download`.
`/{uuid}/latest`, `/{uuid}/download`가 `/{uuid}/{verSn}`보다 우선. path를 섞지 말 것.
다운로드:
- axios `responseType: "blob"`
- 파일명: `Content-Disposition` `filename*=UTF-8''...` (`parseFilenameFromResponse`)
- 서버 파일명: `재원협의 실무협의서({bizNm})_v{verSn}.hwp`
- Content-Type은 `application/x-hwp` 또는 `application/haansofthwpx`일 수 있음. 타입에 의존하지 말 것
Open은 axios `responseType: "blob"`으로 인증된 API를 호출하고, 응답 Blob을 HWPX `File`로 감싸 `HwpCtrl.Open`에 전달한다. `blob:` Object URL은 사용하지 않는다.
multipart `Content-Type` 헤더를 수동 지정하지 않는다 (boundary). JSON `saveJson`은 UI에서 쓰지 않는다.
---
## 5. 전자결재 (`as-elapr-service`)
| API | URI | FE |
| --- | --- | --- |
| 검토자지정·1차 결재요청 | `POST /elapr-dmnd` | O |
| 결재 처리 | `PUT /elapr-prgrs-stts/{id}/process` | O |
| 결재선 복사 | `POST /elapr-apln/copy-line` | **X (서버)** |
`copy-line` body (참고만):
```json
{
"docNo": "P-20260827-0001",
"sourceElaprCycl": 1,
"targetElaprCycl": 2,
"rgtrUuid": "system-uuid"
}
```
`RK`에서 결재현황은 `findDocPrgrsByDocNo`(전체) 조회 후 `elaprCycl`로 1차/2차 탭 표시.
---
## 6. 프론트 호출 금지
| URI | 이유 |
| --- | --- |
| `POST /dcrag/documents/verify-result` | AI→AS 콜백 |
| `POST /dcrag/draft/complete` | AI→AS 콜백 |
| `POST /dcrag/cost/verify-result` | AI→AS 콜백. 여기서 copy-line도 호출 |
| `POST /elapr-apln/copy-line` | 콜백 Feign |
| `POST /pwrk-cnsltn-mng` (`save` 초안 트리거) | v3.0 1차 트리거는 `elapr-dmnd`. 컨트롤러가 남아 있어도 화면에서 쓰지 않음 |
`POST /dcrag-to/documents/draft/generate`는 **Fallback에서만** 허용.
---
## 7. Gap Analysis (프론트 엔티티)
### 이미 있는 것
`fnrsCnsltnMngService`: `findPage`, `findById`, `save`, `saveApply`, `update`, `techReview`, `submitSupplement`, `submitReqSupplement`, `remove`, `changePrgrsSttsComCd`
`dcRagApiToService`: `requestDocumentTransfer`, `requestDraftGeneration`, `getDraftGenerationStatus`, `requestCostVerification`
`elaprDmndService.save`, `elaprPrgrsSttsService.process`
### 추가할 것
| 메서드 | URI |
| --- | --- |
| `requestExamine` | `POST /fnrs-cnsltn-mng/{id}/request-examine` |
| `findFileVrfcResults` | `GET /fnrs-cnsltn-mng/{id}/file-vrfc-results` |
| `requestActnReview` | `POST /fnrs-cnsltn-mng/{id}/request-actn-review` |
| `pwrkCnsltnMngService.*` | 슬라이스 신규 |
| `submitActnResult` | `POST /pwrk-cnsltn-mng/{id}/submit-actn-result` |
| `pwrkCnsltnDocCrtRsltService.*` | 슬라이스 신규 |
`copyLine`은 프론트 서비스에 넣지 않는다.
@@ -0,0 +1,350 @@
# 레퍼런스 — 프론트 엔티티·DTO
> 상위: [개발가이드.md](./개발가이드.md)
> 엔티티 규칙: `frontend/.cursor/skills/backend-controller-to-entity`
---
## 1. ENDPOINT
`apps/as/src/shared/config/api.ts`
```ts
AS_SERVICE: {
// 기존
FNRS_CNSLTN_MNG: "/as-service/api/v1/fnrs-cnsltn-mng",
DCRAG_TO: "/as-service/api/v1/dcrag-to",
// 추가
PWRK_CNSLTN_MNG: "/as-service/api/v1/pwrk-cnsltn-mng",
PWRK_CNSLTN_DOC_CRT_RSLT: "/as-service/api/v1/pwrk-cnsltn-doc-crt-rslt",
}
AS_ELAPR_SERVICE: {
ELAPR_TMPLT: "/as-elapr-service/api/v1/elapr-tmplt",
ELAPR_APLN: "/as-elapr-service/api/v1/elapr-apln",
ELAPR_DMND: "/as-elapr-service/api/v1/elapr-dmnd",
ELAPR_PRGRS_STTS: "/as-elapr-service/api/v1/elapr-prgrs-stts",
}
```
`copy-line`은 `ELAPR_APLN` 하위이나 FE에서 호출하지 않음.
---
## 2. 슬라이스 배치
```
entities/fnrs-cnsltn/ # 확장
entities/dcrag-to/ # 구현됨
entities/elapr/ # 구현됨
entities/pwrk-cnsltn-mng/ # 신규
entities/pwrk-cnsltn-doc-crt-rslt/ # 신규
```
```
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
```
`submit-actn-result` Request는 zod SSOT (`PwrkActnResultSubmitDto`).
`request-examine` / `request-actn-review`도 fnrs payload-schema에 추가.
라우트:
| 화면 | 경로 |
| --- | --- |
| 재원협의 목록/상세/작성 | 기존 `app/(main)/(with-base-layout)/fnrs-cnsltn/...` |
| 실무협의 목록 | `.../pwrk-cnsltn/page.tsx` (선택) |
| 실무협의 상세 | `.../pwrk-cnsltn/[id]/page.tsx` (선택) |
| 페이지 UI | `pages/fnrs-cnsltn/`, `pages/pwrk-cnsltn/` |
---
## 3. Service 시그니처
`fnrsCnsltnMngService`에 추가:
```ts
requestExamine(docNo: string, body: FnrsExamineRequestDto): Promise<string>;
findFileVrfcResults(fnrsCnsltnDocNo: string): Promise<FnrsCnsltnFileVrfcRsltResponseDto[]>;
requestActnReview(docNo: string, body: EnvoActnReviewReqDto): Promise<string>;
```
실무협의:
```ts
export interface PwrkCnsltnMngService {
findPage(searchDto: PwrkCnsltnMngSearchDto): Promise<PageResponse<PwrkCnsltnMngResponseDto>>;
findById(pwrkCnsltnDocNo: string): Promise<PwrkCnsltnMngDetResponseDto>;
findByUpDocNo(upDocNo: string): Promise<PwrkCnsltnMngDetResponseDto>;
saveApply(body: PwrkCnsltnApplySaveRequestDto): Promise<string>;
submitActnResult(
pwrkCnsltnDocNo: string,
body: PwrkActnResultSubmitDto,
): Promise<string>;
update(pwrkCnsltnDocNo: string, body: PwrkCnsltnMngUpdateRequestDto): Promise<string>;
remove(pwrkCnsltnDocNo: string): Promise<void>;
changePrgrsSttsComCd(body: DocPrgrsUpdateRequestDto): Promise<void>;
}
```
v1.0의 `save(): PwrkCnsltnMngSaveResponseDto`(초안 트리거)는 화면에서 사용하지 않는다. 컨트롤러가 남아 있으면 엔티티에 넣더라도 UI 연결 금지.
생성결과:
```ts
export interface PwrkCnsltnDocCrtRsltService {
findPage(
searchDto: PwrkCnsltnDocCrtRsltSearchDto,
): Promise<PageResponse<PwrkCnsltnDocCrtRsltResponseDto>>;
findById(crtDmndUuid: string, verSn: number): Promise<PwrkCnsltnDocCrtRsltResponseDto>;
findLatestByCrtDmndUuid(crtDmndUuid: string): Promise<PwrkCnsltnDocCrtRsltResponseDto>;
openWebHwpLatest(crtDmndUuid: string): Promise<AxiosResponse<Blob>>;
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>;
}
```
`webhwp/open`은 인증된 axios 요청으로 Blob을 조회한다:
```ts
async function openWebHwpLatest(crtDmndUuid: string) {
return client.get<Blob>(`${BASE}/${crtDmndUuid}/webhwp/open`, {
responseType: "blob",
});
}
```
응답 Blob은 HWPX `File`로 감싸 `HwpCtrl.Open(file, "HWPX", ...)`에 전달한다. `blob:` Object URL은 사용하지 않는다.
DC RAG TO (구현됨, 시그니처 유지):
```ts
requestDocumentTransfer(body: DcRagDocumentTransferRequestDto): Promise<DcRagCommonResponseDto>;
requestDraftGeneration(body: DcRagDraftGenerateRequestDto): Promise<DcRagDraftGenerateResponseDto>;
getDraftGenerationStatus(crtDmndUuid: string): Promise<DcRagDraftStatusResponseDto>;
requestCostVerification(body: DcRagCostVerifyRequestDto): Promise<DcRagCommonResponseDto>;
```
---
## 4. DTO
타입명 = 백엔드 클래스명. Save/Apply는 zod.
### 4-1. 재원협의 추가
**`FnrsExamineRequestDto`**
| 필드 | 필수 | 값 |
| --- | --- | --- |
| `prgrsSttsComCd` | N | 기본 `FV` |
**`EnvoActnReviewReqDto`**
| 필드 | 필수 |
| --- | --- |
| `specialRqstCn` | N |
**`FnrsCnsltnFileVrfcRsltResponseDto`**: [ref-endpoint.md](./ref-endpoint.md) §3-3.
첨부 검증 배지용으로 `AtflMngClientResponseDto` / `EsntlAtchFileMngTreeResponseDto`에 `vrfcDt`, `vrfcRsltCn`, `vrfcSttsComCd` 확장.
### 4-2. `PwrkActnResultSubmitDto` (zod)
| 필드 | 필수 | 비고 |
| --- | --- | --- |
| `isSubmit` | Y | `false` API만(화면 버튼 없음), `true` 조치완료(`RB→RA`) |
| `actnList` | N | 조치완료 시 선택 |
| `actnList[].rvwOpnnSn` | Y (항목 있을 때) | |
| `actnList[].actnRsltCn` | N | |
| `actnList[].atchFileGroupId` | N | |
| `totActnSummary` | N | 제출 시 권장 |
| `muniDocNo` | N | 제출 시 권장 |
| `pwrkAtchFileGroupId` | N | |
### 4-3. `PwrkCnsltnMng*`
**Search**: `pwrkCnsltnDocNo`, `upDocNo`, `upDocSeComCd`, `prgrsSttsComCd`, `admdstCd`, `envoSeComCd`, `taskSeComCd`, `bizNm`
**DetResponse 중첩**
- `cnsltnList`: 협의내역 (`rvwOpnnSn`, `rvwOpnnClsfComCd`, `cnsltnCn`, `rfltYn`, …)
- `pjtcoAjmtList`: 사업비조정
- `crtDmndList[].crtRslt`: 생성요청 + 최신 결과
- `splmntDmndList`: 보완요청
- `sttsChgHstryList`: 상태변경이력 — `SL` 복귀 라벨에 사용
- `trsmFileList`: 전송파일 (Java Det DTO 확인 후 반영)
마스터 조인: `bizNm`, `admdstCd`, `envoSeComCd`, `taskSeComCd`, `aplcntNm` 등.
`crtRslt.orgnlOtptDocCn`은 항상 `null`.
**상세 예**
```json
{
"pwrkCnsltnDocNo": "P-20260827-0001",
"upDocNo": "F-20260824-0001",
"upDocSeComCd": "FNRS",
"prgrsSttsComCd": "TE",
"cnsltnList": [],
"pjtcoAjmtList": [],
"crtDmndList": [
{
"crtDmndUuid": "d0545bef-735f-4d7b-a781-88e96e03577a",
"pwrkCnsltnDocNo": "P-20260827-0001",
"rqstrUuid": "...",
"rqstrNm": "관리자",
"crtSttsComCd": "C",
"crtRslt": {
"crtDmndUuid": "d0545bef-735f-4d7b-a781-88e96e03577a",
"verSn": 1,
"rgtrNm": "관리자",
"orgnlOtptDocCn": null,
"crtCmptnDt": "2026-08-27 10:47:01"
}
}
],
"splmntDmndList": [],
"sttsChgHstryList": []
}
```
### 4-4. `PwrkCnsltnDocCrtRslt*`
복합 PK: `crtDmndUuid` + `verSn`
**Response**
| 필드 | 타입 | 비고 |
| --- | --- | --- |
| `crtDmndUuid` | `string` | |
| `verSn` | `number` | |
| `rgtrUuid` / `rgtrNm` | `string` | |
| `orgnlOtptDocCn` | `null` | 목록/상세 미포함 |
| `crtCmptnDt` | `string` | |
**Search** (`PageableParams`): `crtDmndUuid`(이력 조회 시 필수), `pwrkCnsltnDocNo`, `verSn`, `rgtrUuid`
```http
GET /as-service/api/v1/pwrk-cnsltn-doc-crt-rslt?page=0&size=10&crtDmndUuid={uuid}&sort=verSn,desc
```
**Save (zod)**: `crtDmndUuid` 필수. `rgtrUuid` 생략 시 서버 로그인 사용자. `file`은 multipart part (DTO 필드 아님).
### 4-5. DC RAG (구현됨)
```ts
interface DcRagDocumentTransferRequestDto {
fnrsCnsltnDocNo: string;
taskSeComCd?: string;
taskSeCdNm?: 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";
```
`estimatedMinutes`는 초안 응답에 있을 수 있으나 UX 안내는 **30분 Fallback**을 따른다.
---
## 5. 폴링 훅
초안 (`AC` 또는 `TE`이면서 생성 중):
```ts
useQuery({
queryKey: [...dcragToKeys.draftStatus(crtDmndUuid)],
queryFn: () => dcRagApiToService.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` 시 `fnrs-cnsltn` 상세·`pwrk-cnsltn-mng` `findByUpDocNo` invalidate.
`FV`/`AV`는 `findById`를 5~10초 refetch. `prgrsSttsComCd`가 `ER`/`RK`가 되면 중단. 시작 시각 기준 30분 경과 안내 문구.
`crtDmndUuid` 복원: `findByUpDocNo(fnrsCnsltnDocNo).crtDmndList[0]`.
---
## 6. 다운로드
```ts
const response = await client.get<Blob>(url, { responseType: "blob" });
const filename = parseFilenameFromResponse(response);
const objectUrl = window.URL.createObjectURL(response.data);
```
기존 `attachService`와 동일.
---
## 7. 실무협의 목록 컬럼 (선택 화면)
| 표시명 | Field |
| --- | --- |
| 실무협의번호 | `pwrkCnsltnDocNo` |
| 재원협의번호 | `upDocNo` |
| 진행상태 | `prgrsSttsComCdNm` |
| 사업명 | `bizNm` |
| 시도/구군 | `sido` / `gugun` |
| 신청자 | `aplcntNm` |
| 등록일 | `regDt` |
행 클릭 → `/pwrk-cnsltn/{pwrkCnsltnDocNo}` 또는 재원협의 상세로 이동 (화면설계 확정 후).
---
## 8. `elapr-dmnd` 저장 시 주의
현행 `floating-action-buttons.tsx`:
```ts
elaprCycl: data.detDto?.prgrsSttsComCd === "RK" ? 2 : 1,
docPrgrsSttsComCd: data.detDto?.prgrsSttsComCd, // ER
```
To-Be:
- 검토자지정은 **`ER`(1차)** 과 **`EV`(2차)** 에서.
- `ER`: `elaprCycl: 1`, `docPrgrsSttsComCd: "AC"`, 서식 선택 다이얼로그
- `EV`: `elaprCycl: 2`, `docPrgrsSttsComCd: "RK"`, 1차 결재선 시드 다이얼로그(서식 없음)
`RK` / ATRZ02 FAB: `findDocPrgrsByDocNo` 전체 조회 후 pending은 `elaprCycl === 2`만 사용. 검토완료 후 `ElaprApprovalDialog`에 `elaprCycl={2}`. 보완요청은 TE와 동일 `SplmntDmndDialog`.
@@ -0,0 +1,246 @@
# 레퍼런스 — 프로세스·상태 전이
> 상위: [개발가이드.md](./개발가이드.md)
> SoT: `03.재원협의_실무협의_프로세스_통합_v완결.md` (v3.0, 2026-09-03)
---
## 1. 프로세스 개요
| 항목 | 내용 |
| --- | --- |
| Process No. | PR-AS-003 |
| 메뉴 | 행정지원 > 2.2.2 재원협의 > 2.2.2.1 재원협의 관리 |
| 목적 | 기초 → 광역(경유) → 환경청(승인) → 공단(기술검토/AI) 4자 협의로 사업규모·국비·분담금을 확정 |
2-Pass:
- **Pass 1** 기술 타당성: `SV→ST→LE→FV→ER→AC→TE→TP→RM→RB`
- **Pass 2** 조치결과·금액: `RB→RA→AV→EV→RK→RR→EP→TA`
자체종결(기술검토 생략): `LE→EP→TA`
---
## 2. 4-Lane 시퀀스
```mermaid
sequenceDiagram
autonumber
actor 기초 as 기초 (ROLE_USER05)
actor 광역 as 광역 (ROLE_USER04)
actor 환경청 as 환경청 (ROLE_USER03)
actor 공단 as 공단 (ROLE_USER01)
actor AI as AI 엔진
rect rgb(240, 248, 255)
기초->>기초: [저장] SV
기초->>광역: [신청완료] ST
광역->>환경청: [환경청제출] LE
end
rect rgb(255, 250, 240)
alt 자체종결
환경청->>광역: [결과회신] LE → EP
광역->>기초: [결과회신] EP → TA
else 기술검토 의뢰
환경청->>AI: [기술검토 의뢰] LE → FV
end
end
rect rgb(245, 255, 245)
AI-->>공단: 파일검증 콜백 FV → ER
Note over 공단: 30분 미응답 시 ER + 수동 [파일검증]
alt 파일 미비
공단->>기초: [파일보완] ER → SL
기초->>공단: [파일보완완료] SL → ER
else 적격
공단->>AI: [검토자지정] ER → AC
end
end
rect rgb(255, 245, 245)
AI-->>공단: 초안 콜백 AC → TE
Note over 공단: 30분 미응답 시 TE + 수동 [실무협의서생성]
alt 설계 미비
공단->>기초: [보완요청] TE → SL
기초->>공단: [보완완료] SL → TE
else 검토 완료
공단->>공단: [검토완료] 상신, [결재(승인)] TE 유지
공단->>환경청: [결재(회신)] TE → TP
end
end
rect rgb(250, 240, 255)
환경청->>광역: [재원협의 결과 회신] TP → RM
광역->>기초: [지자체회신] RM → RB
기초->>환경청: [조치완료] RB → RA
end
rect rgb(240, 255, 255)
광역->>환경청: [조치결과서제출] RC → RA
환경청->>AI: [조치결과 검토의뢰] RA → AV
AI-->>공단: 금액검증 콜백 AV → EV
공단->>공단: [검토자지정] EV → RK
Note over 공단: EV에서 1차 결재선 시드로 2차 결재선 수동 배정
end
rect rgb(255, 255, 230)
alt 소명 미비
공단->>기초: [보완요청] RK → SL
기초->>공단: [보완완료] SL → RK
else 확정
공단->>환경청: [결재(회신)] RK → RR
end
end
rect rgb(255, 240, 240)
환경청->>광역: [결과회신] RR → EP
광역->>기초: [결과회신] EP → TA
end
```
거버넌스:
```
기초 (USER05) --ST--> 광역 (USER04) --LE--> 환경청 (USER03)
기초 <-----TA------ 광역 <-----EP/RM------ 환경청
환경청 --FV/ER--> 공단 (USER01) --TP/RR--> 환경청
```
---
## 3. Activity 정의 (14)
| No. | Activity | 주체 | From → To | FE 포인트 |
| --- | --- | --- | --- | --- |
| 1 | 재원협의 조회 | 기초 | — | 기본계획·자가점검 대조 |
| 2 | 등록 및 제출 | 기초·광역 | `SV→ST`, 광역 `LE` | `save-apply`, `updatePrgrsStts` |
| 3 | 접수 및 의뢰 | 환경청 | `LE→FV` / `EP` | 의뢰 vs 자체종결 분기 |
| 4 | AI 파일검증 | 공단·AI | `FV→ER` / `SL` | 30분 Fallback, 파일보완 팝업 |
| 5 | 자체종결 회신 | 환경청·광역 | `LE→EP→TA` | 공단 레인 없음 |
| 6 | 검토자지정·초안 | 공단·AI | `ER→AC→TE` | `elapr-dmnd`, 초안 폴링 |
| 7 | 1차 기술검토 | 공단 결재선 | `TE` / `SL` | 웹기안기, HWP 파싱은 서버 |
| 8 | 기술검토 회신 | 공단 최종 | `TE→TP` | `process` 최종 |
| 9 | 검토결과 회신 | 환경청·광역 | `TP→RM→RB` | 1차 회신. `EP`와 혼동 금지 |
| 10 | 조치결과 작성 | 기초 | `RB→RA` | `submit-actn-result` `isSubmit:true` (조치완료) |
| 11 | 조치 의뢰 | 환경청 | `RA→AV` | `request-actn-review` |
| 12 | AI 금액검증 | AI·공단 | `AV→EV` | 콜백/Fallback 후 접수자 대기 |
| 12a | 2차 검토자지정 | 공단 접수 | `EV→RK` | `elapr-dmnd` cycl=2 |
| 13 | 2차 검토·결재 | 공단 | `RK→RR` / `SL` | 2차 결재선 |
| 14 | 최종 회신 | 환경청·광역·기초 | `RR→EP→TA` | 종결 |
---
## 4. `FNRSSTTS` 상세
관리: `tb_cs_lwpo_cd` / `tb_as_fnrs_cnsltn_mng.prgrs_stts_com_cd`
| 코드 | 국문 | 영문 | 화면 주체 | 전이 |
| --- | --- | --- | --- | --- |
| `SV` | 저장 | Save | 기초 작성 | 신규/`SV` → `SV` |
| `ST` | 신청완료 | Submit to Province | 기초→광역 | `SV→ST` |
| `LE` | 환경청 검토 | Local Environment Review | 광역→환경청 | `ST→LE` |
| `FV` | 파일검증 | File Verification | AI | `LE→FV` |
| `ER` | 기술검토 의뢰 | Examine Request | 공단 접수자 | `FV→ER`, `SL→ER` |
| `SL` | 보완요청 | Supplement Loop | 공단→기초 | `ER\|TE\|RK → SL` |
| `AC` | 초안생성 | AI Auto Creation | AI | `ER→AC` |
| `TE` | 기술검토 | Technical Examine | 공단 1차 결재 | `AC→TE`, `SL→TE` |
| `TP` | 기술검토 회신 | Technical Pass to Basin | 공단→환경청 | `TE→TP` |
| `RM` | 재원협의결과회신 | Result Message to Province | 환경청→광역 | `TP→RM` |
| `RB` | 재원협의결과회신(지자체) | Result Back to Municipality | 광역→기초 | `RM→RB` |
| `RC` | 조치결과서 제출 | Result Complete | 기초→광역 | `RB→RC` |
| `RA` | 조치결과서 제출(환경청) | Result Ask to Basin | 기초→환경청 | `RB→RA` |
| `AV` | 금액검증 | Amount Verification | AI | `RA→AV` |
| `EV` | 조치결과검토의뢰 | Examine Verify | 공단 접수자 | `AV→EV` |
| `RK` | 조치결과서검토 | Result Check | 공단 2차 결재 | `EV→RK`, `SL→RK` |
| `RR` | 조치결과서검토 회신 | Result Return to Basin | 공단→환경청 | `RK→RR` |
| `EP` | 검토결과 회신 | Examine Pass to Province | 환경청→광역 | `LE→EP`(자체), `RR→EP`(최종) |
| `TA` | 검토결과 회신(종료) | Technical Approval | 광역→기초 | `EP→TA` |
### 4-1. v2.1 → v3.0 대조 (프론트 폐기 대상)
| 구간 | v2.1 | v3.0 |
| --- | --- | --- |
| 의뢰 직후 파일검증 | `ER` | **`FV` → `ER`** |
| 검토자지정 후 초안 | `EE` → `DV` | **`ER` → `AC` → `TE`** |
| 1차 내부 결재 | `TR` → `TP` | **`TE` 유지 → `TP`** |
| 환경청→광역 1차 회신 | `EP` | **`RM`** |
| 금액검증 | `RA` | **`RA` → `AV` → `RK`** |
| 공단 2차 회신 | `TP` | **`RR`** |
| 최종 완료 | `TP` / 고시 `TB` | **`TA`** (고시 없음) |
가이드 v1.0 `PWRKSTTS`(`DV`, `AR`, `TR`, `AA` 등)도 동일하게 폐기.
---
## 5. 보완(`SL`) 3루프
복귀 상태는 `tb_as_doc_stts_chg_hstry` 직전 코드. 프론트는 복귀 코드를 요청에 넣지 않고, 라벨만 이력으로 고른다.
```mermaid
stateDiagram-v2
ER --> SL: 파일보완 팝업(파일리스트)
SL --> ER: 파일보완완료
TE --> SL: 보완 검토팝업()
SL --> TE: 보완완료
RK --> SL: 보완 검토팝업(금액)
SL --> RK: 보완완료
```
| 루프 | 팝업 | 요청 | 제출 버튼 |
| --- | --- | --- | --- |
| 파일 무결성 | 파일리스트 + 검증결과 | 공단 접수자 `ER` | [파일보완완료] |
| 1차 기술 | 설계 미달·사유 | 공단 검토자 `TE` | [보완완료] |
| 2차 금액 | 공종별 금액 체크 | 공단 검토자 `RK` | [보완완료] |
---
## 6. AI 3파이프라인·30분 Fallback
| ID | 단계 | 진입 → 완료 | 프론트 진입 | Fallback | 수동 재실행 |
| --- | --- | --- | --- | --- | --- |
| 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→EV` | 환경청 [조치결과 검토의뢰] | 30분 → `EV` + 수동 [금액검증] | `POST /dcrag-to/documents/cost/verify` |
- 예상 소요 안내는 **30분** (v1.0의 10분은 폐기).
- 콜백 수신·스케줄러 전이는 서버. FE는 상세 refetch / draft status 폴링만.
- `AC→TE` 성공 시에만 [실무협의서관리]·[검토결과조회] 활성. 실패·타임아웃이면 [실무협의서생성].
초안 폴링 이벤트 예:
| `eventType` | `eventMessage` |
| --- | --- |
| `REQUESTED` | 요청을 접수했습니다. |
| `DOWNLOADING` | 신청서를 수신하는 중입니다. |
| `ANALYZING` | 자료 준비가 완료되었습니다. |
| `GENERATING` | 문서 저장이 완료되었습니다. |
| `COMPLETED` | 문서 생성이 완료되었습니다. |
| `FAILED` | `errorMessage` 표시 |
종료: `status === "COMPLETED" | "FAILED"` 또는 `events`에 해당 `eventType`.
---
## 7. 2차 결재선 (수동 검토자지정)
1. `TE`에서 1차 결재선(`elaprCycl = 1`) 완료.
2. 금액검증 콜백/Fallback → `AV→EV`.
3. 공단 접수자가 `EV`에서 [검토자지정]: 1차 결재선을 시드로 편집 후 `POST /elapr-dmnd` (`elaprCycl: 2`) → `RK`.
4. `copy-line` 자동 복사는 사용하지 않는다.
결재 `process`:
- 상신·중간 승인: 문서 상태 `TE` 또는 `RK` 유지.
- 1차 최종: 서버가 `TE→TP`.
- 2차 최종: 서버가 `RK→RR`.
- 반려: 직전 결재자 복귀. 문서 상태 유지.
---
## 8. 웹기안기
- `TE`/`RK` [실무협의서관리] → `webhwp/open` Blob 조회 후 팝업에서 `HwpCtrl.Open(File)`.
- [검토완료] 시 HWP 파싱·DB 등재는 **서버**. FE는 결재 `process`만.
- 편집본 저장: `POST /pwrk-cnsltn-doc-crt-rslt` multipart. `verSn`은 클라이언트가 보내지 않음.
@@ -0,0 +1,181 @@
# 레퍼런스 — 화면·버튼 매트릭스
> 상위: [개발가이드.md](./개발가이드.md)
> SoT: `03` §4 화면 및 버튼별 상세 상태전이 명세서 (Row 4~42, 38행)
구현 위치: `pages/fnrs-cnsltn/lib/button-config.ts` + `ui/components/floating-action-buttons.tsx`
---
## 1. 화면 구분
| 화면 | 소유 | 라우트 (현행) | 비고 |
| --- | --- | --- | --- |
| 작성화면 | 기초 `ROLE_USER05` | `/fnrs-cnsltn/new`, `/fnrs-cnsltn/{id}/edit` | `SV` |
| 수정화면 | 기초 | `/fnrs-cnsltn/{id}/edit` | `SL` 보완 |
| 상세화면 | 광역·환경청·공단·기초(`RB`/`TA`) | `/fnrs-cnsltn/{id}` | 조회 + 액션 버튼 |
| 자동화 | AI | 버튼 없음 | `FV`/`AC`/`AV` 진행 패널 |
공단 상세는 결재선 구분으로 같은 상태라도 버튼이 다르다.
| `atrzSeComCd` | 역할 |
| --- | --- |
| `ATRZ01` | 접수자 |
| `ATRZ02` | 검토자 |
| `ATRZ03` | 승인자 |
| `ATRZ99` | 최종승인자 |
---
## 2. 38행 매트릭스
API 컬럼은 [ref-endpoint.md](./ref-endpoint.md)와 동일.
| 순번 | 화면 | 권한 | 결재선 | 상태 | 버튼 | 전이 | 다음 담당 | FE 동작 |
| :---: | --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | 작성 | USER05 | - | 빈값, `SV` | 저장 | `SV` | 본인 | `save-apply` `SV` |
| 2 | 작성 | USER05 | - | `SV` | 신청완료 | `ST` | 광역 | `save-apply` `ST` |
| 3 | 작성 | USER05 | - | `SV` | 삭제 | 삭제 | — | `DELETE` |
| 4 | 상세 | USER04 | - | `ST` | 환경청제출 | `LE` | 환경청 | `updatePrgrsStts` |
| 5 | 상세 | USER03 | - | `LE` | 결과회신 | `EP` | 광역 | `tech-review` 자체종결 |
| 6 | 상세 | USER03 | - | `LE` | 기술검토 의뢰 | `FV` | 공단 접수 | **`request-examine`** |
| 7 | 자동화 | AI | - | `FV` | (파일검증) | `ER` | 공단 접수 | FE 호출 없음. 진행 패널 |
| 8 | 상세 | USER01 | 접수자 | `ER` | 파일보완 | `SL` | 기초 | 팝업(파일리스트) → `reqsupplement` |
| 9 | 상세 | USER01 | 접수자 | `ER` | 파일검증 | `ER` | 본인 | `dcrag-to/transfer` 재실행 |
| 10 | 상세 | USER01 | 접수자 | `ER` | 검토자지정 | `AC` | 다음 결재자 | `POST /elapr-dmnd` |
| 11 | 자동화 | AI | - | `AC` | (초안생성) | `TE` | 공단 검토 | FE 호출 없음. 폴링 |
| 12 | 상세 | USER01 | 검토자 | `TE` | 실무협의서관리 | 유지 | — | 웹기안기 Open. **초안 성공 시만** |
| 13 | 상세 | USER01 | 검토자 | `TE` | 검토결과조회 | 유지 | — | 실무협의 상세 |
| 14 | 상세 | USER01 | 검토자 | `TE` | 실무협의서생성 | 유지 | — | **실패/타임아웃/초안 없음** 시 |
| 15 | 상세 | USER01 | 검토자 | `TE` | 저장 | `TE` | — | `pwrk save-apply` |
| 16 | 상세 | USER01 | 검토자 | `TE` | 검토완료 | `TE` | 다음 결재자 | `elapr process` 상신. 반려 시 직전 결재자 |
| 17 | 상세 | USER01 | 검토자 | `TE` | 보완요청 | `SL` | 기초 | 보완 팝업() → `reqsupplement` |
| 18 | 상세 | USER01 | 승인자 | `TE` | 결재(승인) | `TE` | 다음 결재자 | `process` |
| 19 | 상세 | USER01 | 최종승인자 | `TE` | 결재(회신) | `TP` | 환경청 | `process` 최종 |
| 20 | 수정 | USER05 | - | `SL` | 파일보완완료 | `ER` | 공단 접수 | `submitsupplement` (직전=`ER`) |
| 21 | 수정 | USER05 | - | `SL` | 보완완료 | `TE` | 공단 검토 | `submitsupplement` (직전=`TE`) |
| 22 | 수정 | USER05 | - | `SL` | 보완완료 | `RK` | 공단 검토 | `submitsupplement` (직전=`RK`) |
| 23 | 상세 | USER03 | - | `TP` | 재원협의 결과 회신 | `RM` | 광역 | `tech-review` |
| 24 | 상세 | USER04 | - | `RM` | 지자체회신 | `RB` | 기초 | `updatePrgrsStts` |
| 25 | 상세 | USER05 | - | `RB` | 실무협의서 관리 | 유지 | — | 웹기안기 Open |
| 26 | 상세 | USER05 | - | `RB` | 조치완료 | `RA` | 환경청 | **`submit-actn-result` `isSubmit:true`** |
| 27 | 상세 | USER04 | - | `RC` | 조치결과서제출 | `RA` | 환경청 | `updatePrgrsStts` |
| 28 | 상세 | USER03 | - | `RA` | 실무협의서 관리 | 유지 | — | 웹기안기 Open. **저장 불가** |
| 28a | 상세 | USER03 | - | `RA` | 조치결과 검토의뢰 | `AV` | 공단 검토 | **`request-actn-review`** |
| 29 | 자동화 | AI | - | `AV` | (금액검증) | `EV` | 공단 접수 | FE 호출 없음. 진행 패널 |
| 29a | 상세 | USER01 | 접수자 | `EV` | 검토자지정 | `RK` | 공단 검토 | `POST /elapr-dmnd` cycl=2. 1차 결재선 시드 |
| 30 | 상세 | USER01 | 검토자 | `RK` | 실무협의서관리 | 유지 | — | 웹기안기 |
| 31 | 상세 | USER01 | 검토자 | `RK` | 금액검증 | `RK` | — | `dcrag-to/cost/verify` |
| 32 | 상세 | USER01 | 검토자 | `RK` | 저장 | `RK` | — | `pwrk save-apply` |
| 33 | 상세 | USER01 | 검토자 | `RK` | 검토완료 | `RK` | 다음 결재자 | `process` |
| 34 | 상세 | USER01 | 승인자 | `RK` | 결재(승인) | `RK` | 다음 결재자 | `process` |
| 35 | 상세 | USER01 | 최종승인자 | `RK` | 결재(회신) | `RR` | 환경청 | `process` 최종 |
| 36 | 상세 | USER03 | - | `RR` | 실무협의서 관리(조회), 검토결과 회신 | `EP` | 광역 | `tech-review` |
| 37 | 작성/상세 | USER04 | - | `EP` | 결과회신, 실무협의서 관리(조회) | `TA` | 기초 | `updatePrgrsStts` |
| 38 | 상세 | USER05 | - | `TA` | 결과회신, 실무협의서 관리(조회) | — | — | **API 없음. 조회** |
`SL` 20~22행: 버튼은 직전 상태에 따라 **하나만** 노출. 파일 루프면 [파일보완완료], 그 외 [보완완료].
---
## 3. `button-config` 권장 구조
액션 키는 현행 `FNRS_CNSLTN_VIEW_BUTTON_ACTION`을 확장한다. 신규·이름 변경:
| 신규/변경 액션 | 라벨 | 비고 |
| --- | --- | --- |
| `fileSplmnt` | 파일보완 | `ER` 접수자. 현행에 없음 |
| `fileVrfc` | 파일검증 | 유지. `ER` 재실행 |
| `dsgntRvwr` | 검토자지정 | `ER→AC`(1차), `EV→RK`(2차) |
| `pwrkCnsltnDocMng` | 실무협의서관리 | 유지. `TE`·`RK`·`RB`·`RA`(조회 전용) |
| `pwrkCnsltnDocCrt` | 실무협의서생성 | 유지. 실패/타임아웃만 |
| `rvwRsltInq` | 검토결과조회 | 신규 |
| `actnCmptn` | 조치완료 | `RB→RA`. `isSubmit:true` |
| `actnSbmsn` | 조치결과서제출 | 광역 `RC→RA` |
| `rqstActnRvw` | 조치결과 검토의뢰 | 환경청 `RA→AV` |
| `costVrfc` | 금액검증 | `RK` |
| `lclRply` | 지자체회신 | 광역 `RM→RB` |
| `fnrsRsltRply` | 재원협의 결과 회신 | 환경청 `TP→RM` |
| `rsltRply` | 결과회신 | `LE→EP`, `RR→EP`, `EP→TA`, `TA` 조회 |
| `fileSplmntCmptn` | 파일보완완료 | `SL←ER` |
| `splmntCmptn` | 보완완료 | `SL←TE\|RK` |
**삭제**: `fnrsCnsltnAncmnt`(고시→`TB`), `sncnAprv`(인가승인). `EP`/`TA`는 `rsltRply`로 통일.
### 3-1. 역할 × 상태 (공단 제외)
```
ROLE_USER05
"" | SV → 저장, 신청완료, 삭제
SL → (직전 ER) 파일보완완료 | (직전 TE|RK) 보완완료
RB → 실무협의서 관리, 조치완료
TA → 결과회신 (조회), 실무협의서 관리(조회)
ROLE_USER04
ST → 환경청제출 // 현행 반려(rjct→SV)는 SoT 38행에 없음. 유지 시 별도 합의
RM → 지자체회신
RC → 조치결과서제출
EP → 결과회신, 실무협의서 관리(조회)
ROLE_USER03
LE → 결과회신(자체종결), 기술검토 의뢰
TP → 재원협의 결과 회신
RA → 실무협의서 관리(조회), 조치결과 검토의뢰
RR → 실무협의서 관리(조회), 검토결과 회신 (`tech-review` → `EP`)
```
### 3-2. 공단 결재선 × 상태
```
ER / ATRZ01 접수자
→ 파일보완, 파일검증, 검토자지정
EV / ATRZ01 접수자
→ 검토자지정 (2차 결재선. 1차 결재선 시드, 서식 선택 없음)
TE / ATRZ02 검토자
→ (초안 성공) 실무협의서관리, 검토결과조회, 저장, 검토완료, 보완요청
→ (초안 실패·타임아웃) 실무협의서생성 (+ 위 항목 중 관리/조회는 초안 생긴 뒤)
TE / ATRZ03 승인자 → 결재(승인)
TE / ATRZ99 최종 → 결재(회신)
RK / ATRZ02 검토자
→ (초안 완료) 실무협의서관리
→ (초안 완료 + 결재 대기 ATRZ_PRGRS_002, cycl2) 보완요청, 검토완료
· 보완요청 UI: TE와 동일 SplmntDmndDialog
RK / ATRZ03 → (초안 완료) 실무협의서관리, (cycl2 pending) 결재(승인)
RK / ATRZ99 → (초안 완료) 실무협의서관리, (cycl2 pending) 결재(회신)
· 결재 UI: ElaprApprovalDialog (elaprCycl=2) 승인/반려 → PUT process
```
`FV`, `AC`, `AV`: 액션 빈 배열 + 진행 패널.
`PENDING_APPROVAL_ACTIONS`: `dmndSplmnt`, `rvwCmptn`, `atrzAprv`, `atrzRply`만.
`pwrkCnsltnDocMng`는 결재 대기와 무관(초안 완료만). ATRZ02·ATRZ03·ATRZ99라도 pending 없으면 관리만 노출.
---
## 4. 초안 성공 vs 실패 버튼
`TE`에서 `crtDmndList[].crtSttsComCd` / 폴링 `status`로 분기.
| 조건 | 노출 |
| --- | --- |
| `C` / `COMPLETED` | 실무협의서관리, 검토결과조회, 저장, 검토완료, 보완요청 |
| `F` / `FAILED` 또는 30분 Fallback 후 초안 없음 | 실무협의서생성 |
| `R`/`I` 생성 중 | 진행 패널. 위 버튼 비활성 (`AC`와 동일 UX, 상태가 이미 `TE`면 패널+생성 버튼) |
`RK`는 금액검증 성공이 기본. Fallback 후 [금액검증] 재실행만 추가 노출.
---
## 5. 현행 `button-config.ts`와의 차이
| 항목 | 현재 | To-Be |
| --- | --- | --- |
| USER05 | `""/SV/TA(고시)` | `SL`, `RB` 추가. `TA`는 조회 |
| USER04 | `ST`(제출+반려), `EP`(인가승인) | `RM/RC/EP` 추가. 인가승인 삭제 |
| USER03 | `LE`, `TP`(→EP로 회신) | `RA`, `RR` 추가. `TP`는 `RM` |
| USER01 | `ER`(파일검증, 검토자지정), `TE` | `ER`에 파일보완. `RK` 전면 추가 |
| USER01 ATRZ | `ER`, `TE`만 `needsRoleUser01AtrzResolve` | `RK` 포함 |
File diff suppressed because it is too large Load Diff
-800
View File
@@ -1,800 +0,0 @@
# 실무협의문서 개발 가이드
> **대상 앱**: `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` |
+14
View File
@@ -2,6 +2,12 @@ import type { NextConfig } from "next";
const subDomain = process.env.NEXT_PUBLIC_SUB_DOMAIN || "http://localhost:13000";
const webhwpCtrlBase = (
process.env.NEXT_PUBLIC_WEBHYP_CTRL_URL || "http://hwp.onasys.co.kr/webhwpctrl"
).replace(/\/$/, "");
const webhwpAssetPrefixes = ["js", "hwpsdk", "dist", "commonFrame", "wasm"] as const;
const nextConfig: NextConfig = {
output: "standalone",
reactStrictMode: true,
@@ -30,6 +36,14 @@ const nextConfig: NextConfig = {
source: "/cms/:path*",
destination: `${subDomain}/cms/:path*`,
},
...webhwpAssetPrefixes.map((prefix) => ({
source: `/webhwpctrl/${prefix}/:path*`,
destination: `${webhwpCtrlBase}/${prefix}/:path*`,
})),
{
source: "/webhwpctrl/file/:path*",
destination: `${webhwpCtrlBase}/file/:path*`,
},
],
fallback: [],
};
+1
View File
@@ -36,6 +36,7 @@
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@hookform/devtools": "^4.4.0",
"@types/js-cookie": "^3.0.6",
"eslint": "^9.0.0",
"eslint-config-next": "15.3.8",
+62
View File
@@ -0,0 +1,62 @@
<!--
~ Copyright 2026 Hancom Inc. All rights reserved.
~
~ https://www.hancom.com/
-->
<!doctype html>
<html lang="en-us">
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>Hwp SDK Wasm Sample</title>
<script
type="text/javascript"
data-type="css/app"
data-framework="aori|lab"
src="js/loadVersion.js"
></script>
<script
type="text/javascript"
data-type="css/app"
data-framework="aori|lab"
src="js/loadHead.js"
></script>
<script data-main="js/main" data-bundle="dist/main_bundle" src="js/loadMain.js"></script>
<style>
#hcwoViewScroll::-webkit-scrollbar {
height: 10px;
width: 10px;
}
#hcwoViewScroll::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.35);
border-radius: 5px;
}
#hcwoViewScroll::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.08);
}
</style>
</head>
<body>
<div id="status" style="display: none">
<div class="sppiner-content">
<div class="spinner" id="spinner"></div>
<div class="emscripten" id="statustext"></div>
</div>
</div>
<div id="status_downloading">
<div class="status-content">
<div class="emscriptenDownloading">
<span id="statusDownloadingText">구성 중 ... 0% </span>
<span id="statusDownloadingPercent" class="emscriptenDownloadingPercent"></span>
</div>
<div class="emscriptenDownloadingInfo"></div>
<progress id="download_progress" value="0" max="100"></progress>
</div>
</div>
<iframe id="download_iframe" style="width: 0px; height: 0px; border: 0; display: none"></iframe>
<script type="text/javascript" src="hwpsdk/loadHwpsdk.js"></script>
</body>
</html>
+66
View File
@@ -0,0 +1,66 @@
<!doctype html>
<html lang="ko" style="height: 100%">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>웹한글 기안기 브리지</title>
<script
type="text/javascript"
src="http://hwp.onasys.co.kr/webhwpctrl/js/hwpctrlapp/utils/util.js"
></script>
<script
type="text/javascript"
src="http://hwp.onasys.co.kr/webhwpctrl/js/hwpctrlapp/hwpCtrlApp.js"
></script>
<script
type="text/javascript"
src="http://hwp.onasys.co.kr/webhwpctrl/js/webhwpctrl.js"
></script>
<style>
html,
body,
#hwpctrl {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
border: 0;
}
#hwpctrl_frame {
width: 100% !important;
height: 100% !important;
}
</style>
</head>
<body>
<div id="hwpctrl"></div>
<script type="text/javascript">
(function () {
var params = new URLSearchParams(window.location.search);
var baseUrl = params.get("baseurl") || "http://hwp.onasys.co.kr/webhwpctrl";
if (baseUrl.charAt(baseUrl.length - 1) !== "/") {
baseUrl += "/";
}
var HwpCtrl = BuildWebHwpCtrl(
"hwpctrl",
baseUrl,
function () {
try {
window.parent.postMessage({ type: "hangulgiangi-ready" }, "*");
} catch (e) {
console.warn(e);
}
},
2,
);
// 부모(HangulGiangi)가 contentWindow.HwpCtrl 로 접근
window.HwpCtrl = HwpCtrl;
})();
</script>
</body>
</html>
@@ -0,0 +1,24 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
AiAutoPrcsLatestResponseDto,
AiAutoPrcsMngService,
} from "../model/ai-auto-prcs-mng-interface";
const BASE = ENDPOINT.AS_SERVICE.AI_AUTO_PRCS_MNG;
async function findLatestPerAutoSeByFnrsDocNo(
fnrsCnsltnDocNo: string,
atmtnSeComCd?: string,
): Promise<AiAutoPrcsLatestResponseDto[]> {
const response = await client.get<AiAutoPrcsLatestResponseDto[]>(
`${BASE}/fnrs/${fnrsCnsltnDocNo}/latest`,
{ params: atmtnSeComCd ? { atmtnSeComCd } : undefined },
);
return response.data;
}
export const aiAutoPrcsMngService = {
findLatestPerAutoSeByFnrsDocNo,
} satisfies AiAutoPrcsMngService;
@@ -0,0 +1,30 @@
import { useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import { aiAutoPrcsMngService } from "./ai-auto-prcs-mng-service";
export const aiAutoPrcsMngKeys = {
all: [ENDPOINT.AS_SERVICE.AI_AUTO_PRCS_MNG] as const,
latest: (fnrsCnsltnDocNo: string, atmtnSeComCd?: string) =>
[...aiAutoPrcsMngKeys.all, "latest", fnrsCnsltnDocNo, atmtnSeComCd ?? "all"] as const,
};
interface UseAiAutoPrcsLatestOptions {
enabled?: boolean;
atmtnSeComCd?: string;
}
export function useAiAutoPrcsLatest(
fnrsCnsltnDocNo: string | null | undefined,
options: UseAiAutoPrcsLatestOptions = {},
) {
const { enabled = true, atmtnSeComCd } = options;
return useQuery({
queryKey: aiAutoPrcsMngKeys.latest(fnrsCnsltnDocNo!, atmtnSeComCd),
queryFn: () =>
aiAutoPrcsMngService.findLatestPerAutoSeByFnrsDocNo(fnrsCnsltnDocNo!, atmtnSeComCd),
enabled: Boolean(fnrsCnsltnDocNo) && enabled,
});
}
@@ -0,0 +1,8 @@
export { aiAutoPrcsMngService } from "./api/ai-auto-prcs-mng-service";
export { aiAutoPrcsMngKeys, useAiAutoPrcsLatest } from "./api/queries";
export type {
AiAutoPrcsLatestResponseDto,
AiAutoPrcsMngService,
AiAutoSeComCd,
} from "./model/ai-auto-prcs-mng-interface";
export { AI_AUTO_SE } from "./model/ai-auto-prcs-mng-interface";
@@ -0,0 +1,44 @@
/** 자동화구분 공통코드 (AIAUTOSE) */
export const AI_AUTO_SE = {
FILE_VRFC: "FILE_VRFC",
DRAFT_GEN: "DRAFT_GEN",
COST_VRFC: "COST_VRFC",
} as const;
export type AiAutoSeComCd = (typeof AI_AUTO_SE)[keyof typeof AI_AUTO_SE];
/** 백엔드 AiAutoPrcsLatestResponseDto */
export interface AiAutoPrcsLatestResponseDto {
atmtnPrcsUuid?: string;
fnrsCnsltnDocNo?: string;
pwrkCnsltnDocNo?: string;
crtDmndUuid?: string;
atmtnSeComCd?: string;
atmtnSeComNm?: string;
bfrDocSttsComCd?: string;
bfrDocSttsComNm?: string;
aftrDocSttsComCd?: string;
aftrDocSttsComNm?: string;
atmtnPrcsSttsComCd?: string;
atmtnPrcsSttsComNm?: string;
dmndDt?: string;
dmndSysId?: string;
rplyDt?: string;
rplyRsltComCd?: string;
prcsErrCn?: string | null;
lastSttsInqDt?: string;
sttsInqRsltComCd?: string;
rtrsmCtpvNmtm?: number;
lastRtrsmDt?: string;
lastFrcSttsChgYn?: string;
isFailed?: boolean;
isRetryable?: boolean;
}
/** Controller public 메서드명·시그니처와 동일 */
export interface AiAutoPrcsMngService {
findLatestPerAutoSeByFnrsDocNo(
fnrsCnsltnDocNo: string,
atmtnSeComCd?: string,
): Promise<AiAutoPrcsLatestResponseDto[]>;
}
@@ -3,7 +3,13 @@ import { AxiosResponse } from "axios";
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { AttachService } from "../model/attach-interface";
import type { Attach, AttachService } from "../model/attach-interface";
async function findByAtflCd(atflCd: string): Promise<Attach[]> {
if (!atflCd) return [];
const response = await client.get<Attach[]>(`${ENDPOINT.ATTACH_SERVICE.ATTACHMENTS}/${atflCd}`);
return response.data;
}
async function deleteAttachmentByAtflId(atflId: string): Promise<void> {
if (!atflId) return;
@@ -23,6 +29,7 @@ async function downloadAttachmentByAtflId(atflId: string): Promise<AxiosResponse
}
export const attachService = {
findByAtflCd,
deleteAttachmentByAtflId,
downloadAttachmentByAtflCd,
downloadAttachmentByAtflId,
+5
View File
@@ -4,4 +4,9 @@ export {
useDownloadAttachmentByAtflCd,
useDownloadAttachmentByAtflId,
} from "./api/queries";
export { parseFilenameFromResponse } from "./lib/parse-filename-from-response";
export type { Attach, AttachService } from "./model/attach-interface";
export {
AttachmentDownloadLink,
type AttachmentDownloadLinkProps,
} from "./ui/attachment-download-link";
@@ -17,6 +17,7 @@ export interface Attach {
}
export interface AttachService {
findByAtflCd: (atflCd: string) => Promise<Attach[]>;
deleteAttachmentByAtflId: (atflId: string) => Promise<void>;
downloadAttachmentByAtflCd: (atflCd: string) => Promise<AxiosResponse>;
downloadAttachmentByAtflId: (atflId: string) => Promise<AxiosResponse>;
@@ -0,0 +1,54 @@
"use client";
import { Paperclip } from "lucide-react";
import { cn } from "@common/components";
import { useDownloadAttachmentByAtflCd, useDownloadAttachmentByAtflId } from "../api/queries";
interface AttachmentDownloadLinkProps {
atflCd?: string;
atflId?: string;
fileName?: string;
className?: string;
}
function AttachmentDownloadLink({
atflCd,
atflId,
fileName,
className,
}: AttachmentDownloadLinkProps) {
const downloadByAtflCd = useDownloadAttachmentByAtflCd();
const downloadByAtflId = useDownloadAttachmentByAtflId();
const displayName = fileName || "첨부파일";
const canDownload = Boolean(atflId || atflCd);
const isPending = atflId ? downloadByAtflId.isPending : downloadByAtflCd.isPending;
function handleClick() {
if (atflId) {
downloadByAtflId.mutate(atflId);
return;
}
if (atflCd) downloadByAtflCd.mutate(atflCd);
}
return (
<button
type="button"
className={cn(
"inline-flex cursor-pointer items-start gap-1 text-left text-blue-600 hover:underline disabled:cursor-wait disabled:opacity-60",
className,
)}
aria-label={`${displayName} 다운로드`}
disabled={isPending || !canDownload}
onClick={handleClick}
>
<Paperclip className="my-1 size-5 shrink-0" />
{displayName}
</button>
);
}
export { AttachmentDownloadLink };
export type { AttachmentDownloadLinkProps };
@@ -0,0 +1,85 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
DcRagApiToService,
DcRagCommonResponseDto,
DcRagDraftGenerateResponseDto,
DcRagDraftStatusResponseDto,
} from "../model/dcrag-to-interface";
import type {
DcRagCostVerifyRequestDto,
DcRagDocumentTransferRequestDto,
DcRagDraftGenerateRequestDto,
} from "../model/dcrag-to-payload-schema";
const BASE = ENDPOINT.AS_SERVICE.DCRAG_TO;
async function requestDocumentTransfer(
body: DcRagDocumentTransferRequestDto,
): Promise<DcRagCommonResponseDto> {
const response = await client.post<DcRagCommonResponseDto>(`${BASE}/documents/transfer`, body);
return response.data;
}
async function requestDraftGeneration(
body: DcRagDraftGenerateRequestDto,
): Promise<DcRagDraftGenerateResponseDto> {
const response = await client.post<DcRagDraftGenerateResponseDto>(
`${BASE}/documents/draft/generate`,
body,
);
return response.data;
}
async function getDraftGenerationStatus(crtDmndUuid: string): Promise<DcRagDraftStatusResponseDto> {
const response = await client.get<DcRagDraftStatusResponseDto>(
`${BASE}/documents/draft/status/${crtDmndUuid}`,
);
return response.data;
}
async function requestCostVerification(
body: DcRagCostVerifyRequestDto,
): Promise<DcRagCommonResponseDto> {
const response = await client.post<DcRagCommonResponseDto>(`${BASE}/documents/cost/verify`, body);
return response.data;
}
async function reTransferDocument(
body: DcRagDocumentTransferRequestDto,
): Promise<DcRagCommonResponseDto> {
const response = await client.post<DcRagCommonResponseDto>(
`${BASE}/documents/re-transfer`,
body,
);
return response.data;
}
async function reGenerateDraft(
body: DcRagDraftGenerateRequestDto,
): Promise<DcRagDraftGenerateResponseDto> {
const response = await client.post<DcRagDraftGenerateResponseDto>(
`${BASE}/documents/draft/re-generate`,
body,
);
return response.data;
}
async function reVerifyCost(body: DcRagCostVerifyRequestDto): Promise<DcRagCommonResponseDto> {
const response = await client.post<DcRagCommonResponseDto>(
`${BASE}/documents/cost/re-verify`,
body,
);
return response.data;
}
export const dcRagApiToService = {
requestDocumentTransfer,
requestDraftGeneration,
getDraftGenerationStatus,
requestCostVerification,
reTransferDocument,
reGenerateDraft,
reVerifyCost,
} satisfies DcRagApiToService;
@@ -0,0 +1,121 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import { useMutationWrapper } from "@/shared/lib";
import { aiAutoPrcsMngKeys } from "@/entities/ai-auto-prcs-mng";
import { fnrsCnsltnKeys } from "@/entities/fnrs-cnsltn";
import type { DcRagDraftStatusResponseDto } from "../model/dcrag-to-interface";
import type {
DcRagCostVerifyRequestDto,
DcRagDocumentTransferRequestDto,
DcRagDraftGenerateRequestDto,
} from "../model/dcrag-to-payload-schema";
import { dcRagApiToService } from "./dcrag-to-service";
export const dcRagApiToBaseKey = [ENDPOINT.AS_SERVICE.DCRAG_TO] as const;
const DRAFT_STATUS_POLL_INTERVAL_MS = 5_000;
function isDraftGenerationSettled(data: DcRagDraftStatusResponseDto | undefined): boolean {
if (!data) return false;
if (data.status === "COMPLETED" || data.status === "FAILED") return true;
return data.events?.some((event) => event.eventType === "COMPLETED") ?? false;
}
function invalidateAfterAiRetry(
queryClient: ReturnType<typeof useQueryClient>,
fnrsCnsltnDocNo?: string,
) {
queryClient.invalidateQueries({ queryKey: dcRagApiToBaseKey });
queryClient.invalidateQueries({ queryKey: aiAutoPrcsMngKeys.all });
if (fnrsCnsltnDocNo) {
queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.detail(fnrsCnsltnDocNo) });
queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
}
}
export function useDraftGenerationStatus(crtDmndUuid: string | null | undefined) {
return useQuery({
queryKey: [...dcRagApiToBaseKey, "getDraftGenerationStatus", crtDmndUuid],
queryFn: () => dcRagApiToService.getDraftGenerationStatus(crtDmndUuid!),
enabled: Boolean(crtDmndUuid),
refetchInterval: (query) =>
isDraftGenerationSettled(query.state.data) ? false : DRAFT_STATUS_POLL_INTERVAL_MS,
});
}
export function useRequestDocumentTransfer() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: DcRagDocumentTransferRequestDto) =>
dcRagApiToService.requestDocumentTransfer(body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: dcRagApiToBaseKey });
},
});
}
export function useRequestDraftGeneration() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: DcRagDraftGenerateRequestDto) =>
dcRagApiToService.requestDraftGeneration(body),
onSuccess: (_data, body) => {
queryClient.invalidateQueries({ queryKey: dcRagApiToBaseKey });
if (body.crtDmndUuid) {
queryClient.invalidateQueries({
queryKey: [...dcRagApiToBaseKey, "getDraftGenerationStatus", body.crtDmndUuid],
});
}
},
});
}
export function useRequestCostVerification() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: DcRagCostVerifyRequestDto) =>
dcRagApiToService.requestCostVerification(body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: dcRagApiToBaseKey });
},
});
}
export function useReTransferDocument() {
const queryClient = useQueryClient();
return useMutationWrapper({
mutationFn: (body: DcRagDocumentTransferRequestDto) =>
dcRagApiToService.reTransferDocument(body),
onSuccess: (_data, body) => {
invalidateAfterAiRetry(queryClient, body.fnrsCnsltnDocNo);
},
});
}
export function useReGenerateDraft() {
const queryClient = useQueryClient();
return useMutationWrapper({
mutationFn: (body: DcRagDraftGenerateRequestDto) => dcRagApiToService.reGenerateDraft(body),
onSuccess: (_data, body) => {
invalidateAfterAiRetry(queryClient, body.fnrsCnsltnDocNo);
if (body.crtDmndUuid) {
queryClient.invalidateQueries({
queryKey: [...dcRagApiToBaseKey, "getDraftGenerationStatus", body.crtDmndUuid],
});
}
},
});
}
export function useReVerifyCost() {
const queryClient = useQueryClient();
return useMutationWrapper({
mutationFn: (body: DcRagCostVerifyRequestDto) => dcRagApiToService.reVerifyCost(body),
onSuccess: (_data, body) => {
invalidateAfterAiRetry(queryClient, body.fnrsCnsltnDocNo);
},
});
}
+30
View File
@@ -0,0 +1,30 @@
export { dcRagApiToService } from "./api/dcrag-to-service";
export { dcRagApiToBaseKey } from "./api/queries";
export {
useDraftGenerationStatus,
useReGenerateDraft,
useRequestCostVerification,
useRequestDocumentTransfer,
useRequestDraftGeneration,
useReTransferDocument,
useReVerifyCost,
} from "./api/queries";
export type {
DcRagApiToService,
DcRagCommonResponseDto,
DcRagDraftGenerateResponseDto,
DcRagDraftStatusResponseDto,
EventInfoDto,
} from "./model/dcrag-to-interface";
export type {
DcRagCostVerifyRequestDto,
DcRagDocumentTransferRequestDto,
DcRagDraftGenerateRequestDto,
DcRagFileInfoDto,
} from "./model/dcrag-to-payload-schema";
export {
dcRagCostVerifyRequestSchema,
dcRagDocumentTransferRequestSchema,
dcRagDraftGenerateRequestSchema,
dcRagFileInfoSchema,
} from "./model/dcrag-to-payload-schema";
@@ -0,0 +1,55 @@
import type {
DcRagCostVerifyRequestDto,
DcRagDocumentTransferRequestDto,
DcRagDraftGenerateRequestDto,
} from "./dcrag-to-payload-schema";
/** 백엔드 DcRagCommonResponseDto */
export interface DcRagCommonResponseDto {
resultCode?: string;
message?: string;
requestId?: string;
}
/** 백엔드 DcRagDraftGenerateResponseDto */
export interface DcRagDraftGenerateResponseDto {
resultCode?: string;
message?: string;
crtDmndUuid?: string;
/** R:생성요청, I:생성중, C:생성완료, F:생성실패 */
crtSttsComCd?: "R" | "I" | "C" | "F";
requestId?: string;
estimatedMinutes?: number;
}
/** 백엔드 DcRagDraftStatusResponseDto.EventInfoDto */
export interface EventInfoDto {
eventType?: string;
eventMessage?: string;
occurredDt?: string;
}
/** 백엔드 DcRagDraftStatusResponseDto */
export interface DcRagDraftStatusResponseDto {
crtDmndUuid?: string;
status?: "REQUESTED" | "DOWNLOADING" | "ANALYZING" | "GENERATING" | "COMPLETED" | "FAILED";
statusName?: string;
progressPercent?: number;
events?: EventInfoDto[];
startedDt?: string;
estimatedCompletionDt?: string;
errorMessage?: string;
}
/** Controller public 메서드명·시그니처와 동일 */
export interface DcRagApiToService {
requestDocumentTransfer(body: DcRagDocumentTransferRequestDto): Promise<DcRagCommonResponseDto>;
requestDraftGeneration(
body: DcRagDraftGenerateRequestDto,
): Promise<DcRagDraftGenerateResponseDto>;
getDraftGenerationStatus(crtDmndUuid: string): Promise<DcRagDraftStatusResponseDto>;
requestCostVerification(body: DcRagCostVerifyRequestDto): Promise<DcRagCommonResponseDto>;
reTransferDocument(body: DcRagDocumentTransferRequestDto): Promise<DcRagCommonResponseDto>;
reGenerateDraft(body: DcRagDraftGenerateRequestDto): Promise<DcRagDraftGenerateResponseDto>;
reVerifyCost(body: DcRagCostVerifyRequestDto): Promise<DcRagCommonResponseDto>;
}
@@ -0,0 +1,45 @@
import { z } from "zod";
/** 백엔드 DcRagFileInfoDto */
export const dcRagFileInfoSchema = z.object({
fileId: z.string().nullish(),
fileGroupId: z.string().nullish(),
fileName: z.string().nullish(),
physicalPath: z.string().nullish(),
downloadUrl: z.string().nullish(),
fileSize: z.number().nullish(),
esntlAtchFileId: z.string().nullish(),
esntlAtchFileNm: z.string().nullish(),
});
/** 백엔드 DcRagDocumentTransferRequestDto */
export const dcRagDocumentTransferRequestSchema = z.object({
fnrsCnsltnDocNo: z.string(),
taskSeComCd: z.string().nullish(),
taskSeCdNm: z.string().nullish(),
essentialFiles: z.array(dcRagFileInfoSchema).nullish(),
etcFiles: z.array(dcRagFileInfoSchema).nullish(),
callbackUrl: z.string().nullish(),
});
/** 백엔드 DcRagDraftGenerateRequestDto */
export const dcRagDraftGenerateRequestSchema = z.object({
fnrsCnsltnDocNo: z.string(),
pwrkCnsltnDocNo: z.string(),
crtDmndUuid: z.string().nullish(),
essentialFiles: z.array(dcRagFileInfoSchema).nullish(),
etcFiles: z.array(dcRagFileInfoSchema).nullish(),
rqstrUuid: z.string().nullish(),
callbackUrl: z.string().nullish(),
});
/** 백엔드 DcRagCostVerifyRequestDto */
export const dcRagCostVerifyRequestSchema = z.object({
fnrsCnsltnDocNo: z.string(),
pwrkCnsltnDocNo: z.string(),
});
export type DcRagFileInfoDto = z.infer<typeof dcRagFileInfoSchema>;
export type DcRagDocumentTransferRequestDto = z.infer<typeof dcRagDocumentTransferRequestSchema>;
export type DcRagDraftGenerateRequestDto = z.infer<typeof dcRagDraftGenerateRequestSchema>;
export type DcRagCostVerifyRequestDto = z.infer<typeof dcRagCostVerifyRequestSchema>;
@@ -4,9 +4,11 @@ import type { PageResponse } from "@/shared/model";
import type {
DesignCnstnApplySaveRequestDto,
DesignCnstnMngSaveRequestDto,
} from "../model/design-cnstn-apply-payload-schema";
import type {
DesignCnstnMngDetResponseDto,
DesignCnstnMngResponseDto,
DesignCnstnMngSaveRequestDto,
DesignCnstnMngSearchDto,
DesignCnstnMngService,
DesignCnstnMngUpdateRequestDto,
@@ -2,10 +2,10 @@ import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { PageResponse } from "@/shared/model";
import type { DesignCnstnRvwOpnnSaveRequestDto } from "../model/design-cnstn-apply-payload-schema";
import type {
DesignCnstnRvwOpnnId,
DesignCnstnRvwOpnnResponseDto,
DesignCnstnRvwOpnnSaveRequestDto,
DesignCnstnRvwOpnnSearchDto,
DesignCnstnRvwOpnnService,
DesignCnstnRvwOpnnUpdateRequestDto,
@@ -18,11 +18,13 @@ import type {
import type {
DesignCnstnApplySaveRequestDto,
DesignCnstnMngSaveRequestDto,
DesignCnstnRvwOpnnSaveRequestDto,
} from "../model/design-cnstn-apply-payload-schema";
import type {
DesignCnstnMngSearchDto,
DesignCnstnMngUpdateRequestDto,
} from "../model/design-cnstn-mng-interface";
import type {
DesignCnstnRvwOpnnSaveRequestDto,
DesignCnstnRvwOpnnSearchDto,
DesignCnstnRvwOpnnUpdateRequestDto,
} from "../model/design-cnstn-rvw-opnn-interface";
+16 -6
View File
@@ -37,6 +37,22 @@ export {
useUpdateDesignCnstnMng,
useUpdateDesignCnstnRvwOpnn,
} from "./api/queries";
export type {
BizCstItem,
BzScaleItem,
DesignCnstnApplySaveRequestDto,
DesignCnstnMngSaveRequestDto,
DesignCnstnRvwOpnnSaveRequestDto,
EsntlAtchFileItem,
} from "./model/design-cnstn-apply-payload-schema";
export {
designCnstnApplySaveSchema,
designCnstnBizCstItemSchema,
designCnstnBzScaleItemSchema,
designCnstnEsntlAtchFileItemSchema,
designCnstnMngSaveSchema,
designCnstnRvwOpnnSaveSchema,
} from "./model/design-cnstn-apply-payload-schema";
export type {
DesignCnstnBizCstId,
DesignCnstnBizCstResponseDto,
@@ -63,21 +79,15 @@ export type {
} from "./model/design-cnstn-esntl-atch-file-interface";
export type {
AtflMngClientResponseDto,
BizCstItem,
BizCstTreeDto,
BizSclTreeDto,
BzScaleItem,
DesignCnstnApplySaveRequestDto,
DesignCnstnMngDetResponseDto,
DesignCnstnMngResponseDto,
DesignCnstnMngSaveRequestDto,
DesignCnstnMngSearchDto,
DesignCnstnMngService,
DesignCnstnMngUpdateRequestDto,
DesignCnstnRvwOpnnResponseDto,
DesignCnstnRvwOpnnSaveRequestDto,
ElaprDocPrgrsClientResponseDto,
EsntlAtchFileItem,
EsntlAtchFileMngTreeResponseDto,
} from "./model/design-cnstn-mng-interface";
export type {
@@ -0,0 +1,74 @@
import { z } from "zod";
/** 백엔드 DesignCnstnMngSaveRequestDto */
export const designCnstnMngSaveSchema = z.object({
designCnstnDocNo: z.string().nullish(),
admdstCd: z.string().nullish(),
envoSeComCd: z.string().nullish(),
taskSeComCd: z.string().nullish(),
bizNm: z.string().nullish(),
bizBgngYr: z.string().nullish(),
bizEndYr: z.string().nullish(),
bizCn: z.string().nullish(),
aplcntNm: z.string().nullish(),
aplcntMblTelno: z.string().nullish(),
aplcntOgdpNm: z.string().nullish(),
aplcntEmlAddr: z.string().nullish(),
dsgnrNm: z.string().nullish(),
dsgnrTelno: z.string().nullish(),
dsgnrOgdpNm: z.string().nullish(),
dsgnrEmlAddr: z.string().nullish(),
atchFileGroupId: z.string().nullish(),
prgrsSttsComCd: z.string().nullish(),
});
/** 백엔드 DesignCnstnApplySaveRequestDto.BzScaleItem */
export const designCnstnBzScaleItemSchema = z.object({
cnstpSeCd: z.string().nullish(),
bscPl: z.number().nullish(),
budgetReflct: z.number().nullish(),
thtiDsgn: z.number().nullish(),
});
/** 백엔드 DesignCnstnApplySaveRequestDto.BizCstItem */
export const designCnstnBizCstItemSchema = z.object({
acntgSeCd: z.string().nullish(),
bscPlanAmt: z.number().nullish(),
bgtRfltAmt: z.number().nullish(),
tstmDesignAmt: z.number().nullish(),
});
/** 백엔드 DesignCnstnApplySaveRequestDto.EsntlAtchFileItem */
export const designCnstnEsntlAtchFileItemSchema = z.object({
esntlAtchFileId: z.string().nullish(),
atchFileGroupId: z.string().nullish(),
});
/** 백엔드 DesignCnstnRvwOpnnSaveRequestDto */
export const designCnstnRvwOpnnSaveSchema = z.object({
designCnstnDocNo: z.string(),
rvwOpnnSn: z.number().nullish(),
opnrSeComCd: z.string().nullish(),
opnrNm: z.string().nullish(),
rvwRsltCn: z.string().nullish(),
rvwRsltAnmlyYn: z.string().nullish(),
atchFileGroupId: z.string().nullish(),
});
/** 백엔드 DesignCnstnApplySaveRequestDto */
export const designCnstnApplySaveSchema = z.object({
techReViewSaveCd: z.string().nullish(),
rvwStpCd: z.string().nullish(),
designCnstnMng: designCnstnMngSaveSchema,
bzScaleList: z.array(designCnstnBzScaleItemSchema).nullish(),
bizCstList: z.array(designCnstnBizCstItemSchema).nullish(),
esntlAtchFileList: z.array(designCnstnEsntlAtchFileItemSchema).nullish(),
rvwOpnnList: z.array(designCnstnRvwOpnnSaveSchema).nullish(),
});
export type DesignCnstnMngSaveRequestDto = z.infer<typeof designCnstnMngSaveSchema>;
export type BzScaleItem = z.infer<typeof designCnstnBzScaleItemSchema>;
export type BizCstItem = z.infer<typeof designCnstnBizCstItemSchema>;
export type EsntlAtchFileItem = z.infer<typeof designCnstnEsntlAtchFileItemSchema>;
export type DesignCnstnRvwOpnnSaveRequestDto = z.infer<typeof designCnstnRvwOpnnSaveSchema>;
export type DesignCnstnApplySaveRequestDto = z.infer<typeof designCnstnApplySaveSchema>;
@@ -1,22 +1,12 @@
import type {
BizCstSaveBase,
BzScaleSaveBase,
EsntlAtchFileSaveBase,
PageableParams,
PageResponse,
PbadmsApplySaveHeader,
PbadmsMngDetResponseBase,
} from "@/shared/model";
import type { PageableParams, PageResponse, PbadmsMngDetResponseBase } from "@/shared/model";
import type {
DesignCnstnRvwOpnnResponseDto,
DesignCnstnRvwOpnnSaveRequestDto,
} from "./design-cnstn-rvw-opnn-interface";
DesignCnstnApplySaveRequestDto,
DesignCnstnMngSaveRequestDto,
} from "./design-cnstn-apply-payload-schema";
import type { DesignCnstnRvwOpnnResponseDto } from "./design-cnstn-rvw-opnn-interface";
export type {
DesignCnstnRvwOpnnResponseDto,
DesignCnstnRvwOpnnSaveRequestDto,
} from "./design-cnstn-rvw-opnn-interface";
export type { DesignCnstnRvwOpnnResponseDto } from "./design-cnstn-rvw-opnn-interface";
export type {
AtflMngClientResponseDto,
BizCstTreeDto,
@@ -25,28 +15,6 @@ export type {
EsntlAtchFileMngTreeResponseDto,
} from "@/shared/model";
/** 백엔드 DesignCnstnMngSaveRequestDto */
export interface DesignCnstnMngSaveRequestDto {
designCnstnDocNo?: string;
admdstCd?: string;
envoSeComCd?: string;
taskSeComCd?: string;
bizNm?: string;
bizBgngYr?: string;
bizEndYr?: string;
bizCn?: string;
aplcntNm?: string;
aplcntMblTelno?: string;
aplcntOgdpNm?: string;
aplcntEmlAddr?: string;
dsgnrNm?: string;
dsgnrTelno?: string;
dsgnrOgdpNm?: string;
dsgnrEmlAddr?: string;
atchFileGroupId?: string;
prgrsSttsComCd?: string;
}
/** 백엔드 DesignCnstnMngUpdateRequestDto */
export type DesignCnstnMngUpdateRequestDto = Omit<DesignCnstnMngSaveRequestDto, "designCnstnDocNo">;
@@ -88,30 +56,12 @@ interface DesignCnstnMngResponseExtension {
export type DesignCnstnMngResponseDto = Omit<DesignCnstnMngSaveRequestDto, "designCnstnDocNo"> &
DesignCnstnMngResponseExtension;
/** 백엔드 DesignCnstnApplySaveRequestDto.BzScaleItem */
export type BzScaleItem = BzScaleSaveBase;
/** 백엔드 DesignCnstnApplySaveRequestDto.BizCstItem */
export type BizCstItem = BizCstSaveBase;
/** 백엔드 DesignCnstnApplySaveRequestDto.EsntlAtchFileItem */
export type EsntlAtchFileItem = EsntlAtchFileSaveBase;
/** 백엔드 DesignCnstnMngDetResponseDto */
export type DesignCnstnMngDetResponseDto = PbadmsMngDetResponseBase<
DesignCnstnMngResponseDto,
DesignCnstnRvwOpnnResponseDto
>;
/** 백엔드 DesignCnstnApplySaveRequestDto */
export interface DesignCnstnApplySaveRequestDto extends PbadmsApplySaveHeader {
designCnstnMng: DesignCnstnMngSaveRequestDto;
bzScaleList?: BzScaleItem[];
bizCstList?: BizCstItem[];
esntlAtchFileList?: EsntlAtchFileItem[];
rvwOpnnList?: DesignCnstnRvwOpnnSaveRequestDto[];
}
/** Controller public 메서드명·시그니처와 동일 */
export interface DesignCnstnMngService {
findPage(searchDto: DesignCnstnMngSearchDto): Promise<PageResponse<DesignCnstnMngResponseDto>>;
@@ -1,22 +1,13 @@
import type { PageableParams, PageResponse, RvwOpnnResponseExtension } from "@/shared/model";
import type { DesignCnstnRvwOpnnSaveRequestDto } from "./design-cnstn-apply-payload-schema";
/** 백엔드 DesignCnstnRvwOpnnId */
export interface DesignCnstnRvwOpnnId {
designCnstnMng: string;
rvwOpnnSn: number;
}
/** 백엔드 DesignCnstnRvwOpnnSaveRequestDto */
export interface DesignCnstnRvwOpnnSaveRequestDto {
designCnstnDocNo: string;
rvwOpnnSn?: number;
opnrSeComCd?: string;
opnrNm?: string;
rvwRsltCn?: string;
rvwRsltAnmlyYn?: string;
atchFileGroupId?: string;
}
/** 백엔드 DesignCnstnRvwOpnnUpdateRequestDto */
export interface DesignCnstnRvwOpnnUpdateRequestDto {
opnrSeComCd?: string;
@@ -49,9 +49,13 @@ async function remove(elaprDmndId: string): Promise<void> {
await client.delete(`${BASE}/${elaprDmndId}`);
}
async function findDocPrgrsByDocNo(elaprDocNo: string): Promise<ElaprDocPrgrsResponseDto[]> {
async function findDocPrgrsByDocNo(
elaprDocNo: string,
elaprCycl?: number,
): Promise<ElaprDocPrgrsResponseDto[]> {
const response = await client.get<ElaprDocPrgrsResponseDto[]>(
`${BASE}/sanc/docprgrs/${elaprDocNo}`,
{ params: elaprCycl != null ? { elaprCycl } : undefined },
);
return response.data;
}
+11 -11
View File
@@ -185,11 +185,16 @@ export function useElaprDmnd(elaprDmndId: string | null | undefined) {
});
}
export function useElaprDocPrgrsByDocNo(elaprDocNo: string | null | undefined) {
export function useElaprDocPrgrsByDocNo(
elaprDocNo: string | null | undefined,
options?: { elaprCycl?: number; enabled?: boolean; structuralSharing?: boolean },
) {
const elaprCycl = options?.elaprCycl;
return useQuery({
queryKey: [...elaprDmndBaseKey, "findDocPrgrsByDocNo", elaprDocNo],
queryFn: () => elaprDmndService.findDocPrgrsByDocNo(elaprDocNo!),
enabled: Boolean(elaprDocNo),
queryKey: [...elaprDmndBaseKey, "findDocPrgrsByDocNo", elaprDocNo, elaprCycl],
queryFn: () => elaprDmndService.findDocPrgrsByDocNo(elaprDocNo!, elaprCycl),
enabled: Boolean(elaprDocNo) && (options?.enabled ?? true),
structuralSharing: options?.structuralSharing ?? true,
});
}
@@ -284,13 +289,8 @@ export function useProcessElaprPrgrsStts() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
elaprPrgrsId,
body,
}: {
elaprPrgrsId: string;
body: ElaprProcessRequestDto;
}) => elaprPrgrsSttsService.process(elaprPrgrsId, body),
mutationFn: ({ elaprPrgrsId, body }: { elaprPrgrsId: string; body: ElaprProcessRequestDto }) =>
elaprPrgrsSttsService.process(elaprPrgrsId, body),
onSuccess: () => {
invalidateElaprPrgrsSttsQueries(queryClient);
},
+6
View File
@@ -41,6 +41,7 @@ export {
validateAllRowsForSave,
validateAplnRow,
validateAtrzOrder,
validateUniqueAtrzAprvr,
validateUniqueSeqNo,
} from "./lib/apln-validators";
export {
@@ -57,6 +58,7 @@ export {
buildElaprPrgrsSttsListFromApln,
buildElaprPrgrsSttsListFromAplnRows,
} from "./lib/dmnd-save-utils";
export { mapDocPrgrsToAplnRows } from "./lib/doc-prgrs-to-apln-rows";
export type { ElaprPrgrsSttsRow } from "./lib/prgrs-stts-save-utils";
export {
assignPrgrsSttsAtrzSeqNumbers,
@@ -113,6 +115,10 @@ export type { ReviewerOption } from "./model/reviewer-option";
export { AprvrPickerDialog } from "./ui/aprvr-picker-dialog";
export { ElaprAplnPickerGrid } from "./ui/elapr-apln-picker-grid";
export { ElaprApprovalDialog } from "./ui/elapr-approval-dialog";
export {
type ElaprDocPrgrsPickerConfirmPayload,
ElaprDocPrgrsPickerDialog,
} from "./ui/elapr-doc-prgrs-picker-dialog";
export { ElaprDocPrgrsStatus } from "./ui/elapr-doc-prgrs-status";
export {
type ElaprTmpltPickerConfirmPayload,
@@ -76,3 +76,16 @@ export function validateUniqueSeqNo(rows: ElaprAplnRow[]): string | null {
}
return null;
}
export function validateUniqueAtrzAprvr(rows: ElaprAplnRow[]): string | null {
const pairSet = new Set<string>();
for (const row of getVisibleRows(rows)) {
const atrz = row.atrzSeComCd?.trim();
const aprvr = row.aprvrUuid?.trim();
if (!atrz || !aprvr) continue;
const key = `${atrz}::${aprvr}`;
if (pairSet.has(key)) return "동일한 결재구분과 결재자가 중복됩니다.";
pairSet.add(key);
}
return null;
}
@@ -0,0 +1,21 @@
import type { ElaprDocPrgrsClientResponseDto } from "@/shared/model";
import type { ElaprApln } from "../model/elapr-apln-interface";
/** 문서 결재진행 → 결재선 그리드 시드 (ElaprApln 호환) */
export function mapDocPrgrsToAplnRows(
list: ElaprDocPrgrsClientResponseDto[] | undefined,
): ElaprApln[] {
if (!list?.length) return [];
return list.map((item, index) => ({
aplnId: item.elaprPrgrsId ?? `prgrs-${item.atrzSeqNo ?? index}`,
elaprTmpltId: "",
atrzSeqNo: item.atrzSeqNo,
atrzSeComCd: item.atrzSeComCd,
atrzSeComNm: item.atrzSeComNm,
aprvrUuid: item.aprvrUuid,
aprvrNm: item.aprvrNm,
aprvrUserId: item.aprvrUserId,
}));
}
@@ -22,33 +22,35 @@ function toRow(item: ElaprApln): ElaprAplnRow {
}
interface UseAplnRowsOptions {
elaprTmpltId: string | null;
seedKey: string | null;
serverRows: ElaprApln[] | undefined;
elaprTmpltId?: string | null;
}
export function createDraftRowKey(): string {
return `draft-${Date.now()}`;
}
export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) {
export function useAplnRows({ seedKey, serverRows, elaprTmpltId = "" }: UseAplnRowsOptions) {
const [rows, setRows] = useState<ElaprAplnRow[]>([]);
const [editingRowKey, setEditingRowKey] = useState<string | null>(null);
useEffect(() => {
if (!elaprTmpltId) {
if (!seedKey) {
setRows([]);
setEditingRowKey(null);
return;
}
setRows((serverRows ?? []).map(toRow));
setEditingRowKey(null);
}, [elaprTmpltId, serverRows]);
}, [seedKey, serverRows]);
const isEditingLocked = editingRowKey != null;
const visibleRows = getVisibleRows(rows);
const canAdd = Boolean(seedKey) && !isEditingLocked;
const addRow = useCallback(() => {
if (!elaprTmpltId || isEditingLocked) return;
if (!canAdd) return;
const rowKey = createDraftRowKey();
@@ -56,7 +58,7 @@ export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) {
...prev,
{
rowKey,
elaprTmpltId,
elaprTmpltId: elaprTmpltId ?? "",
atrzSeComCd: "",
aprvrUuid: "",
aprvrNm: "",
@@ -66,7 +68,7 @@ export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) {
},
]);
setEditingRowKey(rowKey);
}, [elaprTmpltId, isEditingLocked]);
}, [canAdd, elaprTmpltId]);
const startEdit = useCallback(
(rowKey: string) => {
@@ -7,8 +7,8 @@ export interface ElaprDmndSaveRequestDto {
elaprDocNo: string;
dmndTtl: string;
dmndCn: string;
elaprCycl?: number;
docPrgrsSttsComCd?: string;
elaprCycl?: number | null;
docPrgrsSttsComCd?: string | null;
elaprPrgrsSttsList?: ElaprPrgrsSttsSaveRequestDto[];
}
@@ -45,5 +45,5 @@ export interface ElaprDmndService {
update(elaprDmndId: string, body: ElaprDmndSaveRequestDto): Promise<ElaprDmndResponseDto>;
updateStatus(elaprDmndId: string, atrzSttsComCd: string): Promise<ElaprDmndResponseDto>;
remove(elaprDmndId: string): Promise<void>;
findDocPrgrsByDocNo(elaprDocNo: string): Promise<ElaprDocPrgrsResponseDto[]>;
findDocPrgrsByDocNo(elaprDocNo: string, elaprCycl?: number): Promise<ElaprDocPrgrsResponseDto[]>;
}
@@ -17,9 +17,10 @@ import {
} from "@common/components";
import { useElaprAplnPage } from "../api/queries";
import { validateAplnRow } from "../lib/apln-validators";
import { validateAplnRow, validateUniqueAtrzAprvr } from "../lib/apln-validators";
import { useAplnRows } from "../lib/use-apln-rows";
import { useDndSensors } from "../lib/use-dnd-sensors";
import type { ElaprApln } from "../model/elapr-apln-interface";
import type { ElaprAplnRow } from "../model/elapr-apln-row-types";
import type { ReviewerOption } from "../model/reviewer-option";
@@ -27,13 +28,17 @@ import { AprvrPickerDialog } from "./aprvr-picker-dialog";
import { SortableAplnPickerRow } from "./sortable-apln-picker-row";
interface ElaprAplnPickerGridProps {
elaprTmpltId: string | null;
elaprTmpltId?: string | null;
externalServerRows?: ElaprApln[];
isExternalLoading?: boolean;
onRowsChange?: (rows: ElaprAplnRow[]) => void;
onEditingLockedChange?: (locked: boolean) => void;
}
function ElaprAplnPickerGrid({
elaprTmpltId,
elaprTmpltId = null,
externalServerRows,
isExternalLoading = false,
onRowsChange,
onEditingLockedChange,
}: ElaprAplnPickerGridProps) {
@@ -41,14 +46,18 @@ function ElaprAplnPickerGrid({
const [pickingRowKey, setPickingRowKey] = useState<string | null>(null);
const alert = useAlertStore((state) => state.setMessage);
const isExternalMode = externalServerRows != null;
const aplnQuery = useElaprAplnPage({
elaprTmpltId: elaprTmpltId ?? undefined,
elaprTmpltId: !isExternalMode ? (elaprTmpltId ?? undefined) : undefined,
page: 0,
size: 200,
sort: "atrzSeqNo,asc",
});
const seedKey = isExternalMode ? "external-doc-prgrs" : elaprTmpltId;
const serverRows = isExternalMode ? externalServerRows : aplnQuery.data?.content;
const {
rows,
visibleRows,
@@ -62,8 +71,9 @@ function ElaprAplnPickerGrid({
markDeleted,
reorderRows,
} = useAplnRows({
elaprTmpltId,
serverRows: aplnQuery.data?.content,
seedKey,
serverRows,
elaprTmpltId: elaprTmpltId ?? "",
});
useEffect(() => {
@@ -75,8 +85,12 @@ function ElaprAplnPickerGrid({
}, [isEditingLocked, onEditingLockedChange]);
const sensors = useDndSensors();
const isDisabled = !elaprTmpltId;
const isReady = isExternalMode || Boolean(elaprTmpltId);
const isLoading = isExternalMode
? isExternalLoading
: aplnQuery.isPending && Boolean(elaprTmpltId);
const pickingRow = pickingRowKey ? rows.find((r) => r.rowKey === pickingRowKey) : undefined;
const excludedVisibleRows = visibleRows.filter((r) => r.atrzSeComCd !== "ATRZ01");
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
@@ -96,6 +110,12 @@ function ElaprAplnPickerGrid({
return;
}
const duplicateError = validateUniqueAtrzAprvr(rows);
if (duplicateError) {
alert(duplicateError);
return;
}
commitEdit(row.rowKey);
}
@@ -121,7 +141,7 @@ function ElaprAplnPickerGrid({
size="sm"
type="button"
color="green"
disabled={isDisabled || isEditingLocked}
disabled={!isReady || isEditingLocked}
onClick={addRow}
>
결재선 추가
@@ -129,15 +149,15 @@ function ElaprAplnPickerGrid({
</div>
</div>
{aplnQuery.isPending && elaprTmpltId ? (
{isLoading ? (
<div className="flex flex-col gap-2">
<Skeleton className="h-[4rem]" />
<Skeleton className="h-[16rem]" />
</div>
) : !elaprTmpltId ? (
) : !isReady ? (
<p className="py-8 text-center text-lg text-muted-foreground">결재서식을 선택하세요.</p>
) : visibleRows.length === 0 ? (
<p className="py-8 text-center text-lg text-muted-foreground">
) : excludedVisibleRows.length === 0 ? (
<p className="py-16 text-center text-lg text-muted-foreground">
등록된 결재선이 없습니다. [결재선 추가]로 결재자를 등록하세요.
</p>
) : (
@@ -161,10 +181,10 @@ function ElaprAplnPickerGrid({
</TableHeader>
<TableBody>
<SortableContext
items={visibleRows.map((r) => r.rowKey)}
items={excludedVisibleRows.map((r) => r.rowKey)}
strategy={verticalListSortingStrategy}
>
{visibleRows.map((row, index) => (
{excludedVisibleRows.map((row, index) => (
<SortableAplnPickerRow
key={row.rowKey}
row={row}
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useMemo, useState } from "react";
import { closestCenter, DndContext, type DragEndEvent } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
@@ -48,6 +48,7 @@ interface ElaprApprovalDialogProps {
onOpenChange: (open: boolean) => void;
elaprDocNo: string;
currentUserUuid: string;
elaprCycl?: number;
onSuccess: () => void;
}
@@ -58,6 +59,7 @@ function ElaprApprovalDialog({
onOpenChange,
elaprDocNo,
currentUserUuid,
elaprCycl,
onSuccess,
}: ElaprApprovalDialogProps) {
const queryClient = useQueryClient();
@@ -67,9 +69,16 @@ function ElaprApprovalDialog({
const [pickerOpen, setPickerOpen] = useState(false);
const [pickingRowKey, setPickingRowKey] = useState<string | null>(null);
const { data: elaprDocPrgrsList, isLoading } = useElaprDocPrgrsByDocNo(open ? elaprDocNo : null);
const { data: elaprDocPrgrsList, isLoading } = useElaprDocPrgrsByDocNo(open ? elaprDocNo : null, {
elaprCycl,
structuralSharing: false,
});
const cyclDocPrgrsList = useMemo(() => {
if (elaprCycl == null) return elaprDocPrgrsList;
return elaprDocPrgrsList?.filter((row) => Number(row.elaprCycl) === elaprCycl);
}, [elaprCycl, elaprDocPrgrsList]);
const elaprDmndId = elaprDocPrgrsList?.[0]?.elaprDmndId;
const elaprDmndId = cyclDocPrgrsList?.[0]?.elaprDmndId;
const {
rows,
@@ -85,22 +94,19 @@ function ElaprApprovalDialog({
markDeleted,
reorderRows,
} = usePrgrsSttsRows({
serverRows: elaprDocPrgrsList,
serverRows: cyclDocPrgrsList,
elaprDmndId,
});
const savePrgrsSttsMutation = useSaveElaprPrgrsStts();
const sensors = useDndSensors();
const currentUserPrgrs = findCurrentUserPendingApprovalRequest(
elaprDocPrgrsList,
currentUserUuid,
);
const currentUserPrgrs = findCurrentUserPendingApprovalRequest(cyclDocPrgrsList, currentUserUuid);
const canApprove = Boolean(currentUserPrgrs?.elaprPrgrsId);
const pickingRow = pickingRowKey ? rows.find((r) => r.rowKey === pickingRowKey) : undefined;
const isBusy = isProcessing || savePrgrsSttsMutation.isPending;
const isPrgrsSttsUnsaved = hasUnsavedPrgrsSttsChanges(rows, elaprDocPrgrsList ?? []);
const isPrgrsSttsUnsaved = hasUnsavedPrgrsSttsChanges(rows, cyclDocPrgrsList ?? []);
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
@@ -164,7 +170,7 @@ function ElaprApprovalDialog({
return;
}
const payload = buildPrgrsSttsSavePayload(rows, elaprDmndId, elaprDocPrgrsList ?? []);
const payload = buildPrgrsSttsSavePayload(rows, elaprDmndId, cyclDocPrgrsList ?? []);
if (payload.length === 0) {
alert("변경된 결재선이 없습니다.");
return;
@@ -0,0 +1,136 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { useAlertStore } from "@/shared/lib";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@common/components";
import { useElaprDocPrgrsByDocNo } from "../api/queries";
import {
getVisibleRows,
validateAllRowsForSave,
validateAtrzOrder,
validateUniqueAtrzAprvr,
validateUniqueSeqNo,
} from "../lib/apln-validators";
import { mapDocPrgrsToAplnRows } from "../lib/doc-prgrs-to-apln-rows";
import type { ElaprAplnRow } from "../model/elapr-apln-row-types";
import { ElaprAplnPickerGrid } from "./elapr-apln-picker-grid";
interface ElaprDocPrgrsPickerConfirmPayload {
aplnRows: ElaprAplnRow[];
}
interface ElaprDocPrgrsPickerDialogProps {
open: boolean;
elaprDocNo: string;
onClose: () => void;
onConfirm?: (payload: ElaprDocPrgrsPickerConfirmPayload) => void;
}
function ElaprDocPrgrsPickerDialog({
open,
elaprDocNo,
onClose,
onConfirm,
}: ElaprDocPrgrsPickerDialogProps) {
const alert = useAlertStore((state) => state.setMessage);
const [aplnRows, setAplnRows] = useState<ElaprAplnRow[]>([]);
const [isEditingLocked, setIsEditingLocked] = useState(false);
const docPrgrsQuery = useElaprDocPrgrsByDocNo(elaprDocNo, {
elaprCycl: 1,
enabled: open && Boolean(elaprDocNo),
});
const externalServerRows = useMemo(
() => mapDocPrgrsToAplnRows(docPrgrsQuery.data),
[docPrgrsQuery.data],
);
const handleRowsChange = useCallback((rows: ElaprAplnRow[]) => {
setAplnRows(rows);
}, []);
const handleEditingLockedChange = useCallback((locked: boolean) => {
setIsEditingLocked(locked);
}, []);
function handleOpenChange(isOpen: boolean) {
if (!isOpen) {
setAplnRows([]);
setIsEditingLocked(false);
onClose();
}
}
function handleConfirm() {
const rowsError = validateAllRowsForSave(aplnRows, isEditingLocked);
if (rowsError) {
alert(rowsError);
return;
}
const orderError = validateAtrzOrder(aplnRows);
if (orderError) {
alert(orderError);
return;
}
const seqError = validateUniqueSeqNo(aplnRows);
if (seqError) {
alert(seqError);
return;
}
const atrzAprvrError = validateUniqueAtrzAprvr(aplnRows);
if (atrzAprvrError) {
alert(atrzAprvrError);
return;
}
const visibleRows = getVisibleRows(aplnRows);
onConfirm?.({ aplnRows: visibleRows });
setAplnRows([]);
setIsEditingLocked(false);
onClose();
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-[80rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>결재선 선택</DialogTitle>
</DialogHeader>
<ElaprAplnPickerGrid
externalServerRows={externalServerRows}
isExternalLoading={docPrgrsQuery.isPending}
onRowsChange={handleRowsChange}
onEditingLockedChange={handleEditingLockedChange}
/>
<DialogFooter>
<Button type="button" color="white" onClick={() => handleOpenChange(false)}>
닫기
</Button>
<Button type="button" disabled={isEditingLocked} onClick={handleConfirm}>
확인
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { ElaprDocPrgrsPickerDialog };
export type { ElaprDocPrgrsPickerConfirmPayload };
@@ -1,6 +1,6 @@
"use client";
import { Fragment } from "react";
import { Fragment, useEffect, useMemo, useState } from "react";
import { ChevronRight } from "lucide-react";
@@ -8,7 +8,7 @@ import { dayjs, useUserStore } from "@/shared/lib";
import type { ElaprDocPrgrsClientResponseDto } from "@/shared/model";
import { SectionCollapsible } from "@/shared/ui";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { cn, Skeleton } from "@common/components";
import { cn, Skeleton, Tabs, TabsContent, TabsList, TabsTrigger } from "@common/components";
import { useElaprDocPrgrsByDocNo } from "../api/queries";
import { getApproverStatus, sortByAtrzSeqNo } from "../lib/approval-utils";
@@ -67,10 +67,46 @@ function ElaprDocPrgrsStep({ item }: { item: ElaprDocPrgrsClientResponseDto }) {
);
}
function ElaprDocPrgrsStepList({ steps }: { steps: ElaprDocPrgrsClientResponseDto[] }) {
if (steps.length === 0) {
return <p className="px-4 py-6 text-xl text-muted-foreground">결재선이 없습니다.</p>;
}
return (
<div className="flex flex-wrap items-stretch justify-start gap-4 overflow-x-auto">
{steps.map((item, index) => (
<Fragment key={item.elaprPrgrsId ?? `${item.atrzSeqNo}-${item.aprvrUuid ?? index}`}>
{index > 0 && (
<div className="flex items-center">
<ChevronRight className="size-8 text-muted-foreground" />
</div>
)}
<ElaprDocPrgrsStep item={item} />
</Fragment>
))}
</div>
);
}
function ElaprDocPrgrsStatus({ elaprDocNo }: ElaprDocPrgrsStatusProps) {
const userInfo = useUserStore((state) => state.userInfo);
const { data, isLoading } = useElaprDocPrgrsByDocNo(elaprDocNo);
const steps = sortByAtrzSeqNo(data ?? []);
const [activeCycl, setActiveCycl] = useState<"1" | "2">("1");
const { cycl1Steps, cycl2Steps, hasCycl2 } = useMemo(() => {
const rows = data ?? [];
const cycl1 = sortByAtrzSeqNo(rows.filter((item) => Number(item.elaprCycl ?? 1) !== 2));
const cycl2 = sortByAtrzSeqNo(rows.filter((item) => Number(item.elaprCycl) === 2));
return {
cycl1Steps: cycl1,
cycl2Steps: cycl2,
hasCycl2: cycl2.length > 0,
};
}, [data]);
useEffect(() => {
setActiveCycl(hasCycl2 ? "2" : "1");
}, [hasCycl2]);
if (userInfo?.dtlInstCd !== "ROLE_USER01") {
return null;
@@ -88,24 +124,40 @@ function ElaprDocPrgrsStatus({ elaprDocNo }: ElaprDocPrgrsStatusProps) {
);
}
if (steps.length === 0) {
if (cycl1Steps.length === 0 && cycl2Steps.length === 0) {
return null;
}
return (
<SectionCollapsible title="결재현황">
<div className="flex flex-wrap items-stretch justify-start gap-4 overflow-x-auto p-4">
{steps.map((item, index) => (
<Fragment key={item.elaprPrgrsId ?? `${item.atrzSeqNo}-${item.aprvrUuid ?? index}`}>
{index > 0 && (
<div className="flex items-center">
{<ChevronRight className="size-8 text-muted-foreground" />}
</div>
)}
<ElaprDocPrgrsStep item={item} />
</Fragment>
))}
</div>
{hasCycl2 ? (
<Tabs
value={activeCycl}
onValueChange={(value) => setActiveCycl(value as "1" | "2")}
className="p-2"
>
<TabsList>
<TabsTrigger value="1" className="!px-6 !py-3 text-xl">
재원협의
</TabsTrigger>
<TabsTrigger value="2" className="!px-6 !py-3 text-xl">
실무협의서
</TabsTrigger>
</TabsList>
<TabsContent value="1" className="mt-2 border-none p-0 shadow-none">
<div className="-mx-2">
<ElaprDocPrgrsStepList steps={cycl1Steps} />
</div>
</TabsContent>
<TabsContent value="2" className="mt-2 border-none p-0 shadow-none">
<div className="-mx-2">
<ElaprDocPrgrsStepList steps={cycl2Steps} />
</div>
</TabsContent>
</Tabs>
) : (
<ElaprDocPrgrsStepList steps={cycl1Steps} />
)}
</SectionCollapsible>
);
}
@@ -17,6 +17,7 @@ import {
getVisibleRows,
validateAllRowsForSave,
validateAtrzOrder,
validateUniqueAtrzAprvr,
validateUniqueSeqNo,
} from "../lib/apln-validators";
import type { ElaprAplnRow } from "../model/elapr-apln-row-types";
@@ -106,6 +107,12 @@ function ElaprTmpltPickerDialog({
return;
}
const atrzAprvrError = validateUniqueAtrzAprvr(aplnRows);
if (atrzAprvrError) {
alert(atrzAprvrError);
return;
}
const visibleRows = getVisibleRows(aplnRows);
const payload: ElaprTmpltPickerConfirmPayload = {
@@ -3,15 +3,22 @@ import { ENDPOINT } from "@/shared/config";
import type { PageResponse } from "@/shared/model";
import type {
DocPrgrsUpdateRequestDto,
FnrsCnsltnApplySaveRequestDto,
FnrsCnsltnMngSaveRequestDto,
} from "../model/fnrs-cnsltn-apply-payload-schema";
import type {
DocPrgrsUpdateRequestDto,
FnrsCnsltnFileVrfcRsltResponseDto,
FnrsCnsltnMngDetResponseDto,
FnrsCnsltnMngResponseDto,
FnrsCnsltnMngSaveRequestDto,
FnrsCnsltnMngSearchDto,
FnrsCnsltnMngService,
FnrsCnsltnMngUpdateRequestDto,
} from "../model/fnrs-cnsltn-interface";
import type {
EnvoActnReviewReqDto,
FnrsExamineRequestDto,
} from "../model/fnrs-cnsltn-payload-schema";
const BASE = ENDPOINT.AS_SERVICE.FNRS_CNSLTN_MNG;
@@ -51,6 +58,16 @@ async function techReview(id: string, body: FnrsCnsltnApplySaveRequestDto): Prom
await client.post<void>(`${BASE}/${id}/tech-review`, body);
}
async function requestExamine(docNo: string, body: FnrsExamineRequestDto): Promise<string> {
const response = await client.post<string>(`${BASE}/${docNo}/request-examine`, body);
return response.data;
}
async function requestActnReview(docNo: string, body: EnvoActnReviewReqDto): Promise<string> {
const response = await client.post<string>(`${BASE}/${docNo}/request-actn-review`, body);
return response.data;
}
async function submitSupplement(id: string, body: FnrsCnsltnApplySaveRequestDto): Promise<string> {
const response = await client.post<string>(`${BASE}/${id}/submitsupplement`, body);
return response.data;
@@ -68,6 +85,15 @@ async function remove(fnrsCnsltnDocNo: string): Promise<void> {
await client.delete<void>(`${BASE}/${fnrsCnsltnDocNo}`);
}
async function findFileVrfcResults(
fnrsCnsltnDocNo: string,
): Promise<FnrsCnsltnFileVrfcRsltResponseDto[]> {
const response = await client.get<FnrsCnsltnFileVrfcRsltResponseDto[]>(
`${BASE}/${fnrsCnsltnDocNo}/file-vrfc-results`,
);
return response.data;
}
async function changePrgrsSttsComCd(body: DocPrgrsUpdateRequestDto): Promise<void> {
await client.post<void>(`${BASE}/doc/updatePrgrsStts`, body);
}
@@ -79,8 +105,11 @@ export const fnrsCnsltnMngService = {
saveApply,
update,
techReview,
requestExamine,
requestActnReview,
submitSupplement,
submitReqSupplement,
remove,
findFileVrfcResults,
changePrgrsSttsComCd,
} satisfies FnrsCnsltnMngService;
@@ -1,12 +1,18 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
DocPrgrsUpdateRequestDto,
FnrsCnsltnApplySaveRequestDto,
FnrsCnsltnMngSaveRequestDto,
} from "../model/fnrs-cnsltn-apply-payload-schema";
import type {
DocPrgrsUpdateRequestDto,
FnrsCnsltnMngSearchDto,
FnrsCnsltnMngUpdateRequestDto,
} from "../model/fnrs-cnsltn-interface";
import type {
EnvoActnReviewReqDto,
FnrsExamineRequestDto,
} from "../model/fnrs-cnsltn-payload-schema";
import { fnrsCnsltnMngService } from "./fnrs-cnsltn-service";
@@ -14,6 +20,7 @@ export const fnrsCnsltnKeys = {
all: ["fnrs-cnsltn"] as const,
list: () => [...fnrsCnsltnKeys.all, "list"] as const,
detail: (id: string) => [...fnrsCnsltnKeys.all, "detail", id] as const,
fileVrfcResults: (id: string) => [...fnrsCnsltnKeys.all, "fileVrfcResults", id] as const,
};
export function useFnrsCnsltnMngPage(params: FnrsCnsltnMngSearchDto) {
@@ -31,6 +38,14 @@ export function useFnrsCnsltnMng(fnrsCnsltnDocNo: string | null | undefined) {
});
}
export function useFnrsCnsltnFileVrfcResults(fnrsCnsltnDocNo: string | null | undefined) {
return useQuery({
queryKey: fnrsCnsltnKeys.fileVrfcResults(fnrsCnsltnDocNo!),
queryFn: () => fnrsCnsltnMngService.findFileVrfcResults(fnrsCnsltnDocNo!),
enabled: Boolean(fnrsCnsltnDocNo),
});
}
export function useSaveFnrsCnsltnMng() {
const queryClient = useQueryClient();
return useMutation({
@@ -84,6 +99,34 @@ export function useTechReviewFnrsCnsltnMng() {
});
}
export function useRequestExamineFnrsCnsltnMng() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ docNo, body }: { docNo: string; body: FnrsExamineRequestDto }) =>
fnrsCnsltnMngService.requestExamine(docNo, body),
onSuccess: (_data, { docNo }) => {
queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
queryClient.invalidateQueries({
queryKey: fnrsCnsltnKeys.detail(docNo),
});
},
});
}
export function useRequestActnReviewFnrsCnsltnMng() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ docNo, body }: { docNo: string; body: EnvoActnReviewReqDto }) =>
fnrsCnsltnMngService.requestActnReview(docNo, body),
onSuccess: (_data, { docNo }) => {
queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
queryClient.invalidateQueries({
queryKey: fnrsCnsltnKeys.detail(docNo),
});
},
});
}
export function useSubmitSupplementFnrsCnsltnMng() {
const queryClient = useQueryClient();
return useMutation({
+35 -9
View File
@@ -3,8 +3,11 @@ export { fnrsCnsltnKeys } from "./api/queries";
export {
useChangePrgrsSttsComCd,
useDeleteFnrsCnsltnMng,
useFnrsCnsltnFileVrfcResults,
useFnrsCnsltnMng,
useFnrsCnsltnMngPage,
useRequestActnReviewFnrsCnsltnMng,
useRequestExamineFnrsCnsltnMng,
useSaveApplyFnrsCnsltnMng,
useSaveFnrsCnsltnMng,
useSubmitReqSupplementFnrsCnsltnMng,
@@ -13,27 +16,50 @@ export {
useUpdateFnrsCnsltnMng,
} from "./api/queries";
export type {
AtflMngClientResponseDto,
BizCstItem,
BizCstTreeDto,
BizSclTreeDto,
BzScaleItem,
CklstResultItem,
EsntlAtchFileItem,
FnrsCnsltnApplySaveRequestDto,
FnrsCnsltnMngSaveRequestDto,
FnrsCnsltnRvwOpnnSaveRequestDto,
FnrsCnsltnSplmntDmndItem,
FnrsSharItem,
} from "./model/fnrs-cnsltn-apply-payload-schema";
export {
fnrsCnsltnApplySaveSchema,
fnrsCnsltnBizCstItemSchema,
fnrsCnsltnBzScaleItemSchema,
fnrsCnsltnCklstResultItemSchema,
fnrsCnsltnEsntlAtchFileItemSchema,
fnrsCnsltnMngSaveSchema,
fnrsCnsltnRvwOpnnSaveSchema,
fnrsCnsltnSplmntDmndItemSchema,
fnrsSharItemSchema,
} from "./model/fnrs-cnsltn-apply-payload-schema";
export type {
AtflMngClientResponseDto,
BizCstTreeDto,
BizSclTreeDto,
CklstRvwRsltTreeDto,
DocPrgrsUpdateRequestDto,
ElaprDocPrgrsClientResponseDto,
EsntlAtchFileItem,
EsntlAtchFileMngTreeResponseDto,
FnrsCnsltnApplySaveRequestDto,
FnrsCnsltnFileVrfcRsltResponseDto,
FnrsCnsltnMngDetResponseDto,
FnrsCnsltnMngResponseDto,
FnrsCnsltnMngSaveRequestDto,
FnrsCnsltnMngSearchDto,
FnrsCnsltnMngService,
FnrsCnsltnMngUpdateRequestDto,
FnrsCnsltnRvwOpnnResponseDto,
FnrsCnsltnRvwOpnnSaveRequestDto,
FnrsCnsltnSplmntDmndItem,
FnrsCnsltnSplmntDmndResponseDto,
FnrsSharItem,
RagIndexingInfoDto,
} from "./model/fnrs-cnsltn-interface";
export type {
EnvoActnReviewReqDto,
FnrsExamineRequestDto,
} from "./model/fnrs-cnsltn-payload-schema";
export {
envoActnReviewReqSchema,
fnrsExamineRequestSchema,
} from "./model/fnrs-cnsltn-payload-schema";
@@ -0,0 +1,120 @@
import { z } from "zod";
/** 백엔드 FnrsCnsltnMngSaveRequestDto */
export const fnrsCnsltnMngSaveSchema = z.object({
fnrsCnsltnDocNo: z.string().nullish(),
admdstCd: z.string().nullish(),
envoSeComCd: z.string().nullish(),
taskSeComCd: z.string().nullish(),
cfmtnBgtBizNo: z.string().nullish(),
bgtAplyYr: z.string().nullish(),
bizNm: z.string().nullish(),
bizBgngYr: z.string().nullish(),
bizEndYr: z.string().nullish(),
bizCn: z.string().nullish(),
aplcntNm: z.string().nullish(),
aplcntMblTelno: z.string().nullish(),
aplcntOgdpNm: z.string().nullish(),
aplcntEmlAddr: z.string().nullish(),
dsgnrNm: z.string().nullish(),
dsgnrTelno: z.string().nullish(),
dsgnrOgdpNm: z.string().nullish(),
dsgnrEmlAddr: z.string().nullish(),
atchFileGroupId: z.string().nullish(),
sharAtchFileGroupId: z.string().nullish(),
prgrsSttsComCd: z.string().nullish(),
rtlztnFcltSeComCd: z.string().nullish(),
rtlztnUsgSeComCd: z.string().nullish(),
});
/** 백엔드 FnrsCnsltnApplySaveRequestDto.BzScaleItem */
export const fnrsCnsltnBzScaleItemSchema = z.object({
cnstpSeCd: z.string().nullish(),
bscPl: z.number().nullish(),
budgetReflct: z.number().nullish(),
thtiDsgn: z.number().nullish(),
});
/** 백엔드 FnrsCnsltnApplySaveRequestDto.BizCstItem */
export const fnrsCnsltnBizCstItemSchema = z.object({
acntgSeCd: z.string().nullish(),
bscPlanAmt: z.number().nullish(),
bgtRfltAmt: z.number().nullish(),
tstmDesignAmt: z.number().nullish(),
});
/** 백엔드 FnrsCnsltnApplySaveRequestDto.CklstResultItem */
export const fnrsCnsltnCklstResultItemSchema = z.object({
cklstRvwSn: z.number().nullish(),
cklstArtclSn: z.number().nullish(),
cklstSn: z.number().nullish(),
rvwStpComCd: z.string().nullish(),
selected: z.enum(["Y", "N"]).nullish(),
picRvwCn: z.string().nullish(),
});
/** 백엔드 FnrsCnsltnApplySaveRequestDto.EsntlAtchFileItem */
export const fnrsCnsltnEsntlAtchFileItemSchema = z.object({
esntlAtchFileId: z.string().nullish(),
atchFileGroupId: z.string().nullish(),
});
/** 백엔드 FnrsCnsltnApplySaveRequestDto.FnrsCnsltnSplmntDmndItem */
export const fnrsCnsltnSplmntDmndItemSchema = z.object({
splmntDmndSn: z.number().nullish(),
splmntArtclComCd: z.string().nullish(),
splmntDmndCn: z.string().nullish(),
splmntDmndDt: z.string().nullish(),
splmntDmndAtchFileGroupId: z.string().nullish(),
splmntCn: z.string().nullish(),
splmntPicNm: z.string().nullish(),
splmntCmptnDt: z.string().nullish(),
splmntAtchFileGroupId: z.string().nullish(),
cmptnYn: z.string().nullish(),
});
/** 백엔드 FnrsCnsltnRvwOpnnSaveRequestDto */
export const fnrsCnsltnRvwOpnnSaveSchema = z.object({
fnrsCnsltnDocNo: z.string(),
rvwOpnnSn: z.number().nullish(),
opnrSeComCd: z.string().nullish(),
opnrNm: z.string().nullish(),
rvwRsltCn: z.string().nullish(),
rvwRsltAnmlyYn: z.string().nullish(),
atchFileGroupId: z.string().nullish(),
});
/** 백엔드 FnrsCnsltnApplySaveRequestDto.FnrsSharItem */
export const fnrsSharItemSchema = z.object({
fnrsSharSeCd: z.string().nullish(),
exstnAmt: z.number().nullish(),
chgRqtAmt: z.number().nullish(),
ajmtAmt: z.number().nullish(),
rmrkCn: z.string().nullish(),
envoRmrkCn: z.string().nullish(),
});
/** 백엔드 FnrsCnsltnApplySaveRequestDto */
export const fnrsCnsltnApplySaveSchema = z.object({
techReViewSaveCd: z.string().nullish(),
rvwStpCd: z.string().nullish(),
fnrsCnsltnMng: fnrsCnsltnMngSaveSchema,
bzScaleList: z.array(fnrsCnsltnBzScaleItemSchema).nullish(),
bizCstList: z.array(fnrsCnsltnBizCstItemSchema).nullish(),
cklstResultList: z.array(fnrsCnsltnCklstResultItemSchema).nullish(),
cklstResultMap: z.record(z.array(fnrsCnsltnCklstResultItemSchema)).nullish(),
esntlAtchFileList: z.array(fnrsCnsltnEsntlAtchFileItemSchema).nullish(),
rvwOpnnList: z.array(fnrsCnsltnRvwOpnnSaveSchema).nullish(),
splmntDmndList: z.array(fnrsCnsltnSplmntDmndItemSchema).nullish(),
fnrsSharList: z.array(fnrsSharItemSchema).nullish(),
});
export type FnrsCnsltnMngSaveRequestDto = z.infer<typeof fnrsCnsltnMngSaveSchema>;
export type BzScaleItem = z.infer<typeof fnrsCnsltnBzScaleItemSchema>;
export type BizCstItem = z.infer<typeof fnrsCnsltnBizCstItemSchema>;
export type CklstResultItem = z.infer<typeof fnrsCnsltnCklstResultItemSchema>;
export type EsntlAtchFileItem = z.infer<typeof fnrsCnsltnEsntlAtchFileItemSchema>;
export type FnrsCnsltnSplmntDmndItem = z.infer<typeof fnrsCnsltnSplmntDmndItemSchema>;
export type FnrsCnsltnRvwOpnnSaveRequestDto = z.infer<typeof fnrsCnsltnRvwOpnnSaveSchema>;
export type FnrsSharItem = z.infer<typeof fnrsSharItemSchema>;
export type FnrsCnsltnApplySaveRequestDto = z.infer<typeof fnrsCnsltnApplySaveSchema>;
@@ -1,21 +1,25 @@
import type {
AtflMngClientResponseDto,
BizCstSaveBase,
BzScaleSaveBase,
CklstResultSaveBase,
DocPrgrsUpdateRequestDto,
EsntlAtchFileSaveBase,
PageableParams,
PageResponse,
PbadmsApplySaveHeader,
PbadmsMngDetResponseBase,
RvwOpnnResponseExtension,
RvwOpnnSaveBase,
SplmntDmndResponseExtension,
SplmntDmndSaveBase,
} from "@/shared/model";
import type { FnrsSharBizCstTreeDto } from "@/entities/fnrs-shar-se";
import type {
FnrsCnsltnApplySaveRequestDto,
FnrsCnsltnMngSaveRequestDto,
FnrsCnsltnRvwOpnnSaveRequestDto,
FnrsCnsltnSplmntDmndItem,
} from "./fnrs-cnsltn-apply-payload-schema";
import type {
EnvoActnReviewReqDto,
FnrsExamineRequestDto,
} from "./fnrs-cnsltn-payload-schema";
export type {
AtflMngClientResponseDto,
BizCstTreeDto,
@@ -26,33 +30,6 @@ export type {
EsntlAtchFileMngTreeResponseDto,
} from "@/shared/model";
/** 백엔드 FnrsCnsltnMngSaveRequestDto */
export interface FnrsCnsltnMngSaveRequestDto {
fnrsCnsltnDocNo?: string;
admdstCd?: string;
envoSeComCd?: string;
taskSeComCd?: string;
cfmtnBgtBizNo?: string;
bgtAplyYr?: string;
bizNm?: string;
bizBgngYr?: string;
bizEndYr?: string;
bizCn?: string;
aplcntNm?: string;
aplcntMblTelno?: string;
aplcntOgdpNm?: string;
aplcntEmlAddr?: string;
dsgnrNm?: string;
dsgnrTelno?: string;
dsgnrOgdpNm?: string;
dsgnrEmlAddr?: string;
atchFileGroupId?: string;
sharAtchFileGroupId?: string;
prgrsSttsComCd?: string;
rtlztnFcltSeComCd?: string;
rtlztnUsgSeComCd?: string;
}
/** 백엔드 FnrsCnsltnMngUpdateRequestDto */
export type FnrsCnsltnMngUpdateRequestDto = Omit<FnrsCnsltnMngSaveRequestDto, "fnrsCnsltnDocNo">;
@@ -79,6 +56,8 @@ export interface FnrsCnsltnMngSearchDto extends PageableParams {
atchFileGroupId?: string;
sharAtchFileGroupId?: string;
prgrsSttsComCd?: string;
rtlztnFcltSeComCd?: string;
rtlztnUsgSeComCd?: string;
}
interface FnrsCnsltnMngResponseExtension {
@@ -86,6 +65,10 @@ interface FnrsCnsltnMngResponseExtension {
envoSeCdNm?: string;
taskSeCdNm?: string;
prgrsSttsCdNm?: string;
beforePrgrsSttsComCd?: string;
beforePrgrsSttsCdNm?: string;
rtlztnFcltSeComNm?: string;
rtlztnUsgSeComNm?: string;
regDt?: string;
rgtr?: string;
rgtrNm?: string;
@@ -99,34 +82,6 @@ interface FnrsCnsltnMngResponseExtension {
export type FnrsCnsltnMngResponseDto = Omit<FnrsCnsltnMngSaveRequestDto, "fnrsCnsltnDocNo"> &
FnrsCnsltnMngResponseExtension;
export type BzScaleItem = BzScaleSaveBase;
export type BizCstItem = BizCstSaveBase;
export interface CklstResultItem extends CklstResultSaveBase {
selected?: string;
}
export type EsntlAtchFileItem = EsntlAtchFileSaveBase;
export type FnrsCnsltnSplmntDmndItem = SplmntDmndSaveBase;
/** 백엔드 FnrsCnsltnApplySaveRequestDto.FnrsSharItem */
export interface FnrsSharItem {
fnrsSharSeCd?: string;
exstnAmt?: number;
chgRqtAmt?: number;
ajmtAmt?: number;
rmrkCn?: string;
envoRmrkCn?: string;
}
/** 백엔드 FnrsCnsltnRvwOpnnSaveRequestDto */
export interface FnrsCnsltnRvwOpnnSaveRequestDto extends RvwOpnnSaveBase {
fnrsCnsltnDocNo: string;
rvwOpnnSn: number;
}
/** 백엔드 FnrsCnsltnRvwOpnnResponseDto */
export type FnrsCnsltnRvwOpnnResponseDto = Partial<
Pick<FnrsCnsltnRvwOpnnSaveRequestDto, "fnrsCnsltnDocNo" | "rvwOpnnSn">
@@ -140,6 +95,14 @@ export type FnrsCnsltnSplmntDmndResponseDto = FnrsCnsltnSplmntDmndItem &
fnrsCnsltnDocNo?: string;
};
/** 백엔드 RagIndexingInfoDto */
export interface RagIndexingInfoDto {
isInProgress: boolean;
elapsedSeconds?: number;
estimatedRemainingSeconds?: number;
message?: string;
}
/** 백엔드 FnrsCnsltnMngDetResponseDto */
export type FnrsCnsltnMngDetResponseDto = PbadmsMngDetResponseBase<
FnrsCnsltnMngResponseDto,
@@ -148,19 +111,23 @@ export type FnrsCnsltnMngDetResponseDto = PbadmsMngDetResponseBase<
> & {
fnrsSharList?: FnrsSharBizCstTreeDto[];
sharAtchFileList?: AtflMngClientResponseDto[];
ragIndexingInfo?: RagIndexingInfoDto | null;
};
/** 백엔드 FnrsCnsltnApplySaveRequestDto */
export interface FnrsCnsltnApplySaveRequestDto extends PbadmsApplySaveHeader {
fnrsCnsltnMng: FnrsCnsltnMngSaveRequestDto;
bzScaleList?: BzScaleItem[];
bizCstList?: BizCstItem[];
cklstResultList?: CklstResultItem[];
cklstResultMap?: Record<string, CklstResultItem[]>;
esntlAtchFileList?: EsntlAtchFileItem[];
rvwOpnnList?: FnrsCnsltnRvwOpnnSaveRequestDto[];
splmntDmndList?: FnrsCnsltnSplmntDmndItem[];
fnrsSharList?: FnrsSharItem[];
/** 백엔드 FnrsCnsltnFileVrfcRsltResponseDto */
export interface FnrsCnsltnFileVrfcRsltResponseDto {
fileSeComCd?: string;
fileSeNm?: string;
esntlAtchFileId?: string;
atchFileGroupId?: string;
atflId?: string;
orgnlFileNm?: string;
atflFlieSz?: number;
vrfcSttsComCd?: string;
vrfcSttsComNm?: string;
vrfcRsltCn?: string;
vrfcDt?: string;
regDt?: string;
}
/** Controller public 메서드명·시그니처와 동일 */
@@ -171,8 +138,11 @@ export interface FnrsCnsltnMngService {
saveApply(body: FnrsCnsltnApplySaveRequestDto): Promise<string>;
update(fnrsCnsltnDocNo: string, body: FnrsCnsltnMngUpdateRequestDto): Promise<string>;
techReview(id: string, body: FnrsCnsltnApplySaveRequestDto): Promise<void>;
requestExamine(docNo: string, body: FnrsExamineRequestDto): Promise<string>;
requestActnReview(docNo: string, body: EnvoActnReviewReqDto): Promise<string>;
submitSupplement(id: string, body: FnrsCnsltnApplySaveRequestDto): Promise<string>;
submitReqSupplement(id: string, body: FnrsCnsltnApplySaveRequestDto): Promise<string>;
remove(fnrsCnsltnDocNo: string): Promise<void>;
findFileVrfcResults(fnrsCnsltnDocNo: string): Promise<FnrsCnsltnFileVrfcRsltResponseDto[]>;
changePrgrsSttsComCd(body: DocPrgrsUpdateRequestDto): Promise<void>;
}
@@ -0,0 +1,14 @@
import { z } from "zod";
/** 백엔드 FnrsExamineRequestDto */
export const fnrsExamineRequestSchema = z.object({
prgrsSttsComCd: z.string().nullish(),
});
/** 백엔드 EnvoActnReviewReqDto */
export const envoActnReviewReqSchema = z.object({
specialRqstCn: z.string().nullish(),
});
export type FnrsExamineRequestDto = z.infer<typeof fnrsExamineRequestSchema>;
export type EnvoActnReviewReqDto = z.infer<typeof envoActnReviewReqSchema>;
@@ -2,13 +2,15 @@ import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { PageResponse } from "@/shared/model";
import type {
InstlSncnApplySaveRequestDto,
InstlSncnMngSaveRequestDto,
} from "../model/instl-sncn-apply-payload-schema";
import type {
AdstZoneResponseDto,
DocPrgrsUpdateRequestDto,
InstlSncnApplySaveRequestDto,
InstlSncnMngDetResponse,
InstlSncnMngResponseDto,
InstlSncnMngSaveRequestDto,
InstlSncnMngSearchParams,
InstlSncnMngService,
InstlSncnMngUpdateRequestDto,
@@ -3,9 +3,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import type {
DocPrgrsUpdateRequestDto,
InstlSncnApplySaveRequestDto,
InstlSncnMngSaveRequestDto,
} from "../model/instl-sncn-apply-payload-schema";
import type {
DocPrgrsUpdateRequestDto,
InstlSncnMngSearchParams,
InstlSncnMngUpdateRequestDto,
} from "../model/instl-sncn-interface";
+20 -8
View File
@@ -9,6 +9,26 @@ export {
useInstlSncnSubmitSupplement,
useInstlSncnTechReview,
} from "./api/queries";
export type {
InstlSncnApplySaveRequestDto,
InstlSncnBizCstSaveRequestDto,
InstlSncnBzScaleSaveDto,
InstlSncnCklstResultSaveDto,
InstlSncnEsntlAtchFileSaveDto,
InstlSncnMngSaveRequestDto,
InstlSncnRvwOpnnSaveRequestDto,
InstlSncnSplmntDmndSaveDto,
} from "./model/instl-sncn-apply-payload-schema";
export {
instlSncnApplySaveSchema,
instlSncnBizCstItemSchema,
instlSncnBzScaleItemSchema,
instlSncnCklstResultItemSchema,
instlSncnEsntlAtchFileItemSchema,
instlSncnMngSaveSchema,
instlSncnRvwOpnnSaveSchema,
instlSncnSplmntDmndItemSchema,
} from "./model/instl-sncn-apply-payload-schema";
export {
type AdstZoneResponseDto,
type AtflMngClientResponseDto,
@@ -19,18 +39,10 @@ export {
type ElaprDocPrgrsClientResponseDto,
type EsntlAtchFileMngTreeResponseDto,
INSTL_SNCN_REGISTER_SELECTION_KEY,
type InstlSncnApplySaveRequestDto,
type InstlSncnBizCstSaveRequestDto,
type InstlSncnBzScaleSaveDto,
type InstlSncnCklstResultSaveDto,
type InstlSncnEsntlAtchFileSaveDto,
type InstlSncnMngDetResponse,
type InstlSncnMngResponseDto,
type InstlSncnMngSaveRequestDto,
type InstlSncnMngSearchParams,
type InstlSncnRvwOpnnResponseDto,
type InstlSncnRvwOpnnSaveRequestDto,
type InstlSncnSplmntDmndResponseDto,
type InstlSncnSplmntDmndSaveDto,
type ReviewStepCode,
} from "./model/instl-sncn-interface";
@@ -0,0 +1,108 @@
import { z } from "zod";
/** 백엔드 InstlSncnMngSaveRequestDto */
export const instlSncnMngSaveSchema = z.object({
instlSncnDocNo: z.string().nullish(),
admdstCd: z.string().nullish(),
envoSeComCd: z.string().nullish(),
sncnSeComCd: z.string().nullish(),
taskSeComCd: z.string().nullish(),
bgtBizCd: z.string().nullish(),
bgtAplyYr: z.string().nullish(),
bizNm: z.string().nullish(),
bizBgngYr: z.string().nullish(),
bizEndYr: z.string().nullish(),
bizCn: z.string().nullish(),
aplcntNm: z.string().nullish(),
aplcntMblTelno: z.string().nullish(),
aplcntOgdpNm: z.string().nullish(),
aplcntEmlAddr: z.string().nullish(),
dsgnrNm: z.string().nullish(),
dsgnrTelno: z.string().nullish(),
dsgnrOgdpNm: z.string().nullish(),
dsgnrEmlAddr: z.string().nullish(),
atchFileGroupId: z.string().nullish(),
prgrsSttsComCd: z.string().nullish(),
rtlztnFcltSeComCd: z.string().nullish(),
rtlztnUsgSeComCd: z.string().nullish(),
});
/** 백엔드 InstlSncnApplySaveRequestDto.BzScaleItem (diff는 API 미전송) */
export const instlSncnBzScaleItemSchema = z.object({
cnstpSeCd: z.string().nullish(),
bscPl: z.number().nullish(),
budgetReflct: z.number().nullish(),
thtiDsgn: z.number().nullish(),
});
/** 백엔드 InstlSncnApplySaveRequestDto.BizCstItem */
export const instlSncnBizCstItemSchema = z.object({
acntgSeCd: z.string().nullish(),
bscPlanAmt: z.number().nullish(),
bgtRfltAmt: z.number().nullish(),
tstmDesignAmt: z.number().nullish(),
});
/** 백엔드 InstlSncnApplySaveRequestDto.CklstResultItem */
export const instlSncnCklstResultItemSchema = z.object({
cklstRvwSn: z.number().nullish(),
cklstArtclSn: z.number().nullish(),
cklstSn: z.number().nullish(),
rvwStpComCd: z.string().nullish(),
selected: z.enum(["Y", "N"]).nullish(),
picRvwCn: z.string().nullish(),
});
/** 백엔드 InstlSncnApplySaveRequestDto.EsntlAtchFileItem */
export const instlSncnEsntlAtchFileItemSchema = z.object({
esntlAtchFileId: z.string().nullish(),
atchFileGroupId: z.string().nullish(),
});
/** 백엔드 InstlSncnRvwOpnnSaveRequestDto */
export const instlSncnRvwOpnnSaveSchema = z.object({
instlSncnDocNo: z.string(),
rvwOpnnSn: z.number().nullish(),
opnrSeComCd: z.string().nullish(),
opnrNm: z.string().nullish(),
rvwRsltCn: z.string().nullish(),
rvwRsltAnmlyYn: z.string().nullish(),
atchFileGroupId: z.string().nullish(),
});
/** 백엔드 InstlSncnApplySaveRequestDto.InstlSncnSplmntDmndItem */
export const instlSncnSplmntDmndItemSchema = z.object({
splmntDmndSn: z.number().nullish(),
splmntArtclComCd: z.string().nullish(),
splmntDmndCn: z.string().nullish(),
splmntDmndDt: z.string().nullish(),
splmntDmndAtchFileGroupId: z.string().nullish(),
splmntCn: z.string().nullish(),
splmntPicNm: z.string().nullish(),
splmntCmptnDt: z.string().nullish(),
splmntAtchFileGroupId: z.string().nullish(),
cmptnYn: z.string().nullish(),
});
/** 백엔드 InstlSncnApplySaveRequestDto */
export const instlSncnApplySaveSchema = z.object({
techReViewSaveCd: z.string().nullish(),
rvwStpCd: z.string().nullish(),
instlSncnMng: instlSncnMngSaveSchema,
bzScaleList: z.array(instlSncnBzScaleItemSchema).nullish(),
bizCstList: z.array(instlSncnBizCstItemSchema).nullish(),
cklstResultList: z.array(instlSncnCklstResultItemSchema).nullish(),
cklstResultMap: z.record(z.array(instlSncnCklstResultItemSchema)).nullish(),
esntlAtchFileList: z.array(instlSncnEsntlAtchFileItemSchema).nullish(),
rvwOpnnList: z.array(instlSncnRvwOpnnSaveSchema).nullish(),
splmntDmndList: z.array(instlSncnSplmntDmndItemSchema).nullish(),
});
export type InstlSncnMngSaveRequestDto = z.infer<typeof instlSncnMngSaveSchema>;
export type InstlSncnBzScaleSaveDto = z.infer<typeof instlSncnBzScaleItemSchema>;
export type InstlSncnBizCstSaveRequestDto = z.infer<typeof instlSncnBizCstItemSchema>;
export type InstlSncnCklstResultSaveDto = z.infer<typeof instlSncnCklstResultItemSchema>;
export type InstlSncnEsntlAtchFileSaveDto = z.infer<typeof instlSncnEsntlAtchFileItemSchema>;
export type InstlSncnRvwOpnnSaveRequestDto = z.infer<typeof instlSncnRvwOpnnSaveSchema>;
export type InstlSncnSplmntDmndSaveDto = z.infer<typeof instlSncnSplmntDmndItemSchema>;
export type InstlSncnApplySaveRequestDto = z.infer<typeof instlSncnApplySaveSchema>;
@@ -1,24 +1,24 @@
import type {
AtflMngClientResponseDto,
BizCstSaveBase,
BizCstTreeDto,
BizSclTreeDto,
BzScaleSaveBase,
CklstResultSaveBase,
DocPrgrsUpdateRequestDto,
ElaprDocPrgrsClientResponseDto,
EsntlAtchFileMngTreeResponseDto,
EsntlAtchFileSaveBase,
PageableParams,
PageResponse,
PbadmsApplySaveHeader,
PbadmsMngDetResponseBase,
RvwOpnnResponseExtension,
RvwOpnnSaveBase,
SplmntDmndResponseExtension,
SplmntDmndSaveBase,
} from "@/shared/model";
import type {
InstlSncnApplySaveRequestDto,
InstlSncnMngSaveRequestDto,
InstlSncnRvwOpnnSaveRequestDto,
InstlSncnSplmntDmndSaveDto,
} from "./instl-sncn-apply-payload-schema";
export type {
AtflMngClientResponseDto,
BizCstTreeDto,
@@ -41,189 +41,48 @@ export const INSTL_SNCN_REGISTER_SELECTION_KEY = "instlSncnRegisterSelection";
/** 백엔드 InstlSncnMngSearchDto + Pageable */
export interface InstlSncnMngSearchParams extends PageableParams {
/** 설치인가문서번호 */
instlSncnDocNo?: string;
/** 행정구역코드 */
admdstCd?: string;
/** 환경청구분공통코드 */
envoSeComCd?: string;
/** 업무구분공통코드 */
taskSeComCd?: string;
/** 예산사업코드 */
bgtBizCd?: string;
/** 예산신청연도 */
bgtAplyYr?: string;
/** 사업명 */
bizNm?: string;
/** 사업시작연도 */
bizBgngYr?: string;
/** 사업종료연도 */
bizEndYr?: string;
/** 사업내용 */
bizCn?: string;
/** 신청자명 */
aplcntNm?: string;
/** 신청자휴대전화번호 */
aplcntMblTelno?: string;
/** 신청자소속명 */
aplcntOgdpNm?: string;
/** 신청자이메일주소 */
aplcntEmlAddr?: string;
/** 설계자명 */
dsgnrNm?: string;
/** 설계자전화번호 */
dsgnrTelno?: string;
/** 설계자소속명 */
dsgnrOgdpNm?: string;
/** 설계자이메일주소 */
dsgnrEmlAddr?: string;
/** 첨부파일그룹아이디 */
atchFileGroupId?: string;
/** 진행상태공통코드 */
prgrsSttsComCd?: string;
}
/** 설치인가관리 저장 요청 DTO */
export interface InstlSncnMngSaveRequestDto {
/** 설치인가문서번호 */
instlSncnDocNo?: string;
/** 행정구역코드 */
admdstCd?: string;
/** 환경청구분공통코드 */
envoSeComCd?: string;
/** 인가구분공통코드 */
sncnSeComCd: string;
/** 업무구분공통코드 */
taskSeComCd: string;
/** 예산사업코드 */
bgtBizCd?: string;
/** 예산신청연도 */
bgtAplyYr?: string;
/** 사업명 */
bizNm: string;
/** 사업시작연도 */
bizBgngYr: string;
/** 사업종료연도 */
bizEndYr: string;
/** 사업내용 */
bizCn: string;
/** 신청자명 */
aplcntNm?: string;
/** 신청자휴대전화번호 */
aplcntMblTelno?: string;
/** 신청자소속명 */
aplcntOgdpNm?: string;
/** 신청자이메일주소 */
aplcntEmlAddr?: string;
/** 설계자명 */
dsgnrNm: string;
/** 설계자전화번호 */
dsgnrTelno: string;
/** 설계자소속명 */
dsgnrOgdpNm: string;
/** 설계자이메일주소 */
dsgnrEmlAddr: string;
/** 첨부파일그룹아이디 */
atchFileGroupId?: string;
/** 진행상태공통코드 */
prgrsSttsComCd?: string;
/** 재이용시설구분공통코드 */
rtlztnFcltSeComCd?: string;
/** 재이용용도구분공통코드 */
rtlztnUsgSeComCd?: string;
}
/** 설치인가관리 수정 요청 DTO */
/** 백엔드 InstlSncnMngUpdateRequestDto */
export type InstlSncnMngUpdateRequestDto = InstlSncnMngSaveRequestDto;
/** 설치인가관리 응답 DTO */
export interface InstlSncnMngResponseDto extends InstlSncnMngSaveRequestDto {
/** 설치인가문서번호 (필수로 재정의) */
/** 백엔드 InstlSncnMngResponseDto */
export type InstlSncnMngResponseDto = InstlSncnMngSaveRequestDto & {
instlSncnDocNo: string;
/** 환경청구분공통코드명 */
envoSeCdNm: string;
/** 인가구분공통코드명 */
sncnSeCdNm: string;
/** 업무구분공통코드명 */
taskSeCdNm: string;
/** 진행상태공통코드명 */
prgrsSttsCdNm: string;
/** 기술검토회신이력여부 */
techRplyHstryYn: "Y" | "N";
/** 재이용시설구분공통코드명 */
rtlztnFcltSeComNm: string;
/** 재이용용도구분공통코드명 */
rtlztnUsgSeComNm: string;
/** 등록일시 */
regDt: string;
/** 등록자 UUID */
rgtr: string;
/** 등록자명 */
rgtrNm: string;
/** 수정 일시 */
mdfcnDt: string;
/** 시도 */
sido: string;
/** 구군 */
gugun: string;
}
/** 설치인가 신청 - 사업규모 저장 DTO(배열 원소) */
export interface InstlSncnBzScaleSaveDto extends BzScaleSaveBase {
/** 공종구분코드 */
cnstpSeCd: string;
/** 기본계획수량 */
bscPl: number;
/** 예산반영수량 */
budgetReflct: number;
/** 금회설계수량 */
thtiDsgn: number;
/** 증감(C-B) — 클라이언트 계산용, API 미전송 */
diff?: number;
}
/** 설치인가 신청 - 총사업비 저장 DTO(배열 원소) */
export interface InstlSncnBizCstSaveRequestDto extends BizCstSaveBase {
/** 회계구분코드 */
acntgSeCd: string;
/** 기본계획금액 */
bscPlanAmt: number;
/** 예산반영금액 */
bgtRfltAmt: number;
/** 금회설계금액 */
tstmDesignAmt: number;
}
/** 설치인가 신청 - 체크리스트 결과 저장 DTO(배열 원소) */
export interface InstlSncnCklstResultSaveDto extends CklstResultSaveBase {
/** 점검표일련번호 */
cklstSn: number;
/** 점검표항목일련번호 */
cklstArtclSn: number;
/** 점검표검토일련번호 */
cklstRvwSn: number;
/** 선택값(Y/N/null) */
selected: "Y" | "N" | null;
/** 담당자검토내용(기술검토의견) */
picRvwCn?: string | null;
}
/** 설치인가 신청 - 필수첨부파일 저장 DTO(배열 원소) */
export interface InstlSncnEsntlAtchFileSaveDto extends EsntlAtchFileSaveBase {
/** 필수첨부파일아이디 */
esntlAtchFileId: string;
/** 첨부파일그룹아이디 */
atchFileGroupId: string;
}
/** 설치인가검토의견 저장 요청 DTO */
export interface InstlSncnRvwOpnnSaveRequestDto extends RvwOpnnSaveBase {
/** 설치인가문서번호 */
instlSncnDocNo: string;
}
/** 설치인가 보완요구 저장 DTO(배열 원소) */
export type InstlSncnSplmntDmndSaveDto = SplmntDmndSaveBase;
};
/** 설치인가 보완요구 응답 DTO */
export type InstlSncnSplmntDmndResponseDto = InstlSncnSplmntDmndSaveDto &
@@ -231,26 +90,6 @@ export type InstlSncnSplmntDmndResponseDto = InstlSncnSplmntDmndSaveDto &
instlSncnDocNo?: string;
};
/** 설치인가 신청 - 통합 저장 요청 DTO */
export interface InstlSncnApplySaveRequestDto extends PbadmsApplySaveHeader {
/** 설치인가관리 정보 */
instlSncnMng: InstlSncnMngSaveRequestDto;
/** 사업규모 목록 */
bzScaleList?: InstlSncnBzScaleSaveDto[];
/** 총사업비 목록 */
bizCstList?: InstlSncnBizCstSaveRequestDto[];
/** 체크리스트 검토결과 목록 */
cklstResultList?: InstlSncnCklstResultSaveDto[];
/** 체크리스트 검토결과 맵 (rvwStpComCd -> 목록) */
cklstResultMap?: Record<string, InstlSncnCklstResultSaveDto[]>;
/** 필수첨부파일 목록 */
esntlAtchFileList?: InstlSncnEsntlAtchFileSaveDto[];
/** 검토의견 목록 */
rvwOpnnList?: InstlSncnRvwOpnnSaveRequestDto[];
/** 보완요구 목록 */
splmntDmndList?: InstlSncnSplmntDmndSaveDto[];
}
export type ReviewStepCode = "LOCAL" | "ENV" | "KECO";
/** 설치인가검토의견 응답 DTO */
@@ -0,0 +1,81 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
PwrkCnsltnDocCrtRsltResponseDto,
PwrkCnsltnDocCrtRsltService,
} from "../model/pwrk-cnsltn-doc-crt-rslt-interface";
import type { PwrkCnsltnDocCrtRsltSaveRequestDto } from "../model/pwrk-cnsltn-doc-crt-rslt-payload-schema";
const BASE = ENDPOINT.AS_SERVICE.PWRK_CNSLTN_DOC_CRT_RSLT;
async function findLatestByCrtDmndUuid(
crtDmndUuid: string,
): Promise<PwrkCnsltnDocCrtRsltResponseDto> {
const response = await client.get<PwrkCnsltnDocCrtRsltResponseDto>(
`${BASE}/${crtDmndUuid}/latest`,
);
return response.data;
}
async function openWebHwpLatest(crtDmndUuid: string) {
return client.get<Blob>(`${BASE}/${crtDmndUuid}/webhwp/open`, {
responseType: "blob",
});
}
async function download(crtDmndUuid: string, verSn: number) {
return client.get<Blob>(`${BASE}/${crtDmndUuid}/${verSn}/download`, {
responseType: "blob",
});
}
async function downloadLatest(crtDmndUuid: string) {
return client.get<Blob>(`${BASE}/${crtDmndUuid}/download`, {
responseType: "blob",
});
}
async function saveMultipart(
saveDto: PwrkCnsltnDocCrtRsltSaveRequestDto,
file?: Blob | File,
fileName?: string,
): Promise<PwrkCnsltnDocCrtRsltResponseDto> {
const formData = new FormData();
formData.append("crtDmndUuid", saveDto.crtDmndUuid);
formData.append("pwrkCnsltnDocNo", saveDto.pwrkCnsltnDocNo);
if (saveDto.atchFileGroupId) {
formData.append("atchFileGroupId", saveDto.atchFileGroupId);
}
if (file) {
const resolvedFileName = fileName ?? (file instanceof File ? file.name : "document.hwpx");
formData.append("file", file, resolvedFileName);
}
const response = await client.post<PwrkCnsltnDocCrtRsltResponseDto>(BASE, formData, {
headers: { "Content-Type": undefined },
});
return response.data;
}
async function updateMultipart(
crtDmndUuid: string,
verSn: number,
formData: FormData,
): Promise<PwrkCnsltnDocCrtRsltResponseDto> {
const response = await client.put<PwrkCnsltnDocCrtRsltResponseDto>(
`${BASE}/${crtDmndUuid}/${verSn}`,
formData,
{ headers: { "Content-Type": undefined } },
);
return response.data;
}
export const pwrkCnsltnDocCrtRsltService = {
findLatestByCrtDmndUuid,
openWebHwpLatest,
download,
downloadLatest,
saveMultipart,
updateMultipart,
} satisfies PwrkCnsltnDocCrtRsltService;
@@ -0,0 +1,10 @@
export { pwrkCnsltnDocCrtRsltService } from "./api/pwrk-cnsltn-doc-crt-rslt-service";
export type {
PwrkCnsltnDocCrtRsltResponseDto,
PwrkCnsltnDocCrtRsltService,
PwrkCnsltnDocCrtRsltUpdateRequestDto,
} from "./model/pwrk-cnsltn-doc-crt-rslt-interface";
export {
type PwrkCnsltnDocCrtRsltSaveRequestDto,
pwrkCnsltnDocCrtRsltSaveRequestSchema,
} from "./model/pwrk-cnsltn-doc-crt-rslt-payload-schema";
@@ -0,0 +1,36 @@
import type { AxiosResponse } from "axios";
import type { PwrkCnsltnDocCrtRsltSaveRequestDto } from "./pwrk-cnsltn-doc-crt-rslt-payload-schema";
export interface PwrkCnsltnDocCrtRsltResponseDto {
crtDmndUuid: string;
verSn: number;
rgtrUuid?: string;
rgtrNm?: string;
orgnlOtptDocCn: null;
crtCmptnDt?: string;
}
/** 백엔드 PwrkCnsltnDocCrtRsltUpdateRequestDto (모두 optional) */
export interface PwrkCnsltnDocCrtRsltUpdateRequestDto {
rgtrUuid?: string;
orgnlOtptDocCn?: string;
crtCmptnDt?: string;
}
export interface PwrkCnsltnDocCrtRsltService {
findLatestByCrtDmndUuid(crtDmndUuid: string): Promise<PwrkCnsltnDocCrtRsltResponseDto>;
openWebHwpLatest(crtDmndUuid: string): Promise<AxiosResponse<Blob>>;
download(crtDmndUuid: string, verSn: number): Promise<AxiosResponse<Blob>>;
downloadLatest(crtDmndUuid: string): Promise<AxiosResponse<Blob>>;
saveMultipart(
saveDto: PwrkCnsltnDocCrtRsltSaveRequestDto,
file?: Blob | File,
fileName?: string,
): Promise<PwrkCnsltnDocCrtRsltResponseDto>;
updateMultipart(
crtDmndUuid: string,
verSn: number,
formData: FormData,
): Promise<PwrkCnsltnDocCrtRsltResponseDto>;
}
@@ -0,0 +1,12 @@
import { z } from "zod";
/** 백엔드 PwrkCnsltnDocCrtRsltSaveRequestDto (orgnlOtptDocCn은 multipart file로 전송) */
export const pwrkCnsltnDocCrtRsltSaveRequestSchema = z.object({
crtDmndUuid: z.string().uuid(),
pwrkCnsltnDocNo: z.string().min(1).max(100),
atchFileGroupId: z.string().max(30).nullish(),
});
export type PwrkCnsltnDocCrtRsltSaveRequestDto = z.infer<
typeof pwrkCnsltnDocCrtRsltSaveRequestSchema
>;
@@ -0,0 +1,19 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
PwrkCnsltnDocEvlId,
PwrkCnsltnDocEvlService,
} from "../model/pwrk-cnsltn-doc-evl-interface";
import type { PwrkCnsltnDocEvlSaveRequestDto } from "../model/pwrk-cnsltn-doc-evl-payload-schema";
const BASE = ENDPOINT.AS_SERVICE.PWRK_CNSLTN_DOC_EVL;
async function save(body: PwrkCnsltnDocEvlSaveRequestDto): Promise<PwrkCnsltnDocEvlId> {
const response = await client.post<PwrkCnsltnDocEvlId>(BASE, body);
return response.data;
}
export const pwrkCnsltnDocEvlService = {
save,
} satisfies PwrkCnsltnDocEvlService;
@@ -0,0 +1,11 @@
import { useMutation } from "@tanstack/react-query";
import type { PwrkCnsltnDocEvlSaveRequestDto } from "../model/pwrk-cnsltn-doc-evl-payload-schema";
import { pwrkCnsltnDocEvlService } from "./pwrk-cnsltn-doc-evl-service";
export function useSavePwrkCnsltnDocEvl() {
return useMutation({
mutationFn: (body: PwrkCnsltnDocEvlSaveRequestDto) => pwrkCnsltnDocEvlService.save(body),
});
}
@@ -0,0 +1,10 @@
export { pwrkCnsltnDocEvlService } from "./api/pwrk-cnsltn-doc-evl-service";
export { useSavePwrkCnsltnDocEvl } from "./api/queries";
export type {
PwrkCnsltnDocEvlId,
PwrkCnsltnDocEvlService,
} from "./model/pwrk-cnsltn-doc-evl-interface";
export {
type PwrkCnsltnDocEvlSaveRequestDto,
pwrkCnsltnDocEvlSaveRequestSchema,
} from "./model/pwrk-cnsltn-doc-evl-payload-schema";
@@ -0,0 +1,12 @@
import type { PwrkCnsltnDocEvlSaveRequestDto } from "./pwrk-cnsltn-doc-evl-payload-schema";
/** 백엔드 PwrkCnsltnDocEvlId (IdClass) */
export interface PwrkCnsltnDocEvlId {
pwrkCnsltnDocMng: string;
pwrkCnsltnDocEvlUuid: string;
}
/** Controller public 메서드명·시그니처와 동일 (save만) */
export interface PwrkCnsltnDocEvlService {
save(body: PwrkCnsltnDocEvlSaveRequestDto): Promise<PwrkCnsltnDocEvlId>;
}
@@ -0,0 +1,12 @@
import { z } from "zod";
/** 백엔드 PwrkCnsltnDocEvlSaveRequestDto */
export const pwrkCnsltnDocEvlSaveRequestSchema = z.object({
pwrkCnsltnDocNo: z.string().min(1),
pwrkCnsltnDocEvlUuid: z.string().uuid().nullish(),
pwrkCnsltnDocEvlCn: z.string().max(2000).nullish(),
pwrkCnsltnDocEvlScr: z.number().nullish(),
pwrkCnsltnDocEvlDt: z.string().nullish(),
});
export type PwrkCnsltnDocEvlSaveRequestDto = z.infer<typeof pwrkCnsltnDocEvlSaveRequestSchema>;
@@ -0,0 +1,24 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { PageResponse } from "@/shared/model";
import type {
PwrkCnsltnDocPjtcoAjmtMngResponseDto,
PwrkCnsltnDocPjtcoAjmtMngSearchDto,
PwrkCnsltnDocPjtcoAjmtMngService,
} from "../model/pwrk-cnsltn-doc-pjtco-ajmt-mng-interface";
const BASE = ENDPOINT.AS_SERVICE.PWRK_CNSLTN_DOC_PJTCO_AJMT_MNG;
async function findPage(
searchDto: PwrkCnsltnDocPjtcoAjmtMngSearchDto,
): Promise<PageResponse<PwrkCnsltnDocPjtcoAjmtMngResponseDto>> {
const response = await client.get<PageResponse<PwrkCnsltnDocPjtcoAjmtMngResponseDto>>(BASE, {
params: searchDto,
});
return response.data;
}
export const pwrkCnsltnDocPjtcoAjmtMngService = {
findPage,
} satisfies PwrkCnsltnDocPjtcoAjmtMngService;
@@ -0,0 +1,22 @@
import { useQuery } from "@tanstack/react-query";
import type { PwrkCnsltnDocPjtcoAjmtMngSearchDto } from "../model/pwrk-cnsltn-doc-pjtco-ajmt-mng-interface";
import { pwrkCnsltnDocPjtcoAjmtMngService } from "./pwrk-cnsltn-doc-pjtco-ajmt-mng-service";
export const pwrkCnsltnDocPjtcoAjmtMngKeys = {
all: ["pwrk-cnsltn-doc-pjtco-ajmt-mng"] as const,
page: (params: PwrkCnsltnDocPjtcoAjmtMngSearchDto) =>
[...pwrkCnsltnDocPjtcoAjmtMngKeys.all, "findPage", params] as const,
};
export function usePwrkCnsltnDocPjtcoAjmtMngPage(
params: PwrkCnsltnDocPjtcoAjmtMngSearchDto,
isEnabled = true,
) {
return useQuery({
queryKey: pwrkCnsltnDocPjtcoAjmtMngKeys.page(params),
queryFn: () => pwrkCnsltnDocPjtcoAjmtMngService.findPage(params),
enabled: Boolean(params.pwrkCnsltnDocNo) && isEnabled,
});
}
@@ -0,0 +1,10 @@
export { pwrkCnsltnDocPjtcoAjmtMngService } from "./api/pwrk-cnsltn-doc-pjtco-ajmt-mng-service";
export {
pwrkCnsltnDocPjtcoAjmtMngKeys,
usePwrkCnsltnDocPjtcoAjmtMngPage,
} from "./api/queries";
export type {
PwrkCnsltnDocPjtcoAjmtMngResponseDto,
PwrkCnsltnDocPjtcoAjmtMngSearchDto,
PwrkCnsltnDocPjtcoAjmtMngService,
} from "./model/pwrk-cnsltn-doc-pjtco-ajmt-mng-interface";
@@ -0,0 +1,44 @@
import type { PageableParams, PageResponse } from "@/shared/model";
/** 백엔드 PwrkCnsltnDocPjtcoAjmtMngResponseDto */
export interface PwrkCnsltnDocPjtcoAjmtMngResponseDto {
pwrkCnsltnDocNo: string;
rvwOpnnSn: number;
acntgSeCd: string;
acntgSeCdNm?: string;
rvwOpnnClsfComCd?: string;
rvwOpnnClsfComCdNm?: string;
ognTpcoAmt?: number;
ognGvexpAmt?: number;
ognLcexpAmt?: number;
ognOrgprBrdnAmt?: number;
chgTpcoAmt?: number;
chgGvexpAmt?: number;
chgLcexpAmt?: number;
chgOrgprBrdnAmt?: number;
ajmtRsn?: string;
vrfcTpcoAmt?: number;
vrfcGvexpAmt?: number;
vrfcLcexpAmt?: number;
vrfcOrgprBrdnAmt?: number;
rsbnYn?: string;
vrfcCn?: string;
regDt?: string;
rgtr?: string;
rgtrNm?: string;
mdfcnDt?: string;
mdfr?: string;
}
/** 백엔드 PwrkCnsltnDocPjtcoAjmtMngSearchDto + Pageable */
export interface PwrkCnsltnDocPjtcoAjmtMngSearchDto extends PageableParams {
pwrkCnsltnDocNo?: string;
rvwOpnnSn?: number;
acntgSeCd?: string;
}
export interface PwrkCnsltnDocPjtcoAjmtMngService {
findPage(
searchDto: PwrkCnsltnDocPjtcoAjmtMngSearchDto,
): Promise<PageResponse<PwrkCnsltnDocPjtcoAjmtMngResponseDto>>;
}
@@ -0,0 +1,41 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
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;
@@ -0,0 +1,28 @@
import { useQuery } from "@tanstack/react-query";
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!),
queryFn: () => pwrkCnsltnMngService.findByUpDocNo(upDocNo!),
enabled: Boolean(upDocNo) && isEnabled,
});
}
@@ -0,0 +1,15 @@
export { pwrkCnsltnMngService } from "./api/pwrk-cnsltn-mng-service";
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";
@@ -0,0 +1,12 @@
import type {
PwrkCnsltnDocCrtDmndResponseDto,
PwrkCnsltnMngDetResponseDto,
} from "../model/pwrk-cnsltn-mng-interface";
export function findCompletedPwrkDraft(
data: PwrkCnsltnMngDetResponseDto | null | undefined,
): PwrkCnsltnDocCrtDmndResponseDto | undefined {
return data?.crtDmndList?.find(
(item) => item.crtSttsComCd === "C" && Boolean(item.crtDmndUuid) && Boolean(item.crtRslt),
);
}
@@ -0,0 +1,97 @@
import type { AtflMngClientResponseDto } from "@/shared/model";
import type { PwrkActnResultSubmitDto } from "./pwrk-cnsltn-mng-payload-schema";
export interface PwrkCnsltnDocCrtRsltResponseDto {
crtDmndUuid: string;
verSn: number;
rgtrUuid?: string;
rgtrNm?: string;
orgnlOtptDocCn: null;
crtCmptnDt?: string;
}
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().nullish(),
rfltYn: z.string().nullish(),
atchFileGroupId: z.string().nullish(),
});
/** 백엔드 PwrkActnResultSubmitDto */
export const pwrkActnResultSubmitSchema = z.object({
isSubmit: z.boolean(),
actnList: z.array(pwrkActnResultActnItemSchema).nullish(),
totActnSummary: z.string().nullish(),
muniDocNo: z.string().nullish(),
pwrkAtchFileGroupId: z.string().nullish(),
});
export type PwrkActnResultSubmitDto = z.infer<typeof pwrkActnResultSubmitSchema>;
@@ -2,160 +2,160 @@ import { z } from "zod";
/** 백엔드 SewerBscPlanSaveRequestDto */
export const sewerBscPlanSaveSchema = z.object({
sewerBscPlanDocNo: z.string().optional(),
admdstCd: z.string().optional(),
envoSeComCd: z.string().optional(),
sewerBscPlanSeComCd: z.string().optional(),
sewerBscPlanNm: z.string().optional(),
taskSeComCd: z.string().optional(),
prgrsSttsComCd: z.string().optional(),
sewerBscPlanDocNo: z.string().nullish(),
admdstCd: z.string().nullish(),
envoSeComCd: z.string().nullish(),
sewerBscPlanSeComCd: z.string().nullish(),
sewerBscPlanNm: z.string().nullish(),
taskSeComCd: z.string().nullish(),
prgrsSttsComCd: z.string().nullish(),
});
/** 백엔드 SewerBscPlanDtlSaveRequestDto */
export const sewerBscPlanDtlSaveSchema = z.object({
sewerBscPlanDocNo: z.string().optional(),
chgCycl: z.number().optional(),
aplcntNm: z.string().optional(),
aplcntMblTelno: z.string().optional(),
aplcntOgdpNm: z.string().optional(),
aplcntEmlAddr: z.string().optional(),
dsgnrNm: z.string().optional(),
dsgnrTelno: z.string().optional(),
dsgnrOgdpNm: z.string().optional(),
dsgnrEmlAddr: z.string().optional(),
planYr: z.string().optional(),
crtrYr: z.string().optional(),
bizOtlnCn: z.string().optional(),
frlvlGoalYr: z.string().optional(),
sclvlGoalYr: z.string().optional(),
thlvlGoalYr: z.string().optional(),
folvlGoalYr: z.string().optional(),
atchFileGroupId: z.string().optional(),
sewerBscPlanDocNo: z.string().nullish(),
chgCycl: z.number().nullish(),
aplcntNm: z.string().nullish(),
aplcntMblTelno: z.string().nullish(),
aplcntOgdpNm: z.string().nullish(),
aplcntEmlAddr: z.string().nullish(),
dsgnrNm: z.string().nullish(),
dsgnrTelno: z.string().nullish(),
dsgnrOgdpNm: z.string().nullish(),
dsgnrEmlAddr: z.string().nullish(),
planYr: z.string().nullish(),
crtrYr: z.string().nullish(),
bizOtlnCn: z.string().nullish(),
frlvlGoalYr: z.string().nullish(),
sclvlGoalYr: z.string().nullish(),
thlvlGoalYr: z.string().nullish(),
folvlGoalYr: z.string().nullish(),
atchFileGroupId: z.string().nullish(),
});
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanOtlnItem */
export const sewerBscPlanOtlnItemSchema = z.object({
planStpNo: z.number().optional(),
sewerPlanPpltnCnt: z.number().optional(),
sewerPrcsPpltnCnt: z.number().optional(),
sewerPsplyRt: z.number().optional(),
planStpNo: z.number().nullish(),
sewerPlanPpltnCnt: z.number().nullish(),
sewerPrcsPpltnCnt: z.number().nullish(),
sewerPsplyRt: z.number().nullish(),
});
/** 백엔드 SewerBscPlanApplySaveRequestDto.CklstResultItem */
export const sewerBscPlanCklstResultItemSchema = z.object({
cklstRvwSn: z.number().optional(),
cklstArtclSn: z.number().optional(),
cklstSn: z.number().optional(),
cklstRvwSn: z.number().nullish(),
cklstArtclSn: z.number().nullish(),
cklstSn: z.number().nullish(),
rvwStpComCd: z.string(),
selected: z.string().optional(),
selected: z.enum(["Y", "N"]).nullish(),
picRvwCn: z.string().nullish(),
});
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanFnrsCstItem */
export const sewerBscPlanFnrsCstItemSchema = z.object({
planStpNo: z.number().optional(),
gvexpAmt: z.number().optional(),
lcexpAmt: z.number().optional(),
orgprBdchgAmt: z.number().optional(),
etcAmt: z.number().optional(),
sumAmt: z.number().optional(),
planStpNo: z.number().nullish(),
gvexpAmt: z.number().nullish(),
lcexpAmt: z.number().nullish(),
orgprBdchgAmt: z.number().nullish(),
etcAmt: z.number().nullish(),
sumAmt: z.number().nullish(),
});
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanSplmntDmndItem */
export const sewerBscPlanSplmntDmndItemSchema = z.object({
splmntDmndSn: z.number().nullish(),
splmntArtclComCd: z.string().optional(),
splmntDmndCn: z.string().optional(),
splmntDmndDt: z.string().optional(),
splmntDmndAtchFileGroupId: z.string().optional(),
splmntCn: z.string().optional(),
splmntPicNm: z.string().optional(),
splmntCmptnDt: z.string().optional(),
splmntAtchFileGroupId: z.string().optional(),
cmptnYn: z.string().optional(),
splmntArtclComCd: z.string().nullish(),
splmntDmndCn: z.string().nullish(),
splmntDmndDt: z.string().nullish(),
splmntDmndAtchFileGroupId: z.string().nullish(),
splmntCn: z.string().nullish(),
splmntPicNm: z.string().nullish(),
splmntCmptnDt: z.string().nullish(),
splmntAtchFileGroupId: z.string().nullish(),
cmptnYn: z.string().nullish(),
});
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanRvwOpnnItem */
export const sewerBscPlanRvwOpnnItemSchema = z.object({
rvwOpnnSn: z.number().optional(),
opnrSeComCd: z.string().optional(),
opnrNm: z.string().optional(),
rvwRsltCn: z.string().optional(),
rvwRsltAnmlyYn: z.string().optional(),
atchFileGroupId: z.string().optional(),
rvwOpnnSn: z.number().nullish(),
opnrSeComCd: z.string().nullish(),
opnrNm: z.string().nullish(),
rvwRsltCn: z.string().nullish(),
rvwRsltAnmlyYn: z.string().nullish(),
atchFileGroupId: z.string().nullish(),
});
/** 백엔드 SewerBscPlanApplySaveRequestDto.EsntlAtchFileItem */
export const sewerBscPlanEsntlAtchFileItemSchema = z.object({
esntlAtchFileId: z.string().optional(),
atchFileGroupId: z.string().optional(),
esntlAtchFileId: z.string().nullish(),
atchFileGroupId: z.string().nullish(),
});
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanBizCstItem */
export const sewerBscPlanBizCstItemSchema = z.object({
planStpNo: z.number().optional(),
acntgSeCd: z.string().optional(),
cstrnAmt: z.number().optional(),
planStpNo: z.number().nullish(),
acntgSeCd: z.string().nullish(),
cstrnAmt: z.number().nullish(),
});
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanTmplntDtlItem */
export const sewerBscPlanTmplntDtlItemSchema = z.object({
planStpNo: z.number().optional(),
swtrmtFcltCd: z.string().optional(),
prcsZoneArea: z.number().optional(),
swtrmtPpltnCnt: z.number().optional(),
planSwqty: z.number().optional(),
fcltCpct: z.number().optional(),
sprsysDrtwPplLen: z.number().optional(),
sprsysExlnPplLen: z.number().optional(),
cbsysPplLen: z.number().optional(),
icptgPplLen: z.number().optional(),
drgCnt: z.number().optional(),
drtwRlyPumpCnt: z.number().optional(),
drtwPumpCnt: z.number().optional(),
exlnPumpCnt: z.number().optional(),
planStpNo: z.number().nullish(),
swtrmtFcltCd: z.string().nullish(),
prcsZoneArea: z.number().nullish(),
swtrmtPpltnCnt: z.number().nullish(),
planSwqty: z.number().nullish(),
fcltCpct: z.number().nullish(),
sprsysDrtwPplLen: z.number().nullish(),
sprsysExlnPplLen: z.number().nullish(),
cbsysPplLen: z.number().nullish(),
icptgPplLen: z.number().nullish(),
drgCnt: z.number().nullish(),
drtwRlyPumpCnt: z.number().nullish(),
drtwPumpCnt: z.number().nullish(),
exlnPumpCnt: z.number().nullish(),
});
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanTmplntItem */
export const sewerBscPlanTmplntItemSchema = z.object({
swtrmtFcltCd: z.string().optional(),
atchFileGroupId: z.string().optional(),
sewerBscPlanTmplntDtlList: z.array(sewerBscPlanTmplntDtlItemSchema).optional(),
swtrmtFcltCd: z.string().nullish(),
atchFileGroupId: z.string().nullish(),
sewerBscPlanTmplntDtlList: z.array(sewerBscPlanTmplntDtlItemSchema).nullish(),
});
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanTmplntTmprStrgItem */
export const sewerBscPlanTmplntTmprStrgItemSchema = z.object({
dtStrgDt: z.string().optional(),
swtrmtFcltCd: z.string().optional(),
prcsZoneArea: z.number().optional(),
swtrmtPpltnCnt: z.number().optional(),
planSwqty: z.number().optional(),
fcltCpct: z.number().optional(),
sprsysDrtwPplLen: z.number().optional(),
sprsysExlnPplLen: z.number().optional(),
cbsysPplLen: z.number().optional(),
icptgPplLen: z.number().optional(),
drgCnt: z.number().optional(),
drtwRlyPumpCnt: z.number().optional(),
drtwPumpCnt: z.number().optional(),
exlnPumpCnt: z.number().optional(),
dtStrgDt: z.string().nullish(),
swtrmtFcltCd: z.string().nullish(),
prcsZoneArea: z.number().nullish(),
swtrmtPpltnCnt: z.number().nullish(),
planSwqty: z.number().nullish(),
fcltCpct: z.number().nullish(),
sprsysDrtwPplLen: z.number().nullish(),
sprsysExlnPplLen: z.number().nullish(),
cbsysPplLen: z.number().nullish(),
icptgPplLen: z.number().nullish(),
drgCnt: z.number().nullish(),
drtwRlyPumpCnt: z.number().nullish(),
drtwPumpCnt: z.number().nullish(),
exlnPumpCnt: z.number().nullish(),
});
/** 백엔드 SewerBscPlanApplySaveRequestDto */
export const sewerBscPlanApplySaveSchema = z.object({
techReViewSaveCd: z.string().optional(),
rvwStpCd: z.string().optional(),
techReViewSaveCd: z.string().nullish(),
rvwStpCd: z.string().nullish(),
sewerBscPlan: sewerBscPlanSaveSchema,
sewerBscPlanDtl: sewerBscPlanDtlSaveSchema,
sewerBscPlanOtlnList: z.array(sewerBscPlanOtlnItemSchema).optional(),
cklstResultList: z.array(sewerBscPlanCklstResultItemSchema).optional(),
fnrsCstList: z.array(sewerBscPlanFnrsCstItemSchema).optional(),
splmntDmndList: z.array(sewerBscPlanSplmntDmndItemSchema).optional(),
rvwOpnnList: z.array(sewerBscPlanRvwOpnnItemSchema).optional(),
esntlAtchFileList: z.array(sewerBscPlanEsntlAtchFileItemSchema).optional(),
bizCstList: z.array(sewerBscPlanBizCstItemSchema).optional(),
sewerBscPlanTmplntList: z.array(sewerBscPlanTmplntItemSchema).optional(),
tmplntTmprStrgList: z.array(sewerBscPlanTmplntTmprStrgItemSchema).optional(),
sewerBscPlanOtlnList: z.array(sewerBscPlanOtlnItemSchema).nullish(),
cklstResultList: z.array(sewerBscPlanCklstResultItemSchema).nullish(),
fnrsCstList: z.array(sewerBscPlanFnrsCstItemSchema).nullish(),
splmntDmndList: z.array(sewerBscPlanSplmntDmndItemSchema).nullish(),
rvwOpnnList: z.array(sewerBscPlanRvwOpnnItemSchema).nullish(),
esntlAtchFileList: z.array(sewerBscPlanEsntlAtchFileItemSchema).nullish(),
bizCstList: z.array(sewerBscPlanBizCstItemSchema).nullish(),
sewerBscPlanTmplntList: z.array(sewerBscPlanTmplntItemSchema).nullish(),
tmplntTmprStrgList: z.array(sewerBscPlanTmplntTmprStrgItemSchema).nullish(),
});
export type SewerBscPlanSaveRequestDto = z.infer<typeof sewerBscPlanSaveSchema>;
@@ -68,6 +68,7 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
markDeleted,
reorderRows,
} = useAplnRows({
seedKey: elaprTmpltId,
elaprTmpltId,
serverRows: aplnQuery.data?.content,
});
@@ -1,7 +1,13 @@
import { z } from "zod";
import {
designCnstnBzScaleItemSchema,
designCnstnEsntlAtchFileItemSchema,
designCnstnMngSaveSchema,
} from "@/entities/design-cnstn";
export const designCnstnRegistSchema = z.object({
designCnstnMng: z.object({
designCnstnMng: designCnstnMngSaveSchema.extend({
bizNm: z.string().min(1, "사업명은 필수입니다"),
bizBgngYr: z.string().min(1, "사업시작연도는 필수입니다"),
bizEndYr: z.string().min(1, "사업종료연도는 필수입니다"),
@@ -17,36 +23,20 @@ export const designCnstnRegistSchema = z.object({
.string()
.min(1, "설계자 이메일은 필수입니다")
.email("올바른 이메일 형식이 아닙니다"),
aplcntNm: z.string().optional(),
aplcntMblTelno: z.string().optional(),
aplcntOgdpNm: z.string().optional(),
aplcntEmlAddr: z.string().optional(),
designCnstnDocNo: z.string().optional(),
admdstCd: z.string().optional(),
envoSeComCd: z.string().optional(),
prgrsSttsComCd: z.string().optional(),
atchFileGroupId: z.string().optional(),
envoSeCdNm: z.string().optional(),
taskSeCdNm: z.string().optional(),
prgrsSttsCdNm: z.string().optional(),
sido: z.string().optional(),
gugun: z.string().optional(),
regDt: z.string().optional(),
rgtr: z.string().optional(),
rgtrNm: z.string().optional(),
mdfcnDt: z.string().optional(),
sido: z.string().nullish(),
gugun: z.string().nullish(),
}),
bzScaleList: z.array(
z.object({
designCnstnBzScaleItemSchema.extend({
cnstpSeCd: z.string(),
bscPl: z.number(),
budgetReflct: z.number(),
thtiDsgn: z.number(),
diff: z.number().optional(),
diff: z.number().nullish(),
}),
),
esntlAtchFileList: z.array(
z.object({
designCnstnEsntlAtchFileItemSchema.extend({
esntlAtchFileId: z.string(),
atchFileGroupId: z.string(),
}),
@@ -62,7 +52,7 @@ export function formatTaskSeComCdForApi(taskSeComCd: string[]): string {
return taskSeComCd.join(",");
}
export function parseTaskSeComCdFromApi(taskSeComCd?: string): string[] {
export function parseTaskSeComCdFromApi(taskSeComCd?: string | null): string[] {
if (!taskSeComCd) return [];
return taskSeComCd.split(",").filter(Boolean);
}
@@ -1,9 +1,13 @@
import type { IUserInfo } from "@/shared/store";
import type { DesignCnstnMngDetResponseDto } from "@/entities/design-cnstn";
import {
type DesignCnstnApplySaveRequestDto,
designCnstnApplySaveSchema,
type DesignCnstnMngDetResponseDto,
} from "@/entities/design-cnstn";
import { toEsntlAtchFileSaveList } from "@/widgets/pbadms-sprt";
import type { DesignCnstnRegistFormData } from "./design-cnstn-regist-schema";
import { parseTaskSeComCdFromApi } from "./design-cnstn-regist-schema";
import { formatTaskSeComCdForApi, parseTaskSeComCdFromApi } from "./design-cnstn-regist-schema";
export function mapDetailToFormData(data: DesignCnstnMngDetResponseDto): DesignCnstnRegistFormData {
const detDto = data.detDto;
@@ -33,15 +37,8 @@ export function mapDetailToFormData(data: DesignCnstnMngDetResponseDto): DesignC
envoSeComCd: detDto.envoSeComCd,
prgrsSttsComCd: detDto.prgrsSttsComCd,
atchFileGroupId: detDto.atchFileGroupId ?? "",
envoSeCdNm: detDto.envoSeCdNm,
taskSeCdNm: detDto.taskSeCdNm,
prgrsSttsCdNm: detDto.prgrsSttsCdNm,
sido: detDto.sido,
gugun: detDto.gugun,
regDt: detDto.regDt,
rgtr: detDto.rgtr,
rgtrNm: detDto.rgtrNm,
mdfcnDt: detDto.mdfcnDt,
},
bzScaleList: bizSclList.map((item) => ({
cnstpSeCd: item.cnstpSeCd,
@@ -90,3 +87,20 @@ export function getCreateDefaultValues(
privacyConsent: "N",
};
}
export function buildFormPayload(
data: DesignCnstnRegistFormData,
prgrsSttsComCd: "CV" | "CT",
options: { isEditMode: boolean; docNo?: string },
): DesignCnstnApplySaveRequestDto {
return designCnstnApplySaveSchema.parse({
designCnstnMng: {
...data.designCnstnMng,
taskSeComCd: formatTaskSeComCdForApi(data.designCnstnMng.taskSeComCd),
designCnstnDocNo: options.isEditMode ? options.docNo : undefined,
prgrsSttsComCd,
},
bzScaleList: data.bzScaleList,
esntlAtchFileList: data.esntlAtchFileList,
});
}
@@ -4,6 +4,7 @@ import type {
DesignCnstnApplySaveRequestDto,
DesignCnstnMngDetResponseDto,
} from "@/entities/design-cnstn";
import { designCnstnApplySaveSchema } from "@/entities/design-cnstn";
import { toEsntlAtchFileSaveList } from "@/widgets/pbadms-sprt";
import type { ReviewStepCode } from "../model/view-types";
@@ -30,11 +31,11 @@ export function buildSavePayload(
throw new Error("상세 정보(detDto)가 없습니다.");
}
return {
return designCnstnApplySaveSchema.parse({
designCnstnMng: { ...detDto, prgrsSttsComCd },
bzScaleList: toBzScaleSaveDto(data.bizSclList ?? []),
esntlAtchFileList: toEsntlAtchFileSaveList(data.esntlFileList),
};
});
}
export function buildTechReviewPayload(
@@ -45,10 +46,10 @@ export function buildTechReviewPayload(
reviewOpinionFormData?: ReviewOpinionGetFormDataResult | null,
opnrNm?: string,
): DesignCnstnApplySaveRequestDto {
return {
return designCnstnApplySaveSchema.parse({
...buildSavePayload(data, prgrsSttsComCd),
techReViewSaveCd,
rvwStpCd: reviewStep,
rvwOpnnList: toRvwOpnnSaveDto(data, reviewOpinionFormData, opnrNm),
};
});
}
@@ -72,7 +72,7 @@ function getBadgeColor(state: BadgeState) {
}
}
function formatOptional(value: string | undefined) {
function formatOptional(value: string | null | undefined) {
return value?.trim() ? value : "-";
}
@@ -9,9 +9,8 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useQueryClient } from "@tanstack/react-query";
import { useAlertStore, useUserStore } from "@/shared/lib";
import { SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
import { RhfDevTool, SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
import {
type DesignCnstnApplySaveRequestDto,
designCnstnKeys,
type DesignCnstnMngDetResponseDto,
designCnstnMngService,
@@ -27,12 +26,12 @@ import {
} from "@/widgets/pbadms-sprt";
import { Button } from "@common/components";
import { type DesignCnstnRegistFormData, designCnstnRegistSchema } from "../lib/design-cnstn-regist-schema";
import {
type DesignCnstnRegistFormData,
designCnstnRegistSchema,
formatTaskSeComCdForApi,
} from "../lib/design-cnstn-regist-schema";
import { getCreateDefaultValues, mapDetailToFormData } from "../lib/form-data-mapper";
buildFormPayload,
getCreateDefaultValues,
mapDetailToFormData,
} from "../lib/form-data-mapper";
import { PROGRESS_STATUS_COLUMNS } from "../model/columns";
import { BasicInfoFormSection } from "./sections/basic-info-form-section";
@@ -68,29 +67,18 @@ function DesignFormPage({ mode, initialData, createDefaults }: DesignFormPagePro
: getCreateDefaultValues(userInfo, createDefaults),
});
function buildFormPayload(
data: DesignCnstnRegistFormData,
prgrsSttsComCd: "CV" | "CT",
): DesignCnstnApplySaveRequestDto {
const { taskSeComCd: formTaskSeComCd, ...designCnstnMngRest } = data.designCnstnMng;
return {
designCnstnMng: {
...designCnstnMngRest,
taskSeComCd: formatTaskSeComCdForApi(formTaskSeComCd),
designCnstnDocNo: isEditMode ? initialData?.detDto?.designCnstnDocNo : undefined,
prgrsSttsComCd,
},
bzScaleList: data.bzScaleList,
esntlAtchFileList: data.esntlAtchFileList,
};
function createFormPayload(data: DesignCnstnRegistFormData, prgrsSttsComCd: "CV" | "CT") {
return buildFormPayload(data, prgrsSttsComCd, {
isEditMode,
docNo: initialData?.detDto?.designCnstnDocNo,
});
}
async function submitForm(
data: DesignCnstnRegistFormData,
prgrsSttsComCd: "CV" | "CT",
): Promise<string | undefined> {
return designCnstnMngService.saveApply(buildFormPayload(data, prgrsSttsComCd));
return designCnstnMngService.saveApply(createFormPayload(data, prgrsSttsComCd));
}
async function onSave(data: DesignCnstnRegistFormData) {
@@ -169,7 +157,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
@@ -227,6 +217,7 @@ function DesignFormPage({ mode, initialData, createDefaults }: DesignFormPagePro
</div>
</SectionCollapsibleGroup>
</form>
<RhfDevTool control={form.control} />
</FormProvider>
);
}
@@ -2,14 +2,11 @@
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
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 { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
import { attachService } from "@/entities/attach";
import { AttachmentDownloadLink, attachService } from "@/entities/attach";
import type { DesignCnstnRvwOpnnResponseDto } from "@/entities/design-cnstn";
import {
Button,
@@ -319,15 +316,10 @@ function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
</TableCell>
<TableCell align="center">
{item.atchFileInfo?.atflCd ? (
<a
className="inline-flex cursor-pointer items-start gap-1 text-left text-blue-600 hover:underline"
href={`${PROXY_HOST}/attach-service/api/v1/atflcd/download/${item.atchFileInfo.atflCd}`}
target="_blank"
rel="noreferrer"
>
<Paperclip className="my-1 size-5 shrink-0" />
{item.atchFileInfo.orgnlFileNm || "다운로드"}
</a>
<AttachmentDownloadLink
atflCd={item.atchFileInfo.atflCd}
fileName={item.atchFileInfo.orgnlFileNm || "다운로드"}
/>
) : (
"-"
)}
@@ -3,11 +3,9 @@
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 { attachService } from "@/entities/attach";
import { AttachmentFileField } from "@/shared/ui";
import { attachService, useDownloadAttachmentByAtflId } from "@/entities/attach";
import { CodeSelect } from "@/entities/code";
import type { EsntlAtchFileMng } from "@/entities/esntl";
import {
@@ -63,6 +61,7 @@ function EsntlAtchFileFormDialog({
const formId = useId();
const atchFileGroupIdRef = useRef("");
const [uploadedFiles, setUploadedFiles] = useState<UploadedTemplateFile[]>([]);
const { mutate: downloadByAtflId } = useDownloadAttachmentByAtflId();
const { register, handleSubmit, reset, setValue, watch } = useForm<EsntlAtchFileFormValues>({
defaultValues: {
@@ -217,57 +216,19 @@ 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,
onDownload: item.atflId
? () => downloadByAtflId(item.atflId!)
: undefined,
}))}
inputId={fileInputId}
isUploadingByKey={isUploading}
onAdd={(fileList) => void handleFileUpload(fileList)}
onRemove={(index) => void handleFileRemove(index)}
/>
</div>
<div className="space-y-2">
<Label>필수첨부여부</Label>
@@ -0,0 +1,35 @@
import type { RagIndexingInfoDto } from "@/entities/fnrs-cnsltn";
const AI_PENDING_STATUS_CODES = ["FV", "AC", "AV"] as const;
type AiPendingStatusCode = (typeof AI_PENDING_STATUS_CODES)[number];
const AI_PENDING_MESSAGES: Record<AiPendingStatusCode, string> = {
FV: "AI 파일 검증이 진행 중입니다",
AC: "AI 실무협의서 초안 생성이 진행 중입니다",
AV: "AI 금액 검증이 진행 중입니다",
};
function isAiPendingStatus(prgrsSttsComCd: string): boolean {
return AI_PENDING_STATUS_CODES.includes(prgrsSttsComCd as AiPendingStatusCode);
}
function getAiPendingMessage(prgrsSttsComCd: string): string {
if (!isAiPendingStatus(prgrsSttsComCd)) return "";
return AI_PENDING_MESSAGES[prgrsSttsComCd as AiPendingStatusCode];
}
function isRagIndexingInProgress(
prgrsSttsComCd: string,
ragIndexingInfo?: RagIndexingInfoDto | null,
): boolean {
return prgrsSttsComCd === "ER" && ragIndexingInfo?.isInProgress === true;
}
export {
AI_PENDING_STATUS_CODES,
getAiPendingMessage,
isAiPendingStatus,
isRagIndexingInProgress,
};
export type { AiPendingStatusCode };
@@ -2,21 +2,26 @@ export const FNRS_CNSLTN_VIEW_BUTTON_ACTION = {
strg: "strg",
aply: "aply",
del: "del",
fnrsCnsltnAncmnt: "fnrsCnsltnAncmnt",
envoSbmsn: "envoSbmsn",
rjct: "rjct",
sncnAprv: "sncnAprv",
rvwRply: "rvwRply",
rvwOpnnRply: "rvwOpnnRply",
rqstTechRvw: "rqstTechRvw",
fileSplmnt: "fileSplmnt",
fileVrfc: "fileVrfc",
dsgntRvwr: "dsgntRvwr",
pwrkCnsltnDocCrt: "pwrkCnsltnDocCrt",
pwrkCnsltnDocMng: "pwrkCnsltnDocMng",
rvwRsltInq: "rvwRsltInq",
rvwStrg: "rvwStrg",
rvwCmptn: "rvwCmptn",
dmndSplmnt: "dmndSplmnt",
atrzAprv: "atrzAprv",
atrzRply: "atrzRply",
actnCmptn: "actnCmptn",
rqstActnRvw: "rqstActnRvw",
costVrfc: "costVrfc",
} as const;
export type FnrsCnsltnViewButtonAction =
@@ -34,37 +39,152 @@ const ROLE_STATUS_BUTTON_ACTIONS: Record<string, Record<string, FnrsCnsltnViewBu
FNRS_CNSLTN_VIEW_BUTTON_ACTION.aply,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.del,
],
TA: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.fnrsCnsltnAncmnt],
RB: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng, FNRS_CNSLTN_VIEW_BUTTON_ACTION.actnCmptn],
RA: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
TA: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
},
ROLE_USER04: {
ST: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.envoSbmsn, FNRS_CNSLTN_VIEW_BUTTON_ACTION.rjct],
EP: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.sncnAprv],
EP: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.sncnAprv, FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
},
ROLE_USER03: {
LE: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRply, FNRS_CNSLTN_VIEW_BUTTON_ACTION.rqstTechRvw],
TP: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRply],
TP: [
FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwOpnnRply,
],
RB: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
RA: [
FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.rqstActnRvw,
],
AV: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
EV: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
RK: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
RR: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng, FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRply],
EP: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
TA: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
},
};
const ROLE_USER01_ATRZ_STATUS_CODES = ["ER", "TE"] as const;
const ROLE_USER01_ATRZ_STATUS_CODES = [
"ER",
"SL",
"TE",
"TP",
"RB",
"RA",
"AV",
"EV",
"RK",
"RR",
"EP",
"TA",
] as const;
const ROLE_USER01_ATRZ_STATUS_ACTIONS: Record<
string,
Record<string, FnrsCnsltnViewButtonAction[]>
> = {
ER: {
ATRZ01: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.fileVrfc, FNRS_CNSLTN_VIEW_BUTTON_ACTION.dsgntRvwr],
ATRZ01: [
FNRS_CNSLTN_VIEW_BUTTON_ACTION.fileSplmnt,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.fileVrfc,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.dsgntRvwr,
],
},
SL: {
ATRZ01: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
ATRZ02: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
ATRZ03: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
ATRZ99: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
},
TE: {
ATRZ01: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
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],
ATRZ99: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzRply],
ATRZ03: [
FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzAprv,
],
ATRZ99: [
FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzRply,
],
},
TP: {
ATRZ01: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
ATRZ02: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
ATRZ03: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
ATRZ99: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
},
RB: {
ATRZ01: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
ATRZ02: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
ATRZ03: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
ATRZ99: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
},
RA: {
ATRZ01: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
ATRZ02: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
ATRZ03: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
ATRZ99: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
},
AV: {
ATRZ01: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
ATRZ02: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
ATRZ03: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
ATRZ99: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
},
EV: {
ATRZ01: [
FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.dsgntRvwr,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.costVrfc,
],
ATRZ02: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
ATRZ03: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
ATRZ99: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
},
RK: {
ATRZ01: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
ATRZ02: [
FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwCmptn,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.dmndSplmnt,
],
ATRZ03: [
FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzAprv,
],
ATRZ99: [
FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzRply,
],
},
RR: {
ATRZ01: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
ATRZ02: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
ATRZ03: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
ATRZ99: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
},
EP: {
ATRZ01: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
ATRZ02: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
ATRZ03: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
ATRZ99: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
},
TA: {
ATRZ01: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
ATRZ02: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
ATRZ03: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
ATRZ99: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
},
};
@@ -74,17 +194,26 @@ function resolveButtonConfigs(actions: FnrsCnsltnViewButtonAction[]): FnrsCnsltn
interface GetButtonConfigsOptions {
hasPendingApprovalRequest?: boolean;
hasCompletedDraft?: boolean;
canRetryFileVrfc?: boolean;
canRetryDraftGen?: boolean;
canRetryCostVrfc?: boolean;
}
const PENDING_APPROVAL_ACTIONS = new Set<FnrsCnsltnViewButtonAction>([
FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRsltInq,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwStrg,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.dmndSplmnt,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwCmptn,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzAprv,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzRply,
]);
function resolveRoleUser01AtrzButtonActions(
prgrsSttsComCd: string,
atrzSeComCdList: string[],
options?: GetButtonConfigsOptions,
): FnrsCnsltnViewButtonAction[] {
if (prgrsSttsComCd === "TE" && options?.hasPendingApprovalRequest === false) {
return [];
}
const atrzStatusMap = ROLE_USER01_ATRZ_STATUS_ACTIONS[prgrsSttsComCd];
if (!atrzStatusMap || atrzSeComCdList.length === 0) return [];
@@ -102,7 +231,38 @@ function resolveRoleUser01AtrzButtonActions(
}
}
return actions;
return actions.filter((action) => {
const needsPendingApproval = PENDING_APPROVAL_ACTIONS.has(action);
if (needsPendingApproval && options?.hasPendingApprovalRequest === false) return false;
if (action === FNRS_CNSLTN_VIEW_BUTTON_ACTION.fileVrfc) {
return options?.canRetryFileVrfc === true;
}
if (action === FNRS_CNSLTN_VIEW_BUTTON_ACTION.fileSplmnt) {
return options?.canRetryFileVrfc !== true;
}
if (action === FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocCrt) {
return options?.canRetryDraftGen === true;
}
if (action === FNRS_CNSLTN_VIEW_BUTTON_ACTION.costVrfc) {
return options?.canRetryCostVrfc === true;
}
if (options?.hasCompletedDraft != null) {
if (prgrsSttsComCd === "SL" || prgrsSttsComCd === "TE" || prgrsSttsComCd === "RK") {
if (action === FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng) {
return options.hasCompletedDraft;
}
}
if (prgrsSttsComCd === "TE") {
if (action === FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRsltInq) {
return options.hasCompletedDraft;
}
}
}
return true;
});
}
export function needsRoleUser01AtrzResolve(
@@ -4,7 +4,6 @@ import {
type ChecklistFormState,
type ChecklistProgress,
type ChecklistRowKey,
type ChecklistStepMap,
REVIEW_STEP_CODES,
type ReviewStepCode,
} from "../model/view-types";
@@ -17,15 +16,6 @@ export function buildChecklistRowKey(
return `${row.taskSeComCd}_${row.cklstSn}_${row.cklstArtclSn}_${row.cklstRvwSn}_${step}`;
}
export function groupByReviewStep(rows: CklstRvwRsltTreeDto[]): ChecklistStepMap {
const base: ChecklistStepMap = { LOCAL: [], ENV: [], KECO: [] };
rows.forEach((row) => {
const step = (row.rvwStpComCd ?? "LOCAL") as ReviewStepCode;
if (REVIEW_STEP_CODES.includes(step)) base[step].push(row);
});
return base;
}
export function extractTaskCodes(rows: CklstRvwRsltTreeDto[]): string[] {
return Array.from(new Set(rows.map((row) => row.taskSeComCd).filter(Boolean)));
}
@@ -70,13 +60,6 @@ export function computeChecklistProgress(
return { total, completed, percent };
}
export function filterChecklistRowsByReviewStep(
rows: CklstRvwRsltTreeDto[],
reviewStep: ReviewStepCode,
): CklstRvwRsltTreeDto[] {
return rows.filter((row) => row.rvwStpComCd === reviewStep);
}
export function computeReviewStepChecklistProgress(
rows: CklstRvwRsltTreeDto[],
results: ChecklistFormState["results"],
@@ -93,23 +76,6 @@ export function computeReviewStepChecklistProgress(
return { total, completed, percent };
}
export function buildChecklistFormState(rows: CklstRvwRsltTreeDto[]): ChecklistFormState {
const results: ChecklistFormState["results"] = {};
const comments: ChecklistFormState["comments"] = {};
rows.forEach((row) => {
const key = buildChecklistRowKey(row);
if (row.rvwRsltAnmlyYn === "Y" || row.rvwRsltAnmlyYn === "N") {
results[key] = row.rvwRsltAnmlyYn;
}
if (row.picRvwCn) comments[key] = row.picRvwCn;
});
return {
results,
comments,
progress: computeChecklistProgress(rows, results),
};
}
export function mergeChecklistFormStateFromServer(
prev: ChecklistFormState,
allRows: CklstRvwRsltTreeDto[],
@@ -1,14 +1,22 @@
import { z } from "zod";
import {
fnrsCnsltnBizCstItemSchema,
fnrsCnsltnBzScaleItemSchema,
fnrsCnsltnCklstResultItemSchema,
fnrsCnsltnEsntlAtchFileItemSchema,
fnrsCnsltnMngSaveSchema,
fnrsCnsltnSplmntDmndItemSchema,
fnrsSharItemSchema,
} from "@/entities/fnrs-cnsltn";
export const fnrsCnsltnRegistSchema = z.object({
fnrsCnsltnMng: z.object({
fnrsCnsltnMng: fnrsCnsltnMngSaveSchema.extend({
bizNm: z.string().min(1, "사업명은 필수입니다"),
bizBgngYr: z.string().min(1, "사업시작연도는 필수입니다"),
bizEndYr: z.string().min(1, "사업종료연도는 필수입니다"),
bizCn: z.string().min(1, "사업내용은 필수입니다"),
taskSeComCd: z.array(z.string()).min(1, "구분은 필수입니다"),
cfmtnBgtBizNo: z.string().optional(),
bgtAplyYr: z.string().optional(),
dsgnrNm: z.string().min(1, "설계자 이름은 필수입니다"),
dsgnrTelno: z
.string()
@@ -19,39 +27,20 @@ export const fnrsCnsltnRegistSchema = z.object({
.string()
.min(1, "설계자 이메일은 필수입니다")
.email("올바른 이메일 형식이 아닙니다"),
aplcntNm: z.string().optional(),
aplcntMblTelno: z.string().optional(),
aplcntOgdpNm: z.string().optional(),
aplcntEmlAddr: z.string().optional(),
fnrsCnsltnDocNo: z.string().optional(),
admdstCd: z.string().optional(),
envoSeComCd: z.string().optional(),
rtlztnFcltSeComCd: z.string().optional(),
rtlztnUsgSeComCd: z.string().optional(),
prgrsSttsComCd: z.string().optional(),
atchFileGroupId: z.string().optional(),
sharAtchFileGroupId: z.string().optional(),
envoSeCdNm: z.string().optional(),
taskSeCdNm: z.string().optional(),
prgrsSttsCdNm: z.string().optional(),
sido: z.string().optional(),
gugun: z.string().optional(),
regDt: z.string().optional(),
rgtr: z.string().optional(),
rgtrNm: z.string().optional(),
mdfcnDt: z.string().optional(),
sido: z.string().nullish(),
gugun: z.string().nullish(),
}),
bzScaleList: z.array(
z.object({
fnrsCnsltnBzScaleItemSchema.extend({
cnstpSeCd: z.string(),
bscPl: z.number(),
budgetReflct: z.number(),
thtiDsgn: z.number(),
diff: z.number().optional(),
diff: z.number().nullish(),
}),
),
bizCstList: z.array(
z.object({
fnrsCnsltnBizCstItemSchema.extend({
acntgSeCd: z.string(),
bscPlanAmt: z.number(),
bgtRfltAmt: z.number(),
@@ -59,46 +48,33 @@ export const fnrsCnsltnRegistSchema = z.object({
}),
),
fnrsSharList: z.array(
z.object({
fnrsSharItemSchema.extend({
fnrsSharSeCd: z.string(),
exstnAmt: z.number(),
chgRqtAmt: z.number(),
ajmtAmt: z.number().optional(),
rmrkCn: z.string().optional(),
ajmtAmt: z.number().nullish(),
rmrkCn: z.string().nullish(),
}),
),
cklstResultList: z.array(
z.object({
fnrsCnsltnCklstResultItemSchema.extend({
cklstSn: z.number(),
cklstArtclSn: z.number(),
cklstRvwSn: z.number(),
rvwStpComCd: z.string(),
selected: z.enum(["Y", "N"]).nullable(),
picRvwCn: z.string().nullable().optional(),
selected: z.enum(["Y", "N"]).nullish(),
picRvwCn: z.string().nullish(),
}),
),
esntlAtchFileList: z.array(
z.object({
fnrsCnsltnEsntlAtchFileItemSchema.extend({
esntlAtchFileId: z.string(),
atchFileGroupId: z.string(),
}),
),
splmntDmndList: z.array(
z.object({
splmntDmndSn: z.number().nullish(),
splmntArtclComCd: z.string().optional(),
fnrsCnsltnSplmntDmndItemSchema.extend({
splmntArtclCdNm: z.string().nullish(),
splmntDmndCn: z.string().optional(),
splmntDmndDt: z.string().optional(),
splmntDmndAtchFileGroupId: z.string().optional(),
splmntDmndAtchFileNm: z.string().optional(),
splmntCn: z.string().optional(),
splmntPicNm: z.string().optional(),
splmntCmptnDt: z.string().optional(),
splmntAtchFileGroupId: z.string().optional(),
splmntAtchFileNm: z.string().optional(),
splmntAtchFileAtflId: z.string().optional(),
cmptnYn: z.string().optional(),
splmntDmndAtchFileNm: z.string().nullish(),
}),
),
privacyConsent: z.enum(["Y", "N"], {
@@ -112,7 +88,7 @@ export function formatTaskSeComCdForApi(taskSeComCd: string[]): string {
return taskSeComCd.join(",");
}
export function parseTaskSeComCdFromApi(taskSeComCd?: string): string[] {
export function parseTaskSeComCdFromApi(taskSeComCd?: string | null): string[] {
if (!taskSeComCd) return [];
return taskSeComCd.split(",").filter(Boolean);
}
@@ -1,9 +1,21 @@
import type { IUserInfo } from "@/shared/store";
import type { FnrsCnsltnMngDetResponseDto } from "@/entities/fnrs-cnsltn";
import { mapFnrsSharDetToFormList, toEsntlAtchFileSaveList } from "@/widgets/pbadms-sprt";
import {
type FnrsCnsltnApplySaveRequestDto,
fnrsCnsltnApplySaveSchema,
type FnrsCnsltnMngDetResponseDto,
} from "@/entities/fnrs-cnsltn";
import {
type CostItemDef,
type FnrsSharItemDef,
mapFnrsSharDetToFormList,
recalculateFnrsSharTotals,
toEsntlAtchFileSaveList,
toFnrsSharSaveList,
validateBizCstSharBalance,
} from "@/widgets/pbadms-sprt";
import type { FnrsCnsltnRegistFormData } from "./fnrs-cnsltn-regist-schema";
import { parseTaskSeComCdFromApi } from "./fnrs-cnsltn-regist-schema";
import { formatTaskSeComCdForApi, parseTaskSeComCdFromApi } from "./fnrs-cnsltn-regist-schema";
function mapSelectedValue(value?: string | null): "Y" | "N" | null {
if (value === "Y" || value === "N") return value;
@@ -47,15 +59,8 @@ export function mapDetailToFormData(data: FnrsCnsltnMngDetResponseDto): FnrsCnsl
prgrsSttsComCd: detDto.prgrsSttsComCd,
atchFileGroupId: detDto.atchFileGroupId ?? "",
sharAtchFileGroupId: detDto.sharAtchFileGroupId ?? "",
envoSeCdNm: detDto.envoSeCdNm,
taskSeCdNm: detDto.taskSeCdNm,
prgrsSttsCdNm: detDto.prgrsSttsCdNm,
sido: detDto.sido,
gugun: detDto.gugun,
regDt: detDto.regDt,
rgtr: detDto.rgtr,
rgtrNm: detDto.rgtrNm,
mdfcnDt: detDto.mdfcnDt,
},
bzScaleList: bizSclList.map((item) => ({
cnstpSeCd: item.cnstpSeCd,
@@ -94,8 +99,6 @@ export function mapDetailToFormData(data: FnrsCnsltnMngDetResponseDto): FnrsCnsl
splmntPicNm: item.splmntPicNm ?? "",
splmntCmptnDt: item.splmntCmptnDt ?? "",
splmntAtchFileGroupId: item.splmntAtchFileGroupId ?? "",
splmntAtchFileNm: item.splmntAtchFileInfo?.orgnlFileNm ?? "",
splmntAtchFileAtflId: item.splmntAtchFileInfo?.atflId ?? "",
cmptnYn: item.cmptnYn,
})) ?? [],
privacyConsent: "Y",
@@ -146,3 +149,46 @@ export function getCreateDefaultValues(
privacyConsent: "N",
};
}
export function buildFormPayload(
data: FnrsCnsltnRegistFormData,
prgrsSttsComCd: string,
options: {
isEditMode: boolean;
docNo?: string;
sharItems: FnrsSharItemDef[];
costItems: CostItemDef[];
},
): FnrsCnsltnApplySaveRequestDto | { validationMessage: string } {
const withTotals = recalculateFnrsSharTotals(data.fnrsSharList, options.sharItems);
const validationMessage = validateBizCstSharBalance(
data.bizCstList,
withTotals,
options.sharItems,
options.costItems,
);
if (validationMessage) {
return { validationMessage };
}
return fnrsCnsltnApplySaveSchema.parse({
fnrsCnsltnMng: {
...data.fnrsCnsltnMng,
taskSeComCd: formatTaskSeComCdForApi(data.fnrsCnsltnMng.taskSeComCd),
fnrsCnsltnDocNo: options.isEditMode ? options.docNo : undefined,
prgrsSttsComCd,
},
bzScaleList: data.bzScaleList,
bizCstList: data.bizCstList,
fnrsSharList: toFnrsSharSaveList(withTotals),
cklstResultList: data.cklstResultList.map((item) => ({
...item,
selected: item.selected ?? undefined,
})),
esntlAtchFileList: data.esntlAtchFileList,
splmntDmndList: data.splmntDmndList.map((item) => ({
...item,
...(prgrsSttsComCd !== "SV" && prgrsSttsComCd !== "ST" && { cmptnYn: "Y" }),
})),
});
}
@@ -0,0 +1,32 @@
import { z } from "zod";
import { pwrkCnsltnDocEvlSaveRequestSchema } from "@/entities/pwrk-cnsltn-doc-evl";
export const PWRK_CNSLTN_DOC_EVL_SCR_OPTIONS = [
{ value: "5", label: "매우 만족" },
{ value: "4", label: "만족" },
{ value: "3", label: "보통" },
{ value: "2", label: "불만족" },
{ value: "1", label: "매우 불만족" },
] as const;
export const pwrkCnsltnDocEvlFormSchema = z.object({
pwrkCnsltnDocEvlScr: z.enum(["1", "2", "3", "4", "5"], {
required_error: "만족도를 선택해 주세요.",
invalid_type_error: "만족도를 선택해 주세요.",
}),
pwrkCnsltnDocEvlCn: z.string().max(2000).nullish(),
});
export type PwrkCnsltnDocEvlFormValues = z.infer<typeof pwrkCnsltnDocEvlFormSchema>;
export function buildPwrkCnsltnDocEvlSavePayload(
pwrkCnsltnDocNo: string,
form: PwrkCnsltnDocEvlFormValues,
) {
return pwrkCnsltnDocEvlSaveRequestSchema.parse({
pwrkCnsltnDocNo,
pwrkCnsltnDocEvlScr: Number(form.pwrkCnsltnDocEvlScr),
pwrkCnsltnDocEvlCn: form.pwrkCnsltnDocEvlCn?.trim() || undefined,
});
}
@@ -1,3 +1,5 @@
import type { CklstRvwRsltTreeDto } from "@/entities/fnrs-cnsltn";
import {
PBADMS_PROGRESS_ORDER,
REVIEW_STEP_CODES,
@@ -28,11 +30,33 @@ export function getVisibleReviewSteps(
return REVIEW_STEP_CODES.slice(0, count);
}
export function resolveVisibleReviewSteps(
prgrsSttsComCd: string,
userRoleCode: string | undefined,
cklstResultList: CklstRvwRsltTreeDto[],
): ReviewStepCode[] {
const byProgressAndRole = getVisibleReviewSteps(prgrsSttsComCd, userRoleCode);
const currentStep = byProgressAndRole.at(-1);
const stepsWithData = new Set<ReviewStepCode>();
for (const row of cklstResultList) {
const step = row.rvwStpComCd as ReviewStepCode | undefined;
if (step && REVIEW_STEP_CODES.includes(step)) stepsWithData.add(step);
}
return REVIEW_STEP_CODES.filter(
(step) => stepsWithData.has(step) || step === currentStep,
);
}
export function getEditableReviewStep(
dtlInstCd?: string,
prgrsSttsComCd?: string,
): ReviewStepCode | undefined {
if (dtlInstCd === "ROLE_USER03" && (prgrsSttsComCd === "LE" || prgrsSttsComCd === "TP")) {
if (
dtlInstCd === "ROLE_USER03" &&
(prgrsSttsComCd === "LE" || prgrsSttsComCd === "TP" || prgrsSttsComCd === "RR")
) {
return "ENV";
}
@@ -5,10 +5,13 @@ import type {
BzScaleItem,
CklstResultItem,
FnrsCnsltnApplySaveRequestDto,
FnrsCnsltnFileVrfcRsltResponseDto,
FnrsCnsltnMngDetResponseDto,
FnrsCnsltnMngResponseDto,
FnrsCnsltnSplmntDmndItem,
} from "@/entities/fnrs-cnsltn";
import { fnrsCnsltnApplySaveSchema } from "@/entities/fnrs-cnsltn";
import type { PwrkCnsltnCnsltnResponseDto } from "@/entities/pwrk-cnsltn-mng";
import {
mapFnrsSharDetToFormList,
type SplmntDmndDialogConfirmPayload,
@@ -59,14 +62,14 @@ export function buildSavePayload(
const cklstResultList = data.cklstResultList ?? [];
const esntlFileList = data.esntlFileList ?? [];
return {
return fnrsCnsltnApplySaveSchema.parse({
fnrsCnsltnMng: { ...detDto, prgrsSttsComCd },
bzScaleList: toBzScaleSaveDto(bizSclList),
bizCstList: toBizCstSaveDto(bizCstList),
cklstResultList: toCklstSaveDto(cklstResultList, checklistFormState, reviewStep),
esntlAtchFileList: toEsntlAtchFileSaveList(esntlFileList),
fnrsSharList: toFnrsSharSaveList(mapFnrsSharDetToFormList(data.fnrsSharList ?? [])),
};
});
}
export function buildTechReviewReplyPayload(
@@ -78,12 +81,12 @@ export function buildTechReviewReplyPayload(
reviewOpinionFormData?: ReviewOpinionGetFormDataResult | null,
opnrNm?: string,
): FnrsCnsltnApplySaveRequestDto {
return {
return fnrsCnsltnApplySaveSchema.parse({
...buildSavePayload(data, checklistFormState, reviewStep, prgrsSttsComCd),
techReViewSaveCd,
rvwStpCd: reviewStep,
rvwOpnnList: toRvwOpnnSaveDto(data, reviewOpinionFormData, opnrNm),
};
});
}
export function buildSupplementReqPayload(
@@ -105,10 +108,47 @@ export function buildSupplementReqPayload(
cmptnYn: "N",
}));
return {
return fnrsCnsltnApplySaveSchema.parse({
...buildSavePayload(data, checklistFormState, "LOCAL", "SL"),
splmntDmndList: [...existingItems, ...newItems],
};
});
}
export function buildFileSplmntReqPayload(
data: FnrsCnsltnMngDetResponseDto,
selected: FnrsCnsltnFileVrfcRsltResponseDto[],
checklistFormState: ChecklistFormState,
): FnrsCnsltnApplySaveRequestDto {
const newItems: FnrsCnsltnSplmntDmndItem[] = selected.map((row) => ({
splmntArtclComCd: row.fileSeComCd,
splmntDmndCn: row.vrfcRsltCn,
cmptnYn: "N",
}));
return fnrsCnsltnApplySaveSchema.parse({
...buildSavePayload(data, checklistFormState, "LOCAL", "SL"),
splmntDmndList: [...newItems],
});
}
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 fnrsCnsltnApplySaveSchema.parse({
...buildSavePayload(data, checklistFormState, "LOCAL", "SL"),
splmntDmndList: [...existingItems, ...newItems],
});
}
export function toSplmntDmndSaveItem(item: FnrsCnsltnSplmntDmndItem): FnrsCnsltnSplmntDmndItem {
@@ -130,21 +170,21 @@ export function buildSupplementSubmitPayload(
data: FnrsCnsltnMngDetResponseDto,
checklistFormState: ChecklistFormState,
): FnrsCnsltnApplySaveRequestDto {
return {
return fnrsCnsltnApplySaveSchema.parse({
...buildSavePayload(data, checklistFormState, "LOCAL", "SL"),
splmntDmndList: data.splmntDmndList,
};
splmntDmndList: (data.splmntDmndList ?? []).map(toSplmntDmndSaveItem),
});
}
export function buildTechReviewPayload(
det: FnrsCnsltnMngResponseDto,
cklstResultList: CklstResultItem[],
): FnrsCnsltnApplySaveRequestDto {
return {
return fnrsCnsltnApplySaveSchema.parse({
fnrsCnsltnMng: { ...det, prgrsSttsComCd: "ER" },
bzScaleList: [],
bizCstList: [],
cklstResultList,
esntlAtchFileList: [],
};
});
}
@@ -12,7 +12,7 @@ const columnHelper = createColumnHelper<FnrsCnsltnMngResponseDto>();
const MAIN_STEP_LABELS = {
apply: "재원협의 신청",
envoReview: "환경청 검토",
envoReview: "재원협의 검토",
techReview: "기술검토",
reply: "검토결과 회신",
} as const;
@@ -20,7 +20,7 @@ const MAIN_STEP_LABELS = {
export const PROGRESS_STATUS_COLUMNS = [
{ label: "재원협의 신청 (시군)", sttsComCd: "ST" },
{ label: "재원협의 신청 (시도)", sttsComCd: "LE" },
{ label: "환경청 검토", sttsComCd: "ER" },
{ label: "재원협의 검토", sttsComCd: "ER" },
{ label: "기술검토", sttsComCd: "TE" },
{ label: "기술검토 회신", sttsComCd: "TP" },
{ label: "환경청 검토 결과 회신", sttsComCd: "EP" },
@@ -61,7 +61,7 @@ function buildProgressBadges(prgrsSttsComCd: string, techRplyHstryYn: "Y" | "N")
];
}
if (prgrsSttsComCd === "ER" || prgrsSttsComCd === "TE" || prgrsSttsComCd === "SL") {
if (["FV", "ER", "AC", "TE", "RB", "RA", "AV", "EV", "RK", "RR", "SL"].includes(prgrsSttsComCd)) {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
@@ -82,7 +82,7 @@ function buildProgressBadges(prgrsSttsComCd: string, techRplyHstryYn: "Y" | "N")
];
}
if (prgrsSttsComCd === "EP") {
if (prgrsSttsComCd === "EP" || prgrsSttsComCd === "TA") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
@@ -113,7 +113,7 @@ function getBadgeColor(state: BadgeState) {
}
}
function formatOptional(value: string | undefined) {
function formatOptional(value: string | null | undefined) {
return value?.trim() ? value : "-";
}
@@ -0,0 +1,73 @@
"use client";
import { createColumnHelper } from "@tanstack/react-table";
import type { FnrsCnsltnFileVrfcRsltResponseDto } from "@/entities/fnrs-cnsltn";
import { Checkbox } from "@common/components";
const FILE_SE_COM_CD_MAP = {
ESNTL: "필수",
ETC: "기타",
};
const columnHelper = createColumnHelper<FnrsCnsltnFileVrfcRsltResponseDto>();
function formatOptional(value: string | undefined) {
return value?.trim() ? value : "-";
}
export function getFileVrfcRsltRowKey(row: FnrsCnsltnFileVrfcRsltResponseDto, index: number) {
if (row.atflId) return row.atflId;
return [row.esntlAtchFileId, row.fileSeComCd, row.orgnlFileNm, String(index)]
.filter(Boolean)
.join("::");
}
export function createFileVrfcSplmntColumns() {
return [
columnHelper.display({
id: "select",
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllRowsSelected()
? true
: table.getIsSomeRowsSelected()
? "indeterminate"
: false
}
onCheckedChange={(checked) => table.toggleAllRowsSelected(!!checked)}
onClick={(event) => event.stopPropagation()}
aria-label="전체 선택"
/>
),
meta: { align: "center", width: 48 },
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
onCheckedChange={(checked) => row.toggleSelected(!!checked)}
onClick={(event) => event.stopPropagation()}
aria-label="행 선택"
/>
),
}),
columnHelper.accessor("fileSeComCd", {
header: () => <p className="whitespace-nowrap">파일유형</p>,
meta: { align: "left" },
cell: ({ getValue }) => (
<p>{formatOptional(FILE_SE_COM_CD_MAP[getValue() as keyof typeof FILE_SE_COM_CD_MAP])}</p>
),
}),
columnHelper.accessor("fileSeNm", {
header: () => <p className="whitespace-nowrap">파일구분</p>,
meta: { align: "left" },
cell: ({ getValue }) => <p className="whitespace-nowrap">{formatOptional(getValue())}</p>,
}),
columnHelper.accessor("vrfcRsltCn", {
header: "검증내역",
meta: { align: "left" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
];
}
@@ -44,10 +44,14 @@ export interface ChecklistFormState {
progress: ChecklistProgress;
}
export type ChecklistStepMap = Record<ReviewStepCode, CklstRvwRsltTreeDto[]>;
export const REVIEW_STEP_CODES: ReviewStepCode[] = ["LOCAL", "ENV", "KECO"];
export const CHECKLIST_TAB_LABELS: Record<ReviewStepCode, string> = {
LOCAL: "자가체크",
ENV: "기술검토",
KECO: "환경청검토",
};
export const REVIEW_STEP_LABELS: Record<ReviewStepCode, string> = {
LOCAL: "지자체",
ENV: "환경청",
@@ -61,16 +65,25 @@ export const TASK_CODE_LABELS: Record<string, string> = {
};
export const PBADMS_PROGRESS_ORDER = [
// LOCAL
"SV",
"SL",
"TA",
"ST",
"SL",
"RB",
"EP",
"TA",
// ENV
"LE",
"TP",
"RA",
"RR",
// KECO
"ER",
"TE",
"TB",
"EV",
"RK",
] as const;
export const ROLE_TO_REVIEW_STEP: Record<string, ReviewStepCode> = {
@@ -0,0 +1,168 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import type { UseQueryResult } from "@tanstack/react-query";
import { getCoreRowModel, type RowSelectionState } from "@tanstack/react-table";
import { useDataTable } from "@/shared/lib";
import type { PageResponse } from "@/shared/model";
import {
type FnrsCnsltnFileVrfcRsltResponseDto,
useFnrsCnsltnFileVrfcResults,
} from "@/entities/fnrs-cnsltn";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@common/components";
import {
createFileVrfcSplmntColumns,
getFileVrfcRsltRowKey,
} from "../../model/file-vrfc-splmnt-columns";
interface FileVrfcSplmntDialogProps {
open: boolean;
fnrsCnsltnDocNo: string;
isSubmitting?: boolean;
onClose: () => void;
onConfirm: (selected: FnrsCnsltnFileVrfcRsltResponseDto[]) => void;
}
function toFileVrfcPageResponse(
rows: FnrsCnsltnFileVrfcRsltResponseDto[],
): PageResponse<FnrsCnsltnFileVrfcRsltResponseDto> {
const content = rows.filter((row) => row.vrfcSttsComCd === "WARNING");
const totalElements = content.length;
return {
content,
pageable: {
sort: { empty: true, sorted: false, unsorted: true },
offset: 0,
pageNumber: 0,
pageSize: totalElements || 1,
paged: false,
unpaged: true,
},
sort: { empty: true, sorted: false, unsorted: true },
totalPages: 1,
totalElements,
number: 0,
numberOfElements: totalElements,
size: totalElements || 1,
first: true,
last: true,
empty: totalElements === 0,
};
}
function FileVrfcSplmntDialog({
open,
fnrsCnsltnDocNo,
isSubmitting = false,
onClose,
onConfirm,
}: FileVrfcSplmntDialogProps) {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const listQuery = useFnrsCnsltnFileVrfcResults(open ? fnrsCnsltnDocNo : null);
const pageData = useMemo(
() => (listQuery.data ? toFileVrfcPageResponse(listQuery.data) : undefined),
[listQuery.data],
);
const pageQuery = useMemo(
() =>
({
isPending: listQuery.isPending,
isError: listQuery.isError,
isSuccess: listQuery.isSuccess,
data: pageData,
error: listQuery.error,
status: listQuery.status,
fetchStatus: listQuery.fetchStatus,
}) as UseQueryResult<PageResponse<FnrsCnsltnFileVrfcRsltResponseDto>>,
[
listQuery.error,
listQuery.fetchStatus,
listQuery.isError,
listQuery.isPending,
listQuery.isSuccess,
listQuery.status,
pageData,
],
);
const columns = useMemo(() => createFileVrfcSplmntColumns(), []);
const { tableComponent } = useDataTable({
query: pageQuery,
tableOptions: {
columns,
state: { rowSelection },
enableRowSelection: true,
enableMultiRowSelection: true,
getRowId: (row, index) => getFileVrfcRsltRowKey(row, index),
onRowSelectionChange: (updater) => {
const next = typeof updater === "function" ? updater(rowSelection) : updater;
setRowSelection(next);
},
getCoreRowModel: getCoreRowModel(),
},
dataTableOptions: {
viewPagination: false,
},
});
const selectedRows = useMemo(() => {
const selectedIds = new Set(Object.keys(rowSelection));
const content = pageData?.content ?? [];
return content.filter((row, index) => selectedIds.has(getFileVrfcRsltRowKey(row, index)));
}, [pageData?.content, rowSelection]);
useEffect(() => {
if (!open) setRowSelection({});
}, [open]);
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen) onClose();
}
function handleConfirm() {
if (selectedRows.length === 0) return;
onConfirm(selectedRows);
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-[80rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>파일보완</DialogTitle>
</DialogHeader>
<div className="space-y-3">{tableComponent}</div>
<DialogFooter>
<Button type="button" color="white" onClick={onClose} disabled={isSubmitting}>
취소
</Button>
<Button
type="button"
onClick={handleConfirm}
disabled={isSubmitting || selectedRows.length === 0}
>
보완요청
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { FileVrfcSplmntDialog };
export type { FileVrfcSplmntDialogProps };
@@ -9,11 +9,19 @@ import { AxiosError } from "axios";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useUserStore } from "@/shared/lib";
import {
AI_AUTO_SE,
type AiAutoPrcsLatestResponseDto,
useAiAutoPrcsLatest,
} from "@/entities/ai-auto-prcs-mng";
import { useReGenerateDraft, useReTransferDocument, useReVerifyCost } from "@/entities/dcrag-to";
import {
buildElaprPrgrsSttsListFromAplnRows,
ElaprApprovalDialog,
type ElaprDmndSaveRequestDto,
elaprDmndService,
type ElaprDocPrgrsPickerConfirmPayload,
ElaprDocPrgrsPickerDialog,
type ElaprTmpltPickerConfirmPayload,
ElaprTmpltPickerDialog,
hasCurrentUserPendingApprovalRequest,
@@ -21,14 +29,25 @@ import {
} from "@/entities/elapr";
import {
type DocPrgrsUpdateRequestDto,
type EnvoActnReviewReqDto,
type FnrsCnsltnApplySaveRequestDto,
type FnrsCnsltnFileVrfcRsltResponseDto,
fnrsCnsltnKeys,
type FnrsCnsltnMngDetResponseDto,
fnrsCnsltnMngService,
type FnrsExamineRequestDto,
} from "@/entities/fnrs-cnsltn";
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";
import { isRagIndexingInProgress } from "../../lib/ai-pending-utils";
import {
FNRS_CNSLTN_VIEW_BUTTON_ACTION,
type FnrsCnsltnViewButtonAction,
@@ -37,6 +56,8 @@ import {
} from "../../lib/button-config";
import { computeReviewStepChecklistProgress } from "../../lib/checklist-utils";
import {
buildFileSplmntReqPayload,
buildRvwRsltSplmntReqPayload,
buildSavePayload,
buildSupplementReqPayload,
buildTechReviewReplyPayload,
@@ -45,6 +66,19 @@ import {
import { type ChecklistFormState } from "../../model/view-types";
import type { TechReviewOpinionSectionHandle } from "../sections/tech-review-opinion-section";
import { FileVrfcSplmntDialog } from "./file-vrfc-splmnt-dialog";
import { PwrkCnsltnDocMngDialog } from "./pwrk-cnsltn-doc-mng-dialog";
import { PwrkCnsltnRvwRsltInqDialog } from "./pwrk-cnsltn-rvw-rslt-inq-dialog";
const AI_RETRY_PRGRS_STTS = new Set(["ER", "TE", "EV"]);
function findLatestByAutoSe(
list: AiAutoPrcsLatestResponseDto[] | undefined,
atmtnSeComCd: string,
): AiAutoPrcsLatestResponseDto | undefined {
return list?.find((item) => item.atmtnSeComCd === atmtnSeComCd);
}
interface FloatingActionButtonsProps {
data: FnrsCnsltnMngDetResponseDto;
docSeComCd: string;
@@ -56,9 +90,19 @@ interface FloatingActionButtonsProps {
checklistFormState: ChecklistFormState;
techReviewOpinionRef?: React.RefObject<TechReviewOpinionSectionHandle | null>;
opnrNm?: string;
isAiPending?: boolean;
}
type ActiveDialog = "elaprTmplt" | "rjct" | "dmndSplmnt" | "elaprApproval" | null;
type ActiveDialog =
| "elaprTmplt"
| "elaprDocPrgrs"
| "rjct"
| "dmndSplmnt"
| "fileSplmnt"
| "elaprApproval"
| "pwrkCnsltnDocMng"
| "rvwRsltInq"
| null;
function FloatingActionButtons({
data,
@@ -71,6 +115,7 @@ function FloatingActionButtons({
checklistFormState,
techReviewOpinionRef,
opnrNm,
isAiPending = false,
}: FloatingActionButtonsProps) {
const router = useRouter();
const { confirm } = useConfirm();
@@ -84,29 +129,89 @@ function FloatingActionButtons({
const isRegister = data.detDto?.rgtr === userInfo?.userUnqId;
const needsAtrzResolve = needsRoleUser01AtrzResolve(dtlInstCd, prgrsSttsComCd);
const needsTeApprovalCheck = needsAtrzResolve && prgrsSttsComCd === "TE";
const { data: elaprDocPrgrsList = [], isLoading: isElaprDocPrgrsLoading } =
useElaprDocPrgrsByDocNo(needsTeApprovalCheck ? docNo : null);
const needsElaprPrgrsCheck = needsAtrzResolve && ["TE", "RK"].includes(prgrsSttsComCd);
const needsPwrkDraftCheck =
dtlInstCd === "ROLE_USER01" && ["SL", "TE", "RK"].includes(prgrsSttsComCd);
const needsPwrkForRb = dtlInstCd === "ROLE_USER05" && prgrsSttsComCd === "RB";
const needsPwrkForAiRetry = dtlInstCd === "ROLE_USER01" && ["TE", "EV"].includes(prgrsSttsComCd);
const needsPwrkLookup = needsPwrkDraftCheck || needsPwrkForRb || needsPwrkForAiRetry;
const needsAiAutoLatest = dtlInstCd === "ROLE_USER01" && AI_RETRY_PRGRS_STTS.has(prgrsSttsComCd);
const { data: elaprDocPrgrsList = [], isLoading: isElaprDocPrgrsLoading } =
useElaprDocPrgrsByDocNo(needsElaprPrgrsCheck ? docNo : null);
const { data: pwrkCnsltnData, isLoading: isPwrkCnsltnLoading } = usePwrkCnsltnMngByUpDocNo(
docNo,
needsPwrkLookup,
);
const { data: aiAutoLatestList, isLoading: isAiAutoLatestLoading } = useAiAutoPrcsLatest(docNo, {
enabled: needsAiAutoLatest,
});
const fileVrfcLatest = findLatestByAutoSe(aiAutoLatestList, AI_AUTO_SE.FILE_VRFC);
const draftGenLatest = findLatestByAutoSe(aiAutoLatestList, AI_AUTO_SE.DRAFT_GEN);
const costVrfcLatest = findLatestByAutoSe(aiAutoLatestList, AI_AUTO_SE.COST_VRFC);
const canRetryFileVrfc = fileVrfcLatest?.isRetryable === true;
const canRetryDraftGen = draftGenLatest?.isRetryable === true;
const canRetryCostVrfc = costVrfcLatest?.isRetryable === true;
const reTransferDocument = useReTransferDocument();
const reGenerateDraft = useReGenerateDraft();
const reVerifyCost = useReVerifyCost();
const isBusy =
isProcessing ||
reTransferDocument.isPending ||
reGenerateDraft.isPending ||
reVerifyCost.isPending;
const hasCompletedDraft = Boolean(findCompletedPwrkDraft(pwrkCnsltnData));
const pwrkCnsltnDocNo =
pwrkCnsltnData?.pwrkCnsltnDocNo ??
draftGenLatest?.pwrkCnsltnDocNo ??
costVrfcLatest?.pwrkCnsltnDocNo;
const elaprPrgrsForPending =
prgrsSttsComCd === "RK"
? elaprDocPrgrsList.filter((row) => Number(row.elaprCycl) === 2)
: elaprDocPrgrsList;
const hasPendingApprovalRequest = hasCurrentUserPendingApprovalRequest(
elaprDocPrgrsList,
elaprPrgrsForPending,
userInfo?.userUnqId,
);
const viewOnlyPwrkCnsltnDoc =
prgrsSttsComCd === "SL" ||
(prgrsSttsComCd === "RB" && dtlInstCd !== "ROLE_USER05") ||
(prgrsSttsComCd === "RK" && dtlInstCd === "ROLE_USER03");
const actionButtons = useMemo(() => {
if (needsAtrzResolve && isRvwLoginUserLoading) return [];
if (needsTeApprovalCheck && isElaprDocPrgrsLoading) return [];
if (needsElaprPrgrsCheck && isElaprDocPrgrsLoading) return [];
if (needsPwrkDraftCheck && isPwrkCnsltnLoading) return [];
if (needsAiAutoLatest && isAiAutoLatestLoading) return [];
return getButtonConfigs(dtlInstCd, prgrsSttsComCd, atrzSeComCdList, {
hasPendingApprovalRequest: needsTeApprovalCheck ? hasPendingApprovalRequest : undefined,
hasPendingApprovalRequest: needsElaprPrgrsCheck ? hasPendingApprovalRequest : undefined,
hasCompletedDraft: needsPwrkDraftCheck ? hasCompletedDraft : undefined,
canRetryFileVrfc: needsAiAutoLatest ? canRetryFileVrfc : undefined,
canRetryDraftGen: needsAiAutoLatest ? canRetryDraftGen : undefined,
canRetryCostVrfc: needsAiAutoLatest ? canRetryCostVrfc : undefined,
});
}, [
atrzSeComCdList,
canRetryCostVrfc,
canRetryDraftGen,
canRetryFileVrfc,
dtlInstCd,
hasCompletedDraft,
hasPendingApprovalRequest,
isAiAutoLatestLoading,
isElaprDocPrgrsLoading,
isPwrkCnsltnLoading,
isRvwLoginUserLoading,
needsAiAutoLatest,
needsAtrzResolve,
needsTeApprovalCheck,
needsElaprPrgrsCheck,
needsPwrkDraftCheck,
prgrsSttsComCd,
]);
@@ -212,19 +317,6 @@ function FloatingActionButtons({
router.push("/fnrs-cnsltn");
}
async function handleFnrsCnsltnAncmntClick() {
const payload: DocPrgrsUpdateRequestDto = {
docNo,
docSeComCd,
prgrsSttsComCd: "TB",
};
await executeAction({
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.fnrsCnsltnAncmnt,
request: payload,
successMessage: "고시되었습니다.",
});
}
async function handleEnvoSbmsnClick() {
const payload: DocPrgrsUpdateRequestDto = {
docNo,
@@ -260,7 +352,7 @@ function FloatingActionButtons({
await executeAction({
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.sncnAprv,
request: payload,
successMessage: "인가승인되었습니다.",
successMessage: "결과 회신이 완료되었습니다.",
});
}
@@ -276,7 +368,7 @@ function FloatingActionButtons({
);
if (percent !== 100) {
alert("모든 체크리스트 항목을 체크해야 검토결과 회신이 가능합니다.");
alert("모든 체크리스트 항목을 체크해야 검토 결과 회신이 가능합니다.");
return;
}
@@ -293,21 +385,58 @@ function FloatingActionButtons({
await executeAction({
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRply,
request: payload,
successMessage: "검토결과 회신이 완료되었습니다.",
successMessage: "검토 결과 회신이 완료되었습니다.",
});
techReviewOpinionRef?.current?.resetForm();
}
async function handleRvwOpnnRplyClick() {
const { percent } = computeReviewStepChecklistProgress(
data.cklstResultList ?? [],
checklistFormState.results,
"ENV",
);
if (percent !== 100) {
alert("모든 체크리스트 항목을 체크해야 검토 의견 회신이 가능합니다.");
return;
}
const payload = buildTechReviewReplyPayload(
data,
checklistFormState,
"ENV",
"RB",
"CP",
getReviewOpinionFormData(),
opnrNm,
);
await executeAction({
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwOpnnRply,
request: payload,
successMessage: "검토 의견 회신이 완료되었습니다.",
});
techReviewOpinionRef?.current?.resetForm();
}
async function handleRqstTechRvwClick() {
const payload: DocPrgrsUpdateRequestDto = {
docNo,
docSeComCd,
prgrsSttsComCd: "ER",
const payload: FnrsExamineRequestDto = {
prgrsSttsComCd: "FV",
};
await executeAction({
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.rqstTechRvw,
request: payload,
successMessage: "기술검토 의뢰가 완료되었습니다.",
successMessage: "기술 검토 의뢰가 완료되었습니다.",
});
}
async function handleRqstActnRvwClick() {
const payload: EnvoActnReviewReqDto = {};
await executeAction({
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.rqstActnRvw,
request: payload,
successMessage: "조치 결과 검토 의뢰가 완료되었습니다.",
});
}
@@ -316,8 +445,27 @@ function FloatingActionButtons({
elaprDocNo: docNo,
dmndTtl: data.detDto?.bizNm ?? "",
dmndCn: data.detDto?.bizCn ?? "",
elaprCycl: data.detDto?.prgrsSttsComCd === "RK" ? 2 : 1,
docPrgrsSttsComCd: data.detDto?.prgrsSttsComCd,
elaprCycl: 1,
docPrgrsSttsComCd: "AC",
elaprPrgrsSttsList: buildElaprPrgrsSttsListFromAplnRows(aplnRows),
};
await executeAction({
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.dsgntRvwr,
request: payload,
successMessage: "검토자가 지정되었습니다.",
});
await queryClient.invalidateQueries({
queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, "findDocPrgrsByDocNo", docNo],
});
}
async function handleElaprDocPrgrsConfirm({ aplnRows }: ElaprDocPrgrsPickerConfirmPayload) {
const payload: ElaprDmndSaveRequestDto = {
elaprDocNo: docNo,
dmndTtl: data.detDto?.bizNm ?? "",
dmndCn: data.detDto?.bizCn ?? "",
elaprCycl: 2,
docPrgrsSttsComCd: "RK",
elaprPrgrsSttsList: buildElaprPrgrsSttsListFromAplnRows(aplnRows),
};
await executeAction({
@@ -350,15 +498,17 @@ function FloatingActionButtons({
}
async function handleRvwCmptnClick() {
const { percent } = computeReviewStepChecklistProgress(
data.cklstResultList ?? [],
checklistFormState.results,
"KECO",
);
if (prgrsSttsComCd === "TE") {
const { percent } = computeReviewStepChecklistProgress(
data.cklstResultList ?? [],
checklistFormState.results,
"KECO",
);
if (percent !== 100) {
alert("모든 체크리스트 항목을 체크해야 검토완료가 가능합니다.");
return;
if (percent !== 100) {
alert("모든 체크리스트 항목을 체크해야 검토완료가 가능합니다.");
return;
}
}
const payload = {
@@ -366,7 +516,7 @@ function FloatingActionButtons({
data,
checklistFormState,
"KECO",
"TE",
prgrsSttsComCd,
"SV",
getReviewOpinionFormData(),
opnrNm,
@@ -382,151 +532,325 @@ function FloatingActionButtons({
setActiveDialog("elaprApproval");
}
function handleDmndSplmntClick() {
setActiveDialog("dmndSplmnt");
}
async function handleDmndSplmntConfirm(payload: SplmntDmndDialogConfirmPayload) {
setActiveDialog(null);
const requestPayload = buildSupplementReqPayload(data, payload, checklistFormState);
await executeAction({
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.dmndSplmnt,
request: requestPayload,
successMessage: "보완요청이 처리되었습니다.",
successMessage: "보완 요청이 처리되었습니다.",
});
}
function handleFileSplmntClick() {
setActiveDialog("fileSplmnt");
}
async function handleFileSplmntConfirm(selected: FnrsCnsltnFileVrfcRsltResponseDto[]) {
setActiveDialog(null);
const requestPayload = buildFileSplmntReqPayload(data, selected, checklistFormState);
await executeAction({
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.fileSplmnt,
request: requestPayload,
successMessage: "파일 보완 요청이 처리되었습니다.",
});
}
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: "조치완료되었습니다.",
});
}
function handleFileVrfcClick() {
reTransferDocument.mutate(
{ fnrsCnsltnDocNo: docNo },
{ onSuccess: () => alert("파일 검증을 요청했습니다.") },
);
}
function handlePwrkCnsltnDocCrtClick() {
if (!pwrkCnsltnDocNo) {
alert("실무협의 문서번호를 찾을 수 없습니다.");
return;
}
reGenerateDraft.mutate(
{
fnrsCnsltnDocNo: docNo,
pwrkCnsltnDocNo,
crtDmndUuid: draftGenLatest?.crtDmndUuid,
},
{ onSuccess: () => alert("초안 생성을 요청했습니다.") },
);
}
function handleCostVrfcClick() {
if (!pwrkCnsltnDocNo) {
alert("실무협의 문서번호를 찾을 수 없습니다.");
return;
}
reVerifyCost.mutate(
{
fnrsCnsltnDocNo: docNo,
pwrkCnsltnDocNo,
},
{ onSuccess: () => alert("금액 검증을 요청했습니다.") },
);
}
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)]">
<div className="mx-4 flex flex-wrap justify-center gap-3 sm:gap-4">
<Button color="white" type="button" onClick={handleGoToList} disabled={isProcessing}>
<Button color="white" type="button" onClick={handleGoToList} disabled={isBusy}>
목록
</Button>
{canEdit && (
<Button color="gray" onClick={handleEdit} disabled={isProcessing}>
수정
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.strg) && (
<Button onClick={handleStrgClick} disabled={isProcessing}>
저장
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.aply) && (
<Button color="green" onClick={handleAplyClick} disabled={isProcessing}>
신청완료
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.del) && (
<Button color="red" onClick={handleDelClick} disabled={isProcessing}>
삭제
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.fnrsCnsltnAncmnt) && (
<Button onClick={handleFnrsCnsltnAncmntClick} disabled={isProcessing}>
재원협의 고시
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.envoSbmsn) && (
<Button onClick={handleEnvoSbmsnClick} disabled={isProcessing}>
환경청제출
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rjct) && (
<Button color="red" onClick={handleRjctClick} disabled={isProcessing}>
반려
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.sncnAprv) && (
<Button onClick={handleSncnAprvClick} disabled={isProcessing}>
인가승인
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRply) && (
<Button color="green" onClick={handleRvwRplyClick} disabled={isProcessing}>
검토결과 회신
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rqstTechRvw) && (
<Button onClick={handleRqstTechRvwClick} disabled={isProcessing}>
기술검토 의뢰
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.fileVrfc) && (
<Button color="gray" onClick={() => {}} 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}>
실무협의서 초안생성
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng) && (
<Button color="orange" onClick={() => {}} disabled={isProcessing}>
실무협의서 관리
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwStrg) && (
<Button onClick={handleRvwStrgClick} disabled={isProcessing}>
저장
</Button>
)}
{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) && (
<Button onClick={() => setActiveDialog("elaprApproval")} disabled={isProcessing}>
결재(승인)
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzRply) && (
<Button onClick={() => setActiveDialog("elaprApproval")} disabled={isProcessing}>
결재(회신)
</Button>
{!isAiPending && (
<>
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocCrt) && (
<Button
color="orange"
onClick={handlePwrkCnsltnDocCrtClick}
disabled={isBusy}
title={draftGenLatest?.prcsErrCn ?? undefined}
>
실무협의서 초안 생성
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng) && (
<Button
color="orange"
onClick={() => setActiveDialog("pwrkCnsltnDocMng")}
disabled={isBusy}
>
실무협의서 관리
</Button>
)}
{canEdit && (
<Button color="gray" onClick={handleEdit} disabled={isBusy}>
수정
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.strg) && (
<Button onClick={handleStrgClick} disabled={isBusy}>
저장
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.aply) && (
<Button color="green" onClick={handleAplyClick} disabled={isBusy}>
신청 완료
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.del) && (
<Button color="red" onClick={handleDelClick} disabled={isBusy}>
삭제
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.envoSbmsn) && (
<Button onClick={handleEnvoSbmsnClick} disabled={isBusy}>
환경청 제출
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rjct) && (
<Button color="red" onClick={handleRjctClick} disabled={isBusy}>
반려
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.sncnAprv) && (
<Button color="green" onClick={handleSncnAprvClick} disabled={isBusy}>
결과 회신
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwOpnnRply) && (
<Button onClick={handleRvwOpnnRplyClick} disabled={isBusy}>
검토 의견 회신
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRply) && (
<Button color="green" onClick={handleRvwRplyClick} disabled={isBusy}>
검토 결과 회신
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rqstTechRvw) && (
<Button onClick={handleRqstTechRvwClick} disabled={isBusy}>
기술 검토 의뢰
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rqstActnRvw) && (
<Button onClick={handleRqstActnRvwClick} disabled={isBusy}>
조치 결과 검토 의뢰
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.fileSplmnt) && (
<Button color="gray" onClick={handleFileSplmntClick} disabled={isBusy}>
파일 보완 요청
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.fileVrfc) && (
<Button
color="gray"
onClick={handleFileVrfcClick}
disabled={isBusy}
title={fileVrfcLatest?.prcsErrCn ?? undefined}
>
파일 검증
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.costVrfc) && (
<Button
color="gray"
onClick={handleCostVrfcClick}
disabled={isBusy}
title={costVrfcLatest?.prcsErrCn ?? undefined}
>
금액 검증
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.dsgntRvwr) &&
!isRagIndexingInProgress(prgrsSttsComCd, data.ragIndexingInfo) && (
<Button
onClick={() =>
setActiveDialog(prgrsSttsComCd === "EV" ? "elaprDocPrgrs" : "elaprTmplt")
}
disabled={isBusy}
>
검토자 지정
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.actnCmptn) && (
<Button color="green" onClick={handleActnCmptnClick} disabled={isBusy}>
조치 완료
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRsltInq) && (
<Button color="sky" onClick={() => setActiveDialog("rvwRsltInq")} disabled={isBusy}>
검토 결과 조회
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwStrg) && (
<Button onClick={handleRvwStrgClick} disabled={isBusy}>
저장
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwCmptn) && (
<Button color="green" onClick={handleRvwCmptnClick} disabled={isBusy}>
검토 완료
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.dmndSplmnt) && (
<Button
color="gray"
onClick={() => setActiveDialog("dmndSplmnt")}
disabled={isBusy}
>
보완 요청
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzAprv) && (
<Button onClick={() => setActiveDialog("elaprApproval")} disabled={isBusy}>
결재(승인)
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzRply) && (
<Button onClick={() => setActiveDialog("elaprApproval")} disabled={isBusy}>
결재(회신)
</Button>
)}
</>
)}
</div>
</div>
<ElaprTmpltPickerDialog
open={activeDialog === "elaprTmplt"}
onClose={() => setActiveDialog(null)}
docSeComCd={docSeComCd}
onConfirm={handleElaprTmpltConfirm}
/>
{!isAiPending && (
<>
<ElaprTmpltPickerDialog
open={activeDialog === "elaprTmplt"}
onClose={() => setActiveDialog(null)}
docSeComCd={docSeComCd}
onConfirm={handleElaprTmpltConfirm}
/>
<SplmntDmndDialog
splmntArtclUpCd="SPLMNTARTCL"
open={activeDialog === "dmndSplmnt"}
onOpenChange={() => setActiveDialog(null)}
isSubmitting={isProcessing}
onConfirm={handleDmndSplmntConfirm}
/>
<ElaprDocPrgrsPickerDialog
open={activeDialog === "elaprDocPrgrs"}
elaprDocNo={docNo}
onClose={() => setActiveDialog(null)}
onConfirm={handleElaprDocPrgrsConfirm}
/>
<ElaprApprovalDialog
open={activeDialog === "elaprApproval"}
onOpenChange={(open) => setActiveDialog(open ? "elaprApproval" : null)}
elaprDocNo={data.detDto?.fnrsCnsltnDocNo ?? fnrsCnsltnDocNo}
currentUserUuid={userInfo?.userUnqId ?? ""}
onSuccess={() => {
queryClient.invalidateQueries({
queryKey: fnrsCnsltnKeys.detail(fnrsCnsltnDocNo),
});
}}
/>
<SplmntDmndDialog
splmntArtclUpCd="SPLMNTARTCL"
open={activeDialog === "dmndSplmnt"}
onOpenChange={() => setActiveDialog(null)}
isSubmitting={isBusy}
onConfirm={handleDmndSplmntConfirm}
/>
<FileVrfcSplmntDialog
open={activeDialog === "fileSplmnt"}
fnrsCnsltnDocNo={docNo}
isSubmitting={isBusy}
onClose={() => setActiveDialog(null)}
onConfirm={handleFileSplmntConfirm}
/>
<ElaprApprovalDialog
open={activeDialog === "elaprApproval"}
onOpenChange={(open) => setActiveDialog(open ? "elaprApproval" : null)}
elaprDocNo={data.detDto?.fnrsCnsltnDocNo ?? fnrsCnsltnDocNo}
currentUserUuid={userInfo?.userUnqId ?? ""}
elaprCycl={prgrsSttsComCd === "RK" ? 2 : undefined}
onSuccess={() => {
queryClient.invalidateQueries({
queryKey: fnrsCnsltnKeys.detail(fnrsCnsltnDocNo),
});
}}
/>
<PwrkCnsltnDocMngDialog
open={activeDialog === "pwrkCnsltnDocMng"}
fnrsCnsltnDocNo={docNo}
atrzSeComCdList={atrzSeComCdList}
prgrsSttsComCd={prgrsSttsComCd}
canSave={
(needsElaprPrgrsCheck && !hasPendingApprovalRequest) || viewOnlyPwrkCnsltnDoc
? false
: null
}
onClose={() => setActiveDialog(null)}
/>
<PwrkCnsltnRvwRsltInqDialog
open={activeDialog === "rvwRsltInq"}
fnrsCnsltnDocNo={docNo}
isSubmitting={isBusy}
onClose={() => setActiveDialog(null)}
onConfirm={handleRvwRsltSplmntConfirm}
/>
</>
)}
</>
);
}
@@ -602,35 +926,73 @@ async function handleDmndSplmnt(
}
}
async function handleAtrzAprv(context: ActionHandlerContext): Promise<void> {
// TODO: 결재 승인 처리 API 호출
context.onSuccess?.();
async function handleRequestExamine(
context: ActionHandlerContext<FnrsExamineRequestDto>,
): Promise<void> {
try {
const docNo = context.data.detDto?.fnrsCnsltnDocNo;
if (!docNo) throw new Error("문서번호가 없습니다.");
await fnrsCnsltnMngService.requestExamine(docNo, context.request);
context.onSuccess?.();
} catch (error) {
context.onError?.(error as Error);
}
}
async function handleAtrzRply(context: ActionHandlerContext): Promise<void> {
// TODO: 결재 회신 처리 API 호출
context.onSuccess?.();
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);
}
}
const buttonActionHandlers: Record<FnrsCnsltnViewButtonAction, ActionHandler> = {
strg: handleSaveApply,
aply: handleSaveApply,
del: handleDel,
fnrsCnsltnAncmnt: handleChangePrgrsSttsComCd,
envoSbmsn: handleChangePrgrsSttsComCd,
rjct: handleChangePrgrsSttsComCd,
sncnAprv: handleChangePrgrsSttsComCd,
rvwRply: handleTechReview,
rqstTechRvw: handleChangePrgrsSttsComCd,
rvwOpnnRply: handleTechReview,
rqstTechRvw: handleRequestExamine,
fileSplmnt: handleDmndSplmnt,
fileVrfc: () => Promise.resolve(),
dsgntRvwr: handleDsgntRvwr,
pwrkCnsltnDocCrt: () => Promise.resolve(),
pwrkCnsltnDocMng: () => Promise.resolve(),
rvwRsltInq: () => Promise.resolve(),
rvwStrg: handleTechReview,
rvwCmptn: handleTechReview,
dmndSplmnt: handleDmndSplmnt,
atrzAprv: handleAtrzAprv,
atrzRply: handleAtrzRply,
atrzAprv: () => Promise.resolve(),
atrzRply: () => Promise.resolve(),
actnCmptn: handleActnCmptn,
rqstActnRvw: handleRequestActnReview,
costVrfc: () => Promise.resolve(),
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -0,0 +1,137 @@
"use client";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useAlertStore } from "@/shared/lib";
import { useSavePwrkCnsltnDocEvl } from "@/entities/pwrk-cnsltn-doc-evl";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Textarea,
} from "@common/components";
import {
buildPwrkCnsltnDocEvlSavePayload,
PWRK_CNSLTN_DOC_EVL_SCR_OPTIONS,
pwrkCnsltnDocEvlFormSchema,
type PwrkCnsltnDocEvlFormValues,
} from "../../lib/pwrk-cnsltn-doc-evl-schema";
interface PwrkCnsltnDocEvlDialogProps {
open: boolean;
pwrkCnsltnDocNo: string;
onClose: () => void;
}
const DEFAULT_VALUES: Partial<PwrkCnsltnDocEvlFormValues> = {
pwrkCnsltnDocEvlScr: undefined,
pwrkCnsltnDocEvlCn: "",
};
function PwrkCnsltnDocEvlDialog({ open, pwrkCnsltnDocNo, onClose }: PwrkCnsltnDocEvlDialogProps) {
const alert = useAlertStore((state) => state.setMessage);
const { mutateAsync: saveEvl, isPending } = useSavePwrkCnsltnDocEvl();
const { handleSubmit, reset, setValue, watch, register } = useForm<PwrkCnsltnDocEvlFormValues>({
resolver: zodResolver(pwrkCnsltnDocEvlFormSchema),
defaultValues: DEFAULT_VALUES,
});
const pwrkCnsltnDocEvlScr = watch("pwrkCnsltnDocEvlScr");
useEffect(() => {
if (!open) return;
reset(DEFAULT_VALUES);
}, [open, reset]);
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen) onClose();
}
async function onSubmit(values: PwrkCnsltnDocEvlFormValues) {
try {
const payload = buildPwrkCnsltnDocEvlSavePayload(pwrkCnsltnDocNo, values);
await saveEvl(payload);
alert("AI 초안 만족도 평가를 등록했습니다.");
onClose();
} catch (error) {
console.error("AI 초안 만족도 평가 등록 실패:", error);
alert("AI 초안 만족도 평가 등록에 실패했습니다.");
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-[40rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>AI 초안 만족도 평가</DialogTitle>
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-2">
<Label>만족도</Label>
<Select
value={pwrkCnsltnDocEvlScr || undefined}
onValueChange={(value) =>
setValue(
"pwrkCnsltnDocEvlScr",
value as PwrkCnsltnDocEvlFormValues["pwrkCnsltnDocEvlScr"],
{
shouldValidate: true,
},
)
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="만족도를 선택해 주세요" />
</SelectTrigger>
<SelectContent>
{PWRK_CNSLTN_DOC_EVL_SCR_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="pwrkCnsltnDocEvlCn">의견</Label>
<Textarea
id="pwrkCnsltnDocEvlCn"
rows={4}
placeholder="의견을 입력해 주세요"
className="!text-xl"
{...register("pwrkCnsltnDocEvlCn")}
/>
</div>
<DialogFooter>
<Button type="button" color="white" onClick={onClose} disabled={isPending}>
닫기
</Button>
<Button type="submit" disabled={isPending}>
제출
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export { PwrkCnsltnDocEvlDialog };
export type { PwrkCnsltnDocEvlDialogProps };
@@ -0,0 +1,394 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import dynamic from "next/dynamic";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { PanelRightClose, PanelRightOpen } from "lucide-react";
import { getDefaultFileKey, useAlertStore, useTus } from "@/shared/lib";
import { AttachmentFileField } from "@/shared/ui";
import {
attachService,
parseFilenameFromResponse,
useDownloadAttachmentByAtflId,
} from "@/entities/attach";
import {
pwrkCnsltnDocCrtRsltSaveRequestSchema,
pwrkCnsltnDocCrtRsltService,
} from "@/entities/pwrk-cnsltn-doc-crt-rslt";
import { usePwrkCnsltnDocPjtcoAjmtMngPage } from "@/entities/pwrk-cnsltn-doc-pjtco-ajmt-mng";
import {
findCompletedPwrkDraft,
pwrkCnsltnMngKeys,
usePwrkCnsltnMngByUpDocNo,
} from "@/entities/pwrk-cnsltn-mng";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Skeleton,
} from "@common/components";
import { PwrkCnsltnDocEvlDialog } from "./pwrk-cnsltn-doc-evl-dialog";
import { PwrkCnsltnDocPjtcoAjmtTable } from "./pwrk-cnsltn-doc-pjtco-ajmt-table";
const HangulGiangi = dynamic(
() => import("@/widgets/hangulgiangi").then((module) => module.HangulGiangi),
{
ssr: false,
loading: () => <Skeleton className="h-[70vh] min-h-[40rem] w-full" />,
},
);
const PWRK_ATCH_FILE_KEY_PREFIX = "pwrk-cnsltn-doc::";
const AI_DRAFT_EVL_MAX_VER_SN = 2;
const APPROVER_ATRZ_SE_COM_CDS = new Set(["ATRZ03", "ATRZ99"]);
const VIEW_ONLY_PRGRS_STTS_COM_CDS = new Set(["TP", "RA", "AV", "EV", "RR", "EP", "TA"]);
interface PwrkCnsltnDocMngDialogProps {
open: boolean;
fnrsCnsltnDocNo: string;
atrzSeComCdList?: string[];
prgrsSttsComCd?: string;
canSave?: boolean | null;
onClose: () => void;
}
interface UploadedAtchFile {
fileName: string;
fileKey: string;
atflId?: string;
atflCd?: string;
}
function createPwrkAtchUploadKey(file: File) {
return `${PWRK_ATCH_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
}
function PwrkCnsltnDocMngDialog({
open,
fnrsCnsltnDocNo,
atrzSeComCdList,
prgrsSttsComCd,
canSave: canSaveProp,
onClose,
}: PwrkCnsltnDocMngDialogProps) {
const queryClient = useQueryClient();
const alert = useAlertStore((state) => state.setMessage);
const [atchFileGroupId, setAtchFileGroupId] = useState("");
const [uploadedFiles, setUploadedFiles] = useState<UploadedAtchFile[]>([]);
const [isPjtcoAjmtTableOpen, setIsPjtcoAjmtTableOpen] = useState(false);
const [isEvlDialogOpen, setIsEvlDialogOpen] = useState(false);
const { mutate: downloadByAtflId } = useDownloadAttachmentByAtflId();
const {
data: pwrkCnsltnData,
isLoading: isPwrkCnsltnLoading,
isError: isPwrkCnsltnError,
} = usePwrkCnsltnMngByUpDocNo(fnrsCnsltnDocNo, open);
const completedDraft = findCompletedPwrkDraft(pwrkCnsltnData);
const crtDmndUuid = completedDraft?.crtDmndUuid;
const {
data: documentBlob,
isLoading: isDocumentLoading,
isError: isDocumentError,
} = useQuery({
queryKey: ["pwrk-cnsltn-doc-crt-rslt", "open-latest", crtDmndUuid],
queryFn: () => pwrkCnsltnDocCrtRsltService.openWebHwpLatest(crtDmndUuid!),
select: (response) => response.data,
enabled: open && Boolean(crtDmndUuid),
});
const documentFileName = `실무협의서_${pwrkCnsltnData?.pwrkCnsltnDocNo ?? crtDmndUuid}.hwpx`;
const isLoading = isPwrkCnsltnLoading || isDocumentLoading;
const isError = isPwrkCnsltnError || isDocumentError;
const pjtcoAjmtSearchParams = useMemo(
() => ({
pwrkCnsltnDocNo: pwrkCnsltnData?.pwrkCnsltnDocNo,
page: 0,
size: 1000,
sort: "rvwOpnnSn,asc",
}),
[pwrkCnsltnData?.pwrkCnsltnDocNo],
);
const {
data: pjtcoAjmtPage,
isLoading: isPjtcoAjmtLoading,
isError: isPjtcoAjmtError,
} = usePwrkCnsltnDocPjtcoAjmtMngPage(pjtcoAjmtSearchParams, open);
const { uploadFileAsync, isUploading, uploads } = useTus({
onSuccess: (atflCd, atflId, _file, fileKey) => {
if (!atflCd || !atflId) return;
setAtchFileGroupId(atflCd);
setUploadedFiles((prev) =>
prev.map((item) => (item.fileKey === fileKey ? { ...item, atflCd, atflId } : item)),
);
},
onError: (_error, _file, fileKey) => {
setUploadedFiles((prev) => prev.filter((item) => item.fileKey !== fileKey));
},
});
useEffect(() => {
if (!open) {
setAtchFileGroupId("");
setUploadedFiles([]);
setIsEvlDialogOpen(false);
return;
}
const initialGroupId = pwrkCnsltnData?.atchFileGroupId ?? "";
setAtchFileGroupId(initialGroupId);
if (!initialGroupId) {
setUploadedFiles([]);
return;
}
let cancelled = false;
void attachService.findByAtflCd(initialGroupId).then((files) => {
if (cancelled) return;
setUploadedFiles(
files.map((file) => ({
fileName: file.orgnlFileNm,
fileKey: `${PWRK_ATCH_FILE_KEY_PREFIX}${file.atflId}`,
atflId: file.atflId,
atflCd: file.atflCd,
})),
);
});
return () => {
cancelled = true;
};
}, [open, pwrkCnsltnData?.atchFileGroupId]);
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen) onClose();
}
async function handleSaveDocument(file: Blob) {
if (!completedDraft) throw new Error("저장할 실무협의서 초안이 없습니다.");
const saveDto = pwrkCnsltnDocCrtRsltSaveRequestSchema.parse({
crtDmndUuid: completedDraft.crtDmndUuid,
pwrkCnsltnDocNo: completedDraft.pwrkCnsltnDocNo,
atchFileGroupId: atchFileGroupId || undefined,
});
const savedDocument = await pwrkCnsltnDocCrtRsltService.saveMultipart(
saveDto,
file,
documentFileName,
);
await Promise.all([
queryClient.invalidateQueries({
queryKey: pwrkCnsltnMngKeys.byUpDocNo(fnrsCnsltnDocNo),
}),
queryClient.invalidateQueries({
queryKey: ["pwrk-cnsltn-doc-crt-rslt", "open-latest", completedDraft.crtDmndUuid],
}),
]);
alert(`실무협의서를 저장했습니다. (버전 ${savedDocument.verSn})`);
if (savedDocument.verSn <= AI_DRAFT_EVL_MAX_VER_SN) {
setIsEvlDialogOpen(true);
}
}
async function handleDownload() {
if (!completedDraft) throw new Error("다운로드할 실무협의서 초안이 없습니다.");
const response = await pwrkCnsltnDocCrtRsltService.downloadLatest(completedDraft.crtDmndUuid);
const objectUrl = window.URL.createObjectURL(response.data);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = parseFilenameFromResponse(response);
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.URL.revokeObjectURL(objectUrl);
}
async function handleAtchFileUpload(files: FileList | null) {
if (!files || files.length === 0) return;
const fileArray = Array.from(files);
let groupId = atchFileGroupId;
for (const file of fileArray) {
const fileKey = createPwrkAtchUploadKey(file);
setUploadedFiles((prev) => [...prev, { fileName: file.name, fileKey }]);
try {
const atflCd = await uploadFileAsync(file, {
fileKey,
metadata: groupId ? { atflCd: groupId } : undefined,
});
if (!groupId && atflCd) {
groupId = atflCd;
setAtchFileGroupId(atflCd);
}
} catch {
// onError callback handles cleanup
}
}
}
async function handleAtchFileRemove(fileIndex: number) {
const fileToRemove = uploadedFiles[fileIndex];
if (!fileToRemove) return;
uploads[fileToRemove.fileKey]?.abort();
if (fileToRemove.atflId) {
try {
await attachService.deleteAttachmentByAtflId(fileToRemove.atflId);
} catch (error) {
console.error("실무협의서 첨부파일 삭제 실패:", error);
alert("첨부파일 삭제에 실패했습니다.");
return;
}
}
const nextFiles = uploadedFiles.filter((_, index) => index !== fileIndex);
setUploadedFiles(nextFiles);
if (nextFiles.length === 0) {
setAtchFileGroupId("");
}
}
const isAnyAtchUploading = uploadedFiles.some((item) => isUploading[item.fileKey]);
const viewOnly = prgrsSttsComCd ? VIEW_ONLY_PRGRS_STTS_COM_CDS.has(prgrsSttsComCd) : false;
const canSaveByAtrz = atrzSeComCdList
? !atrzSeComCdList.some((code) => APPROVER_ATRZ_SE_COM_CDS.has(code))
: true;
const canSave = !viewOnly && (canSaveProp ?? canSaveByAtrz);
return (
<>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
className="max-h-[96vh] max-w-[80vw] overflow-y-auto"
aria-describedby={undefined}
>
<DialogHeader>
<DialogTitle>실무협의서 관리</DialogTitle>
</DialogHeader>
{isLoading ? <Skeleton className="h-[70vh] min-h-[40rem] w-full" /> : null}
{isError ? (
<p className="py-16 text-center text-lg text-muted-foreground">
실무협의서 정보를 불러오지 못했습니다.
</p>
) : null}
{!isLoading && !isError && !completedDraft ? (
<p className="py-16 text-center text-lg text-muted-foreground">
생성 완료된 실무협의서 초안이 없습니다.
</p>
) : null}
{completedDraft && documentBlob ? (
<div className="flex min-h-[40rem] items-stretch gap-2">
<div className="min-w-0 flex-1">
<HangulGiangi
documentBlob={documentBlob}
documentKey={completedDraft.crtDmndUuid}
documentFileName={documentFileName}
canSave={canSave}
onSaveDocument={handleSaveDocument}
onDownload={handleDownload}
/>
</div>
{pjtcoAjmtPage?.content?.length ? (
isPjtcoAjmtTableOpen ? (
<section className="flex h-[calc(64vh+4rem)] w-0 flex-[0.4] flex-col overflow-hidden rounded-md border border-border">
<header className="flex items-center justify-between gap-2 border-b border-border px-3 py-2">
<h3 className="text-xl font-semibold">금액검증결과</h3>
<button
type="button"
className="rounded-sm border border-border p-2 hover:opacity-90"
onClick={() => setIsPjtcoAjmtTableOpen(false)}
aria-label="접기"
>
<PanelRightClose className="size-6" />
</button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto">
<PwrkCnsltnDocPjtcoAjmtTable
rows={pjtcoAjmtPage?.content ?? []}
isLoading={isPjtcoAjmtLoading}
isError={isPjtcoAjmtError}
/>
</div>
</section>
) : (
<button
type="button"
className="flex items-center gap-1 self-start rounded-sm border border-border px-1 py-2 hover:opacity-90"
style={{ writingMode: "vertical-rl" }}
onClick={() => setIsPjtcoAjmtTableOpen(true)}
aria-label="펼치기"
>
<PanelRightOpen className="size-5" />
<span className="text-xl tracking-wide">금액검증결과</span>
</button>
)
) : null}
</div>
) : null}
{completedDraft ? (
<section className="space-y-2 rounded-md border border-border p-4">
<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,
onDownload: item.atflId ? () => downloadByAtflId(item.atflId!) : undefined,
}))}
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}
<DialogFooter>
<Button type="button" color="white" onClick={onClose}>
닫기
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{completedDraft ? (
<PwrkCnsltnDocEvlDialog
open={isEvlDialogOpen}
pwrkCnsltnDocNo={completedDraft.pwrkCnsltnDocNo}
onClose={() => setIsEvlDialogOpen(false)}
/>
) : null}
</>
);
}
export { PwrkCnsltnDocMngDialog };
export type { PwrkCnsltnDocMngDialogProps };
@@ -0,0 +1,107 @@
"use client";
import type { PwrkCnsltnDocPjtcoAjmtMngResponseDto } from "@/entities/pwrk-cnsltn-doc-pjtco-ajmt-mng";
import { fmtNumber } from "@/widgets/pbadms-sprt";
import {
Skeleton,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@common/components";
interface PwrkCnsltnDocPjtcoAjmtTableProps {
rows: PwrkCnsltnDocPjtcoAjmtMngResponseDto[];
isLoading: boolean;
isError: boolean;
}
function PwrkCnsltnDocPjtcoAjmtTable({
rows,
isLoading,
isError,
}: PwrkCnsltnDocPjtcoAjmtTableProps) {
if (isLoading) {
return (
<div className="space-y-2 p-3">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</div>
);
}
if (isError) {
return (
<p className="px-3 py-10 text-center text-muted-foreground">
사업비조정 정보를 불러오지 못했습니다.
</p>
);
}
return (
<Table variant="secondary">
<colgroup>
<col width="30%" />
<col width="50%" />
<col width="20%" />
</colgroup>
<TableHeader className="sticky top-0">
<TableRow>
<TableHead>구분</TableHead>
<TableHead>검증결과</TableHead>
<TableHead className="text-right">
<div className="flex flex-col gap-1">
<span>검증금액</span>
<span className="text-sm">(단위: 백만 원)</span>
</div>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="py-10 text-center text-muted-foreground">
조회된 사업비조정 항목이 없습니다.
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<TableRow key={getPjtcoAjmtRowKey(row)}>
<TableCell>{formatDivision(row)}</TableCell>
<TableCell>
<p className="whitespace-pre-wrap">{formatVrfcCn(row.vrfcCn)}</p>
</TableCell>
<TableCell className="text-right">{formatVrfcAmt(row.vrfcTpcoAmt)}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
);
}
function getPjtcoAjmtRowKey(row: PwrkCnsltnDocPjtcoAjmtMngResponseDto) {
return [row.pwrkCnsltnDocNo, row.rvwOpnnSn, row.acntgSeCd].join("::");
}
function formatDivision(row: PwrkCnsltnDocPjtcoAjmtMngResponseDto) {
const label = row.acntgSeCdNm?.trim() || row.acntgSeCd?.trim();
return label || "-";
}
function formatVrfcCn(value: string | undefined) {
const text = value?.trim();
return text || "-";
}
function formatVrfcAmt(value: number | undefined) {
if (value === undefined || value === null) return "-";
const formatted = fmtNumber(value);
return formatted || "-";
}
export { PwrkCnsltnDocPjtcoAjmtTable };
export type { PwrkCnsltnDocPjtcoAjmtTableProps };
@@ -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="4%" />
<col width="4%" />
<col width="92%" />
</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 align="center">
<p className="whitespace-pre-wrap">{formatOptional(row.rvwOpnnSn)}</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 };
@@ -13,7 +13,7 @@ interface FnrsDetailGateProps {
function FnrsDetailGate({ id }: FnrsDetailGateProps) {
const userInfo = useUserStore((state) => state.userInfo);
const { data, isLoading, isError } = useFnrsCnsltnMng(id);
const shouldRedirectToEdit = canEditPbadmsDoc({
const canEdit = canEditPbadmsDoc({
docSeComCd: "FNRS",
prgrsSttsComCd: data?.detDto?.prgrsSttsComCd,
rgtr: data?.detDto?.rgtr,
@@ -24,7 +24,7 @@ function FnrsDetailGate({ id }: FnrsDetailGateProps) {
<PbadmsDetailGate
isPending={!userInfo || isLoading}
isError={isError}
shouldRedirectToEdit={Boolean(userInfo) && shouldRedirectToEdit}
shouldRedirectToEdit={Boolean(userInfo) && canEdit}
editHref={`/fnrs-cnsltn/${id}/edit`}
>
<FnrsViewPage id={id} />

Some files were not shown because too many files have changed in this diff Show More