Compare commits

...
Author SHA1 Message Date
changwng c3711c2609 [as fr] 금액 없을 경우 메시지 표시 2026-10-23 17:59:12 +09:00
changwng 957fe4637e [as fr] 병렬결재 2차 검토완료 시 문서상태 SL 역전 fr 해결 2026-10-23 16:58:56 +09:00
changwng 4e9bca25ef [as fr] 리스트에서 뷰로 들어갈경우에 TanStack Query(React Query) 캐시 시스템과의 충돌 여부 및 웹한글기안기 등 인접 기능에 미치는 영향을 검토한 뒤 실질적인 해결 방안 추가2 2026-10-23 15:20:26 +09:00
changwng 23d62ab5e6 [as fr] 리스트에서 뷰로 들어갈경우에 TanStack Query(React Query) 캐시 시스템과의 충돌 여부 및 웹한글기안기 등 인접 기능에 미치는 영향을 검토한 뒤 실질적인 해결 방안 추가 2026-10-23 15:20:07 +09:00
changwng bedefd7e2e Merge branch 'main' into feature/1007_forkjoin
# Conflicts:
#	.vscode/launch.json
#	.vscode/settings.json
2026-10-23 14:06:26 +09:00
changwng 56688eb1a6 [as fr] 병합자 웹기안기 새창으로 SP, SF 참조 하도록 하기 2026-10-23 14:03:46 +09:00
mind-one005 ee0049722f Merge branch 'iipbs/sn/dev' 2026-10-22 15:01:40 +09:00
jcchoi 74934d4559 수정
Deploy Frontend / deploy (push) Successful in 4m0s
2026-10-22 14:28:31 +09:00
jcchoi 027f88da6c 대시보드 ㅁ이상치리포트 명칭 변경 2026-10-22 14:24:21 +09:00
changwng 175964b652 [as fr] 광역지자체 설치인가, 재원협의 반려 처리 restapi 추가및 알림처리 *액션 디스패처(executeAction)**와 **룩업 테이블(buttonActionHandlers)** 패턴 2026-10-22 11:54:59 +09:00
mind-one005 eddb1b24d6 sn: 지반침하 지도상세정보 엑셀다운로드 2026-10-22 10:19:52 +09:00
mind-one005 cf2cc11167 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-10-22 09:44:24 +09:00
mind-one005 9e71f529e7 sn: 통수위험 위험여부란 수정 외 2026-10-22 09:33:27 +09:00
changwng 4f4f5112b5 [as fr] 웹기안기에서 지자체는 SP, SF 탭을 볼수있는 권한이 SL에서 존재 한다. 2026-10-21 16:24:54 +09:00
changwng 6dc2965209 [as fr] 필수첨부 파일에 필수파일검토내용에 CRUD 처리 및 AI 요청시 같이 전송처리
Deploy Frontend / deploy (push) Successful in 4m5s
2026-10-21 16:14:39 +09:00
changwng 6d9db4389d [as fr] 필수첨부 파일에 필수파일검토내용에 CRUD 처리 및 AI 요청시 같이 전송처리 2026-10-21 16:13:26 +09:00
changwng b5eceadee8 [as fr] 한글기안기 변경이력 관리 추가 2026-10-21 15:46:10 +09:00
changwng b74f2820da [as fr] // 0-1. 지자체(ROLE_USER05, ROLE_USER04), 환경청(ROLE_USER03) 사용자인 경우: 병렬 Join 결재 시 MAIN 탭만 호출 2026-10-21 15:16:01 +09:00
jcchoi c69c1aa855 Merge branch 'iipbs/st/dev'
Deploy Frontend / deploy (push) Successful in 9m0s
2026-10-21 13:15:44 +09:00
changwng d0f2e41874 Merge branch 'main' into feature/1007_forkjoin 2026-10-21 11:43:24 +09:00
changwng 4be56402d8 [as fr] 2차 결재 진행시 버튼 2차결재시 검토의견 팝업 뜨지 못하도록 수정함 2026-10-21 11:42:30 +09:00
jcchoi 9f0e03c151 이상치 선별보고서 2026-10-21 11:31:50 +09:00
jcchoi 471a431af5 대시보드 진행상태 변경 2026-10-21 10:37:32 +09:00
jcchoi 26d8410649 Merge remote-tracking branch 'origin/iipbs/st/dev'
Deploy Frontend / deploy (push) Successful in 5m0s
2026-10-20 21:52:31 +09:00
jcchoi 8403ad556c 설치인가 버튼 이벤트 수정 2026-10-20 21:50:41 +09:00
rsone001 d26c15f857 Merge branch 'iipbs/st/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/st/dev 2026-10-20 17:49:42 +09:00
rsone001 86ed664e3e [input] - 오류 로직 수정 2026-10-20 17:49:09 +09:00
changwng 7df6f40bc4 [as fr] 체크리스트가 없으면 LOCAL rvwStpComCd 에 디폴트 입력 2026-10-20 17:34:28 +09:00
changwng 690724bc74 [as fr] 빌드 에러 수정 평가 다이얼 로그 쪽 2026-10-20 16:44:50 +09:00
changwng 08bdbe79b5 Merge branch 'main' into feature/1007_forkjoin
# Conflicts:
#	apps/as/src/pages/fnrs-cnsltn/ui/components/pwrk-cnsltn-doc-mng-dialog.tsx
2026-10-20 16:30:04 +09:00
changwng 177f4653c8 [as fr]pm2 통합방법 2026-10-20 16:29:33 +09:00
changwng cc4acb3916 [as fr] NODE_TLS_REJECT_UNAUTHORIZED =0 추가 2026-10-20 15:56:48 +09:00
jcchoi a105f395dd 설치인가 프로세스 변경 2026-10-20 15:50:05 +09:00
changwng a06e4f46cf [as fr] doc 배포 문서및 빌드 에러 해결 방안 2026-10-20 15:39:49 +09:00
changwng 07ae3f7711 [as fr] 웹기안기 호출 팝업에 빌드및 lint 에러 해결 2026-10-20 15:34:24 +09:00
changwng 46c4634868 [as 2] as config로 pm2 로 시작 하기 위한 방법 2026-10-20 15:17:40 +09:00
changwng 0ce35d4c38 [as 2] port 3004번 포트로 수정 2026-10-20 14:25:52 +09:00
jcchoi a5ebc12580 검토의견 수정 오류 처리 2026-10-20 13:48:39 +09:00
jcchoi 270f6e366f Merge branch 'iipbs/st/dev' 2026-10-20 10:53:41 +09:00
jcchoi acf215c1bf 대시보드 이상치선별보고서 수정 2026-10-20 10:45:29 +09:00
jcchoi 8a98cd7d19 행정지원 검토의견 수정 2026-10-20 10:04:42 +09:00
jcchoi 3a5b48b608 통계 도움말 오류 수정 2026-10-20 10:04:26 +09:00
jcchoi d735ad7dd7 통계 파라미터 변경
Deploy Frontend / deploy (push) Successful in 1m57s
2026-10-19 21:28:01 +09:00
jcchoi 25dd0f1504 getVerifyResultList 파라미터 변경
Deploy Frontend / deploy (push) Successful in 2m2s
2026-10-19 19:17:01 +09:00
jcchoi 8e14c6cef5 Merge remote-tracking branch 'origin/iipbs/st/dev' 2026-10-19 19:07:14 +09:00
changwng f220904742 [as] 2차 결재 버튼 표시 부분 검증 2026-10-19 19:05:09 +09:00
jcchoi 9f7abe4413 필수첨부파일 위치 업데이트 2026-10-19 18:41:58 +09:00
rsone001 de730205af [ST] - 일반 검증 등록 및 수정 다이얼로그 css 수정 2026-10-19 17:50:49 +09:00
changwng dc74c07ef4 [as] 웹기안기의 첨부 파일 조정 확인 처리 2026-10-19 17:25:40 +09:00
kiminchan 36b11d6b00 맨홀 조사보고서 단독 교체 및 GIS 번호 기반 조회 수정 2026-10-19 17:01:37 +09:00
changwng 9a77e9c52a [as] 웹기안기의 화면 조정 2026-10-19 16:43:51 +09:00
rsone001 a873f0e0dc [ST] - api 표준화 누락 수정 2026-10-19 15:14:58 +09:00
changwng 42dc2dc027 [as]병렬결재에서 병합자 - 웹기안기 MAIN만 수정가능하게 하고 최종승인자의 경우에는 MAIN 탭만 보이도록 수정함 2026-10-19 14:59:52 +09:00
changwng 14803b2625 [as] 재원협의 병렬및 순차 결재시 버튼및 웹기안기 권한 1차 결재시 승인자까지 점검 완료 2026-10-19 14:23:36 +09:00
mind-one005 79f70d8b4b sn: 도시침수지도 geoserver style적용 2026-10-16 18:05:48 +09:00
mind-one005 7911cff96a sn: GIS편집,GIS관리, 통수위험 정보카드 X버튼 2026-10-16 17:36:31 +09:00
changwng 1a5180551e [as] 재원협의 병렬및 순차 결재시 버튼및 웹기안기 권한 제어 완료 2026-10-16 17:33:22 +09:00
mind-one005 fe501965bf sn: 조사관리>>조사사업 2026-10-16 16:51:47 +09:00
mind-one001 fb82cadc82 준설관리 텍스트 수정 2026-10-16 11:10:47 +09:00
mind-one001 f5b6effeb3 준설관리 관로 미표출로 인한 기능수정 2026-10-16 11:00:13 +09:00
mind-one001 63c2901ae3 feat(sn): allow dredging records without a mapped pipe 2026-10-16 10:09:44 +09:00
jcchoi 32e3765b52 초안 평가 단계 수정
Deploy Frontend / deploy (push) Successful in 1m53s
2026-10-15 19:56:54 +09:00
changwng e697881581 [as elapr] 재원업무 협의 2차 결재시 자동 검토자 지정 로직 추가 3 2026-10-15 17:02:26 +09:00
rsone001 4f62095e8e [ST] - fix: input 2-3 검증 cell 스타일 로직 수정 2026-10-15 16:03:40 +09:00
jcchoi 5234448ae5 기본계획 조회 전체 적용
Deploy Frontend / deploy (push) Successful in 1m40s
2026-10-15 15:53:38 +09:00
mind-one001 0356aef7b2 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-10-15 11:23:53 +09:00
mind-one001 f709dc99f6 chore: stop tracking local VS Code settings 2026-10-15 11:21:44 +09:00
jcchoi f284c67988 hwp 서버 도메인 연결 2026-10-15 10:43:15 +09:00
changwng dd4ee1dcb5 [as elapr] 화면에서 동적으로 결재자 숫자 변경및 체크 리스트 SP, SF 필수 로직으로 저장 변경 2026-10-14 18:00:37 +09:00
changwng 6c71bccc27 [as elapr] 재원업무 협의 2차 결재시 자동 검토자 지정 로직 추가 2026-10-13 18:21:08 +09:00
mind-one005 728a66bac2 sn: apigateway.onasys.co.kr 2026-10-13 15:55:31 +09:00
mind-one005 c850b9ce1e sn: 해당 도시침수지도가 없습니다. 2026-10-13 15:48:24 +09:00
changwng 9a33663f1e [as elapr] 공공하수도 재원협의 복합사업(SF+SP) Fork-Join 전자결재 프론트엔드 병합자(ATRZ04) 결재 버튼 연동 및 순차결재 분리 개선 2026-10-13 15:25:42 +09:00
mind-one005 791bc20e8e sn: 지도 위치 정보가 없는 경우 404>>위치 정보가 없습니다. 2026-10-13 15:22:16 +09:00
changwng b088918cd9 [as elapr] 전자결재 forkjoin 통합 개발 화면 추가 브랜치 항목 2026-10-13 14:05:44 +09:00
changwng 3f2938df1c [as elapr] 전자결재 forkjoin 통합 개발 화면 2026-10-12 17:45:55 +09:00
mind-one001 fed1383ced 시도별 노후관로현황 기본 조회기간을 당해연도로 변경 2026-10-08 10:30:51 +09:00
changwng c39d1f433a [common] vs code debug settings 2026-10-08 09:21:10 +09:00
mind-one001 91ff80a255 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-10-07 15:15:32 +09:00
mind-one001 7f10e20a41 전국 지도 보이게 줌 레벨 수정 2026-10-07 15:03:03 +09:00
230 changed files with 11665 additions and 4467 deletions
+2 -1
View File
@@ -1,6 +1,7 @@
# turbo
.turbo/
.vscode/**/*
**/.vscode/
*.code-workspace
# dependencies
node_modules/
+119 -11
View File
@@ -1,7 +1,5 @@
{
// IntelliSense를 사용하여 가능한 특성에 대해 알아보세요.
// 기존 특성에 대한 설명을 보려면 가리킵니다.
// 자세한 내용을 보려면 https://go.microsoft.com/fwlink/?linkid=830387을(를) 방문하세요.
"version": "0.2.0",
"configurations": [
{
@@ -14,13 +12,91 @@
"dev:as"
],
"skipFiles": [
"<node_internals>/**"
"<node_internals>/**",
"**/node_modules/**"
],
"resolveSourceMapLocations": [
"${workspaceFolder}/**",
"!**/node_modules/**"
],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"sourceMaps": true,
"env": {
"NODE_OPTIONS": "--inspect"
"NODE_OPTIONS": "--inspect --max-old-space-size=8192"
}
},
{
"type": "node-terminal",
"request": "launch",
"name": "dev:as 서버 컴포넌트 디버그 (node-terminal)",
"command": "pnpm --filter as dev",
"cwd": "${workspaceFolder}"
},
{
"type": "node-terminal",
"request": "launch",
"name": "dev:as 통합 디버그 (Server + Client)",
"command": "pnpm --filter as dev",
"cwd": "${workspaceFolder}",
"serverReadyAction": {
"action": "debugWithChrome",
"pattern": "- Local:\\s+(https?://.+)",
"uriFormat": "%s",
"webRoot": "${workspaceFolder}/apps/as"
}
},
{
"type": "node",
"request": "launch",
"name": "Playwright: 현재 활성화된 테스트 디버그",
"runtimeExecutable": "pnpm",
"runtimeArgs": [
"--filter",
"cs",
"exec",
"playwright",
"test",
"${relativeFile}"
],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"skipFiles": [
"<node_internals>/**",
"**/node_modules/**"
]
},
{
"type": "node",
"request": "launch",
"name": "Playwright: cs 로그인 테스트 디버그",
"runtimeExecutable": "pnpm",
"runtimeArgs": [
"--filter",
"cs",
"exec",
"playwright",
"test",
"tests/login.spec.ts"
],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"skipFiles": [
"<node_internals>/**",
"**/node_modules/**"
]
},
{
"type": "node-terminal",
"request": "launch",
"name": "dev:cs 통합 디버그 (Server + Client)",
"command": "pnpm --filter cs dev",
"cwd": "${workspaceFolder}",
"serverReadyAction": {
"action": "debugWithChrome",
"pattern": "- Local:\\s+(https?://.+)",
"uriFormat": "%s",
"webRoot": "${workspaceFolder}/apps/cs"
}
},
{
@@ -33,13 +109,18 @@
"dev:cs"
],
"skipFiles": [
"<node_internals>/**"
"<node_internals>/**",
"**/node_modules/**"
],
"resolveSourceMapLocations": [
"${workspaceFolder}/**",
"!**/node_modules/**"
],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"sourceMaps": true,
"env": {
"NODE_OPTIONS": "--inspect"
"NODE_OPTIONS": "--inspect --max-old-space-size=8192"
}
},
{
@@ -49,7 +130,8 @@
"port": 9229,
"restart": true,
"skipFiles": [
"<node_internals>/**"
"<node_internals>/**",
"**/node_modules/**"
],
"sourceMaps": true,
"resolveSourceMapLocations": [
@@ -63,7 +145,15 @@
"name": "dev:cs 클라이언트 디버그 (Chrome)",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}/apps/cs",
"sourceMaps": true
"skipFiles": [
"<node_internals>/**",
"**/node_modules/**"
],
"sourceMaps": true,
"resolveSourceMapLocations": [
"${workspaceFolder}/apps/cs/**",
"!**/node_modules/**"
]
},
{
"type": "node",
@@ -75,13 +165,31 @@
"dev:st"
],
"skipFiles": [
"<node_internals>/**"
"<node_internals>/**",
"**/node_modules/**"
],
"resolveSourceMapLocations": [
"${workspaceFolder}/**",
"!**/node_modules/**"
],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"sourceMaps": true,
"env": {
"NODE_OPTIONS": "--inspect"
"NODE_OPTIONS": "--inspect --max-old-space-size=8192"
}
},
{
"type": "node-terminal",
"request": "launch",
"name": "dev:st 통합 디버그 (Server + Client)",
"command": "pnpm --filter st dev",
"cwd": "${workspaceFolder}",
"serverReadyAction": {
"action": "debugWithChrome",
"pattern": "- Local:\\s+(https?://.+)",
"uriFormat": "%s",
"webRoot": "${workspaceFolder}/apps/st"
}
}
]
+4 -1
View File
@@ -1,5 +1,8 @@
{
"chat.tools.terminal.autoApprove": {
"pnpm": true
}
},
"playwright.configs": [
"apps/cs/playwright.config.ts"
]
}
@@ -0,0 +1,203 @@
# PM2 배포 및 프로세스 관리 가이드
본 문서는 ONA 프론트엔드 모노레포 프로젝트(`frontend_as`)에서 **PM2를 활용한 애플리케이션 배포, 실행, 중지 및 모니터링 명령**을 체계적으로 정리한 가이드입니다.
---
pnpm build:common && pnpm build:as && pm2 reload as2-app
pnpm --filter=as... build
pnpm --filter=as... build && pm2 reload as2-app
## 1. 개요 및 아키텍처
- **설정 파일**: 프로젝트 루트의 `ecosystem.config.js`
- **배포 워크플로우**: `.gitea/workflows/deploy.yml` (Gitea Actions 자동 배포)
- **실행 모드**: `cluster` (2개 인스턴스로 로드밸런싱 및 무중단 롤링 배포 지원)
- **운영 대상**:
- `as2-app` (또는 `as-app`, `cs-app`, `st-app`, `cm-app` 등)
---
## 2. CI/CD 자동 배포 흐름
Gitea 저장소의 `deploy` 브랜치로 푸시될 때 원격 서버(192.168.0.4)에서 다음 스크립트가 순차 실행됩니다:
```bash
cd ~/frontend
git restore . # 변경된 로컬 파일 원복
git clean -fd # 추적되지 않는 불필요한 파일/디렉터리 정리
git pull origin deploy # 최신 deploy 브랜치 코드 반영
pnpm install # 의존성 패키지 설치
pnpm turbo build # 프로덕션 빌드 (.next 산출물 생성)
pm2 reload all # 무중단 서비스 리로드 (클러스터 롤링 재시작)
```
---
## 3. 수동 배포 (Manual Deploy) 명령어
서버 터미널에 직접 접속하여 수동으로 최신 코드를 배포할 때 사용하는 명령어입니다.
### 3.1 전체 애플리케이션 수동 배포
```bash
# 1. 프로젝트 루트 이동
cd ~/frontend
# 2. 최신 코드 가져오기
git pull origin deploy
# 3. 의존성 설치 및 전체 빌드
pnpm install
pnpm turbo build
# 4. PM2 전체 무중단 리로드
pm2 reload all
```
### 3.2 `as` (행정지원) 앱만 단독 빌드 및 배포
```bash
cd ~/frontend
# as 앱만 타겟 빌드
pnpm --filter as build
# as2-app 프로세스만 무중단 리로드
pm2 reload as2-app
```
---
## 4. PM2 실행 및 프로세스 등록 (Start & Save)
최초 서버 환경 구성 시 또는 프로세스 목록에서 삭제된 후 새롭게 등록할 때 실행합니다.
```bash
# 1. 로그 디렉터리 사전 생성 (필수)
mkdir -p apps/as/logs
# 2. ecosystem.config.js 기반 전체 앱 최초 실행
pm2 start ecosystem.config.js
# 3. 특정 앱(as2-app)만 지정하여 최초 실행
pm2 start ecosystem.config.js --only as2-app
# 4. 서버 재부팅 시에도 PM2 프로세스가 자동 복구되도록 현재 상태 저장 (필수!)
pm2 save
# 5. OS 부팅 시 PM2 데몬 자동 등록 (최초 1회 설정)
pm2 startup
```
---
## 5. 중지, 재시작 및 삭제 명령어
### 5.1 재시작 (Restart vs Reload)
> **TIP: 운영 환경에서는 항상 `reload`를 권장합니다.**
> - `reload`: 0-second downtime(무중단). 클러스터의 인스턴스를 하나씩 순차적으로 재시작하여 사용자 요청이 끊기지 않음.
> - `restart`: 모든 인스턴스를 일시에 강제 종료 후 다시 띄우므로 짧은 다운타임 발생.
```bash
# [권장] 무중단 재로드 (운영 배포 시)
pm2 reload as2-app
pm2 reload all
# 강제 재시작 (설정 변경이 반영되지 않거나 프로세스가 비정상일 때)
pm2 restart as2-app
pm2 restart all
```
### 5.2 프로세스 중지 (Stop)
프로세스를 메모리에서 내리고 실행을 일시 중단합니다 (PM2 목록에는 유지됨).
```bash
# 특정 앱 중지
pm2 stop as2-app
# 전체 앱 중지
pm2 stop all
```
### 5.3 프로세스 목록에서 제거 (Delete)
PM2 관리 대상 목록에서 프로세스를 완전히 삭제합니다.
```bash
# 특정 앱 삭제
pm2 delete as2-app
# 전체 앱 삭제
pm2 delete all
# 삭제 상태를 영구 저장할 경우
pm2 save
```
---
## 6. 상태 모니터링 및 로그 확인
### 6.1 프로세스 상태 확인
```bash
# 간략한 프로세스 상태 표 확인 (online, stopped, cpu, memory 등)
pm2 status
# 또는
pm2 list
# 특정 앱 상세 정보 확인 (환경변수, 파일 경로, 재시작 횟수 등)
pm2 show as2-app
```
### 6.2 실시간 로그 스트리밍 (Logs)
```bash
# 특정 앱 실시간 로그 확인 (표준 출력 + 에러 출력)
pm2 logs as2-app
# 최근 100줄 출력 후 실시간 스트리밍
pm2 logs as2-app --lines 100
# 에러 로그만 확인
pm2 logs as2-app --err
# 전체 프로세스 로그 확인
pm2 logs
# 이전 로그 비우기(Flush)
pm2 flush
```
### 6.3 터미널 대시보드 모니터링 (Monit)
CPU 및 메모리 실시간 사용량을 터미널 대시보드로 확인합니다.
```bash
pm2 monit
```
---
## 7. 주요 트러블슈팅 (FAQ)
### Q1. `EADDRINUSE: address already in use :::3004` 포트 충돌 발생
- **원인**: 로컬 개발 서버(`pnpm --filter as dev`) 또는 이전 Node 프로세스가 3004번 포트를 계속 점유 중인 상태입니다.
- **해결**:
```bash
# 3004 포트 사용 중인 PID 확인
lsof -i :3004
# 해당 프로세스 종료
kill -9 <PID>
# PM2 리로드
pm2 reload as2-app
```
### Q2. `Could not find a production build in the '.next' directory`
- **원인**: Next.js 프로덕션 실행 전에 빌드 산출물(`.next`)이 생성되지 않았습니다.
- **해결**:
```bash
pnpm --filter as build
pm2 reload as2-app
```
### Q3. `logs` 디렉터리 관련 에러 발생
- **원인**: `ecosystem.config.js`의 `cwd: './apps/as'` 설정에 따라 `./logs` 경로가 없을 경우 발생할 수 있습니다.
- **해결**:
```bash
mkdir -p apps/as/logs
```
@@ -0,0 +1,181 @@
# Next.js 프로덕션 빌드 에러 원인 분석 및 조치 보고서
본 문서는 `apps/as` 프로덕션 빌드(`pnpm build:as` / `next build`) 과정에서 발생한 ESLint Hook 규칙 위반 및 TypeScript 컴파일 에러의 원인과 이를 해결한 조치 내역을 정리한 문서입니다.
---
## 1. 개요 및 장애 상황
- **대상 모듈**: `apps/as` (행정지원 프론트엔드 애플리케이션)
- **실행 명령**: `pnpm build:as` (또는 `pnpm --filter as build`)
- **발생 시점**: 2026-10-20
- **장애 영향**: Next.js 프로덕션 최적화 빌드 실패로 인해 배포 산출물(`.next`) 생성이 중단됨
### 빌드 실패 로그 요약
```text
as:build: Failed to compile.
as:build: ./src/pages/fnrs-cnsltn/ui/components/pwrk-cnsltn-doc-mng-dialog.tsx
as:build: 159:35 Error: React Hook "useStore" is called conditionally. React Hooks must be called in the exact same order in every component render. react-hooks/rules-of-hooks
./src/pages/fnrs-cnsltn/ui/components/pwrk-cnsltn-doc-mng-dialog.tsx:164:5
Type error: Type 'boolean' has no properties in common with type '{ enabled?: boolean | undefined; structuralSharing?: boolean | undefined; }'.
./src/pages/fnrs-cnsltn/ui/components/pwrk-cnsltn-doc-mng-dialog.tsx:215:29
Type error: Property 'atrzSeq' does not exist on type 'ElaprDocPrgrsClientResponseDto'. Did you mean 'atrzSeqNo'?
```
---
## 2. 근본 원인 분석 (Root Cause Analysis)
### 2.1 React Hook 규칙 위반 (`react-hooks/rules-of-hooks`)
- **위치**: `apps/as/src/pages/fnrs-cnsltn/ui/components/pwrk-cnsltn-doc-mng-dialog.tsx` (158~159라인)
- **원인 코드**:
```tsx
const storeContext = useContext(StoreContext);
const userInfo = storeContext ? useStore(storeContext.userStore, (state) => state.userInfo) : null;
```
- **문제점**:
- React Hooks는 조건문, 반복문, 삼항 연산자 내부에서 호출될 수 없으며, 모든 렌더링마다 항상 동일한 순서로 호출되어야 합니다.
- 삼항 연산자 내에서 `useStore`를 호출하면서 `react-hooks/rules-of-hooks` 에러가 발생하여 빌드가 즉시 차단되었습니다.
- 또한 프로젝트 전역에서 제공하는 표준 훅인 `useUserStore`(`@/shared/lib`)가 이미 존재함에도 불구하고 이를 사용하지 않고 수동으로 컨텍스트를 조회하여 분기 처리하고 있었습니다.
### 2.2 `useElaprBranchesByDocNo` 훅 옵션 매개변수 타입 불일치
- **원인 코드**:
```tsx
const { data: elaprBranchList = [] } = useElaprBranchesByDocNo(
fnrsCnsltnDocNo,
open && Boolean(isForkJoin), // boolean 직접 전달
);
```
- **문제점**:
- `useElaprBranchesByDocNo`의 시그니처는 `(elaprDocNo, options?: { enabled?: boolean; structuralSharing?: boolean })`로 선언되어 있어, `boolean` 대신 객체 형태(`{ enabled: ... }`)로 전달해야 합니다.
### 2.3 전자결재 결재선 DTO 필드명 불일치
- **원인 코드**:
```tsx
if (currentUserPrgrs?.atrzSeq && Number(currentUserPrgrs.atrzSeq) > 1) return true;
```
- **문제점**:
- `ElaprDocPrgrsClientResponseDto` 인터페이스(`src/shared/model/pbadms-sprt.ts`)에 선언된 정식 결재 순번 필드명은 `atrzSeqNo?: number`입니다.
- 기존 코드가 `atrzSeq`로 접근하여 TypeScript 컴파일 에러가 발생했습니다.
### 2.4 `PwrkCnsltnDocEvlDialog`의 필수 prop `onSaved` 누락
- **원인 코드**:
```tsx
<PwrkCnsltnDocEvlDialog
open={isEvlDialogOpen}
pwrkCnsltnDocNo={completedDraft.pwrkCnsltnDocNo}
onClose={() => setIsEvlDialogOpen(false)}
/>
```
- **문제점**:
- `PwrkCnsltnDocEvlDialogProps`에 `onSaved: () => void;`가 필수로 선언되어 있었으나, 호출부에서 `onSaved` 콜백을 넘기지 않아 `Property 'onSaved' is missing` 컴파일 에러가 발생했습니다.
---
## 3. 조치 내역
### 3.1 컴포넌트 코드 수정 (`pwrk-cnsltn-doc-mng-dialog.tsx`)
1. **표준 `useUserStore` 훅 적용**:
- `useStore`, `StoreContext`, `useContext`를 제거하고, `@/shared/lib`의 `useUserStore`를 무조건부로 호출하도록 변경.
2. **`useElaprBranchesByDocNo` 옵션 객체화**:
- `{ enabled: open && Boolean(isForkJoin) }` 형태로 수정.
3. **결재 순번 속성 방어적 매핑**:
- `atrzSeqNo`를 우선 참조하고 레거시/모의 데이터의 `atrzSeq`도 호환되도록 fallback 처리.
4. **`PwrkCnsltnDocEvlDialog`의 `onSaved` prop 연결 및 선택적(Optional) 처리**:
- `pwrk-cnsltn-doc-evl-dialog.tsx`: `onSaved?: () => void;`로 변경하고 `onSaved?.()` 호출로 방어.
- `pwrk-cnsltn-doc-mng-dialog.tsx`: `<PwrkCnsltnDocEvlDialog ... onSaved={() => setIsEvlDialogOpen(false)} />` 콜백 전달.
#### 핵심 변경 사항 (Diff)
```diff
--- a/apps/as/src/pages/fnrs-cnsltn/ui/components/pwrk-cnsltn-doc-mng-dialog.tsx
+++ b/apps/as/src/pages/fnrs-cnsltn/ui/components/pwrk-cnsltn-doc-mng-dialog.tsx
@@ -1,14 +1,11 @@
"use client";
-import { useContext, useEffect, useMemo, useState } from "react";
+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 { useStore } from "zustand";
-
-import { getDefaultFileKey, useAlertStore, useTus } from "@/shared/lib";
-import { StoreContext } from "@/shared/providers";
+import { getDefaultFileKey, useAlertStore, useTus, useUserStore } from "@/shared/lib";
@@ -155,8 +152,7 @@ function PwrkCnsltnDocMngDialog({
const queryClient = useQueryClient();
const alert = useAlertStore((state) => state.setMessage);
- const storeContext = useContext(StoreContext);
- const userInfo = storeContext ? useStore(storeContext.userStore, (state) => state.userInfo) : null;
+ const userInfo = useUserStore((state) => state.userInfo);
@@ -165,7 +161,7 @@ function PwrkCnsltnDocMngDialog({
const { data: elaprBranchList = [] } = useElaprBranchesByDocNo(
fnrsCnsltnDocNo,
- open && Boolean(isForkJoin),
+ { enabled: open && Boolean(isForkJoin) },
);
@@ -216,11 +212,19 @@ function PwrkCnsltnDocMngDialog({
if (effectiveBranchSeCd === "TOTAL_AGG") {
if (effectiveAtrzSeList.includes("ATRZ04")) return false;
if (effectiveAtrzSeList.some((cd) => APPROVER_ATRZ_SE_COM_CDS.has(cd))) return true;
- if (currentUserPrgrs?.atrzSeq && Number(currentUserPrgrs.atrzSeq) > 1) return true;
+ const userSeq = currentUserPrgrs?.atrzSeqNo ?? (currentUserPrgrs as Record<string, unknown> | undefined)?.atrzSeq;
+ if (userSeq && Number(userSeq) > 1) return true;
if (effectiveAtrzSeList.length > 0 && !effectiveAtrzSeList.includes("ATRZ04")) return true;
}
return false;
```
### 3.2 단위 테스트 모킹 보완 (`pwrk-cnsltn-doc-mng-dialog.test.tsx`)
- 단위 테스트 환경에서 `StoreProvider` 미감싸임 에러(`useUserStore must be used within StoreProvider`)가 발생하지 않도록 `@/shared/lib` 모킹 객체에 `useUserStore`를 추가.
```tsx
let mockUserInfo: any = null;
vi.mock("@/shared/lib", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/shared/lib")>();
return {
...actual,
getDefaultFileKey: () => "mock-key",
useAlertStore: () => vi.fn(),
useUserStore: (selector: (state: { userInfo: any }) => any) =>
selector ? selector({ userInfo: mockUserInfo }) : { userInfo: mockUserInfo },
useTus: () => ({
uploadFileAsync: vi.fn(),
isUploading: {},
uploads: {},
}),
};
});
```
---
## 4. 검증 결과
| 검증 단계 | 명령어 | 결과 | 비고 |
|---|---|---|---|
| **1. 정적 타입 검사** | `pnpm --filter as exec tsc --noEmit` | **성공 (Exit 0)** | 컴파일 에러 0건 |
| **2. ESLint 린트 검사** | `pnpm --filter as lint` | **성공 (Exit 0)** | Hook 위반 에러 해결 |
| **3. 단위 테스트 (Vitest)** | `pnpm --filter as exec vitest run` | **전체 통과 (73/73 Tests)** | 14개 테스트 파일 100% Pass |
| **4. 프로덕션 빌드** | `pnpm build:as` | **성공 (Exit 0)** | 22/22 정적 페이지 최적화 완료 |
---
## 5. 재발 방지 표준 가이드
1. **React Hook 호출 규칙 준수**:
- `useUserStore`, `useQuery`, `useStore` 등 모든 React Hook은 반드시 컴포넌트 함수의 최상단(Top-level)에서 무조건부로 호출해야 합니다.
- 사용자 정보가 필요할 경우 개별 컨텍스트 접근 대신 표준 훅인 `useUserStore((state) => state.userInfo)`를 사용합니다.
2. **React Query 커스텀 훅 매개변수 규칙**:
- 조건부 쿼리 실행이 필요한 경우 boolean 값을 단독으로 넘기지 않고 `{ enabled: boolean }` 형태의 옵션 객체 인터페이스를 유지합니다.
3. **엔티티 DTO 필드명 엄격 확인**:
- 전자결재 결재선 DTO는 백엔드 명세에 따라 `atrzSeqNo`를 표준으로 사용하므로 레거시 명칭(`atrzSeq`)을 사용하지 않도록 타입을 확인합니다.
@@ -0,0 +1,160 @@
# 개발(Dev) 및 운영(Production) 실행 가이드 & 환경변수 관리
본 문서는 `apps/as` 애플리케이션의 **개발(Dev) 모드** 및 **운영(Production) 모드** 실행 방법과 **환경변수(.env) 변경 시의 재빌드 규칙**을 상세히 정리한 가이드입니다.
---
## 1. Dev 모드 vs Production 모드 비교
| 구분 | 개발 모드 (Dev) | 운영 모드 (Production) |
|---|---|---|
| **실행 목적** | 로컬 개발, 디버깅, 실시간 코드 수정 | 실서버 운영, 성능 최적화, 안정성 보장 |
| **사전 빌드 필요 여부** | **불필요** (즉시 실행) | **필수** (`.next` 번들 생성 선행) |
| **코드 반영 방식** | 핫 리로딩(HMR, 저장 즉시 반영) | 빌드 시점의 번들링 코드 고정 서빙 |
| **참조 환경변수** | `.env.local` > `.env.development` | `.env.local` > `.env.production` |
| **프로세스 관리** | Node 단일 프로세스 (터미널) | **PM2 Cluster 모드** (2개 인스턴스 무중단) |
| **서비스 포트** | 기본 `3004` 포트 | 기본 `3004` 포트 |
---
## 2. 환경변수(.env) 변경 시 재빌드 필수 여부
### 2.1 결론: `.env.production` 변경 시 반드시 재빌드 필요
Next.js 환경에서는 `.env.production`을 수정했을 때 단순히 PM2 재시작(`pm2 reload` / `pm2 restart`)만으로는 변경 사항이 브라우저에 반영되지 않으며, **반드시 빌드(`next build`)를 다시 수행해야 합니다.**
### 2.2 이유: Next.js의 환경변수 인라이닝(Inlining) 원리
1. **`NEXT_PUBLIC_*` 변수의 빌드타임 주입**:
- `NEXT_PUBLIC_SERVER_API_URL`, `NEXT_PUBLIC_PROXY_HOST`, `NEXT_PUBLIC_WEBHYP_CTRL_URL` 등 브라우저에서 참조하는 변수들은 `next build` 시점에 생성되는 클라이언트 JavaScript 파일(`.next/static/...`) 안에 **문자열 값으로 직접 하드코딩(Inlining)**됩니다.
- 따라서 코드를 다시 빌드하지 않으면 예전 주소나 설정값이 그대로 유지됩니다.
2. **정적 페이지(SSG) 생성 시점의 값 고정**:
- 빌드 시 정적 생성된 페이지(`Generating static pages (22/22)`)는 빌드 당시 읽어들인 환경변수 값을 기준으로 생성되므로, 런타임에 환경변수를 바꿔도 페이지가 갱신되지 않습니다.
### 2.3 환경변수 우선순위 주의사항
Next.js의 환경변수 로딩 우선순위는 다음과 같습니다:
```text
[운영 환경: NODE_ENV=production]
1순위: .env.production.local
2순위: .env.local (NODE_ENV=test 제외 항상 최우선)
3순위: .env.production
4순위: .env
```
> [!WARNING]
> 실서버 환경에 `.env.local` 파일이 존재할 경우 `.env.production`보다 우선 적용됩니다. `.env.production`을 수정했는데도 반영되지 않는다면 같은 경로에 `.env.local`이 존재하는지 확인하십시오.
---
## 3. Dev (개발 모드) 실행 방법
사전 빌드 과정 없이 소스 코드를 실시간으로 수정하며 개발할 때 사용합니다.
### 3.1 프로젝트 루트 디렉터리에서 실행
```bash
# 루트 디렉터리: ~/frontend_as
pnpm dev:as
# 또는 pnpm filter 플래그 사용
pnpm --filter as dev
```
### 3.2 `apps/as` 디렉터리 내부에서 실행
```bash
cd ~/frontend_as/apps/as
pnpm dev
```
- **접속 주소**: [http://localhost:3004](http://localhost:3004)
- **종료**: 터미널에서 `Ctrl + C`
---
## 4. Production (운영 모드) 실행 방법
반드시 **빌드 산출물(`.next`)이 최신 상태로 생성되어 있어야 정상 실행**됩니다.
### 4.1 1단계: 프로덕션 빌드 (필수)
모노레포 전체를 빌드할 필요 없이 `as` 앱만 단독으로 빌드할 수 있습니다:
```bash
# 프로젝트 루트 디렉터리에서 실행 (약 20~30초 소요)
pnpm build:as
# 또는
pnpm --filter as build
```
---
### 4.2 2단계: 서버 프로세스 실행
#### 방법 A: 터미널에서 직접 실행 (로컬 프로덕션 점검용)
포그라운드 터미널에서 프로덕션 번들의 빌드 결과와 동작을 직접 테스트할 때 사용합니다:
```bash
# 루트 디렉터리에서 실행
pnpm --filter as start
# 또는 apps/as 디렉터리에서 실행
cd apps/as
pnpm start
```
---
#### 방법 B: PM2 프로세스 매니저로 실행 (실서버 운영 표준)
실제 운영 서버에서는 `ecosystem.config.js` 설정을 통해 무중단 클러스터(2개 인스턴스)로 실행합니다.
```bash
# 1. 프로세스가 아직 등록되지 않은 경우 (최초 기동)
pm2 start ecosystem.config.js --only as2-app
# 2. 이미 실행 중인 경우 (무중단 롤링 리로드 - 권장)
# 순차적으로 인스턴스를 재시작하므로 사용자 요청 끊김이 없습니다.
pm2 reload as2-app
# 3. 프로세스 완전 재시작이 필요한 경우
pm2 restart as2-app
```
---
## 5. PM2 운영 및 모니터링 치트시트
```bash
# 1. 전체 프로세스 상태 확인 (상태, 메모리, CPU 점유율 등)
pm2 status
# 또는
pm2 list
# 2. as2-app 실시간 로그 모니터링
pm2 logs as2-app
# 3. 최근 로그 100줄 출력
pm2 logs as2-app --lines 100
# 4. 프로세스 일시 중지 및 삭제
pm2 stop as2-app # 프로세스 일시 중지
pm2 delete as2-app # PM2 목록에서 제거
# 5. 서버 재부팅 시 자동 실행 등록
pm2 save # 현재 실행 중인 프로세스 목록 저장
pm2 startup # OS 부팅 스크립트 생성
```
---
## 6. 실무 배포 요약 워크플로우
`.env.production` 수정 또는 프론트엔드 코드 수정 후 배포 시 아래 3단계를 수행합니다:
```bash
# Step 1. 최신 코드 반영 (필요시)
git pull origin deploy
# Step 2. as 앱 단독 프로덕션 재빌드
pnpm build:as
# Step 3. PM2 무중단 롤링 리로드
pm2 reload as2-app
```
@@ -0,0 +1,302 @@
# 재원협의(fnrs-cnsltn) 플로팅 액션 버튼(FloatingActionButtons) 호출 메커니즘 분석
## 1. 개요 (Overview)
본 문서는 **디지털 행정지원(Administrative Support, AS)** 모듈의 **재원협의 상세 조회 화면(`FnrsViewPage`)** 하단에 위치한 [FloatingActionButtons](file:///Users/changwng/workspace.ona/frontend_as/apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx) 컴포넌트의 액션 처리 메커니즘을 상세히 분석한 기술 문서입니다.
사용자가 화면 하단 액션바에서 **반려(`rjct`) 버튼**을 클릭했을 때 실행되는 `handleRjctClick()` 함수가 내부적으로 중앙 집중식 디스패처인 `executeAction()`과 룩업 테이블(`buttonActionHandlers`)을 거쳐 최종 API 호출 함수인 `handleRejectPrgrsStts()`로 라우팅되는 전 과정을 추적하고, 적용된 아키텍처 디자인 패턴을 설명합니다.
---
## 2. 핵심 아키텍처 디자인 패턴
[FloatingActionButtons](file:///Users/changwng/workspace.ona/frontend_as/apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx)는 수많은 행정 업무 액션(저장, 신청, 삭제, 환경청 제출, 승인, 반려, 검토의견 회신, 보완요청, 기술검토요청 등)을 안전하고 확장 가능하게 관리하기 위해 **전략 패턴(Strategy Pattern)**과 **디스패처(Dispatcher) 패턴**을 결합하여 설계되었습니다.
```
┌────────────────────────────────────────────────────────────────────────┐
│ UI 컴포넌트 이벤트 (예: handleRjctClick) │
│ - 사용자 Confirm 확인 │
│ - 요청 DTO(DocPrgrsUpdateRequestDto) 조립 │
└──────────────────────────────────┬─────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────────────┐
│ 중앙 액션 디스패처 (executeAction) │
│ - 중복 클릭 방지 (isProcessing 상태 관리) │
│ - buttonActionHandlers 룩업 테이블에서 적절한 Strategy 함수 검색 │
│ - 일원화된 성공 처리 (Alert 메시지, React Query 캐시 무효화) │
│ - 일원화된 에러 처리 (Axios 에러 메시지 추출 및 Alert) │
└──────────────────────────────────┬─────────────────────────────────────┘
│ action: "rjct" 키로 조회
▼
┌────────────────────────────────────────────────────────────────────────┐
│ 룩업 테이블 (buttonActionHandlers: Record<Action, Handler>) │
│ - rjct: handleRejectPrgrsStts │
└──────────────────────────────────┬─────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────────────┐
│ 개별 액션 핸들러 (handleRejectPrgrsStts) │
│ - fnrsCnsltnMngService.rejectPrgrsStts(payload) 백엔드 API 호출 │
│ - context.onSuccess() / context.onError() 콜백 트리거 │
└────────────────────────────────────────────────────────────────────────┘
```
### 2.1 적용된 핵심 원칙 및 이점
1. **단일 책임 원칙 (SRP)**:
- 각 UI 이벤트 함수(`handleRjctClick`)는 사용자 확인 및 페이로드 빌드만 담당합니다.
- 중앙 디스패처(`executeAction`)는 로딩 상태, 캐시 갱신, 공통 에러 핸들링을 전담합니다.
- 실제 비즈니스 통신은 순수 비동기 함수(`handleRejectPrgrsStts`)로 격리됩니다.
2. **개방-폐쇄 원칙 (OCP)**:
- 신규 액션 버튼이 추가될 경우 `buttonActionHandlers` 레코드에 새로운 키-핸들러 쌍만 등록하면 되며, 공통 에러 처리나 캐시 무효화 로직을 중복 구현할 필요가 없습니다.
3. **무결성 및 안정성**:
- `setIsProcessing(true)`를 통해 네트워크 요청 진행 중 다중 클릭(Double Submit)을 원천 차단합니다.
- 작업 완료 후 `queryClient.invalidateQueries`를 통해 상세 데이터 및 전자결재 분기 상태를 최신 상태로 즉시 동기화합니다.
---
## 3. 전체 호출 흐름 시퀀스 다이어그램 (Sequence Diagram)
```mermaid
sequenceDiagram
autonumber
actor User as 사용자
participant UI as FloatingActionButtons
participant Rjct as handleRjctClick
participant Exec as executeAction (디스패처)
participant Table as buttonActionHandlers (룩업)
participant Handler as handleRejectPrgrsStts
participant Service as fnrsCnsltnMngService
participant Backend as 백엔드 REST API
participant Query as TanStack QueryClient
User->>UI: [반려] 버튼 클릭
UI->>Rjct: handleRjctClick() 트리거
Rjct->>User: confirm("해당 신청 건을 반려하시겠습니까?")
User-->>Rjct: 확인 (true)
Note over Rjct: DocPrgrsUpdateRequestDto 생성<br/>{ docNo, docSeComCd, prgrsSttsComCd: "SV", rgtrUuid }
Rjct->>Exec: executeAction({ action: "rjct", request: payload, successMessage: "반려되었습니다." })
rect rgb(240, 245, 255)
Note over Exec: 1. setIsProcessing(true)<br/>2. const handler = buttonActionHandlers["rjct"]
Exec->>Table: buttonActionHandlers["rjct"] 검색
Table-->>Exec: handleRejectPrgrsStts 반환
end
Exec->>Handler: handler({ request, data, onSuccess, onError }) 실행
Handler->>Service: rejectPrgrsStts(context.request) 호출
Service->>Backend: PUT /api/v1/as/fnrs-cnsltn-mng/reject-prgrs-stts
Backend-->>Service: 200 OK 응답
Service-->>Handler: 완료 반환
Handler->>Exec: context.onSuccess() 콜백 호출
rect rgb(245, 255, 240)
Note over Exec: onSuccess 후속 처리 실행
Exec->>User: alert("반려되었습니다.")
Exec->>Query: invalidateQueries({ queryKey: fnrsCnsltnKeys.detail(...) })
Exec->>Query: invalidateQueries({ queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_BRANCH] })
Note over Query: 백엔드 최신 상세 데이터 재조회 및 화면 UI 갱신 (상태: ST -> SV)
end
Note over Exec: finally { setIsProcessing(false) }
```
---
## 4. 단계별 상세 소스 코드 분석
### 4.1 Step 1: UI 이벤트 트리거 및 페이로드 조립 (`handleRjctClick`)
사용자가 화면의 반려 버튼을 클릭하면 [handleRjctClick](file:///Users/changwng/workspace.ona/frontend_as/apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx#L474-L489)이 실행됩니다.
```typescript
// apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx (Line 474~489)
async function handleRjctClick() {
// 1. 사용자 재확인 대화상자 노출
if (!(await confirm("해당 신청 건을 반려하시겠습니까?"))) {
return;
}
// 2. 백엔드 진행상태 업데이트 DTO 빌드 (임시저장 'SV' 상태로 롤백)
const payload: DocPrgrsUpdateRequestDto = {
docNo, // 대상 문서번호
docSeComCd, // 문서구분 공통코드
prgrsSttsComCd: "SV", // 변경할 진행상태: 작성중/임시저장(SV)
rgtrUuid: userInfo?.userUnqId, // 처리자 고유 식별자
};
// 3. 중앙 디스패처 호출
await executeAction({
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.rjct, // "rjct" 상수
request: payload,
successMessage: "반려되었습니다.",
});
}
```
* **핵심 포인트**: 반려 처리는 문서의 진행 상태를 신청완료(`ST`)에서 작성자(지자체)가 다시 수정할 수 있는 임시저장/작성중(`SV`) 상태로 되돌리는 동작입니다.
---
### 4.2 Step 2: 중앙 집중식 액션 디스패처 (`executeAction`)
[executeAction](file:///Users/changwng/workspace.ona/frontend_as/apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx#L370-L408)은 전달받은 액션 식별자에 따라 룩업 테이블에서 핸들러를 찾아 실행하고, 공통 라이프사이클(로딩, 성공 알림, 쿼리 캐시 무효화, 에러 처리)을 관리합니다.
```typescript
// apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx (Line 370~408)
async function executeAction({
action,
request,
successMessage,
}: {
action: FnrsCnsltnViewButtonAction;
request?: unknown;
successMessage?: string;
}) {
// 1. 룩업 테이블에서 액션명에 해당하는 실행 함수 조회
const handler = buttonActionHandlers[action];
if (!handler) return;
// 2. 비동기 처리 중 중복 호출 방지 플래그 활성화
setIsProcessing(true);
try {
// 3. 전략 핸들러에 Context 주입 및 실행
await handler({
request,
data,
onSuccess: () => {
// 성공 메시지 출력
if (successMessage) alert(successMessage);
// 상세 데이터 쿼리 캐시 무효화 -> 화면 자동 갱신
queryClient.invalidateQueries({
queryKey: fnrsCnsltnKeys.detail(fnrsCnsltnDocNo),
});
// 전자결재 분기 상태 쿼리 캐시 무효화
queryClient.invalidateQueries({
queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_BRANCH],
});
},
onError: (error) => {
// 통일된 에러 메시지 추출 및 Alert
let errorMessage = "";
if (error instanceof AxiosError) {
errorMessage = error.response?.data?.message ?? "";
} else {
errorMessage = error.message ?? "처리 중 오류가 발생했습니다.";
}
alert(errorMessage);
},
});
} finally {
// 4. 로딩 플래그 해제
setIsProcessing(false);
}
}
```
---
### 4.3 Step 3: 전략 매핑 룩업 테이블 (`buttonActionHandlers`)
[buttonActionHandlers](file:///Users/changwng/workspace.ona/frontend_as/apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx#L1215-L1239)는 컴포넌트 파일 하단에 위치하며, 각 액션 문자열과 실행할 실제 비즈니스 함수를 1:1로 매핑합니다.
```typescript
// apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx (Line 1215~1239)
const buttonActionHandlers: Record<FnrsCnsltnViewButtonAction, ActionHandler> = {
strg: handleSaveApply,
aply: handleSaveApply,
del: handleDel,
envoSbmsn: handleChangePrgrsSttsComCd,
rjct: handleRejectPrgrsStts, // <--- "rjct" 액션이 handleRejectPrgrsStts 함수로 바인딩됨
sncnAprv: handleChangePrgrsSttsComCd,
rvwRply: handleTechReview,
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: () => Promise.resolve(),
atrzRply: () => Promise.resolve(),
actnCmptn: handleActnCmptn,
rqstActnRvw: handleRequestActnReview,
costVrfc: () => Promise.resolve(),
};
```
---
### 4.4 Step 4: 실제 비즈니스 API 호출 핸들러 (`handleRejectPrgrsStts`)
[handleRejectPrgrsStts](file:///Users/changwng/workspace.ona/frontend_as/apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx#L1123-L1132) 함수는 전달받은 Context를 언팩하여 백엔드 REST API 서비스를 호출합니다.
```typescript
// apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx (Line 1123~1132)
async function handleRejectPrgrsStts(
context: ActionHandlerContext<DocPrgrsUpdateRequestDto>,
): Promise<void> {
try {
// 백엔드 엔티티 서비스 호출
await fnrsCnsltnMngService.rejectPrgrsStts(context.request);
// 성공 시 executeAction의 onSuccess 실행
context.onSuccess?.();
} catch (error) {
// 실패 시 executeAction의 onError 실행
context.onError?.(error as Error);
}
}
```
#### 백엔드 서비스 연동부 (`fnrsCnsltnMngService.rejectPrgrsStts`)
```typescript
// apps/as/src/entities/fnrs-cnsltn/api/fnrs-cnsltn-mng-service.ts
async rejectPrgrsStts(body: DocPrgrsUpdateRequestDto): Promise<void> {
await this.client.put<CommonResponse<void>>(
"/api/v1/as/fnrs-cnsltn-mng/reject-prgrs-stts",
body,
);
}
```
---
## 5. 반려 처리 후 화면 갱신 메커니즘 (Post-Action Reaction)
`handleRejectPrgrsStts`가 성공적으로 종료된 후 화면이 자동으로 갱신되는 과정은 다음과 같습니다:
1. **콜백 체인 트리거**:
`handleRejectPrgrsStts` -> `context.onSuccess()` 실행.
2. **Alert 알림 표시**:
사용자에게 `"반려되었습니다."` 안내 메시지 팝업 노출.
3. **TanStack Query 캐시 무효화 (`invalidateQueries`)**:
- `fnrsCnsltnKeys.detail(fnrsCnsltnDocNo)` 키를 가진 React Query 캐시가 Stale 상태로 마킹되어 백엔드로부터 최신 문서 상세 정보를 즉시 다시 조회합니다.
- `[ENDPOINT.AS_ELAPR_SERVICE.ELAPR_BRANCH]` 키의 결재 분기 쿼리도 함께 무효화되어 결재선 및 버튼 상태가 재계산됩니다.
4. **버튼 가시성 및 권한 자동 재계산**:
- 상세 응답 DTO의 `detDto.prgrsSttsComCd`가 `"ST"`(신청완료)에서 `"SV"`(임시저장/작성중)로 변경됩니다.
- [button-config.ts](file:///Users/changwng/workspace.ona/frontend_as/apps/as/src/pages/fnrs-cnsltn/lib/button-config.ts)의 권한-상태 매트릭스(`ROLE_STATUS_BUTTON_ACTIONS`)에 따라 화면 하단 버튼이 반려/접수 모드에서 **수정/신청/삭제 모드**로 자동 전환됩니다.
- [InstlSncnDetailGate](file:///Users/changwng/workspace.ona/frontend_as/apps/as/src/pages/instl-sncn/ui/instl-detail-gate.tsx) 또는 상세 라우팅 규칙에 의해 지자체 작성자는 수정 페이지(`/edit`)로 접근할 수 있게 권한이 활성화됩니다.
---
## 6. 요약 및 권장 개발 가이드라인
| 항목 | 구현 내용 | 아키텍처적 가치 |
| :--- | :--- | :--- |
| **액션 트리거** | `handleRjctClick` | 사용자 의사 확인(`confirm`) 및 DTO 캡슐화 |
| **공통 처리** | `executeAction` | 로딩 상태(`isProcessing`), 에러 Alert, 캐시 무효화 일원화 |
| **동적 디스패치** | `buttonActionHandlers[action]` | 조건문(if-else/switch) 없는 깔끔한 O(1) 룩업 테이블 |
| **API 실행** | `handleRejectPrgrsStts` | 도메인 엔티티 서비스(`fnrsCnsltnMngService`)에 통신 위임 |
| **화면 동기화** | `invalidateQueries` | 수동 상태 변이 없이 선언적 서버 상태 재동기화 |
다른 업무 모듈(`instl-sncn`, `pwrk-cnsltn`, `design-cnstn` 등) 개발 시에도 본 메커니즘과 동일하게 **`[ActionHandler] -> [buttonActionHandlers] -> [executeAction]`** 구조를 준수하여 구현하면 일관되고 견고한 엔터프라이즈 UI를 유지할 수 있습니다.
@@ -0,0 +1,183 @@
# 병합자 실무협의서(MAIN) 웹기안기 탭 전환 시 문서 초기화 이슈 및 해결방안
> **문서 대상**: `apps/as/src/pages/fnrs-cnsltn/ui/components/pwrk-cnsltn-doc-mng-dialog.tsx`
> **관련 모듈**: 재원협의·실무협의(`fnrs-cnsltn` / `pwrk-cnsltn`), 웹한글기안기(`@/widgets/hangulgiangi`), 전자결재(`elapr`)
> **작성일**: 2026-10-23
> **상태**: 구현 완료 (`PwrkCnsltnBranchRefPanel` 사이드 패널 Split View 적용 완료)
---
## 1. 개요 (Overview)
재원협의(FNRS)의 하위 실무협의(PWRK) 프로세스 중 병렬 결재(Fork-Join, `TE`/`RK` 상태) 단계에서, **종합병합자(TOTAL_AGG 또는 ATRZ04 권한자)**는 관로(`SP`)와 시설(`SF`) 부서의 초안 및 검토 의견을 종합하여 최종 종합 실무협의서인 **`MAIN` 탭 문서를 편집·반영하고 저장**하는 법적·업무적 책임을 집니다.
현재 구현된 [PwrkCnsltnDocMngDialog](file:///Users/changwng/workspace.ona/frontend_as/apps/as/src/pages/fnrs-cnsltn/ui/components/pwrk-cnsltn-doc-mng-dialog.tsx)에서는 병합자가 `MAIN` 문서를 수정하던 중 부서별 작성 내용을 확인하기 위해 `[관로 초안 (SP)]` 또는 `[시설 초안 (SF)]` 탭 버튼을 클릭하고 다시 `[종합 실무협의서 (MAIN)]` 탭으로 돌아왔을 때, **기안기 내부에서 편집 중이던 수정 내용이 모두 사라지고 서버에 저장된 원래 버전으로 초기화(Overwrite)되는 치명적인 데이터 유실 문제**가 발생하고 있습니다.
본 문서는 해당 현상의 근본 기술 원인을 분석하고, 병합자의 업무 흐름과 시스템 제약사항을 만족하는 실질적 해결 방안을 검토·제시합니다.
---
## 2. 현상 및 업무적 배경 (Business Context & Issue)
### 2.1 병합자의 역할 및 책임
1. **수정 및 저장의 단일 주체**:
- 병렬 결재(`isForkJoin === true`) 환경에서 관로(`SP`) 및 시설(`SF`) 부서의 1차 검토가 완료되면 병합자가 전체 내용을 종합합니다.
- 병합자는 오직 **`MAIN` 종합본만 수정·저장**할 수 있으며, `SP`와 `SF` 문서는 수정 권한이 없고 **오직 참조(열람)만 가능**합니다 (`canSaveCurrentBranch` 조건 참조).
2. **반영 및 대조(Cross-Check) 업무**:
- 병합자는 `SP`의 검토 의견/사업비 조정 결과와 `SF`의 검토 의견/사업비 조정 결과를 문단별·항목별로 대조하며 `MAIN` 문서의 본문과 표를 직접 수기 편집(병합)해야 합니다.
### 2.2 발생 현상 (Problem Scenario)
```
[사용자 행동 흐름]
1. 병합자가 다이얼로그 진입 시 기본 활성화된 [종합 실무협의서 (MAIN)] 탭에서 한글 문서 수정 시작
2. 관로 부서가 수정한 상세 내역을 확인하기 위해 상단 [관로 초안 (SP)] 탭 버튼 클릭
3. 관로 초안 확인 후, 다시 상단 [종합 실무협의서 (MAIN)] 탭 버튼 클릭
4. 결과: 방금 전까지 작성/수정 중이던 MAIN 한글 문서 내용이 전부 사라지고 서버 원본으로 롤백됨
```
---
## 3. 기술적 원인 분석 (Root Cause Analysis)
### 3.1 단일 웹한글기안기(iframe) 인스턴스 공유 및 무조건 문서 로드 (`HwpCtrl.Open`)
[PwrkCnsltnDocMngDialog.tsx](file:///Users/changwng/workspace.ona/frontend_as/apps/as/src/pages/fnrs-cnsltn/ui/components/pwrk-cnsltn-doc-mng-dialog.tsx#L357-L383)의 구조:
```tsx
// 탭 변경 시 targetBranchForApi 변경 -> React Query가 해당 탭의 최신 HWPX Blob 재조회
const targetBranchForApi = isForkJoin ? currentBranchSeCd : undefined;
const { data: documentBlob } = useQuery({
queryKey: ["pwrk-cnsltn-doc-crt-rslt", ..., targetBranchForApi, ...],
queryFn: () => pwrkCnsltnDocCrtRsltService.openWebHwpLatest(crtDmndUuid!, targetBranchForApi),
});
```
기안기 렌더링부:
```tsx
<HangulGiangi
documentBlob={documentBlob}
documentKey={`${completedDraft.crtDmndUuid}_${currentBranchSeCd}_${selectedVerSn ?? "latest"}`}
...
/>
```
[HangulGiangi.tsx](file:///Users/changwng/workspace.ona/frontend_as/apps/as/src/widgets/hangulgiangi/ui/hangul-giangi.tsx#L120-L130)의 문서 로드 이펙트:
```tsx
useEffect(() => {
if (!isCtrlReady || ...) return;
// documentKey가 변경되면 기존 기안기 컨트롤에 새 Blob을 바로 Open해버림!
openHwpxBlob(documentBlob, documentFileName);
}, [documentBlob, documentFileName, documentKey, isCtrlReady, openHwpxBlob]);
```
- 사용자가 탭을 전환하면 `currentBranchSeCd`가 바뀌면서 `documentKey`가 달라집니다.
- 단일 iframe에 위치한 기안기 컨트롤러(`HwpCtrl`)는 즉시 `HwpCtrl.Open(documentFile)`을 실행하여 새로운 HWPX 문서를 캔버스에 로드합니다.
- 이 과정에서 기안기 메모리 버퍼에 존재하던 `MAIN` 문서의 편집 변경사항은 **서버 저장이나 로컬 캐싱 없이 즉시 파기**됩니다.
- 다시 `MAIN` 탭을 클릭하면 서버의 최신 저장본(`openWebHwpLatest`)이 다시 `Open`되므로, 작성 중이던 내용이 완전히 날아가게 됩니다.
### 3.2 변경 감지(Dirty Check) 및 화면 이탈 가드 부재
- 한컴 웹기안기는 문서 수정 여부를 판별할 수 있는 프로퍼티(`HwpCtrl.IsModified`)를 제공하지만([ref-webhwp-api.md:L269](file:///Users/changwng/workspace.ona/frontend_as/apps/as/docs/pwrk-cnsltn/ref-webhwp-api.md#L269)), 탭 전환 이벤트 핸들러에서 이를 전혀 검사하지 않고 즉시 state를 변경합니다.
- 미저장 변경사항이 있을 때 사용자에게 확인을 요청하는 컨펌/모달 가드가 없습니다.
### 3.3 UX 설계와 병합자 업무 특성의 불일치
- 병합자는 `SP`/`SF`를 보면서 **동시에 `MAIN`에 입력·복사**해야 합니다.
- 화면 전체를 `SP` $\leftrightarrow$ `MAIN`으로 번갈아 스위칭하는 단일 캔버스 탭 방식은 초기화 문제 외에도 **"앞서 본 내용을 외우거나 메모장에 복사한 뒤 탭을 넘나들어야 하는" 극심한 업무 비효율**을 초래합니다.
---
## 4. 해결 방안 비교 검토 (Solution Analysis)
| 평가 항목 | [방안 1] 병합자용 참조 문서 분할/별도 창 뷰어 제공 (강력 권장 ★★★★★) | [방안 2] 탭 전환 시 인메모리 Blob 임시 캐싱 | [방안 3] 탭 전환 시 Dirty Check 및 저장 강제 컨펌 |
| :--- | :--- | :--- | :--- |
| **개념** | `MAIN` 기안기는 항상 화면에 유지하고, `SP`/`SF`는 **별도 새 창(Window) 또는 팝업 모달/우측 패널**로 열람 | 탭 전환 시 `HwpCtrl.SaveAs`로 HWPX Blob을 추출해 React State/Ref에 임시 보관 후 복귀 시 재오픈 | 미저장 변경사항(`IsModified`)이 있을 때 탭 전환을 차단하고 서버에 먼저 저장하도록 유도 |
| **초기화 방지** | **원천 차단** (`MAIN` 기안기 인스턴스가 절대 언로드되지 않음) | 해결 가능 (메모리에서 복구) | 해결 가능 (저장 후 이동) |
| **병합 업무 편의성** | **최상** (듀얼 모니터 또는 분할 창으로 두 문서를 나란히 대조 가능) | 보통 (여전히 탭을 왔다 갔다 하며 외워서 작업해야 함) | 낮음 (SP 한번 보려면 MAIN 서버 저장 필수, 버전 폭증) |
| **시스템 안정성** | **매우 높음** (복잡한 기안기 상태 관리 불필요) | 낮음 ~ 보통 (비동기 콜백 레이스 컨디션 및 메모리 누수 위험) | 보통 (불필요한 `verSn` 이력 증가) |
| **구현 복잡도** | 낮음 ~ 보통 | 높음 | 낮음 |
---
## 5. 상세 권장 해결책 (Recommended Architecture)
가장 안전하고 병합자의 업무 생산성을 극대화할 수 있는 **하이브리드 접근법(단계별 적용)**을 권장합니다.
```mermaid
graph TD
subgraph 병합자(TOTAL_AGG) 화면 구조
MAIN_VIEW["MAIN 기안기 (항상 유지, 수정 내용 보존)"]
BTN_SP["[관로(SP) 초안 새 창 열기 / 대조]"]
BTN_SF["[시설(SF) 초안 새 창 열기 / 대조]"]
GUARD{"탭 전환 시도 시<br/>HwpCtrl.IsModified 체크"}
end
subgraph 별도 참조 뷰 (Popup / Window / Split)
REF_SP["관로(SP) 읽기 전용 뷰어"]
REF_SF["시설(SF) 읽기 전용 뷰어"]
end
BTN_SP -->|독립 실행| REF_SP
BTN_SF -->|독립 실행| REF_SF
REF_SP -.동시 대조 및 MAIN에 복사/반영.-> MAIN_VIEW
REF_SF -.동시 대조 및 MAIN에 복사/반영.-> MAIN_VIEW
GUARD -->|미저장 내용 있음| WARN["경고 알림: 저장 후 이동 또는 취소"]
```
---
### 단계 1: 병합자를 위한 "SP / SF 참조 문서 별도 창 열람" 제공 (방안 1 적용 완료)
병합자에게 `SP`와 `SF`는 편집 대상이 아니라 **참조(열람) 대상**이므로, `MAIN` 기안기 화면을 전환하지 않고 별도 창(Popup Window)으로 띄워 듀얼 모니터나 분할 화면으로 대조할 수 있도록 구현했습니다.
#### 1) UI 액션 버튼 분기
병합자(`isMerger === true`)가 `MAIN` 탭을 보고 있을 때, 상단 탭 영역 우측에 **독립 새 창 열기 버튼**을 배치:
- `[관로(SP) 새 창 보기]`
- `[시설(SF) 새 창 보기]`
#### 2) 새 창 열기 및 한컴 라이선스 오류 해결 메커니즘
- **라이선스 오류 원인 및 해결**:
- 기존 새 창 iframe 호출 시 `/webhwpctrl/index.html`로만 호출하여 `baseurl` 쿼리 파라미터가 누락되었고, 로컬 주소(`localhost:3004`)를 컨트롤 기본 URL로 인식하여 한컴 기안기 스크립트에서 `라이센스 오류` alert이 발생함.
- 이를 해결하기 위해 `buildBridgeSrc(DEFAULT_BRIDGE_URL, WEBHYP_CTRL_URL)` 유틸 함수를 통해 `?baseurl=https://hwp.onasys.co.kr/webhwpctrl`이 안전하게 주입되도록 개선.
- **HWPX 다운로드 Fallback 지원**:
- 새 창 상단 헤더에 `[HWPX 다운로드]` 버튼을 함께 제공하여, 웹기안기 뷰어 외에도 로컬 한컴오피스 프로그램으로 즉시 문서를 내려받아 열람할 수 있도록 이중 보장.
---
### 단계 2: 탭 전환 시 Dirty Check 및 데이터 유실 방지 가드 (안전 가드 적용 완료)
만약 사용자가 기존 탭 버튼(`SP`, `SF`)을 눌러 탭을 전환하려고 시도할 때, 작성 중이던 내용이 소실되지 않도록 확인 가드를 배치했습니다.
#### 1) `HwpCtrl.IsModified` 감지 함수
- `apps/as/src/widgets/hangulgiangi/lib/get-hwp-ctrl.ts`의 `getHwpCtrl(null)`을 통해 현재 활성화된 한컴 기안기 컨트롤의 `IsModified` 플래그를 실시간 확인.
#### 2) 탭 전환 및 창 닫기 안전 확인
```typescript
function checkDirtyAndConfirm(targetDesc: string): boolean {
if (currentBranchSeCd === "MAIN") {
try {
const ctrl = getHwpCtrl(null);
if (ctrl?.IsModified) {
return window.confirm(
`종합 실무협의서(MAIN)에 아직 저장되지 않은 수정 내용이 있습니다.\n` +
`${targetDesc}(으)로 이동하면 작성 중인 내용이 초기화됩니다.\n\n` +
`계속 진행하시겠습니까? (취소 후 [실무협의서 저장]을 권장합니다.)`
);
}
} catch (e) {
console.warn("기안기 변경 감지 실패:", e);
}
}
return true;
}
```
---
## 6. 결론 및 적용 결과
1. **초기화 버그 원천 해결**:
- `MAIN` 탭을 벗어나지 않고도 `[관로(SP) 새 창 보기]`, `[시설(SF) 새 창 보기]`를 통해 독립 팝업으로 각 부서의 원본 문서를 대조할 수 있어 `MAIN` 기안기 인스턴스가 절대 언로드되지 않습니다.
- 실수로 탭을 누르거나 창을 닫으려 할 때도 `IsModified` 기반 안전 컨펌 창이 작동하여 데이터 유실을 100% 방지합니다.
2. **새 창 웹기안기 라이선스 인증 정상화**:
- 새 창 iframe의 브리지 URL에 `WEBHYP_CTRL_URL` 파라미터를 정상 바인딩하여 "라이센스 오류" alert을 근본적으로 제거하였습니다.
- 새 창 헤더에 `[HWPX 다운로드]` 버튼을 추가하여 로컬 환경에서도 편리하게 문서를 열어볼 수 있도록 지원합니다.
3. **단위 테스트 검증 완료**:
- 총 19개 단위 테스트(`pnpm --filter as exec vitest run src/pages/fnrs-cnsltn/ui/__tests__/pwrk-cnsltn-doc-mng-dialog.test.tsx`)가 전체 통과(`19 passed (19)`)함을 검증했습니다.
@@ -0,0 +1,236 @@
# TanStack Query 상세 데이터 캐시 동기화 이슈 및 해결방안
> **문서 대상**: `apps/as/src/entities/fnrs-cnsltn/api/queries.ts`, `apps/as/src/shared/lib/query-client.ts`
> **관련 모듈**: 재원협의(`fnrs-cnsltn`), 실무협의(`pwrk-cnsltn`), 웹한글기안기(`@/widgets/hangulgiangi`), 인허가 공통(`instl-sncn`, `design-cnstn` 등)
> **작성일**: 2026-10-23
> **상태**: 분석 및 전체 코드 적용 완료 (검증 완료)
---
## 1. 개요 (Overview)
재원협의(`fnrs-cnsltn`) 및 인허가 업무 프로세스는 지자체 담당자(신청자), 한국환경공단 담당자(검토자), 결재권자 등 **다수의 사용자가 실시간으로 문서를 조회·작성·보완·결재하는 다자간 협업 환경**입니다.
최근 운영 및 테스트 환경에서 **"목록 리스트에서 상세 화면으로 진입했을 때, 다른 사용자가 직전에 입력/수정한 최신 데이터가 바로 조회되지 않고, 브라우저 새로고침(F5, 새로 읽기)을 해야만 화면에 정상 표시되는 현상"**이 보고되었습니다.
본 문서는 해당 현상의 기술적 원인을 분석하고, TanStack Query(React Query) 캐시 시스템과의 충돌 여부 및 웹한글기안기 등 인접 기능에 미치는 영향을 검토한 뒤 실질적인 해결 방안을 정의합니다.
---
## 2. 현상 및 재현 시나리오 (Problem Scenario)
```mermaid
sequenceDiagram
autonumber
actor UserA as 사용자 A (공단 검토자)
actor UserB as 사용자 B (지자체 신청자)
participant ClientA as 사용자 A 브라우저 (React Query)
participant Server as 백엔드 API (Spring Boot / DB)
Note over UserA, ClientA: 1. 문서 1번 상세 조회 (최초 진입)
UserA->>ClientA: 목록에서 문서 1번 클릭
ClientA->>Server: GET /server/as/v1/fnrs-cnsltn-mng/1
Server-->>ClientA: 200 OK (구 데이터 반환)
ClientA->>ClientA: React Query 캐시에 저장 (staleTime: 60초 시작)
UserA->>ClientA: 목록 화면으로 이동 (SPA 클라이언트 라우팅)
Note over UserB, Server: 2. 타 사용자의 데이터 수정 및 저장
UserB->>Server: POST /server/as/v1/fnrs-cnsltn-mng (데이터 보완/수정 완료)
Server-->>UserB: 200 OK (DB 최신화 완료)
Note over UserA, ClientA: 3. 60초 이내에 문서 1번 상세 재진입 (문제 발생!)
UserA->>ClientA: 목록에서 문서 1번 다시 클릭
Note over ClientA: ⚠️ staleTime(60초) 미경과로 캐시를 'fresh' 상태로 판단<br/>→ 서버 API 요청 없이 기존 구 캐시 데이터 반환!
ClientA-->>UserA: 이전 구 데이터 화면 표시 (수정 내용 미반영)
Note over UserA, ClientA: 4. 브라우저 새로고침(F5 / 새로 읽기)
UserA->>ClientA: 브라우저 새로고침 (F5)
Note over ClientA: JS 런타임 메모리 초기화 → 캐시 삭제
ClientA->>Server: GET /server/as/v1/fnrs-cnsltn-mng/1
Server-->>ClientA: 200 OK (최신 데이터 반환)
ClientA-->>UserA: 최신 데이터 정상 표시!
```
---
## 3. 기술적 근본 원인 분석 (Root Cause Analysis)
### 3.1 [핵심 원인] 전역 `staleTime: 60초` 캐싱 및 네트워크 요청 생략
- **설정 파일**: [query-client.ts](file:///Users/changwng/workspace.ona/frontend_as/apps/as/src/shared/lib/query-client.ts#L3-L11)
```typescript
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
staleTime: 60 * 1000, // 👈 60초(1분) 동안 캐시를 "fresh(신선한)" 상태로 간주
},
},
});
```
- **상세 쿼리**: [queries.ts: useFnrsCnsltnMng](file:///Users/changwng/workspace.ona/frontend_as/apps/as/src/entities/fnrs-cnsltn/api/queries.ts#L33-L39)
```typescript
export function useFnrsCnsltnMng(fnrsCnsltnDocNo: string | null | undefined) {
return useQuery({
queryKey: fnrsCnsltnKeys.detail(fnrsCnsltnDocNo!),
queryFn: () => fnrsCnsltnMngService.findById(fnrsCnsltnDocNo!),
enabled: Boolean(fnrsCnsltnDocNo),
// ⚠️ staleTime이 지정되지 않아 전역 기본값인 60초가 그대로 상속됨
});
}
```
#### 동작 메커니즘
1. TanStack Query는 데이터가 한 번 패칭되면 `staleTime`(60초) 동안 데이터를 `fresh` 상태로 취급합니다.
2. 목록 화면에서 상세 화면으로 이동할 때(`router.push()`) 컴포넌트가 다시 마운트되더라도, 데이터가 `fresh` 상태이면 **네트워크 요청(refetch)을 일절 수행하지 않고 메모리 캐시를 그대로 반환**합니다.
3. 본인이 직접 저장/수정(`mutation`)을 실행한 경우에는 `queryClient.invalidateQueries`가 호출되어 본인 브라우저 캐시는 무효화되지만, **다른 사용자의 브라우저에는 어떠한 무효화 신호도 전달되지 않습니다.**
4. 따라서 다른 사용자가 실시간으로 문서를 변경하더라도 60초 동안은 무조건 구 데이터가 표시됩니다.
5. 브라우저 새로고침(F5)을 누르면 SPA 런타임 메모리가 초기화되어 React Query 캐시가 사라지므로, 네트워크 요청을 새로 보내 최신 데이터가 조회되는 것입니다.
---
### 3.2 [부차 원인] 브라우저 HTTP GET 캐싱 (Cache-Control 부재)
- [packages/common/network/client.ts](file:///Users/changwng/workspace.ona/frontend_as/packages/common/network/client.ts#L20-L27)의 Axios 인스턴스 기본 요청 헤더에 `Cache-Control: no-cache`가 설정되어 있지 않습니다.
- 백엔드(Spring Boot / Nginx 프록시) 응답에 `Cache-Control: no-cache, no-store, must-revalidate`가 누락된 경우, 브라우저 엔진이 동일한 GET 요청을 디스크/메모리 캐시(`200 OK from disk cache` 또는 `304 Not Modified`)로 처리할 수 있습니다.
- 브라우저 새로고침 시에는 브라우저가 강제로 `Cache-Control: max-age=0`을 포함하여 요청하므로 최신 데이터를 가져오게 됩니다.
---
### 3.3 [파생 이슈] 상세 게이트(`FnrsDetailGate`) 및 폼 페이지 영향
1. **게이트 권한 판별 오작동**:
- [fnrs-detail-gate.tsx](file:///Users/changwng/workspace.ona/frontend_as/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-detail-gate.tsx#L16-L21)는 상세 데이터의 진행상태(`prgrsSttsComCd`)를 기반으로 수정 권한(`canEditPbadmsDoc`)을 판별하여 `/edit` 페이지로 보낼지, 뷰 페이지로 보낼지 결정합니다.
- 캐시로 인해 다른 사용자가 문서를 제출(`ST`)했음에도 이전 상태(임시저장 `SV`)로 남아있으면, 잘못된 페이지로 분기될 위험이 발생합니다.
2. **React Hook Form 초기화 바인딩 문제**:
- 수정 모드([fnrs-form-page.tsx](file:///Users/changwng/workspace.ona/frontend_as/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-form-page.tsx#L90-L97))에서는 `initialData`로 `useForm({ defaultValues: ... })`를 마운트 시점에 1회 초기화합니다.
- 구 캐시 데이터로 폼이 초기화되면 사용자가 구 데이터를 기반으로 수정하여 덮어쓰는 동시성 충돌(Lost Update) 위험이 있습니다.
---
## 4. TanStack Query 캐시 기능과의 충돌 여부 검토
### 4.1 기본 캐시 메커니즘(`staleTime` vs `gcTime`)과의 충돌 여부
> **결론**: 상세 쿼리에 `staleTime: 0`을 설정하더라도 **TanStack Query의 캐시 기능과 전혀 충돌하지 않으며, TanStack Query 고유의 SWR(Stale-While-Revalidate) 아키텍처에 완벽히 부합**합니다.
- `staleTime`: 데이터가 신선하다고 간주되는 시간 $\rightarrow$ `0`으로 두면 진입 시 항상 최신성 검증(재요청).
- `gcTime`(구 cacheTime): 사용되지 않는 캐시를 메모리에 유지하는 시간 $\rightarrow$ 기본 `5분` 유지.
- **결과**: `staleTime: 0`이라 하더라도 기존 캐시가 메모리에 존재하므로, 화면 진입 시 **이전 데이터를 먼저 즉시 보여주어 화면 깜빡임(Flicker/Skeleton)을 방지**하고, 백그라운드에서 최신 데이터를 가져와 부드럽게 화면을 갱신합니다.
---
### 4.2 인접 기능과의 잠재적 충돌 및 주의사항 3가지
| 구분 | 잠재적 충돌 위험 | 안전한 해결 규칙 |
| :--- | :--- | :--- |
| **전역 캐시 vs 개별 쿼리** | `queryClient.ts`의 전역 기본값을 `staleTime: 0`으로 변경 시 공통코드, 조직도, 회계트리 등 마스터 데이터 요청이 폭증함 | 전역 설정은 유지하고, **업무 상세 쿼리(`useFnrsCnsltnMng` 등)에만 국소적으로 `staleTime: 0` 부여** |
| **웹한글기안기 (HWPX Blob)** | 기안기 Blob 쿼리에 `staleTime: 0` 또는 `refetchOnWindowFocus: true`가 걸리면, 다른 탭을 다녀올 때 Blob이 재조회되어 **편집 중이던 기안기 캔버스가 서버 원본으로 덮어써짐(데이터 유실)** | **웹기안기 Blob 쿼리는 자동 refetch 대상에서 제외**, 오직 저장 성공 시(`invalidateQueries`)에만 갱신 (분리 원칙) |
| **수정 폼 (React Hook Form)** | 캐시 데이터로 `defaultValues`가 바인딩된 후 백그라운드 fetch가 완료되어도 RHF는 입력값 보호를 위해 자동 갱신하지 않음 | 수정 페이지 진입 시 최신 데이터 로딩 완료를 보장하거나, 뷰 페이지 위주로 `staleTime: 0`을 적극 활용 |
---
## 5. 단계별 해결 방안 (Recommended Solutions)
### 단계 1: 상세 조회 쿼리에 도메인 캐시 정책 명시 (필수 적용 ★★★★★)
[apps/as/src/entities/fnrs-cnsltn/api/queries.ts](file:///Users/changwng/workspace.ona/frontend_as/apps/as/src/entities/fnrs-cnsltn/api/queries.ts)의 `useFnrsCnsltnMng`에 업무 특성에 맞춘 옵션을 선언합니다.
```typescript
export function useFnrsCnsltnMng(fnrsCnsltnDocNo: string | null | undefined) {
return useQuery({
queryKey: fnrsCnsltnKeys.detail(fnrsCnsltnDocNo!),
queryFn: () => fnrsCnsltnMngService.findById(fnrsCnsltnDocNo!),
enabled: Boolean(fnrsCnsltnDocNo),
staleTime: 0, // 상세 화면 진입 시 항상 최신 데이터 조회 (fresh 상태 유지 해제)
refetchOnMount: "always", // 마운트 시 항상 백그라운드 재조회 수행
refetchOnWindowFocus: false, // 웹기안기 팝업이나 외부 창 이동 중 캔버스 리로드 방지를 위해 false 유지
});
}
```
---
### 단계 2: 클라이언트 Axios HTTP 캐시 방지 헤더 보강 (안전장치 적용)
브라우저 레벨의 HTTP 디스크/메모리 캐싱을 원천 방지하기 위해 [packages/common/network/client.ts](file:///Users/changwng/workspace.ona/frontend_as/packages/common/network/client.ts)에 캐시 제어 헤더를 주입합니다.
```typescript
// packages/common/network/client.ts
this.axiosInstance = axios.create({
baseURL: config.baseURL,
headers: {
"Content-Type": "application/json",
"X-Site-Id": config.siteId,
"Cache-Control": "no-cache",
"Pragma": "no-cache",
},
withCredentials: true,
});
```
---
### 단계 3: 상세 화면 상단에 '새로고침(동기화)' UX 제공 (선택 권장)
전화나 메신저로 소통하며 실시간 결재/보완 작업을 진행하는 공단-지자체 업무 특성을 고려하여, 브라우저 전체(F5)를 새로고침하지 않고도 상세 데이터만 즉시 동기화할 수 있는 **동기화 버튼**을 상단 바에 배치합니다.
```tsx
// apps/as/src/pages/fnrs-cnsltn/ui/fnrs-view-page.tsx
const { data, refetch, isFetching } = useFnrsCnsltnMng(id);
// SectionCollapsibleToggle 옆에 배치:
<Button
type="button"
variant="outline"
size="sm"
onClick={() => refetch()}
disabled={isFetching}
className="flex items-center gap-1"
>
<RefreshCw className={cn("size-4", isFetching && "animate-spin")} />
<span>새로고침</span>
</Button>
```
---
## 5. 실제 코드 적용 내역 (Applied Implementation)
분석된 내용을 바탕으로 프로젝트 코드에 다음 사항들이 100% 적용되었습니다.
### 5.1 재원협의(`fnrs-cnsltn`) 상세 쿼리 캐시 정책 적용
- **적용 파일**: [apps/as/src/entities/fnrs-cnsltn/api/queries.ts](file:///Users/changwng/workspace.ona/frontend_as/apps/as/src/entities/fnrs-cnsltn/api/queries.ts)
- `useFnrsCnsltnMng`, `useFnrsCnsltnFileVrfcResults`에 `staleTime: 0`, `refetchOnMount: "always"` 옵션 주입 완료.
### 5.2 타 인허가 모듈 확산 적용
1. **설치인가(`instl-sncn`)**:
- [apps/as/src/entities/instl-sncn/api/queries.ts](file:///Users/changwng/workspace.ona/frontend_as/apps/as/src/entities/instl-sncn/api/queries.ts): `useInstlSncnDetail`에 `staleTime: 0`, `refetchOnMount: "always"` 적용 완료.
2. **설계검토(`design-cnstn`)**:
- [apps/as/src/entities/design-cnstn/api/queries.ts](file:///Users/changwng/workspace.ona/frontend_as/apps/as/src/entities/design-cnstn/api/queries.ts): `useDesignCnstnMng`에 `staleTime: 0`, `refetchOnMount: "always"` 적용 완료.
3. **기본계획(`sewer-bsc-plan`)**:
- [apps/as/src/entities/sewer-bsc-plan/api/queries.ts](file:///Users/changwng/workspace.ona/frontend_as/apps/as/src/entities/sewer-bsc-plan/api/queries.ts): `useSewerBscPlan`, `useSewerBscPlanDetails`에 `staleTime: 0`, `refetchOnMount: "always"` 적용 완료.
### 5.3 클라이언트 Axios HTTP 캐시 방지 헤더 주입
- **적용 파일**: [packages/common/network/client.ts](file:///Users/changwng/workspace.ona/frontend_as/packages/common/network/client.ts)
- `axios.create`의 기본 헤더에 `Cache-Control: "no-cache"`, `Pragma: "no-cache"` 주입 완료.
### 5.4 전체 인허가 상세 조회 화면 상단 '새로 고침' 버튼 UX 적용
상단 `sticky` 헤더 영역에 `refetch()`를 트리거하는 `[새로 고침]` 버튼 및 로딩 회전 애니메이션(`animate-spin`)을 일괄 적용 완료했습니다.
1. **재원협의(`fnrs-cnsltn`)**: [apps/as/src/pages/fnrs-cnsltn/ui/fnrs-view-page.tsx](file:///Users/changwng/workspace.ona/frontend_as/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-view-page.tsx)
2. **설치인가(`instl-sncn`)**: [apps/as/src/pages/instl-sncn/ui/instl-view-page.tsx](file:///Users/changwng/workspace.ona/frontend_as/apps/as/src/pages/instl-sncn/ui/instl-view-page.tsx)
3. **설계검토(`design-cnstn`)**: [apps/as/src/pages/design-cnstn/ui/design-view-page.tsx](file:///Users/changwng/workspace.ona/frontend_as/apps/as/src/pages/design-cnstn/ui/design-view-page.tsx)
4. **기본계획(`sewer-bsc-plan`)**: [apps/as/src/pages/sewer-bsc-plan/ui/sewer-bsc-plan-view-page.tsx](file:///Users/changwng/workspace.ona/frontend_as/apps/as/src/pages/sewer-bsc-plan/ui/sewer-bsc-plan-view-page.tsx)
---
## 6. 검증 및 결과
1. **TypeScript 컴파일 검증**:
- `pnpm --filter as exec tsc --noEmit` $\rightarrow$ 에러 없이 100% 정상 통과.
- `pnpm --filter @common/network exec tsc --noEmit` $\rightarrow$ 에러 없이 100% 정상 통과.
2. **ESLint 정적 검사 통과**:
- `pnpm --filter as lint` 및 `eslint --fix` 적용 완료.
3. **적용 효과**:
- 사용자가 목록에서 상세로 진입할 때마다 서버로부터 항상 최신 데이터를 재조회하여 화면에 반영.
- 이전 캐시가 존재할 경우 SWR 패턴에 따라 깜빡임 없이 즉시 렌더링된 후 백그라운드 최신화 완료.
- 웹한글기안기 HWPX Blob 쿼리는 영향받지 않아 캔버스 덮어쓰기 위험 원천 방지.
@@ -33,10 +33,10 @@
├──────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ [영역 B] 필수첨부파일 목록 │
│ ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ 필수첨부파일아이디 | 문서구분 | 필수첨부파일명 | 필수첨부여부 | 비고 | 등록일시 | 수정일시 | 관리 │
│ │ 필수첨부파일아이디 | 문서구분 | 필수첨부파일명 | 필수첨부여부 | 필수파일검토내용 | 비고 | 관리 │ │
│ ├────────────────────────────────────────────────────────────────────────────────────────────────┤ │
│ │ EAF-0001 | 설치인가 | 사업계획서 | 예 | 서명본 첨부 | 2026-04-22 10:10:00 | — | [수정][삭제] │ │
│ │ EAF-0002 | 기본계획 | 변경사유서 | 예 | — | 2026-04-22 11:00:00 | — | [수정][삭제] │ │
│ │ EAF-0001 | 설치인가 | 사업계획서 | 예 | 사업비 내역 검토 | 서명본 첨부 | [수정][삭제] │ │
│ │ EAF-0002 | 기본계획 | 변경사유서 | 예 | 변경 전후 비교표 | — | [수정][삭제] │ │
│ └────────────────────────────────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────────────────────────────┘
@@ -47,7 +47,9 @@
│ 필수첨부파일아이디(자동발급) [EAF-0004] │
│ 문서구분 [설치인가 ▼] │
│ 필수첨부파일명 [예: 사업계획서] │
│ 양식파일 [파일 선택] │
│ 필수첨부여부 [ ] 예 [ ] 아니오 │
│ 필수파일검토내용 [검토내용을 입력하세요]│
│ 비고 [비고를 입력하세요] │
├──────────────────────────────────────────────────────┤
│ [취소] [저장] │
@@ -139,16 +141,16 @@ END
### 5-2. 그리드 컬럼
| 컬럼명 | Field | 타입 | 비고 |
| ------------------ | ------------------- | ------ | ------------------------------------- |
| 필수첨부파일아이디 | `esntlAtchFileId` | string | 예: `EAF-0001`, 클라이언트 생성/입력 |
| 문서구분 | `docSeComCd` | string | 코드명 표시 — 공통코드 (TBD 상위코드) |
| 필수첨부파일명 | `esntlAtchFileNm` | string | |
| 필수첨부여부 | `esntlAtchYn` | string | `Y`→예, `N`→아니오 |
| 비고 | `esntlAtchFileRmrk` | string | 없으면 `—` 표시 |
| 등록일시 | `regDt` | string | `yyyy-MM-dd HH:mm:ss` |
| 수정일시 | `mdfcnDt` | string | 미수정 시 빈 값 또는 `—` |
| 관리 | — | — | 행별 **[수정] · [삭제]** |
| 컬럼명 | Field | 타입 | 비고 |
| ------------------ | -------------------- | ------ | ------------------------------------- |
| 필수첨부파일아이디 | `esntlAtchFileId` | string | 예: `EAF-0001`, 클라이언트 생성/입력 |
| 문서구분 | `docSeComCd` | string | 코드명 표시 — 공통코드 (TBD 상위코드) |
| 필수첨부파일명 | `esntlAtchFileNm` | string | |
| 필수첨부여부 | `esntlAtchYn` | string | `Y`→예, `N`→아니오 |
| 필수파일검토내용 | `esntlAtchFileRvwCn` | string | 없으면 `—` 표시, 최대 2줄 clamp |
| 비고 | `esntlAtchFileRmrk` | string | 없으면 `—` 표시 |
| 양식 내려받기 | `atchFileGroupId` | string | 양식파일 다운로드 버튼 |
| 관리 | — | — | 행별 **[수정] · [삭제]** |
- 행 클릭 시 별도 상세 화면 없음. 편집은 **[수정]** 버튼으로 다이얼로그 C를 연다.
@@ -167,13 +169,15 @@ END
### 6-1. 폼 필드
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
| ------------------ | ---------- | ---- | ------ | ------------------- | -------------------------------- |
| 필수첨부파일아이디 | (Readonly) | Y | — | `esntlAtchFileId` | 등록 시 미노출 |
| 문서구분 | CodeSelect | Y | — | `docSeComCd` | 예: 설치인가, 기본계획 |
| 필수첨부파일명 | Text | Y | — | `esntlAtchFileNm` | placeholder: "예: 사업계획서" |
| 필수첨부여부 | RadioGroup | Y | Y | `esntlAtchYn` | Y/N, UI 표시: 예/아니오 |
| 비고 | Textarea | N | — | `esntlAtchFileRmrk` | placeholder: "비고를 입력하세요" |
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
| ------------------ | ------------------- | ---- | ------ | -------------------- | ---------------------------------- |
| 필수첨부파일아이디 | (Readonly) | Y | — | `esntlAtchFileId` | 등록 시 미노출 |
| 문서구분 | CodeSelect | Y | — | `docSeComCd` | 예: 설치인가, 기본계획 |
| 필수첨부파일명 | Text | Y | — | `esntlAtchFileNm` | placeholder: "예: 사업계획서" |
| 양식파일 | AttachmentFileField | N | — | `atchFileGroupId` | TUS 파일 업로드 |
| 필수첨부여부 | RadioGroup | Y | Y | `esntlAtchYn` | Y/N, UI 표시: 예/아니오 |
| 필수파일검토내용 | Textarea | N | — | `esntlAtchFileRvwCn` | placeholder: "검토내용을 입력하세요"|
| 비고 | Textarea | N | — | `esntlAtchFileRmrk` | placeholder: "비고를 입력하세요" |
### 6-2. 버튼
@@ -210,28 +214,32 @@ END
**응답 DTO**
| Field | 타입 | 설명 |
| ------------------- | ------- | -------------------------------- |
| `esntlAtchFileId` | string | 필수첨부파일아이디 |
| `docSeComCd` | string | 문서구분공통코드 |
| `esntlAtchFileNm` | string | 필수첨부파일명 |
| `esntlAtchYn` | string | 필수첨부여부 (Y/N) |
| `esntlAtchFileRmrk` | string? | 필수첨부파일비고 |
| `rgtr` | string? | 등록자 UUID |
| `rgtrNm` | string? | 등록자명 |
| `mdfr` | string? | 수정자 UUID |
| `regDt` | string? | 등록일시 (`yyyy-MM-dd HH:mm:ss`) |
| `mdfcnDt` | string? | 수정일시 |
| Field | 타입 | 설명 |
| -------------------- | -------------------------- | -------------------------------- |
| `esntlAtchFileId` | string | 필수첨부파일아이디 |
| `docSeComCd` | string | 문서구분공통코드 |
| `esntlAtchFileNm` | string | 필수첨부파일명 |
| `esntlAtchYn` | string | 필수첨부여부 (Y/N) |
| `esntlAtchFileRvwCn` | string? | 필수첨부파일검토내용 |
| `esntlAtchFileRmrk` | string? | 필수첨부파일비고 |
| `atchFileGroupId` | string? | 첨부파일그룹아이디 (양식파일) |
| `atchFileList` | AtflMngClientResponseDto[]?| 첨부파일 목록 |
| `rgtr` | string? | 등록자 UUID |
| `rgtrNm` | string? | 등록자명 |
| `mdfr` | string? | 수정자 UUID |
| `regDt` | string? | 등록일시 (`yyyy-MM-dd HH:mm:ss`) |
| `mdfcnDt` | string? | 수정일시 |
**저장 Request**
| Field | 필수 | 설명 |
| ------------------- | ---- | ------------------ |
| `esntlAtchFileId` | Y | 필수첨부파일아이디 |
| `docSeComCd` | N | 문서구분 |
| `esntlAtchFileNm` | N | 필수첨부파일명 |
| `esntlAtchYn` | N | 필수첨부여부 |
| `esntlAtchFileRmrk` | N | 비고 |
| Field | 필수 | 설명 |
| -------------------- | ---- | -------------------- |
| `docSeComCd` | Y | 문서구분 |
| `esntlAtchFileNm` | N | 필수첨부파일명 |
| `esntlAtchYn` | N | 필수첨부여부 |
| `esntlAtchFileRvwCn` | N | 필수첨부파일검토내용 |
| `esntlAtchFileRmrk` | N | 비고 |
| `atchFileGroupId` | N | 첨부파일그룹아이디 |
**검색 Query**
+2 -2
View File
@@ -3,9 +3,9 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3003",
"dev": "next dev -p 3004",
"build": "next build",
"start": "next start -p 3003",
"start": "next start -p 3004",
"lint": "next lint",
"lint:fsd": "steiger ."
},
+63
View File
@@ -0,0 +1,63 @@
{
"name": "as",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3004",
"build": "next build",
"start": "next start -p 3004",
"lint": "next lint",
"lint:fsd": "steiger .",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test"
},
"dependencies": {
"@common/assets": "workspace:*",
"@common/auth": "workspace:*",
"@common/components": "workspace:*",
"@common/network": "workspace:*",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@faker-js/faker": "^9.9.0",
"@frontend-opensource/use-react-hooks": "^1.1.4",
"@hookform/resolvers": "^4.1.3",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.75.5",
"@tanstack/react-query-devtools": "^5.75.5",
"@tanstack/react-table": "^8.21.2",
"axios": "^1.8.1",
"class-variance-authority": "^0.7.1",
"dayjs": "^1.11.13",
"js-cookie": "^3.0.5",
"lucide-react": "^0.474.0",
"next": "15.5.18",
"react-hook-form": "^7.54.2",
"tus-js-client": "^4.3.1",
"zod": "^3.24.2"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@hookform/devtools": "^4.4.0",
"@playwright/test": "^1.61.1",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@types/js-cookie": "^3.0.6",
"@vitejs/plugin-react": "^4.7.0",
"@vitejs/plugin-react-oxc": "^0.4.3",
"eslint": "^9.0.0",
"eslint-config-next": "15.3.8",
"eslint-plugin-react-hooks": "^5.0.0",
"jsdom": "^30.1.2",
"postcss": "^8.4.49",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.11",
"steiger": "^0.5.11",
"tailwindcss": "^3.4.14",
"typescript": "^5.7.2",
"vite": "^8.3.3",
"vitest": "^5.0.3"
}
}
+4 -4
View File
@@ -14,15 +14,15 @@
type="text/javascript"
data-type="css/app"
data-framework="aori|lab"
src="https://hwp.onasys.co.kr/webhwpctrl/js/loadVersion.js"
src="js/loadVersion.js"
></script>
<script
type="text/javascript"
data-type="css/app"
data-framework="aori|lab"
src="https://hwp.onasys.co.kr/webhwpctrl/js/loadHead.js"
src="js/loadHead.js"
></script>
<script data-main="js/main" data-bundle="dist/main_bundle" src="https://hwp.onasys.co.kr/webhwpctrl/js/loadMain.js"></script>
<script data-main="js/main" data-bundle="dist/main_bundle" src="js/loadMain.js"></script>
<style>
#hcwoViewScroll::-webkit-scrollbar {
height: 10px;
@@ -57,6 +57,6 @@
</div>
<iframe id="download_iframe" style="width: 0px; height: 0px; border: 0; display: none"></iframe>
<script type="text/javascript" src="https://hwp.onasys.co.kr/webhwpctrl/hwpsdk/loadHwpsdk.js"></script>
<script type="text/javascript" src="hwpsdk/loadHwpsdk.js"></script>
</body>
</html>
+44 -27
View File
@@ -5,18 +5,6 @@
<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="https://hwp.onasys.co.kr/webhwpctrl/js/hwpctrlapp/utils/util.js"
></script>
<script
type="text/javascript"
src="https://hwp.onasys.co.kr/webhwpctrl/js/hwpctrlapp/hwpCtrlApp.js"
></script>
<script
type="text/javascript"
src="https://hwp.onasys.co.kr/webhwpctrl/js/webhwpctrl.js"
></script>
<style>
html,
body,
@@ -39,27 +27,56 @@
<div id="hwpctrl"></div>
<script type="text/javascript">
(function () {
// baseurl: 부모(HangulGiangi)가 NEXT_PUBLIC_WEBHYP_CTRL_URL 값으로 전달.
// 없으면 same-origin 프록시 경로(/webhwpctrl/ → next.config rewrites)를 사용한다.
var params = new URLSearchParams(window.location.search);
var baseUrl = params.get("baseurl") || "https://hwp.onasys.co.kr/webhwpctrl";
var baseUrl = params.get("baseurl") || new URL("./", window.location.href).href;
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,
);
var scripts = [
"js/hwpctrlapp/utils/util.js",
"js/hwpctrlapp/hwpCtrlApp.js",
"js/webhwpctrl.js",
];
// 부모(HangulGiangi)가 contentWindow.HwpCtrl 로 접근
window.HwpCtrl = HwpCtrl;
function loadScript(index) {
if (index >= scripts.length) {
init();
return;
}
var script = document.createElement("script");
script.type = "text/javascript";
script.src = baseUrl + scripts[index];
script.onload = function () {
loadScript(index + 1);
};
script.onerror = function () {
console.error("웹한글 기안기 스크립트 로드 실패: " + script.src);
};
document.head.appendChild(script);
}
function init() {
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;
}
loadScript(0);
})();
</script>
</body>
+5 -1
View File
@@ -13,12 +13,16 @@ import type {
NowDmgMtncGnsvySearchDto,
} from "../model/dc-interface";
/** 빈 문자열 파라미터를 보내면 서버 오류가 나므로 값이 있는 항목만 전송한다. */
async function getNowDmgMtncGnsvy(
params: NowDmgMtncGnsvySearchDto,
): Promise<NowDmgMtncGnsvyResponseDto[]> {
const filledParams = Object.fromEntries(
Object.entries(params).filter(([, value]) => value !== undefined && value !== ""),
);
const response = await client.get<NowDmgMtncGnsvyResponseDto[]>(
`${ENDPOINT.DC_SERVICE}/nowDmgMtncGnsvy`,
{ params },
{ params: filledParams },
);
return response.data;
}
@@ -1,5 +1,10 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
DesignCnstnApplySaveRequestDto,
DesignCnstnMngSaveRequestDto,
DesignCnstnRvwOpnnSaveRequestDto,
} from "../model/design-cnstn-apply-payload-schema";
import type {
DesignCnstnBizCstSaveRequestDto,
DesignCnstnBizCstSearchDto,
@@ -15,11 +20,6 @@ import type {
DesignCnstnEsntlAtchFileSearchDto,
DesignCnstnEsntlAtchFileUpdateRequestDto,
} from "../model/design-cnstn-esntl-atch-file-interface";
import type {
DesignCnstnApplySaveRequestDto,
DesignCnstnMngSaveRequestDto,
DesignCnstnRvwOpnnSaveRequestDto,
} from "../model/design-cnstn-apply-payload-schema";
import type {
DesignCnstnMngSearchDto,
DesignCnstnMngUpdateRequestDto,
@@ -83,6 +83,8 @@ export function useDesignCnstnMng(id: string | null | undefined) {
queryKey: designCnstnKeys.detail(id!),
queryFn: () => designCnstnMngService.findById(id!),
enabled: Boolean(id),
staleTime: 0,
refetchOnMount: "always",
});
}
@@ -0,0 +1,15 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { ElaprBranchDto } from "../model/elapr-forkjoin-types";
const BASE = ENDPOINT.AS_ELAPR_SERVICE.ELAPR_BRANCH;
async function getBranchesByDocNo(elaprDocNo: string): Promise<ElaprBranchDto[]> {
const response = await client.get<ElaprBranchDto[]>(`${BASE}/doc/${elaprDocNo}`);
return response.data;
}
export const elaprBranchService = {
getBranchesByDocNo,
};
+15
View File
@@ -23,6 +23,7 @@ import type {
} from "../model/elapr-tmplt-interface";
import { elaprAplnService } from "./elapr-apln-service";
import { elaprBranchService } from "./elapr-branch-service";
import { elaprDmndService } from "./elapr-dmnd-service";
import { elaprPrgrsSttsService } from "./elapr-prgrs-stts-service";
import { elaprTmpltService } from "./elapr-tmplt-service";
@@ -31,6 +32,7 @@ const elaprTmpltBaseKey = [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_TMPLT] as const;
const elaprAplnBaseKey = [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN] as const;
const elaprDmndBaseKey = [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND] as const;
const elaprPrgrsSttsBaseKey = [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_PRGRS_STTS] as const;
const elaprBranchBaseKey = [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_BRANCH] as const;
function invalidateElaprPrgrsSttsQueries(queryClient: ReturnType<typeof useQueryClient>) {
queryClient.invalidateQueries({ queryKey: elaprPrgrsSttsBaseKey });
@@ -360,3 +362,16 @@ export function useInsertElaprPrgrsSttsApprover() {
},
});
}
export function useElaprBranchesByDocNo(
elaprDocNo: string | null | undefined,
options?: { enabled?: boolean; structuralSharing?: boolean },
) {
return useQuery({
queryKey: [...elaprBranchBaseKey, "getBranchesByDocNo", elaprDocNo],
queryFn: () => elaprBranchService.getBranchesByDocNo(elaprDocNo!),
enabled: Boolean(elaprDocNo) && (options?.enabled ?? true),
structuralSharing: options?.structuralSharing ?? true,
});
}
+21
View File
@@ -1,4 +1,5 @@
export { elaprAplnService } from "./api/elapr-apln-service";
export { elaprBranchService } from "./api/elapr-branch-service";
export { elaprDmndService } from "./api/elapr-dmnd-service";
export { elaprPrgrsSttsService } from "./api/elapr-prgrs-stts-service";
export { elaprTmpltService } from "./api/elapr-tmplt-service";
@@ -9,6 +10,7 @@ export {
useDeleteElaprTmplt,
useElaprApln,
useElaprAplnPage,
useElaprBranchesByDocNo,
useElaprDmnd,
useElaprDmndPage,
useElaprDocPrgrsByDocNo,
@@ -59,6 +61,11 @@ export {
buildElaprPrgrsSttsListFromAplnRows,
} from "./lib/dmnd-save-utils";
export { mapDocPrgrsToAplnRows } from "./lib/doc-prgrs-to-apln-rows";
export {
type ForkJoinTrackKey,
createDefaultForkJoinRow,
mapDocPrgrsToForkJoinTrackRows,
} from "./lib/doc-prgrs-to-forkjoin-rows";
export type { ElaprPrgrsSttsRow } from "./lib/prgrs-stts-save-utils";
export {
assignPrgrsSttsAtrzSeqNumbers,
@@ -91,6 +98,14 @@ export type {
ElaprDmndService,
ElaprDocPrgrsResponseDto,
} from "./model/elapr-dmnd-interface";
export type {
AtrzSttsComCd,
BranchSeCd,
BranchSttsCd,
ElaprBranchDto,
ElaprForkJoinDataMap,
ElaprForkJoinStepDto,
} from "./model/elapr-forkjoin-types";
export type {
ElaprApproverChangeRequestDto,
ElaprApproverInsertRequestDto,
@@ -115,11 +130,17 @@ 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 { ElaprDocPrgrsForkJoinStep } from "./ui/elapr-doc-prgrs-forkjoin-step";
export {
type ElaprDocPrgrsPickerConfirmPayload,
ElaprDocPrgrsPickerDialog,
} from "./ui/elapr-doc-prgrs-picker-dialog";
export {
type ElaprDocPrgrsForkJoinPickerConfirmPayload,
ElaprDocPrgrsForkJoinPickerDialog,
} from "./ui/elapr-doc-prgrs-forkjoin-picker-dialog";
export { ElaprDocPrgrsStatus } from "./ui/elapr-doc-prgrs-status";
export { ElaprForkJoinPickerGrid } from "./ui/elapr-forkjoin-picker-grid";
export {
type ElaprTmpltPickerConfirmPayload,
ElaprTmpltPickerDialog,
@@ -6,8 +6,11 @@ export function hasCurrentUserPendingApprovalRequest(
): boolean {
if (!currentUserUuid) return false;
const normalizedUserUuid = currentUserUuid.toLowerCase();
return list.some(
(row) => row.aprvrUuid === currentUserUuid && row.atrzPrgrsSttsComCd === "ATRZ_PRGRS_002",
(row) =>
row.aprvrUuid?.toLowerCase() === normalizedUserUuid &&
row.atrzPrgrsSttsComCd === "ATRZ_PRGRS_002",
);
}
@@ -17,7 +20,10 @@ export function findCurrentUserPendingApprovalRequest(
): ElaprDocPrgrsResponseDto | undefined {
if (!currentUserUuid) return undefined;
const normalizedUserUuid = currentUserUuid.toLowerCase();
return list?.find(
(row) => row.aprvrUuid === currentUserUuid && row.atrzPrgrsSttsComCd === "ATRZ_PRGRS_002",
(row) =>
row.aprvrUuid?.toLowerCase() === normalizedUserUuid &&
row.atrzPrgrsSttsComCd === "ATRZ_PRGRS_002",
);
}
@@ -8,6 +8,9 @@ function toPrgrsSttsItem(
aprvrUuid: string | undefined,
atrzSeComCd: string | undefined,
atrzSeqNo: number | undefined,
branchId?: string,
branchSeCd?: string,
aprvrDeptNm?: string,
): ElaprPrgrsSttsSaveRequestDto {
if (!aprvrUuid?.trim()) {
throw new Error("결재자가 지정되지 않은 행이 있습니다.");
@@ -23,6 +26,9 @@ function toPrgrsSttsItem(
aprvrUuid,
atrzSeComCd,
atrzSeqNo,
branchId,
branchSeCd,
aprvrDeptNm,
};
}
@@ -38,6 +44,13 @@ export function buildElaprPrgrsSttsListFromAplnRows(
rows: ElaprAplnRow[],
): ElaprPrgrsSttsSaveRequestDto[] {
return getVisibleRows(rows).map((row) =>
toPrgrsSttsItem(row.aprvrUuid, row.atrzSeComCd, row.atrzSeqNo),
toPrgrsSttsItem(
row.aprvrUuid,
row.atrzSeComCd,
row.atrzSeqNo,
row.branchId,
row.branchSeCd,
row.aprvrDeptNm,
),
);
}
@@ -0,0 +1,120 @@
import type { ElaprDocPrgrsClientResponseDto } from "@/shared/model";
import type { ElaprAplnRow } from "../model/elapr-apln-row-types";
export type ForkJoinTrackKey = "SP" | "SF" | "TOTAL_AGG";
function normalizeBranchKey(
rawBranchSeCd?: string,
atrzSeComCd?: string,
): ForkJoinTrackKey | null {
if (rawBranchSeCd) {
const upper = rawBranchSeCd.toUpperCase().trim();
if (upper === "SP" || upper.includes("관로")) return "SP";
if (upper === "SF" || upper.includes("시설")) return "SF";
if (upper.includes("AGG") || upper.includes("정책") || upper.includes("TOTAL"))
return "TOTAL_AGG";
}
if (atrzSeComCd === "ATRZ04") return "TOTAL_AGG";
return null;
}
export function createDefaultForkJoinRow(
branchSeCd: ForkJoinTrackKey,
deptNm: string,
seq: number,
seComCd: string,
): ElaprAplnRow {
return {
rowKey: `forkjoin-${branchSeCd}-${seq}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
elaprTmpltId: "TMPLT_SF_SP_FORKJOIN",
atrzSeqNo: seq,
atrzSeComCd: seComCd,
aprvrDeptNm: deptNm,
branchSeCd,
isNew: true,
isDirty: false,
dcarbYn: seComCd === "ATRZ99" ? "Y" : "N",
};
}
/**
* 1차 결재선(elaprCycl=1)의 진행상태 목록을 Fork-Join 3트랙(관로, 시설, 정책) 결재선 행으로 매핑합니다.
*/
export function mapDocPrgrsToForkJoinTrackRows(
list: ElaprDocPrgrsClientResponseDto[] | undefined,
): Record<ForkJoinTrackKey, ElaprAplnRow[]> {
const tracks: Record<ForkJoinTrackKey, ElaprAplnRow[]> = {
SP: [],
SF: [],
TOTAL_AGG: [],
};
if (list && list.length > 0) {
list.forEach((item, index) => {
const branchKey = normalizeBranchKey(item.branchSeCd, item.atrzSeComCd);
if (branchKey) {
const seq = Number(item.atrzSeqNo ?? tracks[branchKey].length + 1);
const defaultDept =
branchKey === "SP"
? "하수관로부"
: branchKey === "SF"
? "하수처리시설부"
: "정책총괄부";
const defaultSeComCd =
branchKey === "TOTAL_AGG"
? seq === 1
? "ATRZ04"
: "ATRZ99"
: seq === 1
? "ATRZ02"
: "ATRZ03";
tracks[branchKey].push({
rowKey: `forkjoin-docprgrs-${branchKey}-${seq}-${item.elaprPrgrsId ?? item.aprvrUuid ?? index}`,
elaprTmpltId: "TMPLT_SF_SP_FORKJOIN",
atrzSeqNo: seq,
atrzSeComCd: item.atrzSeComCd || defaultSeComCd,
atrzSeComNm: item.atrzSeComNm,
aprvrUuid: item.aprvrUuid,
aprvrNm: item.aprvrNm,
aprvrUserId: item.aprvrUserId,
aprvrDeptNm: item.aprvrDeptNm || item.ognzNm || defaultDept,
branchId: item.branchId,
branchSeCd: branchKey,
dcarbYn: item.dcarbYn ?? (item.atrzSeComCd === "ATRZ99" ? "Y" : "N"),
isNew: false,
isDirty: false,
});
}
});
// 각 트랙별 atrzSeqNo 오름차순 정렬
tracks.SP.sort((a, b) => Number(a.atrzSeqNo ?? 0) - Number(b.atrzSeqNo ?? 0));
tracks.SF.sort((a, b) => Number(a.atrzSeqNo ?? 0) - Number(b.atrzSeqNo ?? 0));
tracks.TOTAL_AGG.sort((a, b) => Number(a.atrzSeqNo ?? 0) - Number(b.atrzSeqNo ?? 0));
}
// 트랙 데이터가 없는 경우 기본 2행 채우기
if (tracks.SP.length === 0) {
tracks.SP = [
createDefaultForkJoinRow("SP", "하수관로부", 1, "ATRZ02"),
createDefaultForkJoinRow("SP", "하수관로부", 2, "ATRZ03"),
];
}
if (tracks.SF.length === 0) {
tracks.SF = [
createDefaultForkJoinRow("SF", "하수처리시설부", 1, "ATRZ02"),
createDefaultForkJoinRow("SF", "하수처리시설부", 2, "ATRZ03"),
];
}
if (tracks.TOTAL_AGG.length === 0) {
tracks.TOTAL_AGG = [
createDefaultForkJoinRow("TOTAL_AGG", "정책총괄부", 1, "ATRZ04"),
createDefaultForkJoinRow("TOTAL_AGG", "정책총괄부", 2, "ATRZ99"),
];
}
return tracks;
}
@@ -13,6 +13,8 @@ export interface PrgrsSttsServerRow {
atrzSeComCd?: string;
atrzSeqNo?: number;
dcarbYn?: string;
branchId?: string;
branchSeCd?: string;
}
/** 결재선 그리드 로컬 행 — 진행상태 API 응답 + UI 보조 필드 */
@@ -20,6 +22,9 @@ export interface ElaprPrgrsSttsRow {
rowKey: string;
elaprPrgrsId?: string;
elaprDmndId?: string;
branchId?: string;
branchSeCd?: string;
branchNm?: string;
aprvrUuid?: string;
aprvrNm?: string;
atrzSeComCd?: string;
@@ -52,8 +57,22 @@ function getVisibleRows(rows: ElaprPrgrsSttsRow[]): ElaprPrgrsSttsRow[] {
return rows.filter((row) => !row.isDeleted);
}
/** 그리드 행 순서(1-based)로 결재순서번호 일괄 채번 */
/** 그리드 행 순서(1-based)로 결재순서번호 채번 (브랜치가 분기된 Fork-Join의 경우 각 브랜치별 독립 순번 채번) */
export function assignPrgrsSttsAtrzSeqNumbers(rows: ElaprPrgrsSttsRow[]): ElaprPrgrsSttsRow[] {
const branchKeys = new Set(rows.map((r) => r.branchId || r.branchSeCd).filter(Boolean));
if (branchKeys.size > 1) {
const branchCounters = new Map<string, number>();
return rows.map((row) => {
const key = row.branchId || row.branchSeCd;
if (key) {
const current = (branchCounters.get(key) ?? 0) + 1;
branchCounters.set(key, current);
return { ...row, atrzSeqNo: current };
}
return row;
});
}
return rows.map((row, index) => ({ ...row, atrzSeqNo: index + 1 }));
}
@@ -92,7 +111,7 @@ function rowDiffersFromServer(row: ElaprPrgrsSttsRow, serverRow: PrgrsSttsServer
return (
row.aprvrUuid !== serverRow.aprvrUuid ||
row.atrzSeComCd !== serverRow.atrzSeComCd ||
row.atrzSeqNo !== serverRow.atrzSeqNo ||
Number(row.atrzSeqNo) !== Number(serverRow.atrzSeqNo) ||
(row.dcarbYn ?? "N") !== (serverRow.dcarbYn ?? "N")
);
}
@@ -23,6 +23,9 @@ function toRow(item: ElaprDocPrgrsClientResponseDto): ElaprPrgrsSttsRow {
rowKey: item.elaprPrgrsId ?? createDraftRowKey(),
elaprPrgrsId: item.elaprPrgrsId,
elaprDmndId: item.elaprDmndId,
branchId: item.branchId,
branchSeCd: item.branchSeCd,
branchNm: item.branchNm,
aprvrUuid: item.aprvrUuid,
aprvrNm: item.aprvrNm,
atrzSeComCd: item.atrzSeComCd,
@@ -1,3 +1,4 @@
import type { BranchSeCd } from "./elapr-forkjoin-types";
import type { ElaprApln } from "./elapr-apln-interface";
/** 그리드 로컬 행 — API 응답 + UI 보조 필드 */
@@ -7,6 +8,11 @@ export interface ElaprAplnRow extends Omit<ElaprApln, "aplnId"> {
aplnId?: string;
/** 결재자 표시명 — 응답 DTO 또는 선택 시 로컬 유지 */
aprvrNm?: string;
/** 소속 브랜치 ID 및 구분코드 */
branchId?: string;
branchSeCd?: BranchSeCd;
aprvrDeptNm?: string;
dcarbYn?: string;
isNew?: boolean;
isDirty?: boolean;
/** 소프트 삭제 — 최종 저장 시 mode:D */
@@ -1,5 +1,6 @@
import type { ElaprDocPrgrsClientResponseDto, PageableParams, PageResponse } from "@/shared/model";
import type { ElaprBranchDto } from "./elapr-forkjoin-types";
import type { ElaprPrgrsSttsSaveRequestDto } from "./elapr-prgrs-stts-interface";
/** 백엔드 ElaprDmndSaveRequestDto */
@@ -7,6 +8,9 @@ export interface ElaprDmndSaveRequestDto {
elaprDocNo: string;
dmndTtl: string;
dmndCn: string;
taskSeComCd?: string | null;
forkJoinSeCd?: string | null;
crntStageNo?: number | null;
elaprCycl?: number | null;
docPrgrsSttsComCd?: string | null;
elaprPrgrsSttsList?: ElaprPrgrsSttsSaveRequestDto[];
@@ -19,8 +23,13 @@ export interface ElaprDmndResponseDto {
elaprDocNo: string;
dmndTtl: string;
dmndCn: string;
taskSeComCd?: string;
forkJoinSeCd?: string;
crntStageNo?: number;
elaprBranchList?: ElaprBranchDto[];
atrzSttsComCd: string;
atrzSttsComNm?: string;
elaprCycl?: number;
dmndDt?: string;
updtDt?: string;
}
@@ -0,0 +1,90 @@
/**
* 공공하수도 재원협의 복합사업(SF+SP) Fork-Join 병렬결재 모델 및 타입 정의 (10월 12일 개정: 6인 체제)
*/
/** 전자결재 마스터 진행상태 공통코드 (ATRZSTTS: tb_as_elapr_dmnd.atrz_stts_com_cd) */
export type AtrzSttsComCd =
| "ATRZ_STTS_001" // 대기중
| "ATRZ_STTS_002" // 결재진행중
| "ATRZ_STTS_003" // 결재완료
| "ATRZ_STTS_004" // 반려
| "ATRZ_STTS_005" // 회수
| "ATRZ_STTS_006"; // 취소
/** 결재 구분 공통코드 (ATRZSE: tb_as_elapr_prgrs_stts.atrz_se_com_cd) */
export type AtrzSeComCd =
| "ATRZ01" // 접수 (민원/신청 접수)
| "ATRZ02" // 검토 (실무 기술검토)
| "ATRZ03" // 승인 (부서 승인 / 병합에 회신)
| "ATRZ04" // 병합 (정책총괄 AI 병합의견 종합 심의 - 10-12 신설)
| "ATRZ99"; // 최종승인(회신) (환경청에 회신)
/** 브랜치 구분코드 (10월 14일 개정: SP 하수관로, SF 하수처리시설, TOTAL_AGG 정책부 종합병합, MAIN 주결재선) */
export type BranchSeCd = "MAIN" | "SP" | "SF" | "TOTAL_AGG";
/** 브랜치 진행상태코드 */
export type BranchSttsCd =
| "WAITING"
| "IN_PROGRESS"
| "COMPLETED"
| "SUPPLEMENT"
| "REJECTED"
| "CANCELLED";
/** 브랜치 단위 정보 DTO */
export interface ElaprBranchDto {
branchId: string;
elaprDmndId: string;
stageSeqNo: number; // 2: 병렬검토, 3: 합류취합
branchSeCd: BranchSeCd;
branchNm: string;
branchSttsCd: BranchSttsCd;
joinReqYn: "Y" | "N";
strtDt?: string;
endDt?: string;
}
/** 결재 진행 스텝 DTO (브랜치 확장) */
export interface ElaprForkJoinStepDto {
elaprPrgrsId: string;
elaprDmndId?: string;
branchId?: string;
branchSeCd?: BranchSeCd;
aprvrUuid: string;
aprvrNm?: string;
aprvrDeptNm?: string;
ognzNm?: string;
jbpsSeNm?: string;
atrzSeComCd: AtrzSeComCd | string; // ATRZ01: 접수, ATRZ02: 검토, ATRZ03: 승인, ATRZ04: 병합, ATRZ99: 최종승인(회신)
atrzSeComNm?: string;
atrzSeqNo: number;
atrzPrgrsSttsComCd: string; // ATRZ_PRGRS_001: 대기, ATRZ_PRGRS_002: 요청, ATRZ_PRGRS_003: 완료, ATRZ_PRGRS_004: 반려, ATRZ_PRGRS_999: 결재안함
atrzPrgrsSttsComNm?: string;
rjctRsn?: string;
atrzDt?: string;
dcarbYn?: string;
elaprCycl?: number;
}
/** 3개 트랙으로 분할된 결재선 데이터 맵 (총 6인 체제: SP, SF, TOTAL_AGG) */
export interface ElaprForkJoinDataMap {
spTrack: ElaprForkJoinStepDto[]; // 관로부 (2명: 검토(02) ➔ 승인(03))
sfTrack: ElaprForkJoinStepDto[]; // 시설부 (2명: 검토(02) ➔ 승인(03))
aggTrack: ElaprForkJoinStepDto[]; // 정책부 종합병합 (2명: 병합(04) ➔ 최종승인(99))
}
/** 결재 승인 요청 DTO */
export interface ElaprApproveRequestDto {
elaprDmndId: string;
branchId: string;
elaprPrgrsId: string;
atrzOpnn?: string;
}
/** 결재 단계별 반려 요청 DTO (10-12 개정) */
export interface ElaprRejectRequestDto {
elaprDmndId: string;
branchId: string;
elaprPrgrsId: string;
rjctRsn: string; // 반려 사유 필수
}
@@ -14,6 +14,9 @@ export type ElaprPrgrsSttsSaveMode =
export interface ElaprPrgrsSttsResponseDto {
elaprPrgrsId: string;
elaprDmndId?: string;
branchId?: string;
branchSeCd?: string;
aprvrDeptNm?: string;
aprvrUuid?: string;
atrzSeComCd?: string;
atrzSeComNm?: string;
@@ -30,6 +33,9 @@ export interface ElaprPrgrsSttsSaveRequestDto {
mode?: ElaprPrgrsSttsSaveMode;
elaprPrgrsId?: string;
elaprDmndId?: string;
branchId?: string;
branchSeCd?: string;
aprvrDeptNm?: string;
aprvrUuid?: string;
atrzSeComCd?: string;
atrzSeqNo?: number;
@@ -49,6 +49,7 @@ interface ElaprApprovalDialogProps {
elaprDocNo: string;
currentUserUuid: string;
elaprCycl?: number;
taskSeComCd?: string | null;
onSuccess: () => void;
}
@@ -60,6 +61,7 @@ function ElaprApprovalDialog({
elaprDocNo,
currentUserUuid,
elaprCycl,
taskSeComCd,
onSuccess,
}: ElaprApprovalDialogProps) {
const queryClient = useQueryClient();
@@ -73,10 +75,22 @@ function ElaprApprovalDialog({
elaprCycl,
structuralSharing: false,
});
const effectiveCycl = useMemo(() => {
if (elaprCycl != null) return elaprCycl;
if (!elaprDocPrgrsList?.length) return undefined;
const myPending = elaprDocPrgrsList.find(
(row) =>
row.aprvrUuid?.toLowerCase() === currentUserUuid?.toLowerCase() &&
row.atrzPrgrsSttsComCd === "ATRZ_PRGRS_002",
);
if (myPending?.elaprCycl != null) return Number(myPending.elaprCycl);
return Math.max(...elaprDocPrgrsList.map((r) => Number(r.elaprCycl || 1)));
}, [elaprCycl, elaprDocPrgrsList, currentUserUuid]);
const cyclDocPrgrsList = useMemo(() => {
if (elaprCycl == null) return elaprDocPrgrsList;
return elaprDocPrgrsList?.filter((row) => Number(row.elaprCycl) === elaprCycl);
}, [elaprCycl, elaprDocPrgrsList]);
if (effectiveCycl == null) return elaprDocPrgrsList;
return elaprDocPrgrsList?.filter((row) => Number(row.elaprCycl) === effectiveCycl);
}, [effectiveCycl, elaprDocPrgrsList]);
const elaprDmndId = cyclDocPrgrsList?.[0]?.elaprDmndId;
@@ -104,7 +118,29 @@ function ElaprApprovalDialog({
const currentUserPrgrs = findCurrentUserPendingApprovalRequest(cyclDocPrgrsList, currentUserUuid);
const canApprove = Boolean(currentUserPrgrs?.elaprPrgrsId);
const canReject =
currentUserPrgrs?.atrzSeComCd === "ATRZ03" || currentUserPrgrs?.atrzSeComCd === "ATRZ99";
currentUserPrgrs?.atrzSeComCd === "ATRZ03" ||
currentUserPrgrs?.atrzSeComCd === "ATRZ04" ||
currentUserPrgrs?.atrzSeComCd === "ATRZ99" ||
(currentUserPrgrs?.atrzSeqNo ?? 1) > 1;
const branchSeCd = (currentUserPrgrs as { branchSeCd?: string } | undefined)?.branchSeCd;
const isForkJoin =
taskSeComCd === "SF_SP" ||
branchSeCd === "SP" ||
branchSeCd === "SF" ||
branchSeCd === "TOTAL_AGG";
const isLastInBranch =
currentUserPrgrs?.atrzSeComCd === "ATRZ99" || currentUserPrgrs?.atrzSeComCd === "ATRZ03";
const approveButtonLabel = useMemo(() => {
if (!isForkJoin) return "결재";
if (branchSeCd === "TOTAL_AGG" && isLastInBranch) return "결재 (환경청에 회신)";
if (branchSeCd === "TOTAL_AGG") return "결재 (병합)";
if ((branchSeCd === "SP" || branchSeCd === "SF") && isLastInBranch)
return "결재 (병합에 회신)";
return "결재 (검토)";
}, [isForkJoin, branchSeCd, isLastInBranch]);
const pickingRow = pickingRowKey ? rows.find((r) => r.rowKey === pickingRowKey) : undefined;
const isBusy = isProcessing || savePrgrsSttsMutation.isPending;
@@ -239,7 +275,11 @@ function ElaprApprovalDialog({
atrzPrgrsSttsComCd: "ATRZ_PRGRS_004",
rjctRsn: rjctRsn.trim(),
});
alert("반려 처리가 완료되었습니다.");
alert(
isForkJoin
? "결재가 반려 처리되었습니다.\n직전 결재자가 '결재 대기중'으로 변경되었습니다. 상대 부서 결재는 정상 진행됩니다."
: "반려 처리가 완료되었습니다.",
);
queryClient.invalidateQueries({
queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, "findDocPrgrsByDocNo", elaprDocNo],
});
@@ -272,6 +312,42 @@ function ElaprApprovalDialog({
</DialogHeader>
<div className="space-y-6">
{isForkJoin && (
<div className="rounded-lg border border-blue-200 bg-blue-50/70 p-4 text-sm text-blue-900">
<div className="mb-1 flex items-center gap-2 font-bold">
<span className="rounded-sm bg-blue-200 px-2 py-0.5 text-xs text-blue-950">
{branchSeCd === "SP"
? "🌊 하수관로 전문기술검토 (Stage 2)"
: branchSeCd === "SF"
? "🏭 하수처리시설 전문기술검토 (Stage 2)"
: branchSeCd === "TOTAL_AGG"
? isLastInBranch
? "⚖️ 정책총괄 최종승인 및 회신 (Stage 3)"
: "⚖️ 정책총괄 AI 종합병합 (Stage 3)"
: "복합사업 결재"}
</span>
<span>
{branchSeCd === "TOTAL_AGG"
? isLastInBranch
? "환경청 대외 최종 회신 단계"
: "AI 의견 종합 및 병합 심의 단계"
: isLastInBranch
? "병합부서(정책총괄) 내부 회신(이관) 단계"
: "실무 기술검토 단계"}
</span>
</div>
<p className="text-xs text-blue-800">
{branchSeCd === "TOTAL_AGG"
? isLastInBranch
? "ℹ️ [중요] 본 승인은 환경청에 최종 회신하는 결재입니다. 승인 즉시 공문이 대외 발송되며 원천문서 상태가 TP(기술검토회신) 또는 TR(최종실무협의완료)로 전이됩니다."
: "ℹ️ [안내] 본 승인은 'AI의견 종합 및 병합 심의' 단계입니다. 양 부서 검토의견을 종합 반영하여 정책실장(최종 전결권자)에게 이관됩니다."
: isLastInBranch
? "ℹ️ [안내] 본 승인은 '병합에 회신'하는 내부 이관 단계입니다. 환경청으로 직접 공문이 나가지 않으며, 상대방 부서 검토 완료 시 종합병합 단계로 자동 합류(Join)됩니다."
: "ℹ️ [안내] 본 단계는 실무 기술검토 단계입니다. 검토 완료 후 부서 승인자에게 이관됩니다."}
</p>
</div>
)}
<div>
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
<h4 className="text-xl font-semibold">결재 진행 단계</h4>
@@ -280,7 +356,7 @@ function ElaprApprovalDialog({
size="sm"
type="button"
color="white"
disabled={!elaprDmndId || isEditingLocked || isBusy}
disabled={isForkJoin || !elaprDmndId || isEditingLocked || isBusy}
onClick={addRow}
>
결재선 추가
@@ -345,11 +421,11 @@ function ElaprApprovalDialog({
<SortablePrgrsSttsRow
key={row.rowKey}
row={row}
displaySeqNo={index + 1}
displaySeqNo={isForkJoin ? Number(row.atrzSeqNo ?? index + 1) : index + 1}
isEditing={editingRowKey === row.rowKey}
isLocked={isEditingLocked}
dragDisabled={
isEditingLocked || !isPrgrsSttsRowEditable(row) || !row.atrzSeComCd
isForkJoin || isEditingLocked || !isPrgrsSttsRowEditable(row) || !row.atrzSeComCd
}
onStartEdit={() => startEdit(row.rowKey)}
onSave={() => handleRowSave(row.rowKey)}
@@ -372,6 +448,17 @@ function ElaprApprovalDialog({
{canApprove && (
<div>
{isForkJoin && canReject && (
<div className="mb-2 space-y-1 rounded-md border border-amber-300 bg-amber-50 p-3 text-xs text-amber-900">
<div className="font-bold">⚠️ [단계별 반려 안내]</div>
<p>현재 복합사업 병렬 검토가 진행 중입니다. 본 단계에서 반려 처리 시:</p>
<ul className="list-disc pl-4 space-y-0.5">
<li>귀하(현재 결재자)는 반려(ATRZ_PRGRS_004) 처리됩니다.</li>
<li>이전(직전) 결재자만 &apos;결재 대기중(ATRZ_PRGRS_002)&apos;으로 변경되어 보완 및 재상신 기회가 부여됩니다.</li>
<li>동시에 진행 중인 상대방 부서 결재는 중단되지 않고 독립 정상 진행됩니다.</li>
</ul>
</div>
)}
<h4 className="mb-3 text-xl font-semibold">반려 의견</h4>
<Textarea
value={rjctRsn}
@@ -392,7 +479,7 @@ function ElaprApprovalDialog({
{canApprove && (
<div className="flex items-center gap-2">
<Button onClick={handleApprove} disabled={isBusy}>
결재
{approveButtonLabel}
</Button>
{canReject && (
<Button color="red" onClick={handleReject} disabled={isBusy}>
@@ -0,0 +1,190 @@
"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 } from "../lib/apln-validators";
import { mapDocPrgrsToForkJoinTrackRows } from "../lib/doc-prgrs-to-forkjoin-rows";
import type { ElaprAplnRow } from "../model/elapr-apln-row-types";
import { ElaprForkJoinPickerGrid } from "./elapr-forkjoin-picker-grid";
export interface ElaprDocPrgrsForkJoinPickerConfirmPayload {
aplnRows: ElaprAplnRow[];
}
export interface ElaprDocPrgrsForkJoinPickerDialogProps {
open: boolean;
elaprDocNo: string;
onClose: () => void;
onConfirm?: (payload: ElaprDocPrgrsForkJoinPickerConfirmPayload) => void;
}
export function ElaprDocPrgrsForkJoinPickerDialog({
open,
elaprDocNo,
onClose,
onConfirm,
}: ElaprDocPrgrsForkJoinPickerDialogProps) {
const alert = useAlertStore((state) => state.setMessage);
const [aplnRows, setAplnRows] = useState<ElaprAplnRow[]>([]);
const [isEditingLocked, setIsEditingLocked] = useState(false);
// 1차 결재(elaprCycl=1)에서 지정된 6인 결재선 조회
const docPrgrsQuery = useElaprDocPrgrsByDocNo(elaprDocNo, {
elaprCycl: 1,
enabled: open && Boolean(elaprDocNo),
});
// 1차 결재선 데이터를 3개 트랙(관로, 시설, 정책)으로 매핑
const initialTrackRows = useMemo(
() => mapDocPrgrsToForkJoinTrackRows(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() {
if (isEditingLocked) {
alert("편집 중인 결재선이 있습니다. 저장을 완료해 주세요.");
return;
}
const unassigned = aplnRows.find((r) => !r.aprvrUuid);
if (unassigned) {
alert("지정되지 않은 결재자가 있습니다. 모든 트랙의 결재자를 선택해 주세요.");
return;
}
const pipeRows = aplnRows
.filter((r) => r.branchSeCd === "SP")
.sort((a, b) => Number(a.atrzSeqNo ?? 0) - Number(b.atrzSeqNo ?? 0));
const facRows = aplnRows
.filter((r) => r.branchSeCd === "SF")
.sort((a, b) => Number(a.atrzSeqNo ?? 0) - Number(b.atrzSeqNo ?? 0));
const aggRows = aplnRows
.filter((r) => r.branchSeCd === "TOTAL_AGG")
.sort((a, b) => Number(a.atrzSeqNo ?? 0) - Number(b.atrzSeqNo ?? 0));
// 1. 부서별 인원 수 (각각 최소 2명 이상, 최대 5명)
if (pipeRows.length < 2 || pipeRows.length > 5) {
alert("하수관로 검토 결재선에 결재자를 최소 2명 이상(최대 5명) 지정해 주세요.");
return;
}
if (facRows.length < 2 || facRows.length > 5) {
alert("하수처리시설 검토 결재선에 결재자를 최소 2명 이상(최대 5명) 지정해 주세요.");
return;
}
if (aggRows.length < 2 || aggRows.length > 5) {
alert("정책부 종합병합 결재선에 결재자를 최소 2명 이상(최대 5명) 지정해 주세요.");
return;
}
// 2. 정책부 1순번 병합자(ATRZ04) 지정 검증
if (aggRows[0]?.atrzSeComCd !== "ATRZ04") {
alert("[정책부] 첫 번째 결재구분은 '병합(ATRZ04)'이어야 합니다.");
return;
}
// 3. 정책부 최종 결재구분(ATRZ99) 지정 검증
const lastAggRow = aggRows[aggRows.length - 1];
if (lastAggRow?.atrzSeComCd !== "ATRZ99") {
alert("[정책부] 최종 결재구분은 '최종승인(ATRZ99)'이어야 합니다.");
return;
}
// 4. 동일 브랜치 내 중복 결재자 검증
const checkDuplicateInBranch = (rows: typeof pipeRows, branchName: string) => {
const uuids = rows.map((r) => r.aprvrUuid).filter(Boolean);
const uniqueUuids = new Set(uuids);
if (uniqueUuids.size !== uuids.length) {
const dupRow = rows.find((r, i) => uuids.indexOf(r.aprvrUuid) !== i);
return `[${dupRow?.aprvrNm || "결재자"}] 결재자가 ${branchName} 결재선에 중복 지정되었습니다.`;
}
return null;
};
const pipeDup = checkDuplicateInBranch(pipeRows, "관로부");
if (pipeDup) {
alert(pipeDup);
return;
}
const facDup = checkDuplicateInBranch(facRows, "시설부");
if (facDup) {
alert(facDup);
return;
}
const aggDup = checkDuplicateInBranch(aggRows, "정책부");
if (aggDup) {
alert(aggDup);
return;
}
const visibleRows = getVisibleRows(aplnRows);
onConfirm?.({ aplnRows: visibleRows });
setAplnRows([]);
setIsEditingLocked(false);
onClose();
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-[100rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>2차 결재선 선택 (복합사업 처리시설+하수관로 Fork-Join 3트랙)</DialogTitle>
</DialogHeader>
{/* 1차 결재자 자동 매핑 안내 배너 */}
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900">
<p className="font-semibold">
ℹ️ 1차 기술검토 시 지정된 결재자(6인) 정보가 자동으로 매핑되었습니다.
</p>
<p className="mt-0.5 text-xs text-emerald-700">
동일 결재선으로 진행하거나 필요 시 결재자 변경/순서 조정한 후 하단의 [확인] 버튼을 눌러
2차 결재선을 확정해 주세요.
</p>
</div>
<ElaprForkJoinPickerGrid
initialTrackRows={initialTrackRows}
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>
);
}
@@ -0,0 +1,245 @@
"use client";
import { useMemo } from "react";
import { ArrowDown, ArrowRight, CheckCircle2, ChevronRight, GitFork, GitMerge } from "lucide-react";
import { dayjs } from "@/shared/lib";
import type { ElaprDocPrgrsClientResponseDto } from "@/shared/model";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { cn } from "@common/components";
import { getApproverStatus } from "../lib/approval-utils";
const REJECT_STATUS_CD = "ATRZ_PRGRS_004";
interface ElaprDocPrgrsForkJoinStepProps {
steps: ElaprDocPrgrsClientResponseDto[];
}
function ApproverCard({ item }: { item: ElaprDocPrgrsClientResponseDto }) {
const status = getApproverStatus(item.atrzPrgrsSttsComCd, item.atrzSeComCd);
const isRejected = item.atrzPrgrsSttsComCd === REJECT_STATUS_CD;
const isCurrent = item.atrzPrgrsSttsComCd === "ATRZ_PRGRS_002";
const rjctRsn = item.rjctRsn?.trim();
const metaParts = [item.ognzNm, item.jbpsSeNm].filter(Boolean);
const statusBadge = (
<span
className={cn(
"inline-flex items-center rounded-sm px-3 py-1 text-sm font-semibold",
status.badgeClass,
isCurrent && "animate-pulse ring-2 ring-blue-400",
isRejected && rjctRsn && "cursor-help underline decoration-dotted underline-offset-4",
)}
>
{status.label}
</span>
);
return (
<div className="flex min-w-[13rem] flex-1 flex-col items-center gap-2 rounded-lg border border-gray-200 bg-white p-4 text-center shadow-xs">
{isRejected && rjctRsn ? (
<Tooltip>
<TooltipTrigger asChild>{statusBadge}</TooltipTrigger>
<TooltipContent sideOffset={4} className="max-w-md whitespace-pre-wrap">
{rjctRsn}
</TooltipContent>
</Tooltip>
) : (
statusBadge
)}
<div className="flex flex-col items-center gap-1">
<p className="text-base font-semibold text-gray-900">
<span className="text-muted-foreground">[{item.atrzSeComNm || "-"}]</span>{" "}
{item.aprvrNm || item.aprvrUuid || "-"}
</p>
{metaParts.length > 0 && (
<span className="text-xs text-gray-500">{metaParts.join(" · ")}</span>
)}
{item.atrzDt && (
<span className="text-xs text-gray-400">{dayjs(item.atrzDt).format("MM-DD HH:mm")}</span>
)}
</div>
</div>
);
}
export function ElaprDocPrgrsForkJoinStep({ steps }: ElaprDocPrgrsForkJoinStepProps) {
// 브랜치별 분할 (branchSeCd 우선, 없으면 seqNo fallback)
const { pipeSteps, facSteps, aggSteps } = useMemo(() => {
const pipe: ElaprDocPrgrsClientResponseDto[] = [];
const fac: ElaprDocPrgrsClientResponseDto[] = [];
const agg: ElaprDocPrgrsClientResponseDto[] = [];
steps.forEach((step) => {
const seCd = step.branchSeCd;
if (seCd === "SP") {
pipe.push(step);
} else if (seCd === "SF") {
fac.push(step);
} else if (seCd === "TOTAL_AGG") {
agg.push(step);
} else {
// 순번 fallback (6인 체제: 2/2/2)
const seq = Number(step.atrzSeqNo ?? 1);
if (seq <= 2) pipe.push(step);
else if (seq <= 4) fac.push(step);
else agg.push(step);
}
});
return {
pipeSteps: pipe.sort((a, b) => Number(a.atrzSeqNo) - Number(b.atrzSeqNo)),
facSteps: fac.sort((a, b) => Number(a.atrzSeqNo) - Number(b.atrzSeqNo)),
aggSteps: agg.sort((a, b) => Number(a.atrzSeqNo) - Number(b.atrzSeqNo)),
};
}, [steps]);
// 완료 상태 및 결재 완료 수 동적 판정
const pipeCompletedCount = pipeSteps.filter((s) => s.atrzPrgrsSttsComCd === "ATRZ_PRGRS_003").length;
const isPipeCompleted =
pipeSteps.length > 0 && pipeCompletedCount === pipeSteps.length;
const facCompletedCount = facSteps.filter((s) => s.atrzPrgrsSttsComCd === "ATRZ_PRGRS_003").length;
const isFacCompleted =
facSteps.length > 0 && facCompletedCount === facSteps.length;
const isJoinReady = isPipeCompleted && isFacCompleted;
const stage2CountText =
pipeSteps.length === facSteps.length
? `(각 ${pipeSteps.length}인)`
: `(관로 ${pipeSteps.length}인 / 시설 ${facSteps.length}인)`;
return (
<div className="flex flex-col gap-6 p-2">
<div className="flex flex-col gap-6 lg:flex-row lg:items-stretch">
{/* Stage 2: 상/하 2열 병렬 트랙 */}
<div className="flex flex-1 flex-col gap-4 rounded-xl border border-blue-100 bg-blue-50/40 p-4">
<div className="flex items-center gap-2 border-b border-blue-200/60 pb-2">
<GitFork className="size-5 text-blue-600" />
<h4 className="text-base font-bold text-blue-950">
Stage 2: 부서별 병렬 전문검토 {stage2CountText}
</h4>
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-semibold text-blue-800">
동시 진행
</span>
</div>
{/* 1) 관로부 트랙 */}
<div className="rounded-lg border border-cyan-200 bg-cyan-50/50 p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-bold text-cyan-900">🌊 하수관로 전문검토</span>
<span
className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
isPipeCompleted
? "bg-emerald-100 text-emerald-800"
: "bg-cyan-100 text-cyan-800"
}`}
>
{isPipeCompleted
? `병합에 회신 완료 (${pipeCompletedCount}/${pipeSteps.length})`
: `진행중 (${pipeCompletedCount}/${pipeSteps.length})`}
</span>
</div>
<div className="flex flex-wrap items-center gap-2">
{pipeSteps.map((item, idx) => (
<div key={item.elaprPrgrsId ?? idx} className="flex items-center gap-2">
<ApproverCard item={item} />
{idx < pipeSteps.length - 1 && (
<ChevronRight className="size-5 text-cyan-400" />
)}
</div>
))}
</div>
</div>
{/* 2) 시설부 트랙 */}
<div className="rounded-lg border border-indigo-200 bg-indigo-50/50 p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-bold text-indigo-900">🏭 하수처리시설 전문검토</span>
<span
className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
isFacCompleted
? "bg-emerald-100 text-emerald-800"
: "bg-indigo-100 text-indigo-800"
}`}
>
{isFacCompleted
? `병합에 회신 완료 (${facCompletedCount}/${facSteps.length})`
: `진행중 (${facCompletedCount}/${facSteps.length})`}
</span>
</div>
<div className="flex flex-wrap items-center gap-2">
{facSteps.map((item, idx) => (
<div key={item.elaprPrgrsId ?? idx} className="flex items-center gap-2">
<ApproverCard item={item} />
{idx < facSteps.length - 1 && (
<ChevronRight className="size-5 text-indigo-400" />
)}
</div>
))}
</div>
</div>
</div>
{/* JOIN 합류 다이어그램 연결 */}
<div className="flex items-center justify-center px-2">
<div className="flex flex-col items-center gap-2 rounded-xl border border-emerald-200 bg-emerald-50/80 p-4 text-center shadow-xs">
<GitMerge className="size-6 text-emerald-600" />
<span className="text-xs font-bold text-emerald-900">JOIN 합류</span>
<div className="flex flex-col gap-1 text-[11px] text-emerald-800">
<span className="flex items-center gap-1">
{isPipeCompleted ? "🟢" : "⏳"} 관로 {isPipeCompleted ? "완료" : "대기"}
</span>
<span className="flex items-center gap-1">
{isFacCompleted ? "🟢" : "⏳"} 시설 {isFacCompleted ? "완료" : "대기"}
</span>
</div>
<div
className={`mt-1 rounded-sm px-2 py-1 text-xs font-bold ${
isJoinReady
? "bg-emerald-200 text-emerald-900"
: "bg-gray-200 text-gray-700"
}`}
>
{isJoinReady ? "Stage 3 활성화됨" : "병렬 대기중"}
</div>
<ArrowRight className="hidden size-5 text-emerald-600 lg:block" />
<ArrowDown className="size-5 text-emerald-600 lg:hidden" />
</div>
</div>
{/* Stage 3: 정책총괄부 종합취합 및 환경청 회신 트랙 */}
<div className="flex flex-1 flex-col gap-4 rounded-xl border border-purple-100 bg-purple-50/40 p-4">
<div className="flex items-center gap-2 border-b border-purple-200/60 pb-2">
<CheckCircle2 className="size-5 text-purple-600" />
<h4 className="text-base font-bold text-purple-950">
Stage 3: 정책총괄 종합병합 및 환경청 회신 ({aggSteps.length}인)
</h4>
<span
className={`rounded-full px-2 py-0.5 text-xs font-semibold ${
isJoinReady
? "bg-purple-100 text-purple-800"
: "bg-gray-100 text-gray-600"
}`}
>
{isJoinReady ? "진행 가능" : "대기"}
</span>
</div>
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-purple-200 bg-purple-50/50 p-3">
{aggSteps.map((item, idx) => (
<div key={item.elaprPrgrsId ?? idx} className="flex items-center gap-2">
<ApproverCard item={item} />
{idx < aggSteps.length - 1 && (
<ChevronRight className="size-5 text-purple-400" />
)}
</div>
))}
</div>
</div>
</div>
</div>
);
}
@@ -13,10 +13,13 @@ import { cn, Skeleton, Tabs, TabsContent, TabsList, TabsTrigger } from "@common/
import { useElaprDocPrgrsByDocNo } from "../api/queries";
import { getApproverStatus, sortByAtrzSeqNo } from "../lib/approval-utils";
import { ElaprDocPrgrsForkJoinStep } from "./elapr-doc-prgrs-forkjoin-step";
const REJECT_STATUS_CD = "ATRZ_PRGRS_004";
interface ElaprDocPrgrsStatusProps {
elaprDocNo: string | null | undefined;
taskSeComCd?: string | null;
}
function ElaprDocPrgrsStep({ item }: { item: ElaprDocPrgrsClientResponseDto }) {
@@ -88,21 +91,33 @@ function ElaprDocPrgrsStepList({ steps }: { steps: ElaprDocPrgrsClientResponseDt
);
}
function ElaprDocPrgrsStatus({ elaprDocNo }: ElaprDocPrgrsStatusProps) {
function ElaprDocPrgrsStatus({ elaprDocNo, taskSeComCd }: ElaprDocPrgrsStatusProps) {
const userInfo = useUserStore((state) => state.userInfo);
const { data, isLoading } = useElaprDocPrgrsByDocNo(elaprDocNo);
const [activeCycl, setActiveCycl] = useState<"1" | "2">("1");
const { cycl1Steps, cycl2Steps, hasCycl2 } = useMemo(() => {
const { cycl1Steps, cycl2Steps, hasCycl2, isForkJoin1, isForkJoin2 } = 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));
const checkForkJoin = (list: typeof rows) =>
taskSeComCd === "SF_SP" ||
list.some(
(s) =>
(s as { branchSeCd?: string }).branchSeCd === "SP" ||
(s as { branchSeCd?: string }).branchSeCd === "SF",
) ||
list.length >= 6;
return {
cycl1Steps: cycl1,
cycl2Steps: cycl2,
hasCycl2: cycl2.length > 0,
isForkJoin1: checkForkJoin(cycl1),
isForkJoin2: checkForkJoin(cycl2),
};
}, [data]);
}, [data, taskSeComCd]);
useEffect(() => {
setActiveCycl(hasCycl2 ? "2" : "1");
@@ -138,23 +153,33 @@ function ElaprDocPrgrsStatus({ elaprDocNo }: ElaprDocPrgrsStatusProps) {
>
<TabsList>
<TabsTrigger value="1" className="!px-6 !py-3 text-xl">
재원협의
1차 실무협의 (기술검토)
</TabsTrigger>
<TabsTrigger value="2" className="!px-6 !py-3 text-xl">
실무협의서
2차 조치결과 검토 (종결)
</TabsTrigger>
</TabsList>
<TabsContent value="1" className="mt-2 border-none p-0 shadow-none">
<div className="-mx-2">
<ElaprDocPrgrsStepList steps={cycl1Steps} />
{isForkJoin1 ? (
<ElaprDocPrgrsForkJoinStep steps={cycl1Steps} />
) : (
<ElaprDocPrgrsStepList steps={cycl1Steps} />
)}
</div>
</TabsContent>
<TabsContent value="2" className="mt-2 border-none p-0 shadow-none">
<div className="-mx-2">
<ElaprDocPrgrsStepList steps={cycl2Steps} />
{isForkJoin2 ? (
<ElaprDocPrgrsForkJoinStep steps={cycl2Steps} />
) : (
<ElaprDocPrgrsStepList steps={cycl2Steps} />
)}
</div>
</TabsContent>
</Tabs>
) : isForkJoin1 ? (
<ElaprDocPrgrsForkJoinStep steps={cycl1Steps} />
) : (
<ElaprDocPrgrsStepList steps={cycl1Steps} />
)}
@@ -0,0 +1,472 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { closestCenter, DndContext, type DragEndEvent } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { useAlertStore } from "@/shared/lib";
import {
Button,
Table,
TableBody,
TableHead,
TableHeader,
TableRow,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@common/components";
import { useDndSensors } from "../lib/use-dnd-sensors";
import type { ElaprAplnRow } from "../model/elapr-apln-row-types";
import type { ReviewerOption } from "../model/reviewer-option";
import { AprvrPickerDialog } from "./aprvr-picker-dialog";
import { SortableAplnPickerRow } from "./sortable-apln-picker-row";
type ForkJoinTrackKey = "SP" | "SF" | "TOTAL_AGG";
interface TrackConfig {
key: ForkJoinTrackKey;
label: string;
defaultDeptNm: string;
guideText: string;
minCount: number;
maxCount: number;
}
const TRACK_CONFIGS: TrackConfig[] = [
{
key: "SP",
label: "하수관로 검토",
defaultDeptNm: "하수관로부",
guideText: "ℹ️ 하수관로 부서 검토·승인 완료 시 종합병합부서로 내부 회신(이관)됩니다.",
minCount: 2,
maxCount: 5,
},
{
key: "SF",
label: "하수처리시설 검토",
defaultDeptNm: "하수처리시설부",
guideText: "ℹ️ 하수처리시설 부서 검토·승인 완료 시 종합병합부서로 내부 회신(이관)됩니다.",
minCount: 2,
maxCount: 5,
},
{
key: "TOTAL_AGG",
label: "정책부 종합병합",
defaultDeptNm: "정책총괄부",
guideText: "ℹ️ 두 부서(관로+시설) Join 완료 후 정책취합 및 환경청 회신 단계로 진행됩니다.",
minCount: 2,
maxCount: 5,
},
];
function createDefaultRow(
branchSeCd: ForkJoinTrackKey,
deptNm: string,
seq: number,
seComCd: string,
): ElaprAplnRow {
return {
rowKey: `forkjoin-${branchSeCd}-${seq}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
elaprTmpltId: "TMPLT_SF_SP_FORKJOIN",
atrzSeqNo: seq,
atrzSeComCd: seComCd,
aprvrDeptNm: deptNm,
branchSeCd,
isNew: true,
isDirty: false,
dcarbYn: seComCd === "ATRZ99" ? "Y" : "N",
};
}
export function createDefaultTrackRows(
perTrackCount: number = 2,
): Record<ForkJoinTrackKey, ElaprAplnRow[]> {
const safeCount = Math.max(2, Math.min(5, perTrackCount));
const sp: ElaprAplnRow[] = [];
const sf: ElaprAplnRow[] = [];
const totalAgg: ElaprAplnRow[] = [];
for (let seq = 1; seq <= safeCount; seq++) {
sp.push(
createDefaultRow(
"SP",
"하수관로부",
seq,
seq === 1 ? "ATRZ02" : "ATRZ03",
),
);
sf.push(
createDefaultRow(
"SF",
"하수처리시설부",
seq,
seq === 1 ? "ATRZ02" : "ATRZ03",
),
);
totalAgg.push(
createDefaultRow(
"TOTAL_AGG",
"정책총괄부",
seq,
seq === 1 ? "ATRZ04" : seq === safeCount ? "ATRZ99" : "ATRZ03",
),
);
}
return { SP: sp, SF: sf, TOTAL_AGG: totalAgg };
}
export interface ElaprForkJoinPickerGridProps {
onRowsChange?: (rows: ElaprAplnRow[]) => void;
onEditingLockedChange?: (locked: boolean) => void;
initialTrackRows?: Record<ForkJoinTrackKey, ElaprAplnRow[]> | null;
/** 트랙당 기본 초기 결재자 수 (기본 2명, 필요 시 3명 지원) */
defaultPerTrack?: number;
}
export function ElaprForkJoinPickerGrid({
onRowsChange,
onEditingLockedChange,
initialTrackRows,
defaultPerTrack = 2,
}: ElaprForkJoinPickerGridProps) {
const alert = useAlertStore((state) => state.setMessage);
const [activeTab, setActiveTab] = useState<ForkJoinTrackKey>("SP");
// 3개 트랙별 결재선 상태 (기본 2인 또는 3인 초기화 지원)
const [trackRows, setTrackRows] = useState<Record<ForkJoinTrackKey, ElaprAplnRow[]>>(() => {
if (initialTrackRows) {
return {
SP: initialTrackRows.SP ?? [],
SF: initialTrackRows.SF ?? [],
TOTAL_AGG: initialTrackRows.TOTAL_AGG ?? [],
};
}
return createDefaultTrackRows(defaultPerTrack);
});
useEffect(() => {
if (initialTrackRows) {
setTrackRows({
SP: initialTrackRows.SP ?? [],
SF: initialTrackRows.SF ?? [],
TOTAL_AGG: initialTrackRows.TOTAL_AGG ?? [],
});
}
}, [initialTrackRows]);
const [editingRowKey, setEditingRowKey] = useState<string | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [pickingRowKey, setPickingRowKey] = useState<string | null>(null);
const sensors = useDndSensors();
// 통합 전체 결재선 목록
const allRows = useMemo(() => {
return [...(trackRows.SP ?? []), ...(trackRows.SF ?? []), ...(trackRows.TOTAL_AGG ?? [])];
}, [trackRows]);
useEffect(() => {
onRowsChange?.(allRows);
}, [allRows, onRowsChange]);
useEffect(() => {
onEditingLockedChange?.(editingRowKey !== null);
}, [editingRowKey, onEditingLockedChange]);
const currentTrackConfig = useMemo(() => {
return TRACK_CONFIGS.find((c) => c.key === activeTab) ?? TRACK_CONFIGS[0];
}, [activeTab]);
const currentRows = trackRows[activeTab] ?? [];
const pickingRow = useMemo(() => {
if (!pickingRowKey) return undefined;
return allRows.find((r) => r.rowKey === pickingRowKey);
}, [pickingRowKey, allRows]);
// 행 추가 (부서별 최대 5명 제한)
const handleAddRow = useCallback(() => {
if (editingRowKey) {
alert("편집 중인 결재선이 있습니다. 저장 또는 취소 후 추가해 주세요.");
return;
}
if (currentRows.length >= currentTrackConfig.maxCount) {
alert(`[${currentTrackConfig.label}] 결재선은 최대 ${currentTrackConfig.maxCount}명까지 지정 가능합니다.`);
return;
}
const newSeq = currentRows.length + 1;
let defaultSeComCd = "ATRZ02";
if (activeTab === "TOTAL_AGG") {
defaultSeComCd = newSeq === 1 ? "ATRZ04" : "ATRZ99";
} else {
defaultSeComCd = newSeq === 1 ? "ATRZ02" : "ATRZ03";
}
const newRow = createDefaultRow(
activeTab,
currentTrackConfig.defaultDeptNm,
newSeq,
defaultSeComCd,
);
setTrackRows((prev) => {
const list = [...prev[activeTab]];
if (activeTab === "TOTAL_AGG" && list.length > 0) {
const lastIdx = list.length - 1;
if (list[lastIdx].atrzSeComCd === "ATRZ99") {
list[lastIdx] = {
...list[lastIdx],
atrzSeComCd: "ATRZ03",
dcarbYn: "N",
};
}
}
return {
...prev,
[activeTab]: [...list, newRow],
};
});
setEditingRowKey(newRow.rowKey);
}, [activeTab, currentRows.length, currentTrackConfig, editingRowKey, alert]);
// 편집 시작
const handleStartEdit = useCallback((rowKey: string) => {
const target = currentRows.find((r) => r.rowKey === rowKey);
if (!target) return;
target.snapshot = { ...target };
setEditingRowKey(rowKey);
}, [currentRows]);
// 편집 취소
const handleCancelEdit = useCallback(() => {
if (!editingRowKey) return;
setTrackRows((prev) => {
const list = prev[activeTab];
const target = list.find((r) => r.rowKey === editingRowKey);
if (target?.isNew && !target.aprvrUuid) {
return {
...prev,
[activeTab]: list.filter((r) => r.rowKey !== editingRowKey),
};
}
if (target?.snapshot) {
return {
...prev,
[activeTab]: list.map((r) =>
r.rowKey === editingRowKey ? { ...r.snapshot!, snapshot: undefined } : r,
),
};
}
return prev;
});
setEditingRowKey(null);
}, [activeTab, editingRowKey]);
// 행 필드 수정
const handleUpdateRow = useCallback(
(rowKey: string, patch: Partial<ElaprAplnRow>) => {
setTrackRows((prev) => ({
...prev,
[activeTab]: prev[activeTab].map((r) =>
r.rowKey === rowKey ? { ...r, ...patch, isDirty: true } : r,
),
}));
},
[activeTab],
);
// 저장 (커밋)
const handleCommitEdit = useCallback((rowKey: string) => {
setTrackRows((prev) => ({
...prev,
[activeTab]: prev[activeTab].map((r) =>
r.rowKey === rowKey ? { ...r, isDirty: false, isNew: false, snapshot: undefined } : r,
),
}));
setEditingRowKey(null);
}, [activeTab]);
// 행 삭제
const handleDeleteRow = useCallback(
(rowKey: string) => {
setTrackRows((prev) => {
const nextList = prev[activeTab]
.filter((r) => r.rowKey !== rowKey)
.map((r, idx) => ({ ...r, atrzSeqNo: idx + 1 }));
return {
...prev,
[activeTab]: nextList,
};
});
if (editingRowKey === rowKey) {
setEditingRowKey(null);
}
},
[activeTab, editingRowKey],
);
// 드래그 순서 재정렬
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
setTrackRows((prev) => {
const items = [...prev[activeTab]];
const oldIndex = items.findIndex((r) => r.rowKey === active.id);
const newIndex = items.findIndex((r) => r.rowKey === over.id);
if (oldIndex === -1 || newIndex === -1) return prev;
const [moved] = items.splice(oldIndex, 1);
items.splice(newIndex, 0, moved);
const reordered = items.map((r, idx) => ({
...r,
atrzSeqNo: idx + 1,
isDirty: true,
}));
return {
...prev,
[activeTab]: reordered,
};
});
},
[activeTab],
);
// 결재자 선택 팝업 오픈
const handlePickApprover = useCallback((rowKey: string) => {
setPickingRowKey(rowKey);
setPickerOpen(true);
}, []);
// 결재자 선택 완료
const handleSelectReviewer = useCallback(
(reviewer: ReviewerOption) => {
if (!pickingRowKey) return;
handleUpdateRow(pickingRowKey, {
aprvrUuid: reviewer.uuid,
aprvrNm: reviewer.name,
aprvrDeptNm: reviewer.deptNm ?? currentTrackConfig.defaultDeptNm,
});
setPickingRowKey(null);
setPickerOpen(false);
},
[pickingRowKey, handleUpdateRow, currentTrackConfig.defaultDeptNm],
);
return (
<div className="flex flex-1 flex-col gap-4">
{/* 3트랙 탭 헤더 */}
<Tabs value={activeTab} onValueChange={(val) => setActiveTab(val as ForkJoinTrackKey)}>
<TabsList className="grid w-full grid-cols-3">
{TRACK_CONFIGS.map((track) => {
const count = trackRows[track.key].filter((r) => Boolean(r.aprvrUuid)).length;
const total = trackRows[track.key].length;
const isFull = count >= track.minCount;
return (
<TabsTrigger
key={track.key}
value={track.key}
className="flex items-center gap-2 py-3 text-lg font-semibold"
>
<span>{track.label}</span>
<span
className={`rounded-full px-2 py-0.5 text-xs font-bold ${
isFull
? "bg-emerald-100 text-emerald-700"
: "bg-amber-100 text-amber-700"
}`}
>
{count}/{total}
</span>
</TabsTrigger>
);
})}
</TabsList>
{TRACK_CONFIGS.map((track) => (
<TabsContent key={track.key} value={track.key} className="mt-3">
<div className="mb-3 flex items-center justify-between rounded-lg bg-blue-50 p-3 text-blue-900">
<span className="text-sm font-medium">{track.guideText}</span>
<Button
type="button"
size="sm"
color="white"
onClick={handleAddRow}
disabled={Boolean(editingRowKey) || trackRows[track.key].length >= track.maxCount}
>
+ 결재자 추가
</Button>
</div>
{/* 해당 탭 결재선 테이블 */}
<div className="rounded-lg border border-gray-200">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<Table className="w-full">
<colgroup>
<col width="6%" />
<col width="10%" />
<col width="24%" />
<col width="44%" />
<col width="16%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead className="w-8" />
<TableHead>순서</TableHead>
<TableHead>결재구분</TableHead>
<TableHead>결재자 (성명/부서)</TableHead>
<TableHead>관리</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<SortableContext
items={trackRows[track.key].map((r) => r.rowKey)}
strategy={verticalListSortingStrategy}
>
{trackRows[track.key].map((row, index) => (
<SortableAplnPickerRow
key={row.rowKey}
row={row}
displaySeqNo={index + 1}
dragDisabled={Boolean(editingRowKey)}
isEditing={editingRowKey === row.rowKey}
isLocked={Boolean(editingRowKey && editingRowKey !== row.rowKey)}
onStartEdit={() => handleStartEdit(row.rowKey)}
onSave={() => handleCommitEdit(row.rowKey)}
onCancel={handleCancelEdit}
onDelete={() => handleDeleteRow(row.rowKey)}
onChange={(patch) => handleUpdateRow(row.rowKey, patch)}
onPickAprvr={() => handlePickApprover(row.rowKey)}
/>
))}
</SortableContext>
</TableBody>
</Table>
</DndContext>
</div>
</TabsContent>
))}
</Tabs>
{/* 결재자 검색 팝업 */}
<AprvrPickerDialog
open={pickerOpen}
onOpenChange={setPickerOpen}
atrzSeComCd={pickingRow?.atrzSeComCd ?? ""}
onSelect={handleSelectReviewer}
/>
</div>
);
}
@@ -24,6 +24,7 @@ import type { ElaprAplnRow } from "../model/elapr-apln-row-types";
import type { ElaprTmplt } from "../model/elapr-tmplt-interface";
import { ElaprAplnPickerGrid } from "./elapr-apln-picker-grid";
import { ElaprForkJoinPickerGrid } from "./elapr-forkjoin-picker-grid";
import { ElaprTmpltSelectTable } from "./elapr-tmplt-select-table";
const PAGE_SIZE = 100;
@@ -37,6 +38,7 @@ interface ElaprTmpltPickerDialogProps {
open: boolean;
onClose: () => void;
docSeComCd: string;
taskSeComCd?: string | null;
onSelect?: (tmplt: ElaprTmplt) => void;
onConfirm?: (payload: ElaprTmpltPickerConfirmPayload) => void;
}
@@ -45,6 +47,7 @@ function ElaprTmpltPickerDialog({
open,
onClose,
docSeComCd,
taskSeComCd,
onSelect,
onConfirm,
}: ElaprTmpltPickerDialogProps) {
@@ -53,6 +56,8 @@ function ElaprTmpltPickerDialog({
const [aplnRows, setAplnRows] = useState<ElaprAplnRow[]>([]);
const [isEditingLocked, setIsEditingLocked] = useState(false);
const isForkJoinMode = docSeComCd === "FNRS" && taskSeComCd === "SF_SP";
const tmpltQuery = useElaprTmpltPage({
page: 0,
size: PAGE_SIZE,
@@ -70,7 +75,9 @@ function ElaprTmpltPickerDialog({
function handleSelectTmplt(tmplt: ElaprTmplt | null) {
setSelectedTmplt(tmplt);
setAplnRows([]);
if (!isForkJoinMode) {
setAplnRows([]);
}
setIsEditingLocked(false);
}
@@ -84,11 +91,110 @@ function ElaprTmpltPickerDialog({
}
function handleConfirm() {
if (!selectedTmplt) {
if (!selectedTmplt && !isForkJoinMode) {
alert("결재서식을 선택해 주세요.");
return;
}
if (isForkJoinMode) {
if (isEditingLocked) {
alert("편집 중인 결재선이 있습니다. 저장을 완료해 주세요.");
return;
}
const unassigned = aplnRows.find((r) => !r.aprvrUuid);
if (unassigned) {
alert("지정되지 않은 결재자가 있습니다. 모든 트랙의 결재자를 선택해 주세요.");
return;
}
const pipeRows = aplnRows
.filter((r) => r.branchSeCd === "SP")
.sort((a, b) => Number(a.atrzSeqNo ?? 0) - Number(b.atrzSeqNo ?? 0));
const facRows = aplnRows
.filter((r) => r.branchSeCd === "SF")
.sort((a, b) => Number(a.atrzSeqNo ?? 0) - Number(b.atrzSeqNo ?? 0));
const aggRows = aplnRows
.filter((r) => r.branchSeCd === "TOTAL_AGG")
.sort((a, b) => Number(a.atrzSeqNo ?? 0) - Number(b.atrzSeqNo ?? 0));
// 1. 부서별 인원 수 (각각 최소 2명 이상, 최대 5명)
if (pipeRows.length < 2 || pipeRows.length > 5) {
alert("하수관로 검토 결재선에 결재자를 최소 2명 이상(최대 5명) 지정해 주세요.");
return;
}
if (facRows.length < 2 || facRows.length > 5) {
alert("하수처리시설 검토 결재선에 결재자를 최소 2명 이상(최대 5명) 지정해 주세요.");
return;
}
if (aggRows.length < 2 || aggRows.length > 5) {
alert("정책부 종합병합 결재선에 결재자를 최소 2명 이상(최대 5명) 지정해 주세요.");
return;
}
// 2. 정책부 1순번 병합자(ATRZ04) 지정 검증
if (aggRows[0]?.atrzSeComCd !== "ATRZ04") {
alert("[정책부] 첫 번째 결재구분은 '병합(ATRZ04)'이어야 합니다.");
return;
}
// 3. 정책부 최종 결재구분(ATRZ99) 지정 검증
const lastAggRow = aggRows[aggRows.length - 1];
if (lastAggRow?.atrzSeComCd !== "ATRZ99") {
alert("[정책부] 최종 결재구분은 '최종승인(ATRZ99)'이어야 합니다.");
return;
}
// 4. 동일 브랜치 내 중복 결재자 검증
const checkDuplicateInBranch = (rows: typeof pipeRows, branchName: string) => {
const uuids = rows.map((r) => r.aprvrUuid).filter(Boolean);
const uniqueUuids = new Set(uuids);
if (uniqueUuids.size !== uuids.length) {
const dupRow = rows.find((r, i) => uuids.indexOf(r.aprvrUuid) !== i);
return `[${dupRow?.aprvrNm || "결재자"}] 결재자가 ${branchName} 결재선에 중복 지정되었습니다.`;
}
return null;
};
const pipeDup = checkDuplicateInBranch(pipeRows, "관로부");
if (pipeDup) {
alert(pipeDup);
return;
}
const facDup = checkDuplicateInBranch(facRows, "시설부");
if (facDup) {
alert(facDup);
return;
}
const aggDup = checkDuplicateInBranch(aggRows, "정책부");
if (aggDup) {
alert(aggDup);
return;
}
const defaultTmplt: ElaprTmplt = selectedTmplt ?? {
elaprTmpltId: "TMPLT_SF_SP_FORKJOIN",
elaprTmpltNm: "[복합] 시설+관로 병렬 결재선 (6인)",
docSeComCd: "FNRS",
};
const payload: ElaprTmpltPickerConfirmPayload = {
tmplt: defaultTmplt,
aplnRows,
};
if (onConfirm) {
onConfirm(payload);
} else {
onSelect?.(defaultTmplt);
}
setSelectedTmplt(null);
setAplnRows([]);
setIsEditingLocked(false);
onClose();
return;
}
const rowsError = validateAllRowsForSave(aplnRows, isEditingLocked);
if (rowsError) {
alert(rowsError);
@@ -116,14 +222,14 @@ function ElaprTmpltPickerDialog({
const visibleRows = getVisibleRows(aplnRows);
const payload: ElaprTmpltPickerConfirmPayload = {
tmplt: selectedTmplt,
tmplt: selectedTmplt!,
aplnRows: visibleRows,
};
if (onConfirm) {
onConfirm(payload);
} else {
onSelect?.(selectedTmplt);
onSelect?.(selectedTmplt!);
}
setSelectedTmplt(null);
@@ -134,9 +240,16 @@ function ElaprTmpltPickerDialog({
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-[120rem]" aria-describedby={undefined}>
<DialogContent
className={isForkJoinMode ? "max-w-[140rem]" : "max-w-[120rem]"}
aria-describedby={undefined}
>
<DialogHeader>
<DialogTitle>결재선 선택</DialogTitle>
<DialogTitle>
{isForkJoinMode
? "결재선 선택 (복합사업 처리시설+하수관로 Fork-Join 3트랙)"
: "결재선 선택"}
</DialogTitle>
</DialogHeader>
<div className="flex gap-6">
@@ -145,11 +258,19 @@ function ElaprTmpltPickerDialog({
selectedTmpltId={selectedTmplt?.elaprTmpltId ?? null}
onSelectTmplt={handleSelectTmplt}
/>
<ElaprAplnPickerGrid
elaprTmpltId={selectedTmplt?.elaprTmpltId ?? null}
onRowsChange={handleRowsChange}
onEditingLockedChange={handleEditingLockedChange}
/>
{isForkJoinMode ? (
<ElaprForkJoinPickerGrid
onRowsChange={handleRowsChange}
onEditingLockedChange={handleEditingLockedChange}
/>
) : (
<ElaprAplnPickerGrid
elaprTmpltId={selectedTmplt?.elaprTmpltId ?? null}
onRowsChange={handleRowsChange}
onEditingLockedChange={handleEditingLockedChange}
/>
)}
</div>
<DialogFooter>
@@ -158,7 +279,7 @@ function ElaprTmpltPickerDialog({
</Button>
<Button
type="button"
disabled={!selectedTmplt || isEditingLocked}
disabled={(!selectedTmplt && !isForkJoinMode) || isEditingLocked}
onClick={handleConfirm}
>
확인
@@ -19,6 +19,8 @@ export interface EsntlAtchFileMng {
esntlAtchYn: "Y" | "N";
/** 필수첨부파일비고 */
esntlAtchFileRmrk?: string;
/** 필수첨부파일검토내용 */
esntlAtchFileRvwCn?: string;
/** 첨부파일그룹아이디 (양식파일 그룹) */
atchFileGroupId?: string;
/** 첨부파일 목록 (양식파일) */
@@ -77,6 +79,8 @@ export interface EsntlAtchFileMngSaveRequest {
esntlAtchYn?: "Y" | "N";
/** 필수첨부파일비고 */
esntlAtchFileRmrk?: string;
/** 필수첨부파일검토내용 */
esntlAtchFileRvwCn?: string;
/** 첨부파일그룹아이디 (양식파일 그룹) */
atchFileGroupId?: string;
}
@@ -0,0 +1,22 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { RvwOpnnUpdateRequestDto } from "@/shared/model";
const BASE = ENDPOINT.AS_SERVICE.FNRS_CNSLTN_RVW_OPNN;
async function update(
fnrsCnsltnDocNo: string,
rvwOpnnSn: number,
body: RvwOpnnUpdateRequestDto,
): Promise<void> {
await client.put<void>(`${BASE}/${fnrsCnsltnDocNo}/${rvwOpnnSn}`, body);
}
async function deleteFn(fnrsCnsltnDocNo: string, rvwOpnnSn: number): Promise<void> {
await client.delete<void>(`${BASE}/${fnrsCnsltnDocNo}/${rvwOpnnSn}`);
}
export const fnrsCnsltnRvwOpnnService = {
update,
delete: deleteFn,
};
@@ -98,6 +98,10 @@ async function changePrgrsSttsComCd(body: DocPrgrsUpdateRequestDto): Promise<voi
await client.post<void>(`${BASE}/doc/updatePrgrsStts`, body);
}
async function rejectPrgrsStts(body: DocPrgrsUpdateRequestDto): Promise<void> {
await client.post<void>(`${BASE}/doc/rejectPrgrsStts`, body);
}
export const fnrsCnsltnMngService = {
findPage,
findById,
@@ -112,4 +116,5 @@ export const fnrsCnsltnMngService = {
remove,
findFileVrfcResults,
changePrgrsSttsComCd,
rejectPrgrsStts,
} satisfies FnrsCnsltnMngService;
@@ -35,6 +35,8 @@ export function useFnrsCnsltnMng(fnrsCnsltnDocNo: string | null | undefined) {
queryKey: fnrsCnsltnKeys.detail(fnrsCnsltnDocNo!),
queryFn: () => fnrsCnsltnMngService.findById(fnrsCnsltnDocNo!),
enabled: Boolean(fnrsCnsltnDocNo),
staleTime: 0,
refetchOnMount: "always",
});
}
@@ -43,6 +45,8 @@ export function useFnrsCnsltnFileVrfcResults(fnrsCnsltnDocNo: string | null | un
queryKey: fnrsCnsltnKeys.fileVrfcResults(fnrsCnsltnDocNo!),
queryFn: () => fnrsCnsltnMngService.findFileVrfcResults(fnrsCnsltnDocNo!),
enabled: Boolean(fnrsCnsltnDocNo),
staleTime: 0,
refetchOnMount: "always",
});
}
+66 -65
View File
@@ -1,65 +1,66 @@
export { fnrsCnsltnMngService } from "./api/fnrs-cnsltn-service";
export { fnrsCnsltnKeys } from "./api/queries";
export {
useChangePrgrsSttsComCd,
useDeleteFnrsCnsltnMng,
useFnrsCnsltnFileVrfcResults,
useFnrsCnsltnMng,
useFnrsCnsltnMngPage,
useRequestActnReviewFnrsCnsltnMng,
useRequestExamineFnrsCnsltnMng,
useSaveApplyFnrsCnsltnMng,
useSaveFnrsCnsltnMng,
useSubmitReqSupplementFnrsCnsltnMng,
useSubmitSupplementFnrsCnsltnMng,
useTechReviewFnrsCnsltnMng,
useUpdateFnrsCnsltnMng,
} from "./api/queries";
export type {
BizCstItem,
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,
EsntlAtchFileMngTreeResponseDto,
FnrsCnsltnFileVrfcRsltResponseDto,
FnrsCnsltnMngDetResponseDto,
FnrsCnsltnMngResponseDto,
FnrsCnsltnMngSearchDto,
FnrsCnsltnMngService,
FnrsCnsltnMngUpdateRequestDto,
FnrsCnsltnRvwOpnnResponseDto,
FnrsCnsltnSplmntDmndResponseDto,
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";
export { fnrsCnsltnRvwOpnnService } from "./api/fnrs-cnsltn-rvw-opnn-service";
export { fnrsCnsltnMngService } from "./api/fnrs-cnsltn-service";
export { fnrsCnsltnKeys } from "./api/queries";
export {
useChangePrgrsSttsComCd,
useDeleteFnrsCnsltnMng,
useFnrsCnsltnFileVrfcResults,
useFnrsCnsltnMng,
useFnrsCnsltnMngPage,
useRequestActnReviewFnrsCnsltnMng,
useRequestExamineFnrsCnsltnMng,
useSaveApplyFnrsCnsltnMng,
useSaveFnrsCnsltnMng,
useSubmitReqSupplementFnrsCnsltnMng,
useSubmitSupplementFnrsCnsltnMng,
useTechReviewFnrsCnsltnMng,
useUpdateFnrsCnsltnMng,
} from "./api/queries";
export type {
BizCstItem,
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,
EsntlAtchFileMngTreeResponseDto,
FnrsCnsltnFileVrfcRsltResponseDto,
FnrsCnsltnMngDetResponseDto,
FnrsCnsltnMngResponseDto,
FnrsCnsltnMngSearchDto,
FnrsCnsltnMngService,
FnrsCnsltnMngUpdateRequestDto,
FnrsCnsltnRvwOpnnResponseDto,
FnrsCnsltnSplmntDmndResponseDto,
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";
@@ -98,6 +98,7 @@ export const fnrsSharItemSchema = z.object({
export const fnrsCnsltnApplySaveSchema = z.object({
techReViewSaveCd: z.string().nullish(),
rvwStpCd: z.string().nullish(),
branchSeCd: z.string().nullish(),
fnrsCnsltnMng: fnrsCnsltnMngSaveSchema,
bzScaleList: z.array(fnrsCnsltnBzScaleItemSchema).nullish(),
bizCstList: z.array(fnrsCnsltnBizCstItemSchema).nullish(),
@@ -145,4 +145,5 @@ export interface FnrsCnsltnMngService {
remove(fnrsCnsltnDocNo: string): Promise<void>;
findFileVrfcResults(fnrsCnsltnDocNo: string): Promise<FnrsCnsltnFileVrfcRsltResponseDto[]>;
changePrgrsSttsComCd(body: DocPrgrsUpdateRequestDto): Promise<void>;
rejectPrgrsStts(body: DocPrgrsUpdateRequestDto): Promise<void>;
}
@@ -0,0 +1,22 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { RvwOpnnUpdateRequestDto } from "@/shared/model";
const BASE = ENDPOINT.AS_SERVICE.INSTL_SNCN_RVW_OPNN;
async function update(
instlSncnDocNo: string,
rvwOpnnSn: number,
body: RvwOpnnUpdateRequestDto,
): Promise<void> {
await client.put<void>(`${BASE}/${instlSncnDocNo}/${rvwOpnnSn}`, body);
}
async function deleteFn(instlSncnDocNo: string, rvwOpnnSn: number): Promise<void> {
await client.delete<void>(`${BASE}/${instlSncnDocNo}/${rvwOpnnSn}`);
}
export const instlSncnRvwOpnnService = {
update,
delete: deleteFn,
};
@@ -91,6 +91,10 @@ async function changePrgrsSttsComCd(payload: DocPrgrsUpdateRequestDto): Promise<
await client.post(`${BASE}/doc/updatePrgrsStts`, payload);
}
async function rejectPrgrsStts(payload: DocPrgrsUpdateRequestDto): Promise<void> {
await client.post(`${BASE}/doc/rejectPrgrsStts`, payload);
}
export const instlSncnService = {
getPage,
getById,
@@ -102,6 +106,7 @@ export const instlSncnService = {
submitReqSupplement,
remove,
changePrgrsSttsComCd,
rejectPrgrsStts,
} satisfies InstlSncnMngService;
// TODO: st-anls-mst service 로 분리
@@ -35,6 +35,8 @@ export function useInstlSncnDetail(id: string) {
queryKey: instlSncnKeys.detail(id),
queryFn: () => instlSncnService.getById(id),
enabled: Boolean(id),
staleTime: 0,
refetchOnMount: "always",
});
}
+1
View File
@@ -1,3 +1,4 @@
export { instlSncnRvwOpnnService } from "./api/instl-sncn-rvw-opnn-service";
export { instlSncnRegionService, instlSncnService } from "./api/instl-sncn-service";
export {
instlSncnKeys,
@@ -131,4 +131,5 @@ export interface InstlSncnMngService {
submitReqSupplement: (id: string, payload: InstlSncnApplySaveRequestDto) => Promise<string>;
remove: (id: string) => Promise<void>;
changePrgrsSttsComCd: (payload: DocPrgrsUpdateRequestDto) => Promise<void>;
rejectPrgrsStts: (payload: DocPrgrsUpdateRequestDto) => Promise<void>;
}
@@ -11,27 +11,67 @@ const BASE = ENDPOINT.AS_SERVICE.PWRK_CNSLTN_DOC_CRT_RSLT;
async function findLatestByCrtDmndUuid(
crtDmndUuid: string,
branchSeCd?: string,
): Promise<PwrkCnsltnDocCrtRsltResponseDto> {
const response = await client.get<PwrkCnsltnDocCrtRsltResponseDto>(
`${BASE}/${crtDmndUuid}/latest`,
{
params: branchSeCd ? { branchSeCd } : undefined,
},
);
return response.data;
}
async function openWebHwpLatest(crtDmndUuid: string) {
async function findListByCrtDmndUuid(
crtDmndUuid: string,
branchSeCd?: string,
): Promise<PwrkCnsltnDocCrtRsltResponseDto[]> {
const response = await client.get<{ content: PwrkCnsltnDocCrtRsltResponseDto[] } | PwrkCnsltnDocCrtRsltResponseDto[]>(
BASE,
{
params: {
crtDmndUuid,
branchSeCd: branchSeCd || undefined,
page: 0,
size: 100,
sort: "verSn,desc",
},
},
);
if (Array.isArray(response.data)) {
return response.data;
}
return response.data?.content ?? [];
}
async function openWebHwpLatest(crtDmndUuid: string, branchSeCd?: string) {
return client.get<Blob>(`${BASE}/${crtDmndUuid}/webhwp/open`, {
params: branchSeCd ? { branchSeCd } : undefined,
responseType: "blob",
});
}
async function download(crtDmndUuid: string, verSn: number) {
async function openWebHwpByVer(
crtDmndUuid: string,
verSn: number,
branchSeCd?: string,
) {
return client.get<Blob>(`${BASE}/${crtDmndUuid}/${verSn}/webhwp/open`, {
params: branchSeCd ? { branchSeCd } : undefined,
responseType: "blob",
});
}
async function download(crtDmndUuid: string, verSn: number, branchSeCd?: string) {
return client.get<Blob>(`${BASE}/${crtDmndUuid}/${verSn}/download`, {
params: branchSeCd ? { branchSeCd } : undefined,
responseType: "blob",
});
}
async function downloadLatest(crtDmndUuid: string) {
async function downloadLatest(crtDmndUuid: string, branchSeCd?: string) {
return client.get<Blob>(`${BASE}/${crtDmndUuid}/download`, {
params: branchSeCd ? { branchSeCd } : undefined,
responseType: "blob",
});
}
@@ -47,6 +87,9 @@ async function saveMultipart(
if (saveDto.atchFileGroupId) {
formData.append("atchFileGroupId", saveDto.atchFileGroupId);
}
if (saveDto.branchSeCd) {
formData.append("branchSeCd", saveDto.branchSeCd);
}
if (file) {
const resolvedFileName = fileName ?? (file instanceof File ? file.name : "document.hwpx");
formData.append("file", file, resolvedFileName);
@@ -73,7 +116,9 @@ async function updateMultipart(
export const pwrkCnsltnDocCrtRsltService = {
findLatestByCrtDmndUuid,
findListByCrtDmndUuid,
openWebHwpLatest,
openWebHwpByVer,
download,
downloadLatest,
saveMultipart,
@@ -5,6 +5,7 @@ import type { PwrkCnsltnDocCrtRsltSaveRequestDto } from "./pwrk-cnsltn-doc-crt-r
export interface PwrkCnsltnDocCrtRsltResponseDto {
crtDmndUuid: string;
verSn: number;
branchSeCd?: string;
rgtrUuid?: string;
rgtrNm?: string;
orgnlOtptDocCn: null;
@@ -14,15 +15,32 @@ export interface PwrkCnsltnDocCrtRsltResponseDto {
/** 백엔드 PwrkCnsltnDocCrtRsltUpdateRequestDto (모두 optional) */
export interface PwrkCnsltnDocCrtRsltUpdateRequestDto {
rgtrUuid?: string;
branchSeCd?: 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>>;
findLatestByCrtDmndUuid(
crtDmndUuid: string,
branchSeCd?: string,
): Promise<PwrkCnsltnDocCrtRsltResponseDto>;
findListByCrtDmndUuid(
crtDmndUuid: string,
branchSeCd?: string,
): Promise<PwrkCnsltnDocCrtRsltResponseDto[]>;
openWebHwpLatest(crtDmndUuid: string, branchSeCd?: string): Promise<AxiosResponse<Blob>>;
openWebHwpByVer(
crtDmndUuid: string,
verSn: number,
branchSeCd?: string,
): Promise<AxiosResponse<Blob>>;
download(
crtDmndUuid: string,
verSn: number,
branchSeCd?: string,
): Promise<AxiosResponse<Blob>>;
downloadLatest(crtDmndUuid: string, branchSeCd?: string): Promise<AxiosResponse<Blob>>;
saveMultipart(
saveDto: PwrkCnsltnDocCrtRsltSaveRequestDto,
file?: Blob | File,
@@ -5,6 +5,7 @@ export const pwrkCnsltnDocCrtRsltSaveRequestSchema = z.object({
crtDmndUuid: z.string().uuid(),
pwrkCnsltnDocNo: z.string().min(1).max(100),
atchFileGroupId: z.string().max(30).nullish(),
branchSeCd: z.string().max(20).nullish(),
});
export type PwrkCnsltnDocCrtRsltSaveRequestDto = z.infer<
@@ -33,6 +33,8 @@ export function useSewerBscPlan(sewerBscPlanDocNo: string | null | undefined) {
queryKey: sewerBscPlanKeys.detail(sewerBscPlanDocNo!),
queryFn: () => sewerBscPlanService.findById(sewerBscPlanDocNo!),
enabled: Boolean(sewerBscPlanDocNo),
staleTime: 0,
refetchOnMount: "always",
});
}
@@ -44,6 +46,8 @@ export function useSewerBscPlanDetails(
queryKey: sewerBscPlanKeys.details(sewerBscPlanDocNo!, chgCycl ?? undefined),
queryFn: () => sewerBscPlanService.findDetails(sewerBscPlanDocNo!, chgCycl!),
enabled: Boolean(sewerBscPlanDocNo) && chgCycl != null,
staleTime: 0,
refetchOnMount: "always",
});
}
@@ -0,0 +1,23 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { RvwOpnnUpdateRequestDto } from "@/shared/model";
const BASE = ENDPOINT.AS_SERVICE.SEWER_BSC_PLAN_RVW_OPNNS;
async function update(
sewerBscPlanDocNo: string,
chgCycl: number,
rvwOpnnSn: number,
body: RvwOpnnUpdateRequestDto,
): Promise<void> {
await client.put<void>(`${BASE}/${sewerBscPlanDocNo}/${chgCycl}/${rvwOpnnSn}`, body);
}
async function deleteFn(sewerBscPlanDocNo: string, chgCycl: number, rvwOpnnSn: number): Promise<void> {
await client.delete<void>(`${BASE}/${sewerBscPlanDocNo}/${chgCycl}/${rvwOpnnSn}`);
}
export const sewerBscPlanRvwOpnnService = {
update,
delete: deleteFn,
};
+65 -64
View File
@@ -1,64 +1,65 @@
export { sewerBscPlanKeys } from "./api/queries";
export {
useDeleteSewerBscPlan,
useSaveApplySewerBscPlan,
useSaveSewerBscPlan,
useSewerBscPlan,
useSewerBscPlanDetails,
useSewerBscPlanPage,
useSubmitReqSupplementSewerBscPlan,
useSubmitSupplementSewerBscPlan,
useTechReviewSewerBscPlan,
useUpdateSewerBscPlan,
} from "./api/queries";
export { sewerBscPlanService } from "./api/sewer-bsc-plan-service";
export type {
CklstResultItem,
EsntlAtchFileItem,
SewerBscPlanApplySaveRequestDto,
SewerBscPlanBizCstItem,
SewerBscPlanDtlSaveRequestDto,
SewerBscPlanFnrsCstItem,
SewerBscPlanOtlnItem,
SewerBscPlanRvwOpnnItem,
SewerBscPlanSaveRequestDto,
SewerBscPlanSplmntDmndItem,
SewerBscPlanTmplntDtlItem,
SewerBscPlanTmplntItem,
SewerBscPlanTmplntTmprStrgItem,
} from "./model/sewer-bsc-plan-apply-payload-schema";
export {
sewerBscPlanApplySaveSchema,
sewerBscPlanBizCstItemSchema,
sewerBscPlanCklstResultItemSchema,
sewerBscPlanDtlSaveSchema,
sewerBscPlanEsntlAtchFileItemSchema,
sewerBscPlanFnrsCstItemSchema,
sewerBscPlanOtlnItemSchema,
sewerBscPlanRvwOpnnItemSchema,
sewerBscPlanSaveSchema,
sewerBscPlanSplmntDmndItemSchema,
sewerBscPlanTmplntDtlItemSchema,
sewerBscPlanTmplntItemSchema,
sewerBscPlanTmplntTmprStrgItemSchema,
} from "./model/sewer-bsc-plan-apply-payload-schema";
export type {
AtflMngClientResponseDto,
CklstRvwRsltTreeDto,
EsntlAtchFileMngTreeResponseDto,
SewerBscPlanApplySaveResponseDto,
SewerBscPlanBizCstResponseDto,
SewerBscPlanDetResponseDto,
SewerBscPlanDtlResponseDto,
SewerBscPlanFnrsCstResponseDto,
SewerBscPlanOtlnResponseDto,
SewerBscPlanResponseDto,
SewerBscPlanRvwOpnnResponseDto,
SewerBscPlanSearchRequestDto,
SewerBscPlanService,
SewerBscPlanSplmntDmndResponseDto,
SewerBscPlanTmplntDtlResponseDto,
SewerBscPlanTmplntResponseDto,
SewerBscPlanTmplntTmprStrgResponseDto,
SewerBscPlanUpdateRequestDto,
} from "./model/sewer-bsc-plan-interface";
export { sewerBscPlanKeys } from "./api/queries";
export {
useDeleteSewerBscPlan,
useSaveApplySewerBscPlan,
useSaveSewerBscPlan,
useSewerBscPlan,
useSewerBscPlanDetails,
useSewerBscPlanPage,
useSubmitReqSupplementSewerBscPlan,
useSubmitSupplementSewerBscPlan,
useTechReviewSewerBscPlan,
useUpdateSewerBscPlan,
} from "./api/queries";
export { sewerBscPlanRvwOpnnService } from "./api/sewer-bsc-plan-rvw-opnn-service";
export { sewerBscPlanService } from "./api/sewer-bsc-plan-service";
export type {
CklstResultItem,
EsntlAtchFileItem,
SewerBscPlanApplySaveRequestDto,
SewerBscPlanBizCstItem,
SewerBscPlanDtlSaveRequestDto,
SewerBscPlanFnrsCstItem,
SewerBscPlanOtlnItem,
SewerBscPlanRvwOpnnItem,
SewerBscPlanSaveRequestDto,
SewerBscPlanSplmntDmndItem,
SewerBscPlanTmplntDtlItem,
SewerBscPlanTmplntItem,
SewerBscPlanTmplntTmprStrgItem,
} from "./model/sewer-bsc-plan-apply-payload-schema";
export {
sewerBscPlanApplySaveSchema,
sewerBscPlanBizCstItemSchema,
sewerBscPlanCklstResultItemSchema,
sewerBscPlanDtlSaveSchema,
sewerBscPlanEsntlAtchFileItemSchema,
sewerBscPlanFnrsCstItemSchema,
sewerBscPlanOtlnItemSchema,
sewerBscPlanRvwOpnnItemSchema,
sewerBscPlanSaveSchema,
sewerBscPlanSplmntDmndItemSchema,
sewerBscPlanTmplntDtlItemSchema,
sewerBscPlanTmplntItemSchema,
sewerBscPlanTmplntTmprStrgItemSchema,
} from "./model/sewer-bsc-plan-apply-payload-schema";
export type {
AtflMngClientResponseDto,
CklstRvwRsltTreeDto,
EsntlAtchFileMngTreeResponseDto,
SewerBscPlanApplySaveResponseDto,
SewerBscPlanBizCstResponseDto,
SewerBscPlanDetResponseDto,
SewerBscPlanDtlResponseDto,
SewerBscPlanFnrsCstResponseDto,
SewerBscPlanOtlnResponseDto,
SewerBscPlanResponseDto,
SewerBscPlanRvwOpnnResponseDto,
SewerBscPlanSearchRequestDto,
SewerBscPlanService,
SewerBscPlanSplmntDmndResponseDto,
SewerBscPlanTmplntDtlResponseDto,
SewerBscPlanTmplntResponseDto,
SewerBscPlanTmplntTmprStrgResponseDto,
SewerBscPlanUpdateRequestDto,
} from "./model/sewer-bsc-plan-interface";
+183 -183
View File
@@ -1,183 +1,183 @@
"use client";
import { Fragment } from "react";
import { createColumnHelper } from "@tanstack/react-table";
import { dayjs } from "@/shared/lib";
import type { DesignCnstnMngResponseDto } from "@/entities/design-cnstn";
import { Badge } from "@common/components";
const columnHelper = createColumnHelper<DesignCnstnMngResponseDto>();
const MAIN_STEP_LABELS = {
apply: "설계자문 신청",
techReview: "기술검토",
reply: "검토결과 회신",
} as const;
export const PROGRESS_STATUS_COLUMNS = [
{ label: "설계자문 신청 (시군)", sttsComCd: "CT" },
{ label: "기술검토", sttsComCd: "CS" },
{ label: "기술검토 회신", sttsComCd: "CP" },
];
type BadgeState = "completed" | "current" | "pending";
interface ProgressBadge {
label: string;
state: BadgeState;
}
function buildProgressBadges(prgrsSttsComCd: string): ProgressBadge[] {
if (prgrsSttsComCd === "CV") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "CT" || prgrsSttsComCd === "CS" || prgrsSttsComCd === "SL") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.techReview, state: "current" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "CP") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.techReview, state: "completed" },
{ label: MAIN_STEP_LABELS.reply, state: "completed" },
];
}
return [
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
function getProgressStatusBadge(prgrsSttsComCd?: string | null) {
if (prgrsSttsComCd === "SL") {
return <Badge color="orange">보완요청</Badge>;
}
return null;
}
function getBadgeColor(state: BadgeState) {
switch (state) {
case "completed":
return "green";
case "current":
return "blue";
case "pending":
return "lightGray";
}
}
function getBadgeStateText(state: BadgeState) {
switch (state) {
case "completed":
return "완료";
case "current":
return "진행중";
case "pending":
return "대기";
}
}
/** 단계명을 2줄로 분리: "설치인가 신청" → ["설치인가", "신청"], "기술검토" → ["기술검토", ""] */
function splitStepLabel(label: string) {
const [title = "", ...rest] = label.split(" ");
return { title, detail: rest.join(" ") };
}
function formatOptional(value: string | null | undefined) {
return value?.trim() ? value : "-";
}
interface ProgressStepBadgeProps {
prgrsSttsComCd: string;
}
function ProgressStepBadge({ prgrsSttsComCd }: ProgressStepBadgeProps) {
const badges = buildProgressBadges(prgrsSttsComCd);
return (
<div className="flex flex-wrap items-center justify-start">
{badges.map((badge, index) => (
<Fragment key={`${badge.label}-${index}`}>
{index > 0 && <span className="mx-2">&gt;</span>}
{/* 1줄: 단계명 / 2줄: 세부단계 + 상태 (예: 설치인가 / 신청완료, 기술검토 / 완료) */}
<Badge
color={getBadgeColor(badge.state)}
className="flex-col justify-center !px-[20px] py-1 text-center leading-tight"
>
<span>{splitStepLabel(badge.label).title}</span>
<span>
{splitStepLabel(badge.label).detail}
{getBadgeStateText(badge.state)}
</span>
</Badge>
</Fragment>
))}
</div>
);
}
export function createDesignCnstnColumns() {
return [
columnHelper.accessor("sido", {
header: "시도",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("gugun", {
header: "시군구",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("bizNm", {
header: "사업명",
meta: { align: "left" },
cell: ({ row, getValue }) => (
<>
{getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())}
</>
),
}),
columnHelper.accessor("taskSeCdNm", {
header: "업무구분",
meta: { align: "center" },
cell: ({ row }) => formatOptional(row.original.taskSeCdNm || row.original.taskSeComCd),
}),
columnHelper.accessor("aplcntNm", {
header: "담당자",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("envoSeCdNm", {
header: "해당기관",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.display({
id: "progress",
header: "진행상황",
meta: { align: "center" },
cell: ({ row }) => <ProgressStepBadge prgrsSttsComCd={row.original.prgrsSttsComCd || ""} />,
}),
columnHelper.accessor("regDt", {
header: "신청일",
meta: { align: "center" },
cell: ({ getValue }) => {
const value = getValue();
return value ? dayjs(value).format("YYYY-MM-DD") : "-";
},
}),
];
}
"use client";
import { Fragment } from "react";
import { createColumnHelper } from "@tanstack/react-table";
import { dayjs } from "@/shared/lib";
import type { DesignCnstnMngResponseDto } from "@/entities/design-cnstn";
import { Badge } from "@common/components";
const columnHelper = createColumnHelper<DesignCnstnMngResponseDto>();
const MAIN_STEP_LABELS = {
apply: "설계자문 신청",
techReview: "기술검토",
reply: "검토결과 회신",
} as const;
export const PROGRESS_STATUS_COLUMNS = [
{ label: "설계자문 신청 (시군)", sttsComCd: "CT" },
{ label: "기술검토", sttsComCd: "CS" },
{ label: "기술검토 회신", sttsComCd: "CP" },
];
type BadgeState = "completed" | "current" | "pending";
interface ProgressBadge {
label: string;
state: BadgeState;
}
function buildProgressBadges(prgrsSttsComCd: string): ProgressBadge[] {
if (prgrsSttsComCd === "CV") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "CT" || prgrsSttsComCd === "CS" || prgrsSttsComCd === "SL") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.techReview, state: "current" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "CP") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.techReview, state: "completed" },
{ label: MAIN_STEP_LABELS.reply, state: "completed" },
];
}
return [
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
function getProgressStatusBadge(prgrsSttsComCd?: string | null) {
if (prgrsSttsComCd === "SL") {
return <Badge color="orange">보완요청</Badge>;
}
return null;
}
function getBadgeColor(state: BadgeState) {
switch (state) {
case "completed":
return "green";
case "current":
return "blue";
case "pending":
return "lightGray";
}
}
function getBadgeStateText(state: BadgeState) {
switch (state) {
case "completed":
return "완료";
case "current":
return "진행중";
case "pending":
return "대기";
}
}
/** 단계명을 2줄로 분리: "설치인가 신청" → ["설치인가", "신청"], "기술검토" → ["기술검토", ""] */
function splitStepLabel(label: string) {
const [title = "", ...rest] = label.split(" ");
return { title, detail: rest.join(" ") };
}
function formatOptional(value: string | null | undefined) {
return value?.trim() ? value : "-";
}
interface ProgressStepBadgeProps {
prgrsSttsComCd: string;
}
function ProgressStepBadge({ prgrsSttsComCd }: ProgressStepBadgeProps) {
const badges = buildProgressBadges(prgrsSttsComCd);
return (
<div className="flex flex-wrap items-center justify-center">
{badges.map((badge, index) => (
<Fragment key={`${badge.label}-${index}`}>
{index > 0 && <span className="mx-2">&gt;</span>}
{/* 1줄: 단계명 / 2줄: 세부단계 + 상태 (예: 설치인가 / 신청완료, 기술검토 / 완료) */}
<Badge
color={getBadgeColor(badge.state)}
className="w-[100px] flex-col justify-center !px-0 py-1 text-center leading-tight"
>
<span>{splitStepLabel(badge.label).title}</span>
<span>
{splitStepLabel(badge.label).detail}
{getBadgeStateText(badge.state)}
</span>
</Badge>
</Fragment>
))}
</div>
);
}
export function createDesignCnstnColumns() {
return [
columnHelper.accessor("sido", {
header: "시도",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("gugun", {
header: "시군구",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("bizNm", {
header: "사업명",
meta: { align: "left" },
cell: ({ row, getValue }) => (
<>
{getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())}
</>
),
}),
columnHelper.accessor("taskSeCdNm", {
header: "업무구분",
meta: { align: "center" },
cell: ({ row }) => formatOptional(row.original.taskSeCdNm || row.original.taskSeComCd),
}),
columnHelper.accessor("aplcntNm", {
header: "담당자",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("envoSeCdNm", {
header: "해당기관",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.display({
id: "progress",
header: "진행상황",
meta: { align: "center" },
cell: ({ row }) => <ProgressStepBadge prgrsSttsComCd={row.original.prgrsSttsComCd || ""} />,
}),
columnHelper.accessor("regDt", {
header: "신청일",
meta: { align: "center" },
cell: ({ getValue }) => {
const value = getValue();
return value ? dayjs(value).format("YYYY-MM-DD") : "-";
},
}),
];
}
@@ -2,13 +2,15 @@
import { useMemo, useRef } from "react";
import { RefreshCw } from "lucide-react";
import { useUserStore } from "@/shared/lib";
import { SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
import { useDesignCnstnMng } from "@/entities/design-cnstn";
import { ElaprDocPrgrsStatus } from "@/entities/elapr";
import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw";
import { AttachmentsViewSection, ProgressStatusSection } from "@/widgets/pbadms-sprt";
import { Skeleton } from "@common/components";
import { Button, cn, Skeleton } from "@common/components";
import { needsRoleUser01AtrzResolve } from "../lib/button-config";
import { getEditableReviewStep } from "../lib/review-step-utils";
@@ -30,7 +32,7 @@ interface DesignViewPageProps {
function DesignViewPage({ id }: DesignViewPageProps) {
const userInfo = useUserStore((state) => state.userInfo);
const techReviewOpinionRef = useRef<TechReviewOpinionSectionHandle>(null);
const { data, isLoading, isError } = useDesignCnstnMng(id);
const { data, isLoading, isError, refetch, isFetching } = useDesignCnstnMng(id);
const prgrsSttsComCd = data?.detDto?.prgrsSttsComCd || "";
const needsAtrzResolve = needsRoleUser01AtrzResolve(userInfo?.dtlInstCd, prgrsSttsComCd);
@@ -83,8 +85,20 @@ function DesignViewPage({ id }: DesignViewPageProps) {
return (
<div className="mt-4">
<SectionCollapsibleGroup>
<div className="sticky top-[6rem] z-30 bg-container px-4 py-2">
<div className="sticky top-[6rem] z-30 flex items-center gap-3 bg-container px-4 py-2">
<SectionCollapsibleToggle />
<Button
type="button"
variant="outline"
size="xs"
onClick={() => refetch()}
disabled={isFetching}
className="flex items-center gap-1.5 h-[2.8rem] px-3 text-[1.2rem] font-normal"
title="최신 데이터 새로고침"
>
<RefreshCw className={cn("size-3.5", isFetching && "animate-spin")} />
<span>새로 고침</span>
</Button>
</div>
<div className="space-y-4">
<ProgressStatusSection
@@ -8,6 +8,7 @@ import { SectionCollapsible } from "@/shared/ui";
import { Field, FieldError, FieldGroup } from "@/shared/ui/field";
import { findCodeLabel, useCodeList } from "@/entities/code";
import { type BnsApplyData, BnsPickerDialog } from "@/widgets/bns-picker";
import { PreviousPlanLookupButton } from "@/widgets/previous-plan-lookup";
import { Input, Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
import type { DesignCnstnRegistFormData } from "../../lib/design-cnstn-regist-schema";
@@ -16,9 +17,9 @@ function BasicInfoFormSection() {
const { control, watch, setValue } = useFormContext<DesignCnstnRegistFormData>();
const [isPickerOpen, setIsPickerOpen] = useState(false);
const taskSeComCd = watch("designCnstnMng.taskSeComCd");
const localGovLabel = [watch("designCnstnMng.sido"), watch("designCnstnMng.gugun")]
.filter(Boolean)
.join(" ");
const sido = watch("designCnstnMng.sido");
const gugun = watch("designCnstnMng.gugun");
const localGovLabel = [sido, gugun].filter(Boolean).join(" ");
const { data: taskOptions } = useCodeList("CNFM");
function handleBnsSelect(data: BnsApplyData) {
@@ -36,6 +37,7 @@ function BasicInfoFormSection() {
return (
<SectionCollapsible title="기본 정보">
<PreviousPlanLookupButton sidoNm={sido} gugunNm={gugun} />
<Table caption="기본 정보 입력으로 사업명, 사업기간, 사업내용, 구분, 신청 지자체 정보 제공" variant="secondary">
<colgroup>
<col width="15%" />
@@ -2,6 +2,7 @@
import { SectionCollapsible } from "@/shared/ui";
import type { DesignCnstnMngResponseDto } from "@/entities/design-cnstn";
import { PreviousPlanLookupButton } from "@/widgets/previous-plan-lookup";
import { Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
interface BasicInfoSectionProps {
@@ -11,6 +12,7 @@ interface BasicInfoSectionProps {
function BasicInfoSection({ det }: BasicInfoSectionProps) {
return (
<SectionCollapsible title="기본 정보">
<PreviousPlanLookupButton sidoNm={det.sido} gugunNm={det.gugun} />
<Table caption="기본 정보로 사업명, 사업기간, 사업내용, 환경청구분, 구분, 신청지자체 정보 제공" variant="secondary">
<colgroup>
<col width="15%" />
@@ -1,338 +1,334 @@
"use client";
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
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 { AttachmentDownloadLink, attachService } from "@/entities/attach";
import type { DesignCnstnRvwOpnnResponseDto } from "@/entities/design-cnstn";
import {
Button,
RadioGroup,
RadioGroupItem,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Textarea,
} from "@common/components";
import type {
ReviewOpinionFormData,
ReviewOpinionGetFormDataResult,
} from "../../lib/review-opinion-utils";
import type { ReviewStepCode } from "../../model/view-types";
import { REVIEW_STEP_LABELS } from "../../model/view-types";
const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
interface TechReviewOpinionSectionProps {
rvwList: DesignCnstnRvwOpnnResponseDto[];
editableReviewStep?: ReviewStepCode;
atrzSeComCdList?: string[];
isAtrzLoading?: boolean;
}
export interface TechReviewOpinionSectionHandle {
getFormData: () => ReviewOpinionGetFormDataResult | null;
resetForm: () => void;
}
interface TechReviewOpinionTableProps {
items: DesignCnstnRvwOpnnResponseDto[];
}
interface UploadedOpinionFile {
fileName: string;
fileKey: string;
atflId?: string;
atflCd?: string;
}
const EMPTY_FORM_DATA: ReviewOpinionFormData = {
rvwRsltCn: "",
rvwRsltAnmlyYn: "",
atchFileGroupId: "",
opnrSeComCd: "",
};
function createRvwOpnnUploadKey(file: File) {
return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
}
function hasFormContent(formData: ReviewOpinionFormData) {
return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
}
const TechReviewOpinionSection = forwardRef<
TechReviewOpinionSectionHandle,
TechReviewOpinionSectionProps
>(function TechReviewOpinionSection(
{ rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false },
ref,
) {
const canWriteOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
const [formData, setFormData] = useState<ReviewOpinionFormData>({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null);
useEffect(() => {
setFormData((prev) => ({
...prev,
opnrSeComCd: editableReviewStep ?? "",
}));
}, [editableReviewStep]);
const { uploadFileAsync, isUploading, uploads } = useTus({
onSuccess: (atflCd, atflId, _file, fileKey) => {
if (!atflCd || !atflId) return;
setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd }));
setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
},
onError: (_error, _file, fileKey) => {
setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
},
});
useImperativeHandle(
ref,
() => ({
getFormData: () => {
if (!canWriteOpinion || !editableReviewStep || !hasFormContent(formData)) {
return null;
}
return {
formData: {
...formData,
opnrSeComCd: editableReviewStep,
},
rvwList,
};
},
resetForm: () => {
setFormData({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
setUploadedFile(null);
},
}),
[canWriteOpinion, editableReviewStep, formData, rvwList],
);
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
const hasKecoSection =
kecoRvwList.length > 0 || (editableReviewStep === "KECO" && canWriteOpinion);
if (!hasKecoSection) {
return null;
}
async function handleFileUpload(files: FileList | null) {
if (!files || files.length === 0) return;
const file = files[0];
const fileKey = createRvwOpnnUploadKey(file);
const existingAtflCd = formData.atchFileGroupId;
setUploadedFile({ fileName: file.name, fileKey });
try {
await uploadFileAsync(file, {
fileKey,
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
});
} catch {
// onError callback handles cleanup
}
}
async function handleFileRemove() {
if (!uploadedFile) return;
uploads[uploadedFile.fileKey]?.abort();
if (uploadedFile.atflId) {
try {
await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
} catch (error) {
console.error("검토의견 첨부파일 삭제 실패:", error);
}
}
setUploadedFile(null);
setFormData((prev) => ({ ...prev, atchFileGroupId: "" }));
}
function renderOpinionForm(reviewStep: ReviewStepCode) {
if (editableReviewStep !== reviewStep || !canWriteOpinion) return null;
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
return (
<div className="space-y-2">
<h4 className="text-xl font-semibold">검토의견 작성</h4>
<Table caption="검토의견 작성으로 의견내용, 검토결과, 첨부파일 정보 제공" variant="secondary">
<TableHeader>
<TableRow>
<TableHead scope="row" className="w-[12rem]">의견내용</TableHead>
<TableCell>
<Textarea
value={formData.rvwRsltCn}
onChange={(event) =>
setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value }))
}
placeholder="의견내용을 입력하세요"
rows={4}
className="min-h-[8rem] !text-xl"
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead scope="row">검토결과</TableHead>
<TableCell>
<FieldSet>
<RadioGroup
value={formData.rvwRsltAnmlyYn}
onValueChange={(value) =>
setFormData((prev) => ({
...prev,
rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"],
}))
}
>
<div className="flex flex-wrap gap-4">
<FieldLabel
htmlFor={`rvw-opnn-normal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} />
<span className="font-normal">정상</span>
</Field>
</FieldLabel>
<FieldLabel
htmlFor={`rvw-opnn-abnormal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} />
<span className="font-normal">이상</span>
</Field>
</FieldLabel>
</div>
</RadioGroup>
</FieldSet>
</TableCell>
</TableRow>
<TableRow>
<TableHead scope="row">첨부파일</TableHead>
<TableCell>
{uploadedFile ? (
<div className="flex items-center gap-2">
<TextShimmer className="text-xl" active={uploading}>
{uploadedFile.fileName}
</TextShimmer>
<Button
size="icon"
onClick={(e) => {
e.preventDefault();
void handleFileRemove();
}}
disabled={uploading}
className="size-8 !bg-destructive"
>
<i className="diveicon di-x text-xs" aria-label="삭제" />
</Button>
</div>
) : (
<label>
<input
type="file"
className="hidden"
onChange={(event) => {
void handleFileUpload(event.target.files);
event.target.value = "";
}}
/>
<Button size="sm" variant="outline" type="button" asChild>
<span>파일 선택</span>
</Button>
</label>
)}
</TableCell>
</TableRow>
</TableHeader>
</Table>
</div>
);
}
return (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
<div className="space-y-6">
{kecoRvwList.length > 0 ? <TechReviewOpinionTable items={kecoRvwList} /> : null}
{renderOpinionForm("KECO")}
</div>
</SectionCollapsible>
);
});
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
return (
<div className="overflow-x-auto">
<Table caption="검토의견 목록으로 의견자명, 의견내용, 검토결과, 첨부파일, 등록일시 정보 제공" variant="secondary">
<colgroup>
<col width="10%" />
<col width="55%" />
<col width="5%" />
<col width="20%" />
<col width="10%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>의견자명</TableHead>
<TableHead>의견내용</TableHead>
<TableHead>검토결과</TableHead>
<TableHead>첨부파일</TableHead>
<TableHead>등록일시</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={`${item.designCnstnDocNo}_${item.rvwOpnnSn}`}>
<TableCell align="center">{item.opnrNm}</TableCell>
<TableCell align="left">{item.rvwRsltCn || "-"}</TableCell>
<TableCell align="center">
{item.rvwRsltAnmlyYn === "Y" ? "이상" : item.rvwRsltAnmlyYn === "N" ? "정상" : "-"}
</TableCell>
<TableCell align="center">
{item.atchFileInfo?.atflCd ? (
<AttachmentDownloadLink
atflCd={item.atchFileInfo.atflCd}
fileName={item.atchFileInfo.orgnlFileNm || "다운로드"}
/>
) : (
"-"
)}
</TableCell>
<TableCell align="center">
{item.regDt ? dayjs(item.regDt).format("YYYY-MM-DD HH:mm:ss") : "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
export { TechReviewOpinionSection };
"use client";
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { getDefaultFileKey, useTus, useUserStore } 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 {
designCnstnKeys,
type DesignCnstnRvwOpnnResponseDto,
designCnstnRvwOpnnService,
} from "@/entities/design-cnstn";
import { RvwOpnnListTable } from "@/widgets/pbadms-sprt";
import {
Button,
RadioGroup,
RadioGroupItem,
Table,
TableCell,
TableHead,
TableHeader,
TableRow,
Textarea,
} from "@common/components";
import type {
ReviewOpinionFormData,
ReviewOpinionGetFormDataResult,
} from "../../lib/review-opinion-utils";
import type { ReviewStepCode } from "../../model/view-types";
import { REVIEW_STEP_LABELS } from "../../model/view-types";
const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
interface TechReviewOpinionSectionProps {
rvwList: DesignCnstnRvwOpnnResponseDto[];
editableReviewStep?: ReviewStepCode;
atrzSeComCdList?: string[];
isAtrzLoading?: boolean;
}
export interface TechReviewOpinionSectionHandle {
getFormData: () => ReviewOpinionGetFormDataResult | null;
resetForm: () => void;
}
interface TechReviewOpinionTableProps {
items: DesignCnstnRvwOpnnResponseDto[];
}
interface UploadedOpinionFile {
fileName: string;
fileKey: string;
atflId?: string;
atflCd?: string;
}
const EMPTY_FORM_DATA: ReviewOpinionFormData = {
rvwRsltCn: "",
rvwRsltAnmlyYn: "",
atchFileGroupId: "",
opnrSeComCd: "",
};
function createRvwOpnnUploadKey(file: File) {
return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
}
function hasFormContent(formData: ReviewOpinionFormData) {
return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
}
const TechReviewOpinionSection = forwardRef<
TechReviewOpinionSectionHandle,
TechReviewOpinionSectionProps
>(function TechReviewOpinionSection(
{ rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false },
ref,
) {
const canWriteOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
const [formData, setFormData] = useState<ReviewOpinionFormData>({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null);
useEffect(() => {
setFormData((prev) => ({
...prev,
opnrSeComCd: editableReviewStep ?? "",
}));
}, [editableReviewStep]);
const { uploadFileAsync, isUploading, uploads } = useTus({
onSuccess: (atflCd, atflId, _file, fileKey) => {
if (!atflCd || !atflId) return;
setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd }));
setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
},
onError: (_error, _file, fileKey) => {
setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
},
});
useImperativeHandle(
ref,
() => ({
getFormData: () => {
if (!canWriteOpinion || !editableReviewStep || !hasFormContent(formData)) {
return null;
}
return {
formData: {
...formData,
opnrSeComCd: editableReviewStep,
},
rvwList,
};
},
resetForm: () => {
setFormData({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
setUploadedFile(null);
},
}),
[canWriteOpinion, editableReviewStep, formData, rvwList],
);
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
const hasKecoSection =
kecoRvwList.length > 0 || (editableReviewStep === "KECO" && canWriteOpinion);
if (!hasKecoSection) {
return null;
}
async function handleFileUpload(files: FileList | null) {
if (!files || files.length === 0) return;
const file = files[0];
const fileKey = createRvwOpnnUploadKey(file);
const existingAtflCd = formData.atchFileGroupId;
setUploadedFile({ fileName: file.name, fileKey });
try {
await uploadFileAsync(file, {
fileKey,
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
});
} catch {
// onError callback handles cleanup
}
}
async function handleFileRemove() {
if (!uploadedFile) return;
uploads[uploadedFile.fileKey]?.abort();
if (uploadedFile.atflId) {
try {
await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
} catch (error) {
console.error("검토의견 첨부파일 삭제 실패:", error);
}
}
setUploadedFile(null);
setFormData((prev) => ({ ...prev, atchFileGroupId: "" }));
}
function isReviewTurn(reviewStep: ReviewStepCode) {
return editableReviewStep === reviewStep && canWriteOpinion;
}
function renderOpinionForm(reviewStep: ReviewStepCode) {
if (!isReviewTurn(reviewStep)) return null;
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
return (
<div className="space-y-2">
<h4 className="text-xl font-semibold">검토의견 작성</h4>
<Table
caption="검토의견 작성으로 의견내용, 검토결과, 첨부파일 정보 제공"
variant="secondary"
>
<TableHeader>
<TableRow>
<TableHead scope="row" className="w-[12rem]">
의견내용
</TableHead>
<TableCell>
<Textarea
value={formData.rvwRsltCn}
onChange={(event) =>
setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value }))
}
placeholder="의견내용을 입력하세요"
rows={4}
className="min-h-[8rem] !text-xl"
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead scope="row">검토결과</TableHead>
<TableCell>
<FieldSet>
<RadioGroup
value={formData.rvwRsltAnmlyYn}
onValueChange={(value) =>
setFormData((prev) => ({
...prev,
rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"],
}))
}
>
<div className="flex flex-wrap gap-4">
<FieldLabel
htmlFor={`rvw-opnn-normal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} />
<span className="font-normal">정상</span>
</Field>
</FieldLabel>
<FieldLabel
htmlFor={`rvw-opnn-abnormal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} />
<span className="font-normal">이상</span>
</Field>
</FieldLabel>
</div>
</RadioGroup>
</FieldSet>
</TableCell>
</TableRow>
<TableRow>
<TableHead scope="row">첨부파일</TableHead>
<TableCell>
{uploadedFile ? (
<div className="flex items-center gap-2">
<TextShimmer className="text-xl" active={uploading}>
{uploadedFile.fileName}
</TextShimmer>
<Button
size="icon"
onClick={(e) => {
e.preventDefault();
void handleFileRemove();
}}
disabled={uploading}
className="size-8 !bg-destructive"
>
<i className="diveicon di-x text-xs" aria-label="삭제" />
</Button>
</div>
) : (
<label>
<input
type="file"
className="hidden"
onChange={(event) => {
void handleFileUpload(event.target.files);
event.target.value = "";
}}
/>
<Button size="sm" variant="outline" type="button" asChild>
<span>파일 선택</span>
</Button>
</label>
)}
</TableCell>
</TableRow>
</TableHeader>
</Table>
</div>
);
}
return (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
<div className="space-y-6">
{kecoRvwList.length > 0 ? (
<TechReviewOpinionTable items={kecoRvwList} />
) : null}
{renderOpinionForm("KECO")}
</div>
</SectionCollapsible>
);
});
function getRvwOpnnIds(item: DesignCnstnRvwOpnnResponseDto) {
if (!item.designCnstnDocNo || item.rvwOpnnSn == null) {
throw new Error("검토의견 식별값이 없습니다.");
}
return [item.designCnstnDocNo, item.rvwOpnnSn] as const;
}
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
const queryClient = useQueryClient();
const currentUserUuid = useUserStore((state) => state.userInfo?.userUnqId);
return (
<RvwOpnnListTable
items={items}
getRowKey={(item) => `${item.designCnstnDocNo}_${item.rvwOpnnSn}`}
currentUserUuid={currentUserUuid}
onUpdate={async (item, body) => {
await designCnstnRvwOpnnService.update(...getRvwOpnnIds(item), body);
await queryClient.invalidateQueries({ queryKey: designCnstnKeys.all });
}}
onDelete={async (item) => {
await designCnstnRvwOpnnService.delete(...getRvwOpnnIds(item));
await queryClient.invalidateQueries({ queryKey: designCnstnKeys.all });
}}
/>
);
}
export { TechReviewOpinionSection };
@@ -51,6 +51,19 @@ export function createEsntlAtchFileColumns({
meta: { align: "center" },
cell: ({ getValue }) => formatYn(getValue()),
}),
columnHelper.accessor("esntlAtchFileRvwCn", {
header: "필수파일검토내용",
meta: { align: "left" },
cell: ({ getValue }) => {
const val = getValue();
if (!val?.trim()) return "-";
return (
<span className="line-clamp-2 max-w-[20rem] text-left" title={val}>
{val}
</span>
);
},
}),
columnHelper.accessor("esntlAtchFileRmrk", {
header: "비고",
meta: { align: "center" },
@@ -26,6 +26,7 @@ export interface EsntlAtchFileFormValues {
docSeComCd: string;
esntlAtchFileNm: string;
esntlAtchYn: "Y" | "N";
esntlAtchFileRvwCn: string;
esntlAtchFileRmrk: string;
atchFileGroupId: string;
}
@@ -68,6 +69,7 @@ function EsntlAtchFileFormDialog({
docSeComCd: "",
esntlAtchFileNm: "",
esntlAtchYn: "Y",
esntlAtchFileRvwCn: "",
esntlAtchFileRmrk: "",
atchFileGroupId: "",
},
@@ -115,6 +117,7 @@ function EsntlAtchFileFormDialog({
docSeComCd: initial.docSeComCd ?? "",
esntlAtchFileNm: initial.esntlAtchFileNm ?? "",
esntlAtchYn: initial.esntlAtchYn ?? "Y",
esntlAtchFileRvwCn: initial.esntlAtchFileRvwCn ?? "",
esntlAtchFileRmrk: initial.esntlAtchFileRmrk ?? "",
atchFileGroupId: initial.atchFileGroupId ?? "",
});
@@ -127,6 +130,7 @@ function EsntlAtchFileFormDialog({
docSeComCd: "",
esntlAtchFileNm: "",
esntlAtchYn: "Y",
esntlAtchFileRvwCn: "",
esntlAtchFileRmrk: "",
atchFileGroupId: "",
});
@@ -249,6 +253,17 @@ function EsntlAtchFileFormDialog({
</div>
</RadioGroup>
</div>
<div className="space-y-2">
<Label htmlFor="esntlAtchFileRvwCn">필수파일검토내용</Label>
<Textarea
id="esntlAtchFileRvwCn"
rows={3}
placeholder="검토내용을 입력하세요"
maxLength={4000}
{...register("esntlAtchFileRvwCn")}
className="!text-xl"
/>
</div>
<div className="space-y-2">
<Label htmlFor="esntlAtchFileRmrk">비고</Label>
<Textarea
@@ -100,7 +100,7 @@ function EsntlAtchFileList({
manualPagination: true,
},
dataTableOptions: {
caption: "필수첨부파일 목록으로 필수첨부파일아이디, 문서구분, 필수첨부파일명, 필수첨부여부, 비고, 양식 내려받기, 관리 정보 제공",
caption: "필수첨부파일 목록으로 필수첨부파일아이디, 문서구분, 필수첨부파일명, 필수첨부여부, 필수파일검토내용, 비고, 양식 내려받기, 관리 정보 제공",
viewPagination: true,
},
});
@@ -112,6 +112,7 @@ function EsntlAtchFileList({
docSeComCd: values.docSeComCd,
esntlAtchFileNm: values.esntlAtchFileNm,
esntlAtchYn: values.esntlAtchYn,
esntlAtchFileRvwCn: values.esntlAtchFileRvwCn || undefined,
esntlAtchFileRmrk: values.esntlAtchFileRmrk || undefined,
atchFileGroupId: values.atchFileGroupId || undefined,
};
@@ -188,16 +188,149 @@ const ROLE_USER01_ATRZ_STATUS_ACTIONS: Record<
},
};
/**
* Fork-Join (병렬 결재 6인 체제: 관로2 + 시설2 + 정책병합2) 전용 상태별 버튼 액션 매핑
* - Stage 2: 관로/시설 실무검토자(ATRZ02), 부서승인자(ATRZ03)
* - Stage 3: 정책총괄 종합병합자(ATRZ04), 최종회신자(ATRZ99)
* - 일반 순차 결재 라인과 분리하여 독립적으로 관리
*/
const ROLE_USER01_FORK_JOIN_ATRZ_STATUS_ACTIONS: Record<
string,
Record<string, FnrsCnsltnViewButtonAction[]>
> = {
ER: {
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],
ATRZ04: [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.pwrkCnsltnDocMng,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzRply,
],
ATRZ04: [
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],
ATRZ04: [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],
ATRZ04: [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],
ATRZ04: [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],
ATRZ04: [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],
ATRZ04: [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.atrzRply,
],
ATRZ04: [
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],
ATRZ04: [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],
ATRZ04: [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],
ATRZ04: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
ATRZ99: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
},
};
function resolveButtonConfigs(actions: FnrsCnsltnViewButtonAction[]): FnrsCnsltnViewButtonAction[] {
return actions.map((action) => FNRS_CNSLTN_VIEW_BUTTON_ACTION[action]);
}
interface GetButtonConfigsOptions {
export interface GetButtonConfigsOptions {
hasPendingApprovalRequest?: boolean;
hasCompletedDraft?: boolean;
canRetryFileVrfc?: boolean;
canRetryDraftGen?: boolean;
canRetryCostVrfc?: boolean;
isForkJoin?: boolean;
branchSeCd?: "SP" | "SF" | "TOTAL_AGG" | "MAIN" | string;
branchSttsCd?: "WAITING" | "IN_PROGRESS" | "COMPLETED" | "SUPPLEMENT" | "REJECTED" | string;
isSecondApproval?: boolean;
}
const PENDING_APPROVAL_ACTIONS = new Set<FnrsCnsltnViewButtonAction>([
@@ -214,7 +347,35 @@ function resolveRoleUser01AtrzButtonActions(
atrzSeComCdList: string[],
options?: GetButtonConfigsOptions,
): FnrsCnsltnViewButtonAction[] {
const atrzStatusMap = ROLE_USER01_ATRZ_STATUS_ACTIONS[prgrsSttsComCd];
let effectiveStatus = prgrsSttsComCd;
if (options?.isForkJoin && options?.branchSttsCd) {
switch (options.branchSttsCd) {
case "SUPPLEMENT":
// 본인 브랜치가 보완요청 중이면 'SL' 맵핑 적용 (검토/승인 버튼 숨김, 실무협의서관리만 노출)
effectiveStatus = "SL";
break;
case "IN_PROGRESS": {
// 본인 브랜치가 정상 진행 중이면 원천 상태('TE' 또는 'RK') 정상 적용!
// 2차 결재 중이거나(isSecondApproval) 원천 상태가 'RK'이면 'RK', 1차 검토이면 'TE'
const isSecondReview = Boolean(options?.isSecondApproval || prgrsSttsComCd === "RK");
effectiveStatus = isSecondReview ? "RK" : "TE";
break;
}
case "COMPLETED": {
// 본인 브랜치 승인 완료 후 타 브랜치 대기 중이면 조회 전용 맵 적용
const isSecondReview = Boolean(options?.isSecondApproval || prgrsSttsComCd === "RK");
effectiveStatus = isSecondReview ? "RR" : "TP";
break;
}
case "WAITING":
return [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng];
}
}
const atrzStatusMap = options?.isForkJoin
? ROLE_USER01_FORK_JOIN_ATRZ_STATUS_ACTIONS[effectiveStatus]
: ROLE_USER01_ATRZ_STATUS_ACTIONS[effectiveStatus];
if (!atrzStatusMap || atrzSeComCdList.length === 0) return [];
const seen = new Set<FnrsCnsltnViewButtonAction>();
@@ -232,6 +393,11 @@ function resolveRoleUser01AtrzButtonActions(
}
return actions.filter((action) => {
// 2차 결재(조치결과서 검토) 단계에서는 '검토 결과 조회' 버튼 비활성화 (1차 기술검토 전용)
if (options?.isSecondApproval && action === FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRsltInq) {
return false;
}
const needsPendingApproval = PENDING_APPROVAL_ACTIONS.has(action);
if (needsPendingApproval && options?.hasPendingApprovalRequest === false) return false;
@@ -249,12 +415,12 @@ function resolveRoleUser01AtrzButtonActions(
}
if (options?.hasCompletedDraft != null) {
if (prgrsSttsComCd === "SL" || prgrsSttsComCd === "TE" || prgrsSttsComCd === "RK") {
if (effectiveStatus === "SL" || effectiveStatus === "TE" || effectiveStatus === "RK") {
if (action === FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng) {
return options.hasCompletedDraft;
}
}
if (prgrsSttsComCd === "TE") {
if (effectiveStatus === "TE") {
if (action === FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRsltInq) {
return options.hasCompletedDraft;
}
@@ -1,3 +1,4 @@
import type { BranchSeCd } from "@/entities/elapr";
import type { CklstResultItem, CklstRvwRsltTreeDto } from "@/entities/fnrs-cnsltn";
import {
@@ -142,3 +143,108 @@ export function toCklstSaveDto(
};
});
}
/**
* 3. 결재자 branch_se_cd 기반 체크리스트 독립 Validation 유틸리티 (10월 14일 개정)
* 결재자의 branch_se_cd('SP', 'SF', 'TOTAL_AGG', 'MAIN')에 따라 해당 탭 항목만 검증합니다.
*/
export function validateChecklistByBranch(
checklistItems: {
taskSeComCd: string;
bchckUseYn: string;
selected?: string; // 'Y' | 'N'
picRvwCn?: string; // 검토자의견
cklstRvwItemNm: string;
cklstRvwSn: number;
}[],
approverBranchSeCd: BranchSeCd,
docTaskSeComCd: string,
): { isValid: boolean; errorMessage?: string; errorItemSn?: number } {
// TOTAL_AGG(종합병합): 관로/시설 병렬 검토 완료 후 총괄취합 단계이므로 체크리스트 검증 생략
if (approverBranchSeCd === "TOTAL_AGG") {
return { isValid: true };
}
let targetTaskCodes: string[] = [];
if (approverBranchSeCd === "SP") {
targetTaskCodes = ["SP"]; // 관로부: SP 탭만 검증 (시설부 미작성 무관)
} else if (approverBranchSeCd === "SF") {
targetTaskCodes = ["SF"]; // 시설부: SF 탭만 검증 (관로부 미작성 무관)
} else {
// 단일/순차 결재 (MAIN 등): 콤마 분리, 또는 "MAIN"/누락 시 체크리스트 실제 공종 전체 자동 추출
const parsedCodes =
docTaskSeComCd && docTaskSeComCd !== "MAIN"
? docTaskSeComCd.split(",").map((s) => s.trim()).filter(Boolean)
: [];
targetTaskCodes =
parsedCodes.length > 0
? parsedCodes
: Array.from(new Set(checklistItems.map((i) => i.taskSeComCd).filter(Boolean)));
}
const activeItems = checklistItems.filter(
(item) => targetTaskCodes.includes(item.taskSeComCd) && item.bchckUseYn === "Y",
);
for (const item of activeItems) {
const taskName =
item.taskSeComCd === "SP"
? "하수관로"
: item.taskSeComCd === "RE"
? "재이용수"
: "하수처리시설";
// 1. 적합/부적합 미선택 검증
if (!item.selected || !["Y", "N"].includes(item.selected)) {
return {
isValid: false,
errorMessage: `[${taskName}] 체크리스트 '${item.cklstRvwItemNm}' 항목의 적합 여부를 선택해주세요.`,
errorItemSn: item.cklstRvwSn,
};
}
// 2. 부적합('N') 시 검토자 검토의견 필수 검증
if (item.selected === "N" && (!item.picRvwCn || item.picRvwCn.trim() === "")) {
return {
isValid: false,
errorMessage: `[${taskName}] 체크리스트 '${item.cklstRvwItemNm}' 항목이 '부적합'입니다. 검토자 검토의견을 반드시 입력해주세요.`,
errorItemSn: item.cklstRvwSn,
};
}
}
return { isValid: true };
}
/**
* ChecklistFormState와 CklstRvwRsltTreeDto[]를 받아 결재자 브랜치별 체크리스트 검증을 실행하는 헬퍼
*/
export function validateChecklistFormStateByBranch({
rows,
formState,
reviewStep = "KECO",
approverBranchSeCd,
docTaskSeComCd,
}: {
rows: CklstRvwRsltTreeDto[];
formState: ChecklistFormState;
reviewStep?: ReviewStepCode;
approverBranchSeCd: BranchSeCd;
docTaskSeComCd: string;
}): { isValid: boolean; errorMessage?: string; errorItemSn?: number } {
const displayRows = getChecklistDisplayRows(rows);
const checklistItems = displayRows.map((row) => {
const key = buildChecklistRowKey(row, reviewStep);
return {
taskSeComCd: row.taskSeComCd,
bchckUseYn: row.bchckUseYn,
selected: formState.results[key] ?? (row.rvwRsltAnmlyYn as string | undefined),
picRvwCn: formState.comments[key] ?? row.picRvwCn,
cklstRvwItemNm: row.cklstArtclNm ?? "검토항목",
cklstRvwSn: row.cklstRvwSn,
};
});
return validateChecklistByBranch(checklistItems, approverBranchSeCd, docTaskSeComCd);
}
@@ -98,3 +98,27 @@ export function parseTaskSeComCdFromApi(taskSeComCd?: string | null): string[] {
if (!taskSeComCd) return [];
return taskSeComCd.split(",").filter(Boolean);
}
/**
* 재원업무협의 저장 시 코드가 "SF,SP", "SP,SF" 등의 복수 코드로 분리 저장된 경우,
* 전자결재 복합병렬결재(Fork-Join) 화면 및 프로세스 규격에 맞춰 "SF_SP"로 정규화합니다.
*/
export function normalizeTaskSeComCdForElapr(taskSeComCd?: string | null): string {
if (!taskSeComCd) return "";
const trimmed = taskSeComCd.trim();
if (trimmed === "SF_SP") return "SF_SP";
const codes = trimmed.split(",").map((c) => c.trim()).filter(Boolean);
if (codes.includes("SF") && codes.includes("SP")) {
return "SF_SP";
}
return trimmed;
}
/**
* 전자결재 복합병렬결재(Fork-Join) 대상 업무 여부 판단
*/
export function isForkJoinTask(taskSeComCd?: string | null): boolean {
return normalizeTaskSeComCdForElapr(taskSeComCd) === "SF_SP";
}
@@ -81,7 +81,7 @@ export function mapDetailToFormData(data: FnrsCnsltnMngDetResponseDto): FnrsCnsl
cklstSn: item.cklstSn,
cklstArtclSn: item.cklstArtclSn,
cklstRvwSn: item.cklstRvwSn,
rvwStpComCd: item.rvwStpComCd,
rvwStpComCd: item.rvwStpComCd || "LOCAL",
selected: mapSelectedValue(item.rvwRsltAnmlyYn),
picRvwCn: item.picRvwCn,
})) ?? [],
@@ -183,6 +183,7 @@ export function buildFormPayload(
fnrsSharList: toFnrsSharSaveList(withTotals),
cklstResultList: data.cklstResultList.map((item) => ({
...item,
rvwStpComCd: item.rvwStpComCd || "LOCAL",
selected: item.selected ?? undefined,
})),
esntlAtchFileList: data.esntlAtchFileList,
@@ -93,6 +93,7 @@ export function buildSupplementReqPayload(
data: FnrsCnsltnMngDetResponseDto,
payload: SplmntDmndDialogConfirmPayload,
checklistFormState: ChecklistFormState,
branchSeCd?: string,
): FnrsCnsltnApplySaveRequestDto {
const existingItems: FnrsCnsltnSplmntDmndItem[] = (data.splmntDmndList ?? []).map((item) =>
toSplmntDmndSaveItem(item),
@@ -111,6 +112,7 @@ export function buildSupplementReqPayload(
return fnrsCnsltnApplySaveSchema.parse({
...buildSavePayload(data, checklistFormState, "LOCAL", "SL"),
splmntDmndList: [...existingItems, ...newItems],
branchSeCd,
});
}
+230 -230
View File
@@ -1,230 +1,230 @@
"use client";
import { Fragment } from "react";
import { createColumnHelper } from "@tanstack/react-table";
import { dayjs } from "@/shared/lib";
import type { FnrsCnsltnMngResponseDto } from "@/entities/fnrs-cnsltn";
import { Badge } from "@common/components";
const columnHelper = createColumnHelper<FnrsCnsltnMngResponseDto>();
const MAIN_STEP_LABELS = {
apply: "재원협의 신청",
envoReview: "재원협의 검토",
techReview: "기술검토",
reply: "검토결과 회신",
} as const;
export const PROGRESS_STATUS_COLUMNS = [
{ label: "재원협의 신청 (시군)", sttsComCd: "ST" },
{ label: "재원협의 신청 (시도)", sttsComCd: "LE" },
{ label: "재원협의 검토", sttsComCd: "ER" },
{ label: "기술검토", sttsComCd: "TE" },
{ label: "기술검토 회신", sttsComCd: "TP" },
{ label: "환경청 검토 결과 회신", sttsComCd: "EP" },
];
type BadgeState = "completed" | "current" | "pending";
interface ProgressBadge {
label: string;
state: BadgeState;
}
function buildProgressBadges(prgrsSttsComCd: string, techRplyHstryYn: "Y" | "N"): ProgressBadge[] {
if (prgrsSttsComCd === "SV") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "ST") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "current" },
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "LE") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "current" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
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" },
{ label: MAIN_STEP_LABELS.techReview, state: "current" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "TP") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{
label: MAIN_STEP_LABELS.techReview,
state: techRplyHstryYn === "Y" ? "completed" : "pending",
},
{ label: MAIN_STEP_LABELS.reply, state: "current" },
];
}
if (prgrsSttsComCd === "EP" || prgrsSttsComCd === "TA") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{
label: MAIN_STEP_LABELS.techReview,
state: techRplyHstryYn === "Y" ? "completed" : "pending",
},
{ label: MAIN_STEP_LABELS.reply, state: "completed" },
];
}
return [
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
function getProgressStatusBadge(prgrsSttsComCd?: string | null) {
if (prgrsSttsComCd === "SL") {
return <Badge color="orange">보완요청</Badge>;
}
return null;
}
function getBadgeColor(state: BadgeState) {
switch (state) {
case "completed":
return "green";
case "current":
return "blue";
case "pending":
return "lightGray";
}
}
function getBadgeStateText(state: BadgeState) {
switch (state) {
case "completed":
return "완료";
case "current":
return "진행중";
case "pending":
return "대기";
}
}
/** 단계명을 2줄로 분리: "설치인가 신청" → ["설치인가", "신청"], "기술검토" → ["기술검토", ""] */
function splitStepLabel(label: string) {
const [title = "", ...rest] = label.split(" ");
return { title, detail: rest.join(" ") };
}
function formatOptional(value: string | null | undefined) {
return value?.trim() ? value : "-";
}
interface ProgressStepBadgeProps {
prgrsSttsComCd: string;
techRplyHstryYn: "Y" | "N";
}
function ProgressStepBadge({ prgrsSttsComCd, techRplyHstryYn }: ProgressStepBadgeProps) {
const badges = buildProgressBadges(prgrsSttsComCd, techRplyHstryYn);
return (
<div className="flex flex-wrap items-center justify-start">
{badges.map((badge, index) => (
<Fragment key={badge.label}>
{index > 0 && <span className="mx-2">&gt;</span>}
{/* 1줄: 단계명 / 2줄: 세부단계 + 상태 (예: 설치인가 / 신청완료, 기술검토 / 완료) */}
<Badge
color={getBadgeColor(badge.state)}
className="flex-col justify-center !px-[20px] py-1 text-center leading-tight"
>
<span>{splitStepLabel(badge.label).title}</span>
<span>
{splitStepLabel(badge.label).detail}
{getBadgeStateText(badge.state)}
</span>
</Badge>
</Fragment>
))}
</div>
);
}
export function createFnrsCnsltnColumns() {
return [
columnHelper.accessor("sido", {
header: "시도",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("gugun", {
header: "시군구",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("bizNm", {
header: "사업명",
meta: { align: "left" },
cell: ({ row, getValue }) => (
<>
{getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())}
</>
),
}),
columnHelper.accessor("taskSeCdNm", {
header: "업무구분",
meta: { align: "center" },
cell: ({ row }) => formatOptional(row.original.taskSeCdNm || row.original.taskSeComCd),
}),
columnHelper.accessor("aplcntNm", {
header: "담당자",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("envoSeCdNm", {
header: "해당기관",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.display({
id: "progress",
header: "진행상황",
meta: { align: "center" },
cell: ({ row }) => (
<ProgressStepBadge
prgrsSttsComCd={row.original.prgrsSttsComCd || ""}
techRplyHstryYn={row.original.techRplyHstryYn || "N"}
/>
),
}),
columnHelper.accessor("regDt", {
header: "신청일",
meta: { align: "center" },
cell: ({ getValue }) => {
const value = getValue();
return value ? dayjs(value).format("YYYY-MM-DD") : "-";
},
}),
];
}
"use client";
import { Fragment } from "react";
import { createColumnHelper } from "@tanstack/react-table";
import { dayjs } from "@/shared/lib";
import type { FnrsCnsltnMngResponseDto } from "@/entities/fnrs-cnsltn";
import { Badge } from "@common/components";
const columnHelper = createColumnHelper<FnrsCnsltnMngResponseDto>();
const MAIN_STEP_LABELS = {
apply: "재원협의 신청",
envoReview: "재원협의 검토",
techReview: "기술검토",
reply: "검토결과 회신",
} as const;
export const PROGRESS_STATUS_COLUMNS = [
{ label: "재원협의 신청 (시군)", sttsComCd: "ST" },
{ label: "재원협의 신청 (시도)", sttsComCd: "LE" },
{ label: "재원협의 검토", sttsComCd: "ER" },
{ label: "기술검토", sttsComCd: "TE" },
{ label: "기술검토 회신", sttsComCd: "TP" },
{ label: "환경청 검토 결과 회신", sttsComCd: "EP" },
];
type BadgeState = "completed" | "current" | "pending";
interface ProgressBadge {
label: string;
state: BadgeState;
}
function buildProgressBadges(prgrsSttsComCd: string, techRplyHstryYn: "Y" | "N"): ProgressBadge[] {
if (prgrsSttsComCd === "SV") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "ST") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "current" },
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "LE") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "current" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
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" },
{ label: MAIN_STEP_LABELS.techReview, state: "current" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "TP") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{
label: MAIN_STEP_LABELS.techReview,
state: techRplyHstryYn === "Y" ? "completed" : "pending",
},
{ label: MAIN_STEP_LABELS.reply, state: "current" },
];
}
if (prgrsSttsComCd === "EP" || prgrsSttsComCd === "TA") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{
label: MAIN_STEP_LABELS.techReview,
state: techRplyHstryYn === "Y" ? "completed" : "pending",
},
{ label: MAIN_STEP_LABELS.reply, state: "completed" },
];
}
return [
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
function getProgressStatusBadge(prgrsSttsComCd?: string | null) {
if (prgrsSttsComCd === "SL") {
return <Badge color="orange">보완요청</Badge>;
}
return null;
}
function getBadgeColor(state: BadgeState) {
switch (state) {
case "completed":
return "green";
case "current":
return "blue";
case "pending":
return "lightGray";
}
}
function getBadgeStateText(state: BadgeState) {
switch (state) {
case "completed":
return "완료";
case "current":
return "진행중";
case "pending":
return "대기";
}
}
/** 단계명을 2줄로 분리: "설치인가 신청" → ["설치인가", "신청"], "기술검토" → ["기술검토", ""] */
function splitStepLabel(label: string) {
const [title = "", ...rest] = label.split(" ");
return { title, detail: rest.join(" ") };
}
function formatOptional(value: string | null | undefined) {
return value?.trim() ? value : "-";
}
interface ProgressStepBadgeProps {
prgrsSttsComCd: string;
techRplyHstryYn: "Y" | "N";
}
function ProgressStepBadge({ prgrsSttsComCd, techRplyHstryYn }: ProgressStepBadgeProps) {
const badges = buildProgressBadges(prgrsSttsComCd, techRplyHstryYn);
return (
<div className="flex flex-wrap items-center justify-center">
{badges.map((badge, index) => (
<Fragment key={badge.label}>
{index > 0 && <span className="mx-2">&gt;</span>}
{/* 1줄: 단계명 / 2줄: 세부단계 + 상태 (예: 설치인가 / 신청완료, 기술검토 / 완료) */}
<Badge
color={getBadgeColor(badge.state)}
className="w-[100px] flex-col justify-center !px-0 py-1 text-center leading-tight"
>
<span>{splitStepLabel(badge.label).title}</span>
<span>
{splitStepLabel(badge.label).detail}
{getBadgeStateText(badge.state)}
</span>
</Badge>
</Fragment>
))}
</div>
);
}
export function createFnrsCnsltnColumns() {
return [
columnHelper.accessor("sido", {
header: "시도",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("gugun", {
header: "시군구",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("bizNm", {
header: "사업명",
meta: { align: "left" },
cell: ({ row, getValue }) => (
<>
{getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())}
</>
),
}),
columnHelper.accessor("taskSeCdNm", {
header: "업무구분",
meta: { align: "center" },
cell: ({ row }) => formatOptional(row.original.taskSeCdNm || row.original.taskSeComCd),
}),
columnHelper.accessor("aplcntNm", {
header: "담당자",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("envoSeCdNm", {
header: "해당기관",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.display({
id: "progress",
header: "진행상황",
meta: { align: "center" },
cell: ({ row }) => (
<ProgressStepBadge
prgrsSttsComCd={row.original.prgrsSttsComCd || ""}
techRplyHstryYn={row.original.techRplyHstryYn || "N"}
/>
),
}),
columnHelper.accessor("regDt", {
header: "신청일",
meta: { align: "center" },
cell: ({ getValue }) => {
const value = getValue();
return value ? dayjs(value).format("YYYY-MM-DD") : "-";
},
}),
];
}
@@ -20,11 +20,14 @@ import {
ElaprApprovalDialog,
type ElaprDmndSaveRequestDto,
elaprDmndService,
type ElaprDocPrgrsForkJoinPickerConfirmPayload,
ElaprDocPrgrsForkJoinPickerDialog,
type ElaprDocPrgrsPickerConfirmPayload,
ElaprDocPrgrsPickerDialog,
type ElaprTmpltPickerConfirmPayload,
ElaprTmpltPickerDialog,
hasCurrentUserPendingApprovalRequest,
useElaprBranchesByDocNo,
useElaprDocPrgrsByDocNo,
} from "@/entities/elapr";
import {
@@ -54,7 +57,11 @@ import {
getButtonConfigs,
needsRoleUser01AtrzResolve,
} from "../../lib/button-config";
import { computeReviewStepChecklistProgress } from "../../lib/checklist-utils";
import type { BranchSeCd } from "@/entities/elapr";
import {
computeReviewStepChecklistProgress,
validateChecklistFormStateByBranch,
} from "../../lib/checklist-utils";
import {
buildFileSplmntReqPayload,
buildRvwRsltSplmntReqPayload,
@@ -63,6 +70,10 @@ import {
buildTechReviewReplyPayload,
toSplmntDmndSaveItem,
} from "../../lib/save-payload-utils";
import {
isForkJoinTask,
normalizeTaskSeComCdForElapr,
} from "../../lib/fnrs-cnsltn-regist-schema";
import { type ChecklistFormState } from "../../model/view-types";
import type { TechReviewOpinionSectionHandle } from "../sections/tech-review-opinion-section";
@@ -96,6 +107,7 @@ interface FloatingActionButtonsProps {
type ActiveDialog =
| "elaprTmplt"
| "elaprDocPrgrs"
| "elaprDocPrgrsForkJoin"
| "rjct"
| "fileSplmnt"
| "elaprApproval"
@@ -128,8 +140,13 @@ function FloatingActionButtons({
const docNo = data.detDto?.fnrsCnsltnDocNo ?? fnrsCnsltnDocNo;
const isRegister = data.detDto?.rgtr === userInfo?.userUnqId;
const isForkJoin = isForkJoinTask(data.detDto?.taskSeComCd);
const normalizedTaskSeComCd = normalizeTaskSeComCdForElapr(data.detDto?.taskSeComCd);
const needsAtrzResolve = needsRoleUser01AtrzResolve(dtlInstCd, prgrsSttsComCd);
const needsElaprPrgrsCheck = needsAtrzResolve && ["TE", "RK"].includes(prgrsSttsComCd);
const needsElaprPrgrsCheck =
needsAtrzResolve &&
(["TE", "RK"].includes(prgrsSttsComCd) || (isForkJoin && prgrsSttsComCd === "SL"));
const needsPwrkDraftCheck =
dtlInstCd === "ROLE_USER01" && ["SL", "TE", "RK"].includes(prgrsSttsComCd);
const needsPwrkForRb = dtlInstCd === "ROLE_USER05" && prgrsSttsComCd === "RB";
@@ -140,6 +157,10 @@ function FloatingActionButtons({
const { data: elaprDocPrgrsList = [], isLoading: isElaprDocPrgrsLoading } =
useElaprDocPrgrsByDocNo(needsElaprPrgrsCheck ? docNo : null);
const { data: elaprBranchList = [], isLoading: isElaprBranchLoading } = useElaprBranchesByDocNo(
isForkJoin && (needsElaprPrgrsCheck || prgrsSttsComCd === "SL") ? docNo : null,
);
const { data: pwrkCnsltnData, isLoading: isPwrkCnsltnLoading } = usePwrkCnsltnMngByUpDocNo(
docNo,
needsPwrkLookup,
@@ -171,55 +192,167 @@ function FloatingActionButtons({
draftGenLatest?.pwrkCnsltnDocNo ??
costVrfcLatest?.pwrkCnsltnDocNo;
const isSecondApproval = useMemo(() => {
return elaprDocPrgrsList.some((row) => Number(row.elaprCycl) === 2);
}, [elaprDocPrgrsList]);
const elaprPrgrsForPending =
prgrsSttsComCd === "RK"
isSecondApproval || prgrsSttsComCd === "RK"
? elaprDocPrgrsList.filter((row) => Number(row.elaprCycl) === 2)
: elaprDocPrgrsList;
const hasPendingApprovalRequest = hasCurrentUserPendingApprovalRequest(
elaprPrgrsForPending,
userInfo?.userUnqId,
);
const currentUserPrgrsRow = elaprPrgrsForPending.find(
(row) => row.aprvrUuid?.toLowerCase() === userInfo?.userUnqId?.toLowerCase(),
);
const userPendingPrgrs = elaprPrgrsForPending.find(
(row) =>
row.aprvrUuid?.toLowerCase() === userInfo?.userUnqId?.toLowerCase() &&
row.atrzPrgrsSttsComCd === "ATRZ_PRGRS_002",
);
const userBranchId = userPendingPrgrs?.branchId ?? currentUserPrgrsRow?.branchId;
const userBranchSeCd = (userPendingPrgrs?.branchSeCd ?? currentUserPrgrsRow?.branchSeCd) as
| "SP"
| "SF"
| "TOTAL_AGG"
| "MAIN"
| undefined;
const userPendingBranchSeCd = userPendingPrgrs?.branchSeCd ?? userBranchSeCd;
const userPendingAtrzSeComCd = userPendingPrgrs?.atrzSeComCd;
const effectiveAtrzSeComCdList = useMemo(() => {
if (isForkJoin && userPendingAtrzSeComCd) {
return [userPendingAtrzSeComCd];
}
if (isForkJoin && currentUserPrgrsRow?.atrzSeComCd) {
return [currentUserPrgrsRow.atrzSeComCd];
}
return atrzSeComCdList;
}, [atrzSeComCdList, currentUserPrgrsRow?.atrzSeComCd, isForkJoin, userPendingAtrzSeComCd]);
const supplementDmndBranch = useMemo(() => {
if (!isForkJoin) return undefined;
// 1순위: 전자결재 브랜치(tb_as_elapr_branch) 테이블에서 상태가 SUPPLEMENT인 부서 직접 확인
const splmntBranch = elaprBranchList.find((b) => b.branchSttsCd === "SUPPLEMENT");
if (splmntBranch?.branchSeCd && ["SP", "SF"].includes(splmntBranch.branchSeCd)) {
return splmntBranch.branchSeCd as "SP" | "SF";
}
// 2순위: 미완료 보완요청 항목의 등록자 UUID 매칭
const uncompletedItems = data?.splmntDmndList?.filter((item) => item.cmptnYn !== "Y") ?? [];
if (uncompletedItems.length > 0) {
for (const splmnt of uncompletedItems) {
const matchedPrgrs = elaprDocPrgrsList.find(
(p) => p.aprvrUuid?.toLowerCase() === splmnt.rgtr?.toLowerCase(),
);
if (matchedPrgrs?.branchSeCd && ["SP", "SF"].includes(matchedPrgrs.branchSeCd)) {
return matchedPrgrs.branchSeCd as "SP" | "SF";
}
}
}
return undefined;
}, [data?.splmntDmndList, elaprBranchList, elaprDocPrgrsList, isForkJoin]);
const userBranchSttsCd = useMemo<string | undefined>(() => {
if (!isForkJoin || !userBranchSeCd) return undefined;
// 1. elaprBranchList API 응답에서 사용자 브랜치 상태 확인
// 현재 결재선(2차 차수 등)의 고유 branchId로 우선 매칭하고, 없으면 findLast로 최신 차수 브랜치 매칭
const matchedBranch = userBranchId
? elaprBranchList.find((b) => b.branchId === userBranchId)
: elaprBranchList.findLast((b) => b.branchSeCd === userBranchSeCd);
if (matchedBranch?.branchSttsCd) {
return matchedBranch.branchSttsCd;
}
// 2. Fallback: 문서 상태가 SL일 때 보완요청 발생 부서(supplementDmndBranch)와 비교
if (prgrsSttsComCd === "SL") {
if (supplementDmndBranch && userBranchSeCd === supplementDmndBranch) {
return "SUPPLEMENT";
}
const branchRows = elaprPrgrsForPending.filter((row) => row.branchSeCd === userBranchSeCd);
const hasUnfinished = branchRows.some(
(row) =>
row.atrzPrgrsSttsComCd !== "ATRZ_PRGRS_003" && row.atrzPrgrsSttsComCd !== "ATRZ_PRGRS_999",
);
return hasUnfinished ? "IN_PROGRESS" : "COMPLETED";
}
if (["TE", "RK"].includes(prgrsSttsComCd)) {
return "IN_PROGRESS";
}
if (["TP", "RR"].includes(prgrsSttsComCd)) {
return "COMPLETED";
}
return undefined;
}, [
elaprBranchList,
elaprPrgrsForPending,
isForkJoin,
prgrsSttsComCd,
supplementDmndBranch,
userBranchId,
userBranchSeCd,
]);
const isBranchInProgress =
isForkJoin && userBranchSttsCd === "IN_PROGRESS" && dtlInstCd === "ROLE_USER01";
const viewOnlyPwrkCnsltnDoc =
prgrsSttsComCd === "SL" ||
(prgrsSttsComCd === "SL" && dtlInstCd !== "ROLE_USER05" && !isBranchInProgress) ||
(prgrsSttsComCd === "RB" && dtlInstCd !== "ROLE_USER05") ||
(prgrsSttsComCd === "RK" && dtlInstCd === "ROLE_USER03");
const actionButtons = useMemo(() => {
if (needsAtrzResolve && isRvwLoginUserLoading) return [];
if (needsElaprPrgrsCheck && isElaprDocPrgrsLoading) return [];
if (isForkJoin && isElaprBranchLoading) return [];
if (needsPwrkDraftCheck && isPwrkCnsltnLoading) return [];
if (needsAiAutoLatest && isAiAutoLatestLoading) return [];
return getButtonConfigs(dtlInstCd, prgrsSttsComCd, atrzSeComCdList, {
return getButtonConfigs(dtlInstCd, prgrsSttsComCd, effectiveAtrzSeComCdList, {
hasPendingApprovalRequest: needsElaprPrgrsCheck ? hasPendingApprovalRequest : undefined,
hasCompletedDraft: needsPwrkDraftCheck ? hasCompletedDraft : undefined,
canRetryFileVrfc: needsAiAutoLatest ? canRetryFileVrfc : undefined,
canRetryDraftGen: needsAiAutoLatest ? canRetryDraftGen : undefined,
canRetryCostVrfc: needsAiAutoLatest ? canRetryCostVrfc : undefined,
isForkJoin,
branchSeCd: userBranchSeCd,
branchSttsCd: userBranchSttsCd,
isSecondApproval,
});
}, [
atrzSeComCdList,
canRetryCostVrfc,
canRetryDraftGen,
canRetryFileVrfc,
dtlInstCd,
effectiveAtrzSeComCdList,
hasCompletedDraft,
hasPendingApprovalRequest,
isAiAutoLatestLoading,
isElaprBranchLoading,
isElaprDocPrgrsLoading,
isForkJoin,
isPwrkCnsltnLoading,
isRvwLoginUserLoading,
isSecondApproval,
needsAiAutoLatest,
needsAtrzResolve,
needsElaprPrgrsCheck,
needsPwrkDraftCheck,
prgrsSttsComCd,
userBranchSeCd,
userBranchSttsCd,
]);
const canEdit =
isRegister && actionButtons.some((button) => button === FNRS_CNSLTN_VIEW_BUTTON_ACTION.strg);
const canDmndSplmntInPwrkCnsltnDoc =
prgrsSttsComCd === "RK" &&
atrzSeComCdList.includes("ATRZ02") &&
effectiveAtrzSeComCdList.includes("ATRZ02") &&
actionButtons.some((button) => button === FNRS_CNSLTN_VIEW_BUTTON_ACTION.dmndSplmnt);
function handleGoToList() {
@@ -256,6 +389,9 @@ function FloatingActionButtons({
queryClient.invalidateQueries({
queryKey: fnrsCnsltnKeys.detail(fnrsCnsltnDocNo),
});
queryClient.invalidateQueries({
queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_BRANCH],
});
},
onError: (error) => {
let errorMessage = "";
@@ -326,6 +462,7 @@ function FloatingActionButtons({
docNo,
docSeComCd,
prgrsSttsComCd: "LE",
rgtrUuid: userInfo?.userUnqId,
};
await executeAction({
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.envoSbmsn,
@@ -335,10 +472,14 @@ function FloatingActionButtons({
}
async function handleRjctClick() {
if (!(await confirm("해당 신청 건을 반려하시겠습니까?"))) {
return;
}
const payload: DocPrgrsUpdateRequestDto = {
docNo,
docSeComCd,
prgrsSttsComCd: "SV",
rgtrUuid: userInfo?.userUnqId,
};
await executeAction({
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.rjct,
@@ -352,6 +493,7 @@ function FloatingActionButtons({
docNo,
docSeComCd,
prgrsSttsComCd: "TA",
rgtrUuid: userInfo?.userUnqId,
};
await executeAction({
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.sncnAprv,
@@ -449,6 +591,9 @@ function FloatingActionButtons({
elaprDocNo: docNo,
dmndTtl: data.detDto?.bizNm ?? "",
dmndCn: data.detDto?.bizCn ?? "",
taskSeComCd: normalizedTaskSeComCd || undefined,
forkJoinSeCd: isForkJoin ? "FORK_JOIN" : "NONE",
crntStageNo: isForkJoin ? 2 : 1,
elaprCycl: 1,
docPrgrsSttsComCd: "AC",
elaprPrgrsSttsList: buildElaprPrgrsSttsListFromAplnRows(aplnRows),
@@ -463,11 +608,16 @@ function FloatingActionButtons({
});
}
async function handleElaprDocPrgrsConfirm({ aplnRows }: ElaprDocPrgrsPickerConfirmPayload) {
async function handleElaprDocPrgrsConfirm({
aplnRows,
}: ElaprDocPrgrsPickerConfirmPayload | ElaprDocPrgrsForkJoinPickerConfirmPayload) {
const payload: ElaprDmndSaveRequestDto = {
elaprDocNo: docNo,
dmndTtl: data.detDto?.bizNm ?? "",
dmndCn: data.detDto?.bizCn ?? "",
taskSeComCd: normalizedTaskSeComCd || undefined,
forkJoinSeCd: isForkJoin ? "FORK_JOIN" : "NONE",
crntStageNo: isForkJoin ? 2 : 1,
elaprCycl: 2,
docPrgrsSttsComCd: "RK",
elaprPrgrsSttsList: buildElaprPrgrsSttsListFromAplnRows(aplnRows),
@@ -483,11 +633,13 @@ function FloatingActionButtons({
}
async function handleRvwStrgClick() {
const targetPrgrsStts =
isSecondApproval || prgrsSttsComCd === "RK" ? "RK" : prgrsSttsComCd === "SL" ? "SL" : "TE";
const payload = buildTechReviewReplyPayload(
data,
checklistFormState,
"KECO",
"TE",
targetPrgrsStts,
"SV",
getReviewOpinionFormData(),
opnrNm,
@@ -501,26 +653,53 @@ function FloatingActionButtons({
techReviewOpinionRef?.current?.resetForm();
}
async function handleRvwCmptnClick() {
if (prgrsSttsComCd === "TE") {
const { percent } = computeReviewStepChecklistProgress(
data.cklstResultList ?? [],
checklistFormState.results,
"KECO",
);
function handleApprovalOpen() {
const isTeStep = prgrsSttsComCd === "TE" || (isForkJoin && prgrsSttsComCd === "SL");
if (isTeStep && (userBranchSeCd || userPendingBranchSeCd)) {
const branchCd = (userBranchSeCd as BranchSeCd) || (userPendingBranchSeCd as BranchSeCd) || (isForkJoin ? "SP" : "MAIN");
const validation = validateChecklistFormStateByBranch({
rows: data.cklstResultList ?? [],
formState: checklistFormState,
reviewStep: "KECO",
approverBranchSeCd: branchCd,
docTaskSeComCd: normalizedTaskSeComCd || data.detDto?.taskSeComCd || "MAIN",
});
if (percent !== 100) {
alert("모든 체크리스트 항목을 체크해야 검토완료가 가능합니다.");
if (!validation.isValid) {
alert(validation.errorMessage || "체크리스트 항목을 모두 검토해야 결재가 가능합니다.");
return;
}
}
setActiveDialog("elaprApproval");
}
async function handleRvwCmptnClick() {
const isTeStep = prgrsSttsComCd === "TE" || (isForkJoin && prgrsSttsComCd === "SL");
if (isTeStep) {
const branchCd = (userBranchSeCd as BranchSeCd) || (userPendingBranchSeCd as BranchSeCd) || (isForkJoin ? "SP" : "MAIN");
const validation = validateChecklistFormStateByBranch({
rows: data.cklstResultList ?? [],
formState: checklistFormState,
reviewStep: "KECO",
approverBranchSeCd: branchCd,
docTaskSeComCd: normalizedTaskSeComCd || data.detDto?.taskSeComCd || "MAIN",
});
if (!validation.isValid) {
alert(validation.errorMessage || "모든 체크리스트 항목을 체크해야 검토완료가 가능합니다.");
return;
}
}
const targetPrgrsStts =
isSecondApproval || prgrsSttsComCd === "RK" ? "RK" : prgrsSttsComCd === "SL" ? "SL" : "TE";
const payload = {
...buildTechReviewReplyPayload(
data,
checklistFormState,
"KECO",
prgrsSttsComCd,
targetPrgrsStts,
"SV",
getReviewOpinionFormData(),
opnrNm,
@@ -539,7 +718,8 @@ function FloatingActionButtons({
async function handleDmndSplmntConfirm(payload: SplmntDmndDialogConfirmPayload) {
setIsDmndSplmntDialogOpen(false);
setActiveDialog(null);
const requestPayload = buildSupplementReqPayload(data, payload, checklistFormState);
const branchSeCd = userBranchSeCd ?? userPendingBranchSeCd;
const requestPayload = buildSupplementReqPayload(data, payload, checklistFormState, branchSeCd);
await executeAction({
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.dmndSplmnt,
request: requestPayload,
@@ -563,7 +743,11 @@ function FloatingActionButtons({
async function handleRvwRsltSplmntConfirm(selected: PwrkCnsltnCnsltnResponseDto[]) {
setActiveDialog(null);
const requestPayload = buildRvwRsltSplmntReqPayload(data, selected, checklistFormState);
const branchSeCd = userBranchSeCd ?? userPendingBranchSeCd;
const requestPayload = {
...buildRvwRsltSplmntReqPayload(data, selected, checklistFormState),
branchSeCd,
};
await executeAction({
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.dmndSplmnt,
request: requestPayload,
@@ -738,7 +922,13 @@ function FloatingActionButtons({
!isRagIndexingInProgress(prgrsSttsComCd, data.ragIndexingInfo) && (
<Button
onClick={() =>
setActiveDialog(prgrsSttsComCd === "EV" ? "elaprDocPrgrs" : "elaprTmplt")
setActiveDialog(
prgrsSttsComCd === "EV"
? isForkJoin
? "elaprDocPrgrsForkJoin"
: "elaprDocPrgrs"
: "elaprTmplt",
)
}
disabled={isBusy}
>
@@ -750,11 +940,16 @@ function FloatingActionButtons({
조치 완료
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRsltInq) && (
<Button color="sky" onClick={() => setActiveDialog("rvwRsltInq")} disabled={isBusy}>
검토 결과 조회
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRsltInq) &&
!isSecondApproval &&
!(
isForkJoin &&
(userPendingBranchSeCd === "TOTAL_AGG" || userPendingAtrzSeComCd === "ATRZ04")
) && (
<Button color="sky" onClick={() => setActiveDialog("rvwRsltInq")} disabled={isBusy}>
검토 결과 조회
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwStrg) && (
<Button onClick={handleRvwStrgClick} disabled={isBusy}>
저장
@@ -774,16 +969,33 @@ function FloatingActionButtons({
보완 요청
</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>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzAprv) &&
(!isForkJoin ||
(userPendingBranchSeCd === "TOTAL_AGG" && userPendingAtrzSeComCd === "ATRZ04")) && (
<Button onClick={handleApprovalOpen} disabled={isBusy}>
{isForkJoin ? "결재 (병합)" : "결재(승인)"}
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzRply) &&
(!isForkJoin ||
(userPendingBranchSeCd !== "TOTAL_AGG" && userPendingAtrzSeComCd === "ATRZ03") ||
(userPendingBranchSeCd === "TOTAL_AGG" && userPendingAtrzSeComCd === "ATRZ99")) && (
<Button
color={
isForkJoin && userPendingBranchSeCd !== "TOTAL_AGG"
? "green"
: undefined
}
onClick={handleApprovalOpen}
disabled={isBusy}
>
{isForkJoin
? userPendingBranchSeCd === "TOTAL_AGG"
? "결재 (환경청에 회신)"
: "결재 (병합에 회신)"
: "결재(회신)"}
</Button>
)}
</>
)}
</div>
@@ -795,6 +1007,7 @@ function FloatingActionButtons({
open={activeDialog === "elaprTmplt"}
onClose={() => setActiveDialog(null)}
docSeComCd={docSeComCd}
taskSeComCd={normalizedTaskSeComCd || undefined}
onConfirm={handleElaprTmpltConfirm}
/>
@@ -805,6 +1018,13 @@ function FloatingActionButtons({
onConfirm={handleElaprDocPrgrsConfirm}
/>
<ElaprDocPrgrsForkJoinPickerDialog
open={activeDialog === "elaprDocPrgrsForkJoin"}
elaprDocNo={docNo}
onClose={() => setActiveDialog(null)}
onConfirm={handleElaprDocPrgrsConfirm}
/>
<SplmntDmndDialog
splmntArtclUpCd="SPLMNTARTCL"
open={isDmndSplmntDialogOpen}
@@ -826,21 +1046,36 @@ function FloatingActionButtons({
onOpenChange={(open) => setActiveDialog(open ? "elaprApproval" : null)}
elaprDocNo={data.detDto?.fnrsCnsltnDocNo ?? fnrsCnsltnDocNo}
currentUserUuid={userInfo?.userUnqId ?? ""}
elaprCycl={prgrsSttsComCd === "RK" ? 2 : undefined}
elaprCycl={
userPendingPrgrs?.elaprCycl != null
? Number(userPendingPrgrs.elaprCycl)
: isSecondApproval || prgrsSttsComCd === "RK"
? 2
: 1
}
taskSeComCd={normalizedTaskSeComCd || undefined}
onSuccess={() => {
queryClient.invalidateQueries({
queryKey: fnrsCnsltnKeys.detail(fnrsCnsltnDocNo),
});
queryClient.invalidateQueries({
queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_BRANCH],
});
}}
/>
<PwrkCnsltnDocMngDialog
open={activeDialog === "pwrkCnsltnDocMng"}
fnrsCnsltnDocNo={docNo}
atrzSeComCdList={atrzSeComCdList}
atrzSeComCdList={effectiveAtrzSeComCdList}
prgrsSttsComCd={prgrsSttsComCd}
branchSeCd={userBranchSeCd ?? userPendingBranchSeCd}
isForkJoin={isForkJoin}
userRole={dtlInstCd}
supplementDmndBranch={supplementDmndBranch}
canSave={
(needsElaprPrgrsCheck && !hasPendingApprovalRequest) || viewOnlyPwrkCnsltnDoc
(needsElaprPrgrsCheck && !hasPendingApprovalRequest && !isBranchInProgress) ||
viewOnlyPwrkCnsltnDoc
? false
: null
}
@@ -896,6 +1131,17 @@ async function handleChangePrgrsSttsComCd(
}
}
async function handleRejectPrgrsStts(
context: ActionHandlerContext<DocPrgrsUpdateRequestDto>,
): Promise<void> {
try {
await fnrsCnsltnMngService.rejectPrgrsStts(context.request);
context.onSuccess?.();
} catch (error) {
context.onError?.(error as Error);
}
}
async function handleTechReview(
context: ActionHandlerContext<FnrsCnsltnApplySaveRequestDto>,
): Promise<void> {
@@ -982,7 +1228,7 @@ const buttonActionHandlers: Record<FnrsCnsltnViewButtonAction, ActionHandler> =
aply: handleSaveApply,
del: handleDel,
envoSbmsn: handleChangePrgrsSttsComCd,
rjct: handleChangePrgrsSttsComCd,
rjct: handleRejectPrgrsStts,
sncnAprv: handleChangePrgrsSttsComCd,
rvwRply: handleTechReview,
rvwOpnnRply: handleTechReview,
@@ -33,6 +33,9 @@ import {
interface PwrkCnsltnDocEvlDialogProps {
open: boolean;
pwrkCnsltnDocNo: string;
/** 평가 저장 성공 시 호출 */
onSaved?: () => void;
/** 평가 없이 닫을 때 호출 */
onClose: () => void;
}
@@ -41,7 +44,12 @@ const DEFAULT_VALUES: Partial<PwrkCnsltnDocEvlFormValues> = {
pwrkCnsltnDocEvlCn: "",
};
function PwrkCnsltnDocEvlDialog({ open, pwrkCnsltnDocNo, onClose }: PwrkCnsltnDocEvlDialogProps) {
function PwrkCnsltnDocEvlDialog({
open,
pwrkCnsltnDocNo,
onSaved,
onClose,
}: PwrkCnsltnDocEvlDialogProps) {
const alert = useAlertStore((state) => state.setMessage);
const { mutateAsync: saveEvl, isPending } = useSavePwrkCnsltnDocEvl();
@@ -65,8 +73,7 @@ function PwrkCnsltnDocEvlDialog({ open, pwrkCnsltnDocNo, onClose }: PwrkCnsltnDo
try {
const payload = buildPwrkCnsltnDocEvlSavePayload(pwrkCnsltnDocNo, values);
await saveEvl(payload);
alert("AI 초안 만족도 평가를 등록했습니다.");
onClose();
onSaved?.();
} catch (error) {
console.error("AI 초안 만족도 평가 등록 실패:", error);
alert("AI 초안 만족도 평가 등록에 실패했습니다.");
@@ -4,9 +4,8 @@ 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 { ExternalLink, PanelRightClose, PanelRightOpen } from "lucide-react";
import { getDefaultFileKey, useAlertStore, useTus, useUserStore } from "@/shared/lib";
import { AttachmentFileField } from "@/shared/ui";
import {
attachService,
@@ -18,6 +17,8 @@ import {
pwrkCnsltnDocCrtRsltService,
} from "@/entities/pwrk-cnsltn-doc-crt-rslt";
import { usePwrkCnsltnDocPjtcoAjmtMngPage } from "@/entities/pwrk-cnsltn-doc-pjtco-ajmt-mng";
import { useElaprBranchesByDocNo, useElaprDocPrgrsByDocNo } from "@/entities/elapr";
import { useAiAutoPrcsLatest, AI_AUTO_SE } from "@/entities/ai-auto-prcs-mng";
import {
findCompletedPwrkDraft,
pwrkCnsltnMngKeys,
@@ -31,7 +32,9 @@ import {
DialogHeader,
DialogTitle,
Skeleton,
cn,
} from "@common/components";
import { buildBridgeSrc, getHwpCtrl } from "@/widgets/hangulgiangi";
import { PwrkCnsltnDocEvlDialog } from "./pwrk-cnsltn-doc-evl-dialog";
import { PwrkCnsltnDocPjtcoAjmtTable } from "./pwrk-cnsltn-doc-pjtco-ajmt-table";
@@ -60,6 +63,10 @@ interface PwrkCnsltnDocMngDialogProps {
canSave?: boolean | null;
onDmndSplmnt?: () => void;
onClose: () => void;
branchSeCd?: string;
isForkJoin?: boolean;
userRole?: string;
supplementDmndBranch?: string;
}
interface UploadedAtchFile {
@@ -73,6 +80,73 @@ function createPwrkAtchUploadKey(file: File) {
return `${PWRK_ATCH_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
}
function resolveDefaultBranchSeCd({
branchSeCd,
isForkJoin,
userRole,
prgrsSttsComCd,
supplementDmndBranch,
supplementBranches,
isFinalApproverAfterMerger,
}: {
branchSeCd?: string;
isForkJoin?: boolean;
userRole?: string;
prgrsSttsComCd?: string;
supplementDmndBranch?: string;
supplementBranches?: Set<string>;
isFinalApproverAfterMerger?: boolean;
}): string {
// 0. 순차 결재(!isForkJoin)인 경우: 무조건 "MAIN" 고정 (SP, SF 부서 분기가 없으므로 단일 종합본 처리)
if (!isForkJoin) {
return "MAIN";
}
// 0-1. 광역지자체(ROLE_USER04), 환경청(ROLE_USER03) 사용자인 경우: 병렬 Join 결재 시 MAIN 탭만 호출
if (
userRole === "ROLE_USER04" ||
userRole === "ROLE_USER03"
) {
return "MAIN";
}
// 1. TE, SL, RK 상태가 아닌 경우 (예: RB 등): SP, SF 부서 탭이 노출되지 않고 MAIN 탭만 노출되므로 무조건 "MAIN" 고정
// RK 상태는 1차 결재진행상태인 TE 상태와 논리적으로 동일하게 취급되어 부서별(SP, SF) 탭 선택이 유지됨
if (prgrsSttsComCd !== "TE" && prgrsSttsComCd !== "SL" && prgrsSttsComCd !== "RK") {
return "MAIN";
}
// 2. 병합자 후의 최종 승인자는 무조건 MAIN 탭으로 고정
if (isFinalApproverAfterMerger) {
return "MAIN";
}
// 3. 공단 사용자 등 본인 부서(branchSeCd)가 있는 경우 본인 부서 탭을 1순위로 지정
if (branchSeCd === "TOTAL_AGG") {
return "MAIN";
}
if (branchSeCd && ["SP", "SF", "MAIN"].includes(branchSeCd)) {
return branchSeCd;
}
// 4. 지자체 사용자(ROLE_USER05) 또는 본인 부서가 없는 경우: 보완요청 부서 우선 선택 (TE, SL 상태일 때)
if (supplementBranches && supplementBranches.size > 0) {
if (supplementBranches.size > 1) {
return "SP";
}
if (supplementBranches.has("SP")) return "SP";
if (supplementBranches.has("SF")) return "SF";
}
if (supplementDmndBranch && ["SP", "SF"].includes(supplementDmndBranch)) {
return supplementDmndBranch;
}
if (userRole === "ROLE_USER05" && prgrsSttsComCd === "SL") {
return supplementDmndBranch || "SP";
}
return "SP";
}
function PwrkCnsltnDocMngDialog({
open,
fnrsCnsltnDocNo,
@@ -81,13 +155,190 @@ function PwrkCnsltnDocMngDialog({
canSave: canSaveProp,
onDmndSplmnt,
onClose,
branchSeCd,
isForkJoin,
userRole,
supplementDmndBranch,
}: PwrkCnsltnDocMngDialogProps) {
const queryClient = useQueryClient();
const alert = useAlertStore((state) => state.setMessage);
const userInfo = useUserStore((state) => state.userInfo);
const [atchFileGroupId, setAtchFileGroupId] = useState("");
const [uploadedFiles, setUploadedFiles] = useState<UploadedAtchFile[]>([]);
const [isPjtcoAjmtTableOpen, setIsPjtcoAjmtTableOpen] = useState(false);
const [isEvlDialogOpen, setIsEvlDialogOpen] = useState(false);
const [selectedVerSn, setSelectedVerSn] = useState<number | null>(null);
// 전자결재 브랜치 목록 직접 조회 (Single Source of Truth)
const { data: elaprBranchList = [] } = useElaprBranchesByDocNo(
fnrsCnsltnDocNo,
{ enabled: open && Boolean(isForkJoin) },
);
// 전자결재 결재선 목록 직접 조회 (병합자 / 최종승인자 식별용)
const { data: elaprDocPrgrsList = [] } = useElaprDocPrgrsByDocNo(
open && Boolean(isForkJoin) ? fnrsCnsltnDocNo : null,
);
const elaprPrgrsForCycl = useMemo(() => {
if (!elaprDocPrgrsList.length) return [];
if (prgrsSttsComCd === "RK") {
const cycl2 = elaprDocPrgrsList.filter((row) => Number(row.elaprCycl) === 2);
return cycl2.length > 0 ? cycl2 : elaprDocPrgrsList;
}
return elaprDocPrgrsList;
}, [elaprDocPrgrsList, prgrsSttsComCd]);
const currentUserPrgrs = useMemo(() => {
if (!userInfo?.userUnqId || !elaprPrgrsForCycl.length) return undefined;
return (
elaprPrgrsForCycl.find(
(row) => row.aprvrUuid === userInfo.userUnqId && row.atrzPrgrsSttsComCd === "ATRZ_PRGRS_002",
) ?? elaprPrgrsForCycl.find((row) => row.aprvrUuid === userInfo.userUnqId)
);
}, [userInfo?.userUnqId, elaprPrgrsForCycl]);
const effectiveBranchSeCd = branchSeCd ?? currentUserPrgrs?.branchSeCd;
const effectiveAtrzSeList = useMemo(() => {
if (atrzSeComCdList && atrzSeComCdList.length > 0) return atrzSeComCdList;
if (currentUserPrgrs?.atrzSeComCd) return [currentUserPrgrs.atrzSeComCd];
return [];
}, [atrzSeComCdList, currentUserPrgrs?.atrzSeComCd]);
const isTeOrRk = prgrsSttsComCd === "TE" || prgrsSttsComCd === "RK";
// 1. 병합자 여부 (TE/RK 상태에서 TOTAL_AGG 브랜치이거나 ATRZ04 권한을 가진 병합자)
const isMerger = useMemo(() => {
if (!isForkJoin || !isTeOrRk) return false;
if (effectiveAtrzSeList.includes("ATRZ04")) return true;
if (effectiveBranchSeCd === "TOTAL_AGG" && !effectiveAtrzSeList.some((cd) => APPROVER_ATRZ_SE_COM_CDS.has(cd))) {
return true;
}
return false;
}, [isForkJoin, isTeOrRk, effectiveAtrzSeList, effectiveBranchSeCd]);
// 2. 병합자 후의 최종 승인자 여부 (TE/RK 상태에서 TOTAL_AGG 브랜치이면서 ATRZ04가 아니고 ATRZ03 또는 ATRZ99 등 후속 결재/승인자)
const isFinalApproverAfterMerger = useMemo(() => {
if (!isForkJoin || !isTeOrRk) return false;
if (effectiveBranchSeCd === "TOTAL_AGG") {
if (effectiveAtrzSeList.includes("ATRZ04")) return false;
if (effectiveAtrzSeList.some((cd) => APPROVER_ATRZ_SE_COM_CDS.has(cd))) return true;
const userSeq = currentUserPrgrs?.atrzSeqNo ?? (currentUserPrgrs as Record<string, unknown> | undefined)?.atrzSeq;
if (userSeq && Number(userSeq) > 1) return true;
if (effectiveAtrzSeList.length > 0 && !effectiveAtrzSeList.includes("ATRZ04")) return true;
}
return false;
}, [
isForkJoin,
isTeOrRk,
effectiveBranchSeCd,
effectiveAtrzSeList,
currentUserPrgrs?.atrzSeqNo,
(currentUserPrgrs as Record<string, unknown> | undefined)?.atrzSeq,
]);
// 3. 병합 완료(결재 후) 여부
// 병합자가 이미 결재(승인)를 진행한 경우 (ATRZ_PRGRS_003 등) MAIN 탭 저장 불가
const isMergerApproved = useMemo(() => {
if (!isForkJoin || !isMerger) return false;
// 1순위: 현재 로그인 사용자의 결재선 행 확인
if (userInfo?.userUnqId) {
const userRows = elaprPrgrsForCycl.filter((row) => row.aprvrUuid === userInfo.userUnqId);
const mergerRow = userRows.find(
(row) =>
row.atrzSeComCd === "ATRZ04" ||
(row.branchSeCd === "TOTAL_AGG" &&
Number(row.atrzSeqNo ?? (row as Record<string, unknown>).atrzSeq) === 1),
);
if (mergerRow) {
return (
mergerRow.atrzPrgrsSttsComCd === "ATRZ_PRGRS_003" ||
mergerRow.atrzPrgrsSttsComCd === "ATRZ_PRGRS_999"
);
}
}
// 2순위: 결재선 상에서 TOTAL_AGG 브랜치 1순번(병합자)의 결재 완료 여부 확인
const aggMergerRow = elaprPrgrsForCycl.find(
(row) =>
row.branchSeCd === "TOTAL_AGG" &&
(row.atrzSeComCd === "ATRZ04" ||
Number(row.atrzSeqNo ?? (row as Record<string, unknown>).atrzSeq) === 1),
);
if (aggMergerRow) {
return (
aggMergerRow.atrzPrgrsSttsComCd === "ATRZ_PRGRS_003" ||
aggMergerRow.atrzPrgrsSttsComCd === "ATRZ_PRGRS_999"
);
}
return false;
}, [isForkJoin, isMerger, userInfo?.userUnqId, elaprPrgrsForCycl]);
// 전자결재 브랜치 테이블(tb_as_elapr_branch)에서 branchSttsCd === 'SUPPLEMENT'인 부서들을 모두 수집 (SP, SF)
const supplementBranches = useMemo<Set<string>>(() => {
const branches = new Set<string>();
if (!isForkJoin) return branches;
// 1순위: elaprBranchList에서 SUPPLEMENT 상태인 브랜치들을 모두 수집
for (const b of elaprBranchList) {
if (b.branchSttsCd === "SUPPLEMENT" && b.branchSeCd && ["SP", "SF"].includes(b.branchSeCd)) {
branches.add(b.branchSeCd);
}
}
// 2순위: props로 전달된 supplementDmndBranch 추가
if (supplementDmndBranch && ["SP", "SF"].includes(supplementDmndBranch)) {
branches.add(supplementDmndBranch);
}
return branches;
}, [elaprBranchList, isForkJoin, supplementDmndBranch]);
const effectiveUserRole = userRole || userInfo?.dtlInstCd;
const isWideLocalOrEnvUser =
effectiveUserRole === "ROLE_USER04" ||
effectiveUserRole === "ROLE_USER03";
const [currentBranchSeCd, setCurrentBranchSeCd] = useState<string>(() =>
resolveDefaultBranchSeCd({
branchSeCd: effectiveBranchSeCd,
isForkJoin,
userRole: effectiveUserRole,
prgrsSttsComCd,
supplementDmndBranch,
supplementBranches,
isFinalApproverAfterMerger,
}),
);
useEffect(() => {
if (open) {
setSelectedVerSn(null);
setCurrentBranchSeCd(
resolveDefaultBranchSeCd({
branchSeCd: effectiveBranchSeCd,
isForkJoin,
userRole: effectiveUserRole,
prgrsSttsComCd,
supplementDmndBranch,
supplementBranches,
isFinalApproverAfterMerger,
}),
);
}
}, [
open,
effectiveBranchSeCd,
isForkJoin,
effectiveUserRole,
prgrsSttsComCd,
supplementBranches,
supplementDmndBranch,
isFinalApproverAfterMerger,
]);
const { mutate: downloadByAtflId } = useDownloadAttachmentByAtflId();
const {
@@ -98,28 +349,92 @@ function PwrkCnsltnDocMngDialog({
const completedDraft = findCompletedPwrkDraft(pwrkCnsltnData);
const crtDmndUuid = completedDraft?.crtDmndUuid;
// 전체 버전 이력 목록 조회 (verSn 역순 정렬)
const { data: historyList = [] } = useQuery({
queryKey: ["pwrk-cnsltn-doc-crt-rslt", "history", crtDmndUuid],
queryFn: () => pwrkCnsltnDocCrtRsltService.findListByCrtDmndUuid(crtDmndUuid!),
enabled: open && Boolean(crtDmndUuid),
});
const targetBranchForApi = isForkJoin ? currentBranchSeCd : undefined;
const {
data: documentBlob,
isLoading: isDocumentLoading,
isError: isDocumentError,
} = useQuery({
queryKey: ["pwrk-cnsltn-doc-crt-rslt", "open-latest", crtDmndUuid],
queryFn: () => pwrkCnsltnDocCrtRsltService.openWebHwpLatest(crtDmndUuid!),
queryKey: [
"pwrk-cnsltn-doc-crt-rslt",
selectedVerSn !== null ? "open-version" : "open-latest",
crtDmndUuid,
targetBranchForApi,
selectedVerSn,
],
queryFn: () => {
if (selectedVerSn !== null) {
return pwrkCnsltnDocCrtRsltService.openWebHwpByVer(
crtDmndUuid!,
selectedVerSn,
targetBranchForApi,
);
}
return pwrkCnsltnDocCrtRsltService.openWebHwpLatest(crtDmndUuid!, targetBranchForApi);
},
select: (response) => response.data,
enabled: open && Boolean(crtDmndUuid),
staleTime: Infinity,
refetchOnWindowFocus: false,
refetchOnMount: false,
});
const documentFileName = `실무협의서_${pwrkCnsltnData?.pwrkCnsltnDocNo ?? crtDmndUuid}.hwpx`;
const branchLabel = isForkJoin
? currentBranchSeCd === "SP"
? "_관로"
: currentBranchSeCd === "SF"
? "_시설"
: "_종합"
: "";
const verSuffix = selectedVerSn !== null ? `_v${selectedVerSn}` : "";
const documentFileName = `실무협의서${branchLabel}${verSuffix}_${pwrkCnsltnData?.pwrkCnsltnDocNo ?? crtDmndUuid}.hwpx`;
const isLoading = isPwrkCnsltnLoading || isDocumentLoading;
const isError = isPwrkCnsltnError || isDocumentError;
// AI 자동화 이력 조회 (COST_VRFC 완료 여부 확인)
const { data: aiAutoLatestList } = useAiAutoPrcsLatest(fnrsCnsltnDocNo, {
enabled: open && Boolean(fnrsCnsltnDocNo),
atmtnSeComCd: AI_AUTO_SE.COST_VRFC,
});
const costVrfcRecord = useMemo(() => {
return aiAutoLatestList?.find((row) => row.atmtnSeComCd === AI_AUTO_SE.COST_VRFC);
}, [aiAutoLatestList]);
// COST_VRFC 완료 여부 (SUCCESS, COMPLETED, PASS 등)
const hasCostVrfcSuccess = useMemo(() => {
if (!costVrfcRecord) return false;
return (
costVrfcRecord.atmtnPrcsSttsComCd === "SUCCESS" ||
costVrfcRecord.atmtnPrcsSttsComCd === "COMPLETED" ||
costVrfcRecord.rplyRsltComCd === "PASS" ||
Boolean(costVrfcRecord.rplyDt)
);
}, [costVrfcRecord]);
// AI 금액검증(AV) 이후 진행되는 상태 (EV: 조치결과검토의뢰, RK: 2차검토, TE: 2차검토완료, SL: 2차보완 등)
const isPostCostVerifyStatus = useMemo(() => {
return Boolean(prgrsSttsComCd && ["EV", "RK", "TE", "SL", "AP"].includes(prgrsSttsComCd));
}, [prgrsSttsComCd]);
const effectivePwrkDocNo =
pwrkCnsltnData?.pwrkCnsltnDocNo ?? costVrfcRecord?.pwrkCnsltnDocNo;
const pjtcoAjmtSearchParams = useMemo(
() => ({
pwrkCnsltnDocNo: pwrkCnsltnData?.pwrkCnsltnDocNo,
pwrkCnsltnDocNo: effectivePwrkDocNo,
page: 0,
size: 1000,
sort: "rvwOpnnSn,asc",
}),
[pwrkCnsltnData?.pwrkCnsltnDocNo],
[effectivePwrkDocNo],
);
const {
data: pjtcoAjmtPage,
@@ -127,6 +442,17 @@ function PwrkCnsltnDocMngDialog({
isError: isPjtcoAjmtError,
} = usePwrkCnsltnDocPjtcoAjmtMngPage(pjtcoAjmtSearchParams, open);
// 금액검증 슬라이드 노출 조건:
// 1) 실제 금액조정 내역이 1건 이상 있거나
// 2) AI 금액검증이 완료된 상태(EV 또는 RK 등)이거나
// 3) tb_as_atmtn_prcs_mng에 COST_VRFC 완료 이력이 있는 경우
const shouldShowCostVerifySlide = useMemo(() => {
if (Boolean(pjtcoAjmtPage?.content?.length)) return true;
if (isPostCostVerifyStatus) return true;
if (hasCostVrfcSuccess) return true;
return false;
}, [pjtcoAjmtPage?.content?.length, isPostCostVerifyStatus, hasCostVrfcSuccess]);
const { uploadFileAsync, isUploading, uploads } = useTus({
onSuccess: (atflCd, atflId, _file, fileKey) => {
if (!atflCd || !atflId) return;
@@ -145,6 +471,7 @@ function PwrkCnsltnDocMngDialog({
setAtchFileGroupId("");
setUploadedFiles([]);
setIsEvlDialogOpen(false);
setSelectedVerSn(null);
return;
}
@@ -172,19 +499,193 @@ function PwrkCnsltnDocMngDialog({
return () => {
cancelled = true;
};
}, [open, pwrkCnsltnData?.atchFileGroupId]);
}, [open, pwrkCnsltnData?.atchFileGroupId, fnrsCnsltnDocNo, isForkJoin]);
function checkDirtyAndConfirm(targetBranchName: string): boolean {
if (currentBranchSeCd !== "MAIN") return true;
try {
const ctrl = getHwpCtrl(null);
if (ctrl?.IsModified) {
return window.confirm(
`종합 실무협의서(MAIN)에 저장되지 않은 수정사항이 있습니다.\n` +
`${targetBranchName}으로 이동하면 작성 중인 내용이 초기화됩니다.\n\n` +
`계속 이동하시겠습니까? (이동을 취소하고 [실무협의서 저장] 후 이동하는 것을 권장합니다.)`
);
}
} catch {
return true;
}
return true;
}
function handleTabChange(nextBranch: string, branchName: string) {
if (currentBranchSeCd !== nextBranch) {
if (!checkDirtyAndConfirm(branchName)) return;
setCurrentBranchSeCd(nextBranch);
}
setSelectedVerSn(null);
}
async function handleOpenBranchInNewWindow(targetBranch: "SP" | "SF") {
if (!completedDraft) {
alert("열람할 실무협의서 초안 정보가 없습니다.");
return;
}
const branchName = targetBranch === "SP" ? "관로 (SP)" : "시설 (SF)";
const popup = window.open(
"",
`pwrk_viewer_${targetBranch}`,
"width=1200,height=900,menubar=no,toolbar=no,location=no,status=no,resizable=yes",
);
if (!popup) {
alert("팝업이 차단되었습니다. 브라우저의 팝업 허용 설정을 확인해주세요.");
return;
}
const bridgeSrc = buildBridgeSrc();
popup.document.write(`
<!DOCTYPE html>
<html style="height:100%;margin:0;padding:0;">
<head>
<meta charset="utf-8" />
<title>실무협의서 [${branchName}] 참조 뷰어</title>
<style>
body { margin:0; padding:0; height:100%; display:flex; flex-direction:column; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; background:#f8fafc; }
header { display:flex; justify-content:space-between; align-items:center; padding:10px 16px; background:#1e293b; color:#fff; box-shadow:0 1px 3px rgba(0,0,0,0.2); }
h2 { margin:0; font-size:15px; font-weight:600; display:flex; align-items:center; gap:8px; }
.badge { background:#2563eb; color:#fff; padding:2px 8px; border-radius:4px; font-size:12px; font-weight:normal; }
.actions { display:flex; align-items:center; gap:8px; }
.btn { border:0; padding:6px 14px; border-radius:4px; cursor:pointer; font-size:13px; font-weight:500; transition:background 0.15s; }
.btn-download { background:#0284c7; color:#fff; }
.btn-download:hover { background:#0369a1; }
.btn-download:disabled { background:#64748b; cursor:not-allowed; opacity:0.6; }
.btn-close { background:#475569; color:#fff; }
.btn-close:hover { background:#334155; }
.loading { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); font-size:14px; color:#475569; background:rgba(255,255,255,0.95); padding:16px 24px; border-radius:8px; box-shadow:0 4px 6px -1px rgba(0,0,0,0.1); border:1px solid #e2e8f0; pointer-events:none; }
iframe { flex:1; width:100%; border:0; }
</style>
</head>
<body>
<header>
<h2>
<span>실무협의서 ${branchName} 참조 문서</span>
<span class="badge">읽기 전용 참조 모드</span>
</h2>
<div class="actions">
<button id="btn_download" class="btn btn-download" disabled onclick="if (window.onDownloadClick) window.onDownloadClick()">HWPX 다운로드</button>
<button class="btn btn-close" onclick="window.close()">창 닫기</button>
</div>
</header>
<div id="loading" class="loading">웹한글 기안기 및 ${branchName} 초안 문서를 불러오는 중입니다...</div>
<iframe id="viewer_frame" src="${bridgeSrc}"></iframe>
</body>
</html>
`);
popup.document.close();
try {
const response = await pwrkCnsltnDocCrtRsltService.openWebHwpLatest(
completedDraft.crtDmndUuid,
targetBranch,
);
const blob = response.data;
const fileName = `실무협의서_${targetBranch === "SP" ? "관로" : "시설"}.hwpx`;
const documentFile = new File([blob], fileName, { type: "application/haansofthwpx" });
// 다운로드 fallback 핸들러 바인딩
(popup as unknown as { onDownloadClick?: () => void }).onDownloadClick = () => {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const downloadBtn = popup.document.getElementById("btn_download") as HTMLButtonElement | null;
if (downloadBtn) {
downloadBtn.disabled = false;
}
const checkTimer = window.setInterval(() => {
try {
if (popup.closed) {
window.clearInterval(checkTimer);
return;
}
const frame = popup.document.getElementById("viewer_frame") as HTMLIFrameElement | null;
const ctrl = (frame?.contentWindow as Record<string, unknown> | null)?.HwpCtrl as {
Impl?: unknown;
Open?: (
source: File,
format: string,
option: string,
callback?: () => void,
) => boolean;
} | null | undefined;
if (ctrl && ctrl.Impl && typeof ctrl.Open === "function") {
window.clearInterval(checkTimer);
const loadingEl = popup.document.getElementById("loading");
if (loadingEl) loadingEl.style.display = "none";
ctrl.Open(documentFile, "HWPX", "", () => {});
}
} catch {
window.clearInterval(checkTimer);
}
}, 500);
window.setTimeout(() => window.clearInterval(checkTimer), 30_000);
} catch (err) {
console.error("새 창 문서 로드 실패:", err);
const loadingEl = popup.document.getElementById("loading");
if (loadingEl) {
loadingEl.textContent = `${branchName} 문서를 불러오지 못했습니다.`;
loadingEl.style.color = "#ef4444";
}
alert(`${branchName} 문서를 불러오지 못했습니다.`);
}
}
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen) onClose();
if (!nextOpen) {
if (currentBranchSeCd === "MAIN") {
try {
const ctrl = getHwpCtrl(null);
if (ctrl?.IsModified) {
const confirmed = window.confirm(
"종합 실무협의서(MAIN)에 저장되지 않은 수정사항이 있습니다.\n창을 닫으면 작성 중인 내용이 사라집니다. 닫으시겠습니까?",
);
if (!confirmed) return;
}
} catch {
// ignore
}
}
onClose();
}
}
async function handleSaveDocument(file: Blob) {
if (!completedDraft) throw new Error("저장할 실무협의서 초안이 없습니다.");
const saveBranch =
branchSeCd === "TOTAL_AGG" || currentBranchSeCd === "MAIN"
? "MAIN"
: isForkJoin
? currentBranchSeCd
: undefined;
const saveDto = pwrkCnsltnDocCrtRsltSaveRequestSchema.parse({
crtDmndUuid: completedDraft.crtDmndUuid,
pwrkCnsltnDocNo: completedDraft.pwrkCnsltnDocNo,
atchFileGroupId: atchFileGroupId || undefined,
branchSeCd: saveBranch,
});
const savedDocument = await pwrkCnsltnDocCrtRsltService.saveMultipart(
@@ -199,7 +700,11 @@ function PwrkCnsltnDocMngDialog({
queryClient.invalidateQueries({
queryKey: ["pwrk-cnsltn-doc-crt-rslt", "open-latest", completedDraft.crtDmndUuid],
}),
queryClient.invalidateQueries({
queryKey: ["pwrk-cnsltn-doc-crt-rslt", "history", completedDraft.crtDmndUuid],
}),
]);
setSelectedVerSn(null);
alert(`실무협의서를 저장했습니다. (버전 ${savedDocument.verSn})`);
if (savedDocument.verSn <= AI_DRAFT_EVL_MAX_VER_SN) {
@@ -210,7 +715,17 @@ function PwrkCnsltnDocMngDialog({
async function handleDownload() {
if (!completedDraft) throw new Error("다운로드할 실무협의서 초안이 없습니다.");
const response = await pwrkCnsltnDocCrtRsltService.downloadLatest(completedDraft.crtDmndUuid);
const response =
selectedVerSn !== null
? await pwrkCnsltnDocCrtRsltService.download(
completedDraft.crtDmndUuid,
selectedVerSn,
targetBranchForApi,
)
: await pwrkCnsltnDocCrtRsltService.downloadLatest(
completedDraft.crtDmndUuid,
targetBranchForApi,
);
const objectUrl = window.URL.createObjectURL(response.data);
const anchor = document.createElement("a");
anchor.href = objectUrl;
@@ -277,20 +792,259 @@ function PwrkCnsltnDocMngDialog({
const canSaveByAtrz = atrzSeComCdList
? !atrzSeComCdList.some((code) => APPROVER_ATRZ_SE_COM_CDS.has(code))
: true;
const canSave = !viewOnly && (canSaveProp ?? canSaveByAtrz);
const baseCanSave = !viewOnly && (canSaveProp ?? canSaveByAtrz);
const canSaveCurrentBranch = useMemo(() => {
// 1. 지자체 사용자 (ROLE_USER05)
if (effectiveUserRole === "ROLE_USER05") {
// 1-1. RB(조치결과작성) 상태: 병렬결재에서도 프로세스상 한 개의 MAIN만 남아 처리되므로 MAIN 탭 저장 허용!
if (prgrsSttsComCd === "RB") {
return currentBranchSeCd === "MAIN";
}
// 1-2. SL(보완요청/조치결과요청) 상태: 보완요청 부서(SP, SF) 초안만 저장 허용
if (prgrsSttsComCd === "SL") {
if (isForkJoin) {
// 보완요청 부서(supplementBranches)가 존재하는 경우 해당 부서만 저장 허용
if (supplementBranches.size > 0) {
return supplementBranches.has(currentBranchSeCd);
}
return currentBranchSeCd === "SP" || currentBranchSeCd === "SF";
}
return true;
}
return false;
}
// 2. 공단 사용자 등 일반 사용자 (검토자, 결재자 등)
if (!baseCanSave) return false;
if (isForkJoin) {
// 2-1. 병합자 후의 최종 승인자 또는 병합 완료(결재 후)된 병합자: 저장 불가 (읽기 전용)
if (isFinalApproverAfterMerger || isMergerApproved) {
return false;
}
// 2-2. 종합병합자 (ATRZ04 또는 TOTAL_AGG):
// 웹기안기의 한글문서 저장 버튼은 MAIN 탭의 것만 저장 버튼이 보이고 SP, SF 탭의 저장 버튼은 보이지 않음!
if (isMerger || effectiveBranchSeCd === "TOTAL_AGG") {
return currentBranchSeCd === "MAIN";
}
// 2-3. 관로 검토자: 관로(SP) 탭만 저장 가능 (타 부서 보완요청 여부와 무관하게 본인 부서 작성/수정 가능)
if (effectiveBranchSeCd === "SP") {
return currentBranchSeCd === "SP";
}
// 2-4. 시설 검토자: 시설(SF) 탭만 저장 가능 (타 부서 보완요청 여부와 무관하게 본인 부서 작성/수정 가능)
if (effectiveBranchSeCd === "SF") {
return currentBranchSeCd === "SF";
}
// 특정 부서가 지정되지 않은 일반 관리자/사용자
return baseCanSave;
}
return baseCanSave;
}, [
baseCanSave,
effectiveUserRole,
prgrsSttsComCd,
isForkJoin,
isFinalApproverAfterMerger,
isMerger,
isMergerApproved,
effectiveBranchSeCd,
currentBranchSeCd,
supplementBranches,
]);
// 현재 활성화된 탭(브랜치)의 최신(제일 마지막) verSn 계산
const latestVerSnForCurrentBranch = useMemo(() => {
const branchItems = historyList.filter(
(item) => (item.branchSeCd || "MAIN") === (currentBranchSeCd || "MAIN"),
);
if (!branchItems.length) return null;
return Math.max(...branchItems.map((item) => item.verSn));
}, [historyList, currentBranchSeCd]);
// 최신 버전을 열람 중인지 여부 (null이면 최신 모드이거나, 선택된 버전이 해당 탭의 최댓값과 일치)
const isCurrentBranchLatest =
selectedVerSn === null || selectedVerSn === latestVerSnForCurrentBranch;
// 저장 버튼은 해당 탭의 제일 마지막(최신) 버전이 아니면 자동 숨김 처리
const canSaveInCurrentView = canSaveCurrentBranch && isCurrentBranchLatest;
// 변경이력 콤보 박스 컴포넌트
const versionHistorySelect = (
<div className="flex items-center gap-2">
<label htmlFor="pwrk-version-history-select" className="text-sm font-medium text-muted-foreground whitespace-nowrap">
변경이력:
</label>
<select
id="pwrk-version-history-select"
className="h-8 rounded-md border border-input bg-background px-2 py-1 text-sm shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
value={selectedVerSn ?? ""}
onChange={(e) => {
const val = e.target.value;
if (!val) {
setSelectedVerSn(null);
return;
}
const num = Number(val);
const item = historyList.find((h) => h.verSn === num);
if (item) {
const targetBranch = item.branchSeCd || "MAIN";
setCurrentBranchSeCd(targetBranch);
setSelectedVerSn(item.verSn);
}
}}
aria-label="변경이력 선택"
>
<option value="">선택</option>
{historyList.map((item) => {
const branchText =
item.branchSeCd === "SF"
? "[시설]"
: item.branchSeCd === "SP"
? "[관로]"
: "[통합]";
return (
<option key={`${item.branchSeCd}_${item.verSn}`} value={item.verSn}>
{branchText} 버전 {item.verSn}
</option>
);
})}
</select>
</div>
);
const editorIframeHeight = isForkJoin
? "h-[50vh] min-h-[26rem]"
: "h-[58vh] min-h-[32rem]";
const editorContainerMinHeight = isForkJoin ? "min-h-[26rem]" : "min-h-[32rem]";
const pjtcoTableHeight = isForkJoin
? "h-[calc(50vh+4rem)]"
: "h-[calc(58vh+4rem)]";
return (
<>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
className="max-h-[96vh] max-w-[80vw] overflow-y-auto"
className="flex max-h-[96vh] max-w-[80vw] flex-col gap-4 overflow-y-auto"
aria-describedby={undefined}
>
<DialogHeader>
<DialogTitle>실무협의서 관리</DialogTitle>
</DialogHeader>
{isLoading ? <Skeleton className="h-[70vh] min-h-[40rem] w-full" /> : null}
{isForkJoin ? (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-border bg-muted/40 px-3 py-2">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-muted-foreground">부서별 문서 탭:</span>
{!isFinalApproverAfterMerger &&
!isWideLocalOrEnvUser &&
(prgrsSttsComCd === "TE" || prgrsSttsComCd === "SL" || prgrsSttsComCd === "RK") && (
<>
<Button
type="button"
size="sm"
color={currentBranchSeCd === "SP" ? "primary" : "white"}
onClick={() => handleTabChange("SP", "관로 초안 (SP)")}
>
관로 초안 (SP)
</Button>
<Button
type="button"
size="sm"
color={currentBranchSeCd === "SF" ? "primary" : "white"}
onClick={() => handleTabChange("SF", "시설 초안 (SF)")}
>
시설 초안 (SF)
</Button>
</>
)}
<Button
type="button"
size="sm"
color={currentBranchSeCd === "MAIN" ? "primary" : "white"}
onClick={() => handleTabChange("MAIN", "종합 실무협의서 (MAIN)")}
>
종합 실무협의서 (MAIN)
</Button>
{(isMerger || effectiveBranchSeCd === "TOTAL_AGG") && (
<div className="flex items-center gap-1.5 border-l border-border pl-2">
<span className="text-xs font-semibold text-muted-foreground whitespace-nowrap">참조 새 창:</span>
<Button
type="button"
size="sm"
color="white"
className="h-8 px-2 text-xs flex items-center gap-1 text-primary hover:bg-primary/10"
onClick={() => void handleOpenBranchInNewWindow("SP")}
title="관로(SP) 초안을 별도 창으로 열어 MAIN과 나란히 대조합니다."
>
<ExternalLink className="size-3.5" />
<span>관로(SP) 새 창 보기</span>
</Button>
<Button
type="button"
size="sm"
color="white"
className="h-8 px-2 text-xs flex items-center gap-1 text-primary hover:bg-primary/10"
onClick={() => void handleOpenBranchInNewWindow("SF")}
title="시설(SF) 초안을 별도 창으로 열어 MAIN과 나란히 대조합니다."
>
<ExternalLink className="size-3.5" />
<span>시설(SF) 새 창 보기</span>
</Button>
</div>
)}
</div>
<span className="text-xs text-muted-foreground">
{effectiveUserRole === "ROLE_USER05"
? prgrsSttsComCd === "RB"
? "지자체 담당자: 종합 실무협의서(MAIN) 조치결과 작성 모드 (저장 가능)"
: isForkJoin && supplementBranches.size > 0
? supplementBranches.has(currentBranchSeCd)
? supplementBranches.size > 1
? `[보완 수정 모드] 관로·시설 부서 모두 보완요청 상태이며, ${currentBranchSeCd === "SP" ? "관로" : "시설"} 초안을 수정 및 저장할 수 있습니다.`
: `[보완 수정 모드] ${supplementBranches.has("SP") ? "관로" : "시설"} 초안만 수정 및 저장 가능합니다.`
: `[읽기 전용 모드] ${currentBranchSeCd === "MAIN" ? "종합본" : currentBranchSeCd === "SP" ? "관로 초안" : "시설 초안"}은 보완 비대상으로 저장 불가합니다.`
: prgrsSttsComCd === "SL"
? `지자체 담당자: ${currentBranchSeCd === "MAIN" ? "종합본 열람 전용" : `${currentBranchSeCd === "SP" ? "관로" : "시설"} 초안 보완 수정 모드 (저장 가능)`}`
: "지자체 사용자: 문서 열람 모드"
: isWideLocalOrEnvUser
? effectiveUserRole === "ROLE_USER03"
? "환경청 담당자: 종합 실무협의서(MAIN) 열람 모드"
: "광역지자체 담당자: 종합 실무협의서(MAIN) 열람 모드"
: isFinalApproverAfterMerger
? "최종 승인자: MAIN 종합 실무협의서 열람 모드"
: isMerger || effectiveBranchSeCd === "TOTAL_AGG"
? isMergerApproved
? "종합병합자: 병합 결재 완료 (읽기 전용 모드)"
: currentBranchSeCd === "MAIN"
? "종합병합자: 상단 [새 창 보기]로 관로/시설 문서를 띄워 대조하면서, MAIN 종합본을 수동 병합 저장합니다."
: "종합병합자: 타 문서 열람 모드 (읽기 전용)"
: effectiveBranchSeCd === "SP"
? currentBranchSeCd === "SP"
? "관로 검토자: 관로 초안(SP) 편집 모드 (저장 가능)"
: "관로 검토자: 타 문서 열람 모드 (읽기 전용)"
: effectiveBranchSeCd === "SF"
? currentBranchSeCd === "SF"
? "시설 검토자: 시설 초안(SF) 편집 모드 (저장 가능)"
: "시설 검토자: 타 문서 열람 모드 (읽기 전용)"
: isForkJoin && supplementBranches.size > 0
? `[보완 진행 중] ${Array.from(supplementBranches).join(", ")} 부서 보완요청 진행 중`
: "문서 열람 모드"}
</span>
</div>
) : null}
{isLoading ? (
<Skeleton
className={cn("w-full", isForkJoin ? "h-[56vh] min-h-[26rem]" : "h-[64vh] min-h-[32rem]")}
/>
) : null}
{isError ? (
<p className="py-16 text-center text-lg text-muted-foreground">
실무협의서 정보를 불러오지 못했습니다.
@@ -302,21 +1056,28 @@ function PwrkCnsltnDocMngDialog({
</p>
) : null}
{completedDraft && documentBlob ? (
<div className="flex min-h-[40rem] items-stretch gap-2">
<div className={cn("flex items-stretch gap-2", editorContainerMinHeight)}>
<div className="min-w-0 flex-1">
<HangulGiangi
documentBlob={documentBlob}
documentKey={completedDraft.crtDmndUuid}
documentKey={`${completedDraft.crtDmndUuid}_${currentBranchSeCd}_${selectedVerSn ?? "latest"}`}
documentFileName={documentFileName}
canSave={canSave}
canSave={canSaveInCurrentView}
iframeClassName={editorIframeHeight}
onSaveDocument={handleSaveDocument}
onDownload={handleDownload}
onDmndSplmnt={onDmndSplmnt}
rightSlot={versionHistorySelect}
/>
</div>
{pjtcoAjmtPage?.content?.length ? (
{shouldShowCostVerifySlide ? (
isPjtcoAjmtTableOpen ? (
<section className="flex h-[calc(64vh+4rem)] w-0 flex-[0.4] flex-col overflow-hidden rounded-md border border-border">
<section
className={cn(
"flex w-0 flex-[0.4] flex-col overflow-hidden rounded-md border border-border",
pjtcoTableHeight,
)}
>
<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
@@ -333,6 +1094,7 @@ function PwrkCnsltnDocMngDialog({
rows={pjtcoAjmtPage?.content ?? []}
isLoading={isPjtcoAjmtLoading}
isError={isPjtcoAjmtError}
emptyMessage="금액조정 검토의견이 0건(변동 없음)입니다."
/>
</div>
</section>
@@ -366,10 +1128,10 @@ function PwrkCnsltnDocMngDialog({
}))}
inputId="pwrk-cnsltn-doc-atch-file-input"
isUploadingByKey={isUploading}
addDisabled={!canSave || isAnyAtchUploading}
showAddButton={canSave}
addDisabled={!canSaveInCurrentView || isAnyAtchUploading}
showAddButton={canSaveInCurrentView}
onAdd={(files) => void handleAtchFileUpload(files)}
onRemove={canSave ? (index) => void handleAtchFileRemove(index) : undefined}
onRemove={canSaveInCurrentView ? (index) => void handleAtchFileRemove(index) : undefined}
/>
</section>
) : null}
@@ -386,6 +1148,7 @@ function PwrkCnsltnDocMngDialog({
<PwrkCnsltnDocEvlDialog
open={isEvlDialogOpen}
pwrkCnsltnDocNo={completedDraft.pwrkCnsltnDocNo}
onSaved={() => setIsEvlDialogOpen(false)}
onClose={() => setIsEvlDialogOpen(false)}
/>
) : null}
@@ -16,12 +16,14 @@ interface PwrkCnsltnDocPjtcoAjmtTableProps {
rows: PwrkCnsltnDocPjtcoAjmtMngResponseDto[];
isLoading: boolean;
isError: boolean;
emptyMessage?: string;
}
function PwrkCnsltnDocPjtcoAjmtTable({
rows,
isLoading,
isError,
emptyMessage = "금액조정 검토의견이 0건(변동 없음)입니다.",
}: PwrkCnsltnDocPjtcoAjmtTableProps) {
if (isLoading) {
return (
@@ -64,7 +66,7 @@ function PwrkCnsltnDocPjtcoAjmtTable({
{rows.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="py-10 text-center text-muted-foreground">
조회된 사업비조정 항목이 없습니다.
{emptyMessage}
</TableCell>
</TableRow>
) : (
@@ -39,6 +39,7 @@ import { Button } from "@common/components";
import {
type FnrsCnsltnRegistFormData,
fnrsCnsltnRegistSchema,
isForkJoinTask,
} from "../lib/fnrs-cnsltn-regist-schema";
import {
buildFormPayload,
@@ -349,7 +350,10 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
<PwrkCnsltnDocMngDialog
open={isPwrkCnsltnDocMngOpen}
fnrsCnsltnDocNo={fnrsCnsltnDocNo}
canSave
isForkJoin={isForkJoinTask(initialData?.detDto?.taskSeComCd)}
userRole={userInfo?.dtlInstCd}
prgrsSttsComCd={initialData?.detDto?.prgrsSttsComCd ?? undefined}
canSave={isSplmntPrgrsStts && userInfo?.dtlInstCd === "ROLE_USER05" ? null : !isSplmntPrgrsStts}
onClose={() => setIsPwrkCnsltnDocMngOpen(false)}
/>
) : null}
@@ -2,6 +2,8 @@
import { useMemo, useRef } from "react";
import { RefreshCw } from "lucide-react";
import { useUserStore } from "@/shared/lib";
import { SectionCollapsibleGroup, SectionCollapsibleToggle, TextShimmer } from "@/shared/ui";
import { ElaprDocPrgrsStatus } from "@/entities/elapr";
@@ -12,7 +14,7 @@ import {
ProgressStatusSection,
SupplementDemandSection,
} from "@/widgets/pbadms-sprt";
import { Skeleton } from "@common/components";
import { Button, cn, Skeleton } from "@common/components";
import {
getAiPendingMessage,
@@ -20,6 +22,7 @@ import {
isRagIndexingInProgress,
} from "../lib/ai-pending-utils";
import { needsRoleUser01AtrzResolve } from "../lib/button-config";
import { normalizeTaskSeComCdForElapr } from "../lib/fnrs-cnsltn-regist-schema";
import { getEditableReviewStep } from "../lib/review-step-utils";
import { EMPTY_CKLST_RESULT_LIST, useChecklistFormState } from "../lib/use-checklist-form-state";
import { PROGRESS_STATUS_COLUMNS } from "../model/columns";
@@ -43,7 +46,7 @@ interface FnrsViewPageProps {
function FnrsViewPage({ id }: FnrsViewPageProps) {
const userInfo = useUserStore((state) => state.userInfo);
const techReviewOpinionRef = useRef<TechReviewOpinionSectionHandle>(null);
const { data, isLoading, isError } = useFnrsCnsltnMng(id);
const { data, isLoading, isError, refetch, isFetching } = useFnrsCnsltnMng(id);
const { formState: checklistFormState, setFormState: setChecklistFormState } =
useChecklistFormState(data?.cklstResultList ?? EMPTY_CKLST_RESULT_LIST);
@@ -108,19 +111,33 @@ function FnrsViewPage({ id }: FnrsViewPageProps) {
return (
<div className="mt-4">
<SectionCollapsibleGroup>
<div className="sticky top-[6rem] z-30 flex items-center gap-4 bg-container px-4 py-2">
<SectionCollapsibleToggle />
{showPendingBanner && (
<div className="flex items-center gap-2">
<span className="relative flex size-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75"></span>
<span className="relative inline-flex size-3 rounded-full bg-primary"></span>
</span>
<TextShimmer active className="text-xl">
{pendingMessage}
</TextShimmer>
</div>
)}
<div className="sticky top-[6rem] z-30 flex items-center justify-between bg-container px-4 py-2">
<div className="flex items-center gap-3">
<SectionCollapsibleToggle />
<Button
type="button"
variant="outline"
size="xs"
onClick={() => refetch()}
disabled={isFetching}
className="flex items-center gap-1.5 h-[2.8rem] px-3 text-[1.2rem] font-normal"
title="최신 데이터 새로고침"
>
<RefreshCw className={cn("size-3.5", isFetching && "animate-spin")} />
<span>새로 고침</span>
</Button>
{showPendingBanner && (
<div className="flex items-center gap-2">
<span className="relative flex size-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75"></span>
<span className="relative inline-flex size-3 rounded-full bg-primary"></span>
</span>
<TextShimmer active className="text-xl">
{pendingMessage}
</TextShimmer>
</div>
)}
</div>
</div>
<div className="space-y-4">
<ProgressStatusSection
@@ -130,7 +147,10 @@ function FnrsViewPage({ id }: FnrsViewPageProps) {
/>
<BasicInfoSection det={detDto} />
<ContactInfoSection det={detDto} />
<ElaprDocPrgrsStatus elaprDocNo={detDto.fnrsCnsltnDocNo} />
<ElaprDocPrgrsStatus
elaprDocNo={detDto.fnrsCnsltnDocNo}
taskSeComCd={normalizeTaskSeComCdForElapr(detDto.taskSeComCd)}
/>
<BusinessScaleSection bizSclList={bizSclList} />
<BusinessCostSection bizCstList={bizCstList} />
<FnrsSharSection fnrsSharList={fnrsSharList} />
@@ -8,6 +8,7 @@ import { SectionCollapsible } from "@/shared/ui";
import { Field, FieldError, FieldGroup, FieldLabel, FieldSet } from "@/shared/ui/field";
import { findCodeLabel, useCodeList } from "@/entities/code";
import { type BnsApplyData, BnsPickerDialog } from "@/widgets/bns-picker";
import { PreviousPlanLookupButton } from "@/widgets/previous-plan-lookup";
import {
Button,
Checkbox,
@@ -27,9 +28,9 @@ function BasicInfoFormSection() {
const { control, watch, setValue } = useFormContext<FnrsCnsltnRegistFormData>();
const [isPickerOpen, setIsPickerOpen] = useState(false);
const taskSeComCd = watch("fnrsCnsltnMng.taskSeComCd");
const localGovLabel = [watch("fnrsCnsltnMng.sido"), watch("fnrsCnsltnMng.gugun")]
.filter(Boolean)
.join(" ");
const sido = watch("fnrsCnsltnMng.sido");
const gugun = watch("fnrsCnsltnMng.gugun");
const localGovLabel = [sido, gugun].filter(Boolean).join(" ");
const { data: taskOptions } = useCodeList("FNRS");
const { data: rtlztnFcltOptions } = useCodeList("RTLZTNFCLTSE");
const { data: rtlztnUsgOptions } = useCodeList("RTLZTNUSGSE");
@@ -49,6 +50,7 @@ function BasicInfoFormSection() {
return (
<SectionCollapsible title="기본 정보">
<PreviousPlanLookupButton sidoNm={sido} gugunNm={gugun} />
<Table caption="기본 정보 입력으로 사업명, 사업기간, 사업내용, 구분, 신청 지자체, 시설종류, 재이용용도 정보 제공" variant="secondary">
<colgroup>
<col width="15%" />
@@ -2,6 +2,7 @@
import { SectionCollapsible } from "@/shared/ui";
import type { FnrsCnsltnMngResponseDto } from "@/entities/fnrs-cnsltn";
import { PreviousPlanLookupButton } from "@/widgets/previous-plan-lookup";
import { Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
interface BasicInfoSectionProps {
@@ -11,6 +12,7 @@ interface BasicInfoSectionProps {
function BasicInfoSection({ det }: BasicInfoSectionProps) {
return (
<SectionCollapsible title="기본 정보">
<PreviousPlanLookupButton sidoNm={det.sido} gugunNm={det.gugun} />
<Table caption="기본 정보로 사업명, 사업기간, 사업내용, 환경청구분, 구분, 신청지자체, 확정예산사업번호, 예산신청연도 정보 제공" variant="secondary">
<colgroup>
<col width="15%" />
@@ -1,352 +1,345 @@
"use client";
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib";
import { AttachmentFileField, SectionCollapsible } from "@/shared/ui";
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
import {
AttachmentDownloadLink,
attachService,
useDownloadAttachmentByAtflId,
} from "@/entities/attach";
import type { FnrsCnsltnRvwOpnnResponseDto } from "@/entities/fnrs-cnsltn";
import {
RadioGroup,
RadioGroupItem,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Textarea,
} from "@common/components";
import type {
ReviewOpinionFormData,
ReviewOpinionGetFormDataResult,
} from "../../lib/review-opinion-utils";
import type { ReviewStepCode } from "../../model/view-types";
import { REVIEW_STEP_LABELS } from "../../model/view-types";
const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
interface TechReviewOpinionSectionProps {
rvwList: FnrsCnsltnRvwOpnnResponseDto[];
editableReviewStep?: ReviewStepCode;
atrzSeComCdList?: string[];
isAtrzLoading?: boolean;
}
export interface TechReviewOpinionSectionHandle {
getFormData: () => ReviewOpinionGetFormDataResult | null;
resetForm: () => void;
}
interface TechReviewOpinionTableProps {
items: FnrsCnsltnRvwOpnnResponseDto[];
}
interface UploadedOpinionFile {
fileName: string;
fileKey: string;
atflId?: string;
atflCd?: string;
}
const EMPTY_FORM_DATA: ReviewOpinionFormData = {
rvwRsltCn: "",
rvwRsltAnmlyYn: "",
atchFileGroupId: "",
opnrSeComCd: "",
};
function createRvwOpnnUploadKey(file: File) {
return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
}
function hasFormContent(formData: ReviewOpinionFormData) {
return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
}
const TechReviewOpinionSection = forwardRef<
TechReviewOpinionSectionHandle,
TechReviewOpinionSectionProps
>(function TechReviewOpinionSection(
{ rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false },
ref,
) {
const canWriteKecoOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
const [formData, setFormData] = useState<ReviewOpinionFormData>({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null);
const { mutate: downloadByAtflId } = useDownloadAttachmentByAtflId();
useEffect(() => {
setFormData((prev) => ({
...prev,
opnrSeComCd: editableReviewStep ?? "",
}));
}, [editableReviewStep]);
const { uploadFileAsync, isUploading, uploads } = useTus({
onSuccess: (atflCd, atflId, _file, fileKey) => {
if (!atflCd || !atflId) return;
setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd }));
setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
},
onError: (_error, _file, fileKey) => {
setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
},
});
useImperativeHandle(
ref,
() => ({
getFormData: () => {
if (!editableReviewStep || !hasFormContent(formData)) {
return null;
}
if (editableReviewStep === "KECO" && !canWriteKecoOpinion) {
return null;
}
return {
formData: {
...formData,
opnrSeComCd: editableReviewStep,
},
rvwList,
};
},
resetForm: () => {
setFormData({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
setUploadedFile(null);
},
}),
[canWriteKecoOpinion, editableReviewStep, formData, rvwList],
);
const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV");
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
const hasEnvSection = envRvwList.length > 0 || editableReviewStep === "ENV";
const hasKecoSection = kecoRvwList.length > 0 || editableReviewStep === "KECO";
if (!hasEnvSection && !hasKecoSection) {
return null;
}
async function handleFileUpload(files: FileList | null) {
if (!files || files.length === 0) return;
const file = files[0];
const fileKey = createRvwOpnnUploadKey(file);
const existingAtflCd = formData.atchFileGroupId;
setUploadedFile({ fileName: file.name, fileKey });
try {
await uploadFileAsync(file, {
fileKey,
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
});
} catch {
// onError callback handles cleanup
}
}
async function handleFileRemove() {
if (!uploadedFile) return;
uploads[uploadedFile.fileKey]?.abort();
if (uploadedFile.atflId) {
try {
await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
} catch (error) {
console.error("검토의견 첨부파일 삭제 실패:", error);
}
}
setUploadedFile(null);
setFormData((prev) => ({ ...prev, atchFileGroupId: "" }));
}
function renderOpinionForm(reviewStep: ReviewStepCode) {
if (editableReviewStep !== reviewStep) return null;
if (reviewStep === "KECO" && !canWriteKecoOpinion) return null;
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
return (
<div className="space-y-2">
<h4 className="text-xl font-semibold">검토의견 작성</h4>
<Table caption="검토의견 작성으로 의견내용, 검토결과, 첨부파일 정보 제공" variant="secondary">
<TableHeader>
<TableRow>
<TableHead className="w-[12rem]">의견내용</TableHead>
<TableCell>
<Textarea
value={formData.rvwRsltCn}
onChange={(event) =>
setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value }))
}
placeholder="의견내용을 입력하세요"
rows={4}
className="min-h-[8rem] !text-xl"
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>검토결과</TableHead>
<TableCell>
<FieldSet>
<RadioGroup
value={formData.rvwRsltAnmlyYn}
onValueChange={(value) =>
setFormData((prev) => ({
...prev,
rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"],
}))
}
>
<div className="flex flex-wrap gap-4">
<FieldLabel
htmlFor={`rvw-opnn-normal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} />
<span className="font-normal">정상</span>
</Field>
</FieldLabel>
<FieldLabel
htmlFor={`rvw-opnn-abnormal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} />
<span className="font-normal">이상</span>
</Field>
</FieldLabel>
</div>
</RadioGroup>
</FieldSet>
</TableCell>
</TableRow>
<TableRow>
<TableHead>첨부파일</TableHead>
<TableCell>
<AttachmentFileField
files={
uploadedFile
? [
{
fileKey: uploadedFile.fileKey,
fileName: uploadedFile.fileName,
onDownload: uploadedFile.atflId
? () => downloadByAtflId(uploadedFile.atflId!)
: undefined,
},
]
: []
}
inputId={`rvw-opnn-${reviewStep}-file-input`}
isUploadingByKey={isUploading}
multiple={false}
showAddButton={!uploadedFile}
addDisabled={uploading}
onAdd={(fileList) => void handleFileUpload(fileList)}
onRemove={() => void handleFileRemove()}
/>
</TableCell>
</TableRow>
</TableHeader>
</Table>
</div>
);
}
return (
<>
{hasEnvSection ? (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}>
<div className="space-y-6">
{envRvwList.length > 0 ? <TechReviewOpinionTable items={envRvwList} /> : null}
{renderOpinionForm("ENV")}
</div>
</SectionCollapsible>
) : null}
{hasKecoSection ? (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
<div className="space-y-6">
{kecoRvwList.length > 0 ? <TechReviewOpinionTable items={kecoRvwList} /> : null}
{/*
재원협의에서 환경공단 검토의견은 자동 생성
{renderOpinionForm("KECO")}
*/}
</div>
</SectionCollapsible>
) : null}
</>
);
});
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
return (
<div className="overflow-x-auto">
<Table caption="검토의견 목록으로 의견자명, 의견내용, 검토결과, 첨부파일, 등록일시 정보 제공" variant="secondary">
<colgroup>
<col width="10%" />
<col width="55%" />
<col width="5%" />
<col width="20%" />
<col width="10%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>의견자명</TableHead>
<TableHead>의견내용</TableHead>
<TableHead>검토결과</TableHead>
<TableHead>첨부파일</TableHead>
<TableHead>등록일시</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={`${item.fnrsCnsltnDocNo}_${item.rvwOpnnSn}`}>
<TableCell align="center">{item.opnrNm}</TableCell>
<TableCell align="left">{item.rvwRsltCn || "-"}</TableCell>
<TableCell align="center">
{item.rvwRsltAnmlyYn === "Y" ? "이상" : item.rvwRsltAnmlyYn === "N" ? "정상" : "-"}
</TableCell>
<TableCell align="center">
{item.atchFileInfo?.atflCd ? (
<AttachmentDownloadLink
atflCd={item.atchFileInfo.atflCd}
fileName={item.atchFileInfo.orgnlFileNm || "다운로드"}
/>
) : (
"-"
)}
</TableCell>
<TableCell align="center">
{item.regDt ? dayjs(item.regDt).format("YYYY-MM-DD HH:mm:ss") : "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
export { TechReviewOpinionSection };
"use client";
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { getDefaultFileKey, useTus, useUserStore } from "@/shared/lib";
import { AttachmentFileField, SectionCollapsible } from "@/shared/ui";
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
import { attachService, useDownloadAttachmentByAtflId } from "@/entities/attach";
import {
fnrsCnsltnKeys,
type FnrsCnsltnRvwOpnnResponseDto,
fnrsCnsltnRvwOpnnService,
} from "@/entities/fnrs-cnsltn";
import { RvwOpnnListTable } from "@/widgets/pbadms-sprt";
import {
RadioGroup,
RadioGroupItem,
Table,
TableCell,
TableHead,
TableHeader,
TableRow,
Textarea,
} from "@common/components";
import type {
ReviewOpinionFormData,
ReviewOpinionGetFormDataResult,
} from "../../lib/review-opinion-utils";
import type { ReviewStepCode } from "../../model/view-types";
import { REVIEW_STEP_LABELS } from "../../model/view-types";
const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
interface TechReviewOpinionSectionProps {
rvwList: FnrsCnsltnRvwOpnnResponseDto[];
editableReviewStep?: ReviewStepCode;
atrzSeComCdList?: string[];
isAtrzLoading?: boolean;
}
export interface TechReviewOpinionSectionHandle {
getFormData: () => ReviewOpinionGetFormDataResult | null;
resetForm: () => void;
}
interface TechReviewOpinionTableProps {
items: FnrsCnsltnRvwOpnnResponseDto[];
}
interface UploadedOpinionFile {
fileName: string;
fileKey: string;
atflId?: string;
atflCd?: string;
}
const EMPTY_FORM_DATA: ReviewOpinionFormData = {
rvwRsltCn: "",
rvwRsltAnmlyYn: "",
atchFileGroupId: "",
opnrSeComCd: "",
};
function createRvwOpnnUploadKey(file: File) {
return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
}
function hasFormContent(formData: ReviewOpinionFormData) {
return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
}
const TechReviewOpinionSection = forwardRef<
TechReviewOpinionSectionHandle,
TechReviewOpinionSectionProps
>(function TechReviewOpinionSection(
{ rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false },
ref,
) {
const canWriteKecoOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
const [formData, setFormData] = useState<ReviewOpinionFormData>({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null);
const { mutate: downloadByAtflId } = useDownloadAttachmentByAtflId();
useEffect(() => {
setFormData((prev) => ({
...prev,
opnrSeComCd: editableReviewStep ?? "",
}));
}, [editableReviewStep]);
const { uploadFileAsync, isUploading, uploads } = useTus({
onSuccess: (atflCd, atflId, _file, fileKey) => {
if (!atflCd || !atflId) return;
setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd }));
setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
},
onError: (_error, _file, fileKey) => {
setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
},
});
useImperativeHandle(
ref,
() => ({
getFormData: () => {
if (!editableReviewStep || !hasFormContent(formData)) {
return null;
}
if (editableReviewStep === "KECO" && !canWriteKecoOpinion) {
return null;
}
return {
formData: {
...formData,
opnrSeComCd: editableReviewStep,
},
rvwList,
};
},
resetForm: () => {
setFormData({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
setUploadedFile(null);
},
}),
[canWriteKecoOpinion, editableReviewStep, formData, rvwList],
);
const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV");
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
const hasEnvSection = envRvwList.length > 0 || editableReviewStep === "ENV";
const hasKecoSection = kecoRvwList.length > 0 || editableReviewStep === "KECO";
if (!hasEnvSection && !hasKecoSection) {
return null;
}
async function handleFileUpload(files: FileList | null) {
if (!files || files.length === 0) return;
const file = files[0];
const fileKey = createRvwOpnnUploadKey(file);
const existingAtflCd = formData.atchFileGroupId;
setUploadedFile({ fileName: file.name, fileKey });
try {
await uploadFileAsync(file, {
fileKey,
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
});
} catch {
// onError callback handles cleanup
}
}
async function handleFileRemove() {
if (!uploadedFile) return;
uploads[uploadedFile.fileKey]?.abort();
if (uploadedFile.atflId) {
try {
await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
} catch (error) {
console.error("검토의견 첨부파일 삭제 실패:", error);
}
}
setUploadedFile(null);
setFormData((prev) => ({ ...prev, atchFileGroupId: "" }));
}
function isReviewTurn(reviewStep: ReviewStepCode) {
if (editableReviewStep !== reviewStep) return false;
if (reviewStep === "KECO" && !canWriteKecoOpinion) return false;
return true;
}
function renderOpinionForm(reviewStep: ReviewStepCode) {
if (!isReviewTurn(reviewStep)) return null;
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
return (
<div className="space-y-2">
<h4 className="text-xl font-semibold">검토의견 작성</h4>
<Table
caption="검토의견 작성으로 의견내용, 검토결과, 첨부파일 정보 제공"
variant="secondary"
>
<TableHeader>
<TableRow>
<TableHead className="w-[12rem]">의견내용</TableHead>
<TableCell>
<Textarea
value={formData.rvwRsltCn}
onChange={(event) =>
setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value }))
}
placeholder="의견내용을 입력하세요"
rows={4}
className="min-h-[8rem] !text-xl"
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>검토결과</TableHead>
<TableCell>
<FieldSet>
<RadioGroup
value={formData.rvwRsltAnmlyYn}
onValueChange={(value) =>
setFormData((prev) => ({
...prev,
rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"],
}))
}
>
<div className="flex flex-wrap gap-4">
<FieldLabel
htmlFor={`rvw-opnn-normal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} />
<span className="font-normal">정상</span>
</Field>
</FieldLabel>
<FieldLabel
htmlFor={`rvw-opnn-abnormal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} />
<span className="font-normal">이상</span>
</Field>
</FieldLabel>
</div>
</RadioGroup>
</FieldSet>
</TableCell>
</TableRow>
<TableRow>
<TableHead>첨부파일</TableHead>
<TableCell>
<AttachmentFileField
files={
uploadedFile
? [
{
fileKey: uploadedFile.fileKey,
fileName: uploadedFile.fileName,
onDownload: uploadedFile.atflId
? () => downloadByAtflId(uploadedFile.atflId!)
: undefined,
},
]
: []
}
inputId={`rvw-opnn-${reviewStep}-file-input`}
isUploadingByKey={isUploading}
multiple={false}
showAddButton={!uploadedFile}
addDisabled={uploading}
onAdd={(fileList) => void handleFileUpload(fileList)}
onRemove={() => void handleFileRemove()}
/>
</TableCell>
</TableRow>
</TableHeader>
</Table>
</div>
);
}
return (
<>
{hasEnvSection ? (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}>
<div className="space-y-6">
{envRvwList.length > 0 ? (
<TechReviewOpinionTable items={envRvwList} />
) : null}
{renderOpinionForm("ENV")}
</div>
</SectionCollapsible>
) : null}
{hasKecoSection ? (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
<div className="space-y-6">
{kecoRvwList.length > 0 ? (
<TechReviewOpinionTable items={kecoRvwList} />
) : null}
{/*
재원협의에서 환경공단 검토의견은 자동 생성
{renderOpinionForm("KECO")}
*/}
</div>
</SectionCollapsible>
) : null}
</>
);
});
function getRvwOpnnIds(item: FnrsCnsltnRvwOpnnResponseDto) {
if (!item.fnrsCnsltnDocNo || item.rvwOpnnSn == null) {
throw new Error("검토의견 식별값이 없습니다.");
}
return [item.fnrsCnsltnDocNo, item.rvwOpnnSn] as const;
}
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
const queryClient = useQueryClient();
const currentUserUuid = useUserStore((state) => state.userInfo?.userUnqId);
return (
<RvwOpnnListTable
items={items}
getRowKey={(item) => `${item.fnrsCnsltnDocNo}_${item.rvwOpnnSn}`}
currentUserUuid={currentUserUuid}
onUpdate={async (item, body) => {
await fnrsCnsltnRvwOpnnService.update(...getRvwOpnnIds(item), body);
await queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
}}
onDelete={async (item) => {
await fnrsCnsltnRvwOpnnService.delete(...getRvwOpnnIds(item));
await queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
}}
/>
);
}
export { TechReviewOpinionSection };
@@ -3,7 +3,6 @@ export const INSTL_SNCN_VIEW_BUTTON_ACTION = {
aply: "aply",
del: "del",
instlSncnAncmnt: "instlSncnAncmnt",
envoSbmsn: "envoSbmsn",
rjct: "rjct",
sncnAprv: "sncnAprv",
rvwRply: "rvwRply",
@@ -33,14 +32,16 @@ const ROLE_STATUS_BUTTON_ACTIONS: Record<string, Record<string, InstlSncnViewBut
],
TA: [INSTL_SNCN_VIEW_BUTTON_ACTION.instlSncnAncmnt],
},
ROLE_USER04: {
ST: [INSTL_SNCN_VIEW_BUTTON_ACTION.envoSbmsn, INSTL_SNCN_VIEW_BUTTON_ACTION.rjct],
EP: [INSTL_SNCN_VIEW_BUTTON_ACTION.sncnAprv],
},
ROLE_USER03: {
LE: [INSTL_SNCN_VIEW_BUTTON_ACTION.rvwRply, INSTL_SNCN_VIEW_BUTTON_ACTION.rqstTechRvw],
TP: [INSTL_SNCN_VIEW_BUTTON_ACTION.rvwRply],
},
// 환경청 단계 제거: 기존 환경청(LE·TP) 기능을 광역자치단체가 수행
ROLE_USER04: {
ST: [
INSTL_SNCN_VIEW_BUTTON_ACTION.rvwRply,
INSTL_SNCN_VIEW_BUTTON_ACTION.rqstTechRvw,
INSTL_SNCN_VIEW_BUTTON_ACTION.rjct,
],
TP: [INSTL_SNCN_VIEW_BUTTON_ACTION.rvwRply],
EP: [INSTL_SNCN_VIEW_BUTTON_ACTION.sncnAprv],
},
};
const ROLE_USER01_ATRZ_STATUS_CODES = ["ER", "TE"] as const;
@@ -63,7 +63,7 @@ export function mapDetailToFormData(data: InstlSncnMngDetResponse): InstlSncnReg
cklstSn: item.cklstSn,
cklstArtclSn: item.cklstArtclSn,
cklstRvwSn: item.cklstRvwSn,
rvwStpComCd: item.rvwStpComCd,
rvwStpComCd: item.rvwStpComCd || "LOCAL",
selected: mapSelectedValue(item.rvwRsltAnmlyYn),
picRvwCn: item.picRvwCn,
})) ?? [],
@@ -143,6 +143,7 @@ export function buildFormPayload(
bizCstList: data.bizCstList,
cklstResultList: data.cklstResultList.map((item) => ({
...item,
rvwStpComCd: item.rvwStpComCd || "LOCAL",
selected: item.selected ?? undefined,
})),
esntlAtchFileList: data.esntlAtchFileList,
@@ -55,16 +55,8 @@ export const instlSncnRegistSchema = z.object({
tstmDesignAmt: z.number(),
}),
),
cklstResultList: z.array(
instlSncnCklstResultItemSchema.extend({
cklstSn: z.number(),
cklstArtclSn: z.number(),
cklstRvwSn: z.number(),
rvwStpComCd: z.string(),
selected: z.enum(["Y", "N"]).nullish(),
picRvwCn: z.string().nullish(),
}),
),
// 설치인가 화면에서 체크리스트 미사용: 숨겨진 값 때문에 신청·저장 검증이 막히지 않도록 필수값을 두지 않음
cklstResultList: z.array(instlSncnCklstResultItemSchema),
esntlAtchFileList: z.array(
instlSncnEsntlAtchFileItemSchema.extend({
esntlAtchFileId: z.string(),
@@ -50,7 +50,7 @@ export function getEditableReviewStep(
dtlInstCd?: string,
prgrsSttsComCd?: string,
): ReviewStepCode | undefined {
if (dtlInstCd === "ROLE_USER03" && (prgrsSttsComCd === "LE" || prgrsSttsComCd === "TP")) {
if (dtlInstCd === "ROLE_USER04" && (prgrsSttsComCd === "ST" || prgrsSttsComCd === "TP")) {
return "ENV";
}
+231 -231
View File
@@ -1,231 +1,231 @@
"use client";
import { Fragment } from "react";
import { createColumnHelper } from "@tanstack/react-table";
import { dayjs } from "@/shared/lib";
import type { InstlSncnMngResponseDto } from "@/entities/instl-sncn";
import { Badge } from "@common/components";
const columnHelper = createColumnHelper<InstlSncnMngResponseDto>();
const MAIN_STEP_LABELS = {
apply: "설치인가 신청",
envoReview: "설치인가 검토",
techReview: "기술검토",
reply: "검토결과 회신",
} as const;
export const PROGRESS_STATUS_COLUMNS = [
{ label: "설치인가 신청 (시군)", sttsComCd: "ST" },
{ label: "설치인가 신청 (시도)", sttsComCd: "LE" },
{ label: "설치인가 검토", sttsComCd: "ER" },
{ label: "기술검토", sttsComCd: "TE" },
{ label: "기술검토 회신", sttsComCd: "TP" },
{ label: "검토결과 회신", sttsComCd: "EP" },
{ label: "설치인가 승인", sttsComCd: "TA" },
];
type BadgeState = "completed" | "current" | "pending";
interface ProgressBadge {
label: string;
state: BadgeState;
}
function buildProgressBadges(prgrsSttsComCd: string, techRplyHstryYn: "Y" | "N"): ProgressBadge[] {
if (prgrsSttsComCd === "SV") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "ST") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "current" },
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "LE") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "current" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "ER" || prgrsSttsComCd === "TE" || prgrsSttsComCd === "SL") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{ label: MAIN_STEP_LABELS.techReview, state: "current" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "TP" || prgrsSttsComCd === "EP") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{
label: MAIN_STEP_LABELS.techReview,
state: techRplyHstryYn === "Y" ? "completed" : "pending",
},
{ label: MAIN_STEP_LABELS.reply, state: "current" },
];
}
if (prgrsSttsComCd === "TA" || prgrsSttsComCd === "TB") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{
label: MAIN_STEP_LABELS.techReview,
state: techRplyHstryYn === "Y" ? "completed" : "pending",
},
{ label: MAIN_STEP_LABELS.reply, state: "completed" },
];
}
return [
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
function getProgressStatusBadge(prgrsSttsComCd?: string | null) {
if (prgrsSttsComCd === "SL") {
return <Badge color="orange">보완요청</Badge>;
}
return null;
}
function getBadgeColor(state: BadgeState) {
switch (state) {
case "completed":
return "green";
case "current":
return "blue";
case "pending":
return "lightGray";
}
}
function getBadgeStateText(state: BadgeState) {
switch (state) {
case "completed":
return "완료";
case "current":
return "진행중";
case "pending":
return "대기";
}
}
/** 단계명을 2줄로 분리: "설치인가 신청" → ["설치인가", "신청"], "기술검토" → ["기술검토", ""] */
function splitStepLabel(label: string) {
const [title = "", ...rest] = label.split(" ");
return { title, detail: rest.join(" ") };
}
function formatOptional(value: string | null | undefined) {
return value?.trim() ? value : "-";
}
interface ProgressStepBadgeProps {
prgrsSttsComCd: string;
techRplyHstryYn: "Y" | "N";
}
function ProgressStepBadge({ prgrsSttsComCd, techRplyHstryYn }: ProgressStepBadgeProps) {
const badges = buildProgressBadges(prgrsSttsComCd, techRplyHstryYn);
return (
<div className="flex flex-wrap items-center justify-start">
{badges.map((badge, index) => (
<Fragment key={badge.label}>
{index > 0 && <span className="mx-2">&gt;</span>}
{/* 1줄: 단계명 / 2줄: 세부단계 + 상태 (예: 설치인가 / 신청완료, 기술검토 / 완료) */}
<Badge
color={getBadgeColor(badge.state)}
className="flex-col justify-center !px-[20px] py-1 text-center leading-tight"
>
<span>{splitStepLabel(badge.label).title}</span>
<span>
{splitStepLabel(badge.label).detail}
{getBadgeStateText(badge.state)}
</span>
</Badge>
</Fragment>
))}
</div>
);
}
export function createInstlSncnColumns() {
return [
columnHelper.accessor("sido", {
header: "시도",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("gugun", {
header: "시군구",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("bizNm", {
header: "사업명",
meta: { align: "left" },
cell: ({ row, getValue }) => (
<>
{getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())}
</>
),
}),
columnHelper.accessor("taskSeCdNm", {
header: "업무구분",
meta: { align: "center" },
cell: ({ row }) => formatOptional(row.original.taskSeCdNm || row.original.taskSeComCd),
}),
columnHelper.accessor("aplcntNm", {
header: "담당자",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("envoSeCdNm", {
header: "해당기관",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.display({
id: "progress",
header: "진행상황",
meta: { align: "center" },
cell: ({ row }) => (
<ProgressStepBadge
prgrsSttsComCd={row.original.prgrsSttsComCd || ""}
techRplyHstryYn={row.original.techRplyHstryYn || "N"}
/>
),
}),
columnHelper.accessor("regDt", {
header: "신청일",
meta: { align: "center" },
cell: ({ getValue }) => {
const value = getValue();
return value ? dayjs(value).format("YYYY-MM-DD") : "-";
},
}),
];
}
"use client";
import { Fragment } from "react";
import { createColumnHelper } from "@tanstack/react-table";
import { dayjs } from "@/shared/lib";
import type { InstlSncnMngResponseDto } from "@/entities/instl-sncn";
import { Badge } from "@common/components";
const columnHelper = createColumnHelper<InstlSncnMngResponseDto>();
const MAIN_STEP_LABELS = {
apply: "설치인가 신청",
envoReview: "설치인가 검토",
techReview: "기술검토",
reply: "검토결과 회신",
} as const;
export const PROGRESS_STATUS_COLUMNS = [
{ label: "설치인가 신청 (시군)", sttsComCd: "ST" },
{ label: "설치인가 신청 (시도)", sttsComCd: "LE" },
{ label: "설치인가 검토", sttsComCd: "ER" },
{ label: "기술검토", sttsComCd: "TE" },
{ label: "기술검토 회신", sttsComCd: "TP" },
{ label: "검토결과 회신", sttsComCd: "EP" },
{ label: "설치인가 승인", sttsComCd: "TA" },
];
type BadgeState = "completed" | "current" | "pending";
interface ProgressBadge {
label: string;
state: BadgeState;
}
function buildProgressBadges(prgrsSttsComCd: string, techRplyHstryYn: "Y" | "N"): ProgressBadge[] {
if (prgrsSttsComCd === "SV") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "ST") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "current" },
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "LE") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "current" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "ER" || prgrsSttsComCd === "TE" || prgrsSttsComCd === "SL") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{ label: MAIN_STEP_LABELS.techReview, state: "current" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "TP" || prgrsSttsComCd === "EP") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{
label: MAIN_STEP_LABELS.techReview,
state: techRplyHstryYn === "Y" ? "completed" : "pending",
},
{ label: MAIN_STEP_LABELS.reply, state: "current" },
];
}
if (prgrsSttsComCd === "TA" || prgrsSttsComCd === "TB") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{
label: MAIN_STEP_LABELS.techReview,
state: techRplyHstryYn === "Y" ? "completed" : "pending",
},
{ label: MAIN_STEP_LABELS.reply, state: "completed" },
];
}
return [
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
function getProgressStatusBadge(prgrsSttsComCd?: string | null) {
if (prgrsSttsComCd === "SL") {
return <Badge color="orange">보완요청</Badge>;
}
return null;
}
function getBadgeColor(state: BadgeState) {
switch (state) {
case "completed":
return "green";
case "current":
return "blue";
case "pending":
return "lightGray";
}
}
function getBadgeStateText(state: BadgeState) {
switch (state) {
case "completed":
return "완료";
case "current":
return "진행중";
case "pending":
return "대기";
}
}
/** 단계명을 2줄로 분리: "설치인가 신청" → ["설치인가", "신청"], "기술검토" → ["기술검토", ""] */
function splitStepLabel(label: string) {
const [title = "", ...rest] = label.split(" ");
return { title, detail: rest.join(" ") };
}
function formatOptional(value: string | null | undefined) {
return value?.trim() ? value : "-";
}
interface ProgressStepBadgeProps {
prgrsSttsComCd: string;
techRplyHstryYn: "Y" | "N";
}
function ProgressStepBadge({ prgrsSttsComCd, techRplyHstryYn }: ProgressStepBadgeProps) {
const badges = buildProgressBadges(prgrsSttsComCd, techRplyHstryYn);
return (
<div className="flex flex-wrap items-center justify-center">
{badges.map((badge, index) => (
<Fragment key={badge.label}>
{index > 0 && <span className="mx-2">&gt;</span>}
{/* 1줄: 단계명 / 2줄: 세부단계 + 상태 (예: 설치인가 / 신청완료, 기술검토 / 완료) */}
<Badge
color={getBadgeColor(badge.state)}
className="w-[100px] flex-col justify-center !px-0 py-1 text-center leading-tight"
>
<span>{splitStepLabel(badge.label).title}</span>
<span>
{splitStepLabel(badge.label).detail}
{getBadgeStateText(badge.state)}
</span>
</Badge>
</Fragment>
))}
</div>
);
}
export function createInstlSncnColumns() {
return [
columnHelper.accessor("sido", {
header: "시도",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("gugun", {
header: "시군구",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("bizNm", {
header: "사업명",
meta: { align: "left" },
cell: ({ row, getValue }) => (
<>
{getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())}
</>
),
}),
columnHelper.accessor("taskSeCdNm", {
header: "업무구분",
meta: { align: "center" },
cell: ({ row }) => formatOptional(row.original.taskSeCdNm || row.original.taskSeComCd),
}),
columnHelper.accessor("aplcntNm", {
header: "담당자",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("envoSeCdNm", {
header: "해당기관",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.display({
id: "progress",
header: "진행상황",
meta: { align: "center" },
cell: ({ row }) => (
<ProgressStepBadge
prgrsSttsComCd={row.original.prgrsSttsComCd || ""}
techRplyHstryYn={row.original.techRplyHstryYn || "N"}
/>
),
}),
columnHelper.accessor("regDt", {
header: "신청일",
meta: { align: "center" },
cell: ({ getValue }) => {
const value = getValue();
return value ? dayjs(value).format("YYYY-MM-DD") : "-";
},
}),
];
}
@@ -50,13 +50,13 @@ export const REVIEW_STEP_CODES: ReviewStepCode[] = ["LOCAL", "ENV", "KECO"];
export const CHECKLIST_TAB_LABELS: Record<ReviewStepCode, string> = {
LOCAL: "자가체크",
ENV: "환경청검토",
ENV: "광역자치단체검토",
KECO: "기술검토",
};
export const REVIEW_STEP_LABELS: Record<ReviewStepCode, string> = {
LOCAL: "지자체",
ENV: "환경청",
ENV: "광역자치단체",
KECO: "환경공단",
};
@@ -89,7 +89,7 @@ export const INSTL_PROGRESS_ORDER = [
export const ROLE_TO_REVIEW_STEP: Record<string, ReviewStepCode> = {
ROLE_USER05: "LOCAL",
ROLE_USER03: "ENV",
ROLE_USER04: "ENV",
ROLE_USER01: "KECO",
};
@@ -35,7 +35,6 @@ import {
type InstlSncnViewButtonAction,
needsRoleUser01AtrzResolve,
} from "../../lib/button-config";
import { computeReviewStepChecklistProgress } from "../../lib/checklist-utils";
import {
buildSavePayload,
buildSupplementReqPayload,
@@ -179,11 +178,6 @@ function FloatingActionButtons({
}
async function handleAplyClick() {
const { percent } = computeReviewStepChecklistProgress(
data.cklstResultList ?? [],
checklistFormState.results,
"LOCAL",
);
const payload: InstlSncnApplySaveRequestDto = buildSavePayload(
data,
checklistFormState,
@@ -191,11 +185,6 @@ function FloatingActionButtons({
"ST",
);
if (percent !== 100) {
alert("모든 체크리스트 항목을 체크해야 신청이 가능합니다.");
return;
}
await executeAction({
action: INSTL_SNCN_VIEW_BUTTON_ACTION.aply,
request: payload,
@@ -231,27 +220,14 @@ function FloatingActionButtons({
});
}
async function handleEnvoSbmsnClick() {
const payload: DocPrgrsUpdateRequestDto = {
docNo,
docSeComCd,
prgrsSttsComCd: "LE",
};
await executeAction({
action: INSTL_SNCN_VIEW_BUTTON_ACTION.envoSbmsn,
request: payload,
successMessage: "제출되었습니다.",
});
queryClient.invalidateQueries({
queryKey: instlSncnKeys.list(),
});
}
async function handleRjctClick() {
if (!(await confirm("해당 신청 건을 반려하시겠습니까?"))) return;
const payload: DocPrgrsUpdateRequestDto = {
docNo,
docSeComCd,
prgrsSttsComCd: "SV",
rgtrUuid: userInfo?.userUnqId,
};
await executeAction({
action: INSTL_SNCN_VIEW_BUTTON_ACTION.rjct,
@@ -284,16 +260,6 @@ function FloatingActionButtons({
}
async function handleRvwRplyClick() {
const { percent } = computeReviewStepChecklistProgress(
data.cklstResultList ?? [],
checklistFormState.results,
"ENV",
);
if (percent !== 100) {
alert("모든 체크리스트 항목을 체크해야 검토결과 회신이 가능합니다.");
return;
}
const payload = buildTechReviewReplyPayload(
data,
@@ -369,16 +335,6 @@ function FloatingActionButtons({
}
async function handleRvwCmptnClick() {
const { percent } = computeReviewStepChecklistProgress(
data.cklstResultList ?? [],
checklistFormState.results,
"KECO",
);
if (percent !== 100) {
alert("모든 체크리스트 항목을 체크해야 검토완료가 가능합니다.");
return;
}
const payload = {
...buildTechReviewReplyPayload(
@@ -447,11 +403,6 @@ function FloatingActionButtons({
설치인가 고시
</Button>
)}
{shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.envoSbmsn) && (
<Button onClick={handleEnvoSbmsnClick} disabled={isProcessing}>
환경청제출
</Button>
)}
{shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.rjct) && (
<Button color="red" onClick={handleRjctClick} disabled={isProcessing}>
반려
@@ -568,6 +519,17 @@ async function handleChangePrgrsSttsComCd(
}
}
async function handleRejectPrgrsStts(
context: ActionHandlerContext<DocPrgrsUpdateRequestDto>,
): Promise<void> {
try {
await instlSncnService.rejectPrgrsStts(context.request);
context.onSuccess?.();
} catch (error) {
context.onError?.(error as Error);
}
}
async function handleTechReview(
context: ActionHandlerContext<InstlSncnApplySaveRequestDto>,
): Promise<void> {
@@ -618,8 +580,7 @@ const buttonActionHandlers: Record<InstlSncnViewButtonAction, ActionHandler> = {
aply: handleSaveApply,
del: handleDel,
instlSncnAncmnt: handleChangePrgrsSttsComCd,
envoSbmsn: handleChangePrgrsSttsComCd,
rjct: handleChangePrgrsSttsComCd,
rjct: handleRejectPrgrsStts,
sncnAprv: handleChangePrgrsSttsComCd,
rvwRply: handleTechReview,
rqstTechRvw: handleChangePrgrsSttsComCd,
@@ -20,9 +20,7 @@ import {
AttachmentsFormSection,
BusinessCostFormSection,
BusinessScaleFormSection,
ChecklistFormSection,
ContactInfoFormSection,
isFormChecklistComplete,
isFormEsntlAtchComplete,
PrivacyConsentSection,
ProgressStatusSection,
@@ -154,11 +152,6 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
return;
}
if (!isFormChecklistComplete(data.cklstResultList)) {
alert("모든 체크리스트 항목을 체크해야 신청이 가능합니다.");
return;
}
if (!isFormEsntlAtchComplete(essentialFiles, data.esntlAtchFileList)) {
alert("필수 첨부파일을 모두 등록해야 신청이 가능합니다.");
return;
@@ -228,7 +221,6 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
<PrivacyConsentSection />
<BusinessScaleFormSection />
<BusinessCostFormSection />
<ChecklistFormSection docSeComCd="INSTL" fieldPrefix="instlSncnMng" />
<AttachmentsFormSection
docSeComCd="INSTL"
fieldPrefix="instlSncnMng"
@@ -1,149 +1,155 @@
"use client";
import { useMemo, useRef } from "react";
import { useUserStore } from "@/shared/lib";
import { SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
import { ElaprDocPrgrsStatus } from "@/entities/elapr";
import { useInstlSncnDetail } from "@/entities/instl-sncn";
import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw";
import {
AttachmentsViewSection,
ProgressStatusSection,
SupplementDemandSection,
} from "@/widgets/pbadms-sprt";
import { Skeleton } from "@common/components";
import { needsRoleUser01AtrzResolve } from "../lib/button-config";
import { getEditableReviewStep } from "../lib/review-step-utils";
import { EMPTY_CKLST_RESULT_LIST, useChecklistFormState } from "../lib/use-checklist-form-state";
import { PROGRESS_STATUS_COLUMNS } from "../model/columns";
import { FloatingActionButtons } from "./components/floating-action-buttons";
import { BasicInfoSection } from "./sections/basic-info-section";
import { BusinessCostSection } from "./sections/business-cost-section";
import { BusinessScaleSection } from "./sections/business-scale-section";
import { ChecklistSection } from "./sections/checklist-section";
import { ContactInfoSection } from "./sections/contact-info-section";
import {
TechReviewOpinionSection,
type TechReviewOpinionSectionHandle,
} from "./sections/tech-review-opinion-section";
interface InstlViewPageProps {
id: string;
}
function InstlViewPage({ id }: InstlViewPageProps) {
const userInfo = useUserStore((state) => state.userInfo);
const techReviewOpinionRef = useRef<TechReviewOpinionSectionHandle>(null);
const { data, isLoading, isError } = useInstlSncnDetail(id);
const { formState: checklistFormState, setFormState: setChecklistFormState } =
useChecklistFormState(data?.cklstResultList ?? EMPTY_CKLST_RESULT_LIST);
const prgrsSttsComCd = data?.detDto?.prgrsSttsComCd || "";
const needsAtrzResolve = needsRoleUser01AtrzResolve(userInfo?.dtlInstCd, prgrsSttsComCd);
const { data: rvwLoginUserData, isLoading: isRvwLoginUserLoading } = useRvwMnpwMngLoginUserList(
{},
{ enabled: needsAtrzResolve },
);
const atrzSeComCdList = useMemo(
() => rvwLoginUserData?.content?.map((item) => item.atrzSeComCd) ?? [],
[rvwLoginUserData],
);
if (isLoading) {
return (
<div className="mt-6 flex flex-col gap-4 px-6">
<div className="flex flex-col gap-2">
<Skeleton className="h-[4rem] rounded-xl" />
<Skeleton className="h-[16rem] rounded-xl" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-[4rem] rounded-xl" />
<Skeleton className="h-[16rem] rounded-xl" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-[4rem] rounded-xl" />
<Skeleton className="h-[32rem] rounded-xl" />
</div>
</div>
);
}
if (isError || !data) {
return <div className="mt-6 text-[1.4rem]">상세 정보를 찾을 수 없습니다.</div>;
}
const {
detDto,
bizSclList,
bizCstList,
cklstResultList = [],
esntlFileList,
etcFileList,
rvwList = [],
splmntDmndList = [],
sttsChgHstryList = [],
} = data;
const editableReviewStep = getEditableReviewStep(
userInfo?.dtlInstCd,
detDto.prgrsSttsComCd || "",
);
return (
<div className="mt-4">
<SectionCollapsibleGroup>
<div className="sticky top-[6rem] z-30 bg-container px-4 py-2">
<SectionCollapsibleToggle />
</div>
<div className="space-y-4">
<ProgressStatusSection
columns={PROGRESS_STATUS_COLUMNS}
sttsChgHstryList={sttsChgHstryList}
elaprDocNo={detDto.instlSncnDocNo}
/>
<BasicInfoSection det={detDto} />
<ContactInfoSection det={detDto} />
<ElaprDocPrgrsStatus elaprDocNo={detDto.instlSncnDocNo} />
<BusinessScaleSection bizSclList={bizSclList} />
<BusinessCostSection bizCstList={bizCstList} />
<ChecklistSection
cklstResultList={cklstResultList}
prgrsSttsComCd={detDto.prgrsSttsComCd || ""}
userRoleCode={userInfo?.dtlInstCd}
formState={checklistFormState}
setFormState={setChecklistFormState}
/>
<AttachmentsViewSection
esntlFileList={esntlFileList ?? []}
etcFileList={etcFileList ?? []}
/>
<TechReviewOpinionSection
ref={techReviewOpinionRef}
rvwList={rvwList}
editableReviewStep={editableReviewStep}
atrzSeComCdList={atrzSeComCdList}
isAtrzLoading={needsAtrzResolve && isRvwLoginUserLoading}
/>
<SupplementDemandSection splmntDmndList={splmntDmndList} />
<FloatingActionButtons
data={data}
docSeComCd="INSTL"
instlSncnDocNo={detDto.instlSncnDocNo}
prgrsSttsComCd={detDto.prgrsSttsComCd || ""}
dtlInstCd={userInfo?.dtlInstCd}
atrzSeComCdList={atrzSeComCdList}
isRvwLoginUserLoading={needsAtrzResolve && isRvwLoginUserLoading}
checklistFormState={checklistFormState}
techReviewOpinionRef={techReviewOpinionRef}
opnrNm={userInfo?.userName}
/>
</div>
</SectionCollapsibleGroup>
</div>
);
}
export { InstlViewPage };
"use client";
import { useMemo, useRef } from "react";
import { RefreshCw } from "lucide-react";
import { useUserStore } from "@/shared/lib";
import { SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
import { ElaprDocPrgrsStatus } from "@/entities/elapr";
import { useInstlSncnDetail } from "@/entities/instl-sncn";
import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw";
import {
AttachmentsViewSection,
ProgressStatusSection,
SupplementDemandSection,
} from "@/widgets/pbadms-sprt";
import { Button, cn, Skeleton } from "@common/components";
import { needsRoleUser01AtrzResolve } from "../lib/button-config";
import { getEditableReviewStep } from "../lib/review-step-utils";
import { EMPTY_CKLST_RESULT_LIST, useChecklistFormState } from "../lib/use-checklist-form-state";
import { PROGRESS_STATUS_COLUMNS } from "../model/columns";
import { FloatingActionButtons } from "./components/floating-action-buttons";
import { BasicInfoSection } from "./sections/basic-info-section";
import { BusinessCostSection } from "./sections/business-cost-section";
import { BusinessScaleSection } from "./sections/business-scale-section";
import { ContactInfoSection } from "./sections/contact-info-section";
import {
TechReviewOpinionSection,
type TechReviewOpinionSectionHandle,
} from "./sections/tech-review-opinion-section";
interface InstlViewPageProps {
id: string;
}
function InstlViewPage({ id }: InstlViewPageProps) {
const userInfo = useUserStore((state) => state.userInfo);
const techReviewOpinionRef = useRef<TechReviewOpinionSectionHandle>(null);
const { data, isLoading, isError, refetch, isFetching } = useInstlSncnDetail(id);
const { formState: checklistFormState } = useChecklistFormState(
data?.cklstResultList ?? EMPTY_CKLST_RESULT_LIST,
);
const prgrsSttsComCd = data?.detDto?.prgrsSttsComCd || "";
const needsAtrzResolve = needsRoleUser01AtrzResolve(userInfo?.dtlInstCd, prgrsSttsComCd);
const { data: rvwLoginUserData, isLoading: isRvwLoginUserLoading } = useRvwMnpwMngLoginUserList(
{},
{ enabled: needsAtrzResolve },
);
const atrzSeComCdList = useMemo(
() => rvwLoginUserData?.content?.map((item) => item.atrzSeComCd) ?? [],
[rvwLoginUserData],
);
if (isLoading) {
return (
<div className="mt-6 flex flex-col gap-4 px-6">
<div className="flex flex-col gap-2">
<Skeleton className="h-[4rem] rounded-xl" />
<Skeleton className="h-[16rem] rounded-xl" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-[4rem] rounded-xl" />
<Skeleton className="h-[16rem] rounded-xl" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-[4rem] rounded-xl" />
<Skeleton className="h-[32rem] rounded-xl" />
</div>
</div>
);
}
if (isError || !data) {
return <div className="mt-6 text-[1.4rem]">상세 정보를 찾을 수 없습니다.</div>;
}
const {
detDto,
bizSclList,
bizCstList,
esntlFileList,
etcFileList,
rvwList = [],
splmntDmndList = [],
sttsChgHstryList = [],
} = data;
const editableReviewStep = getEditableReviewStep(
userInfo?.dtlInstCd,
detDto.prgrsSttsComCd || "",
);
return (
<div className="mt-4">
<SectionCollapsibleGroup>
<div className="sticky top-[6rem] z-30 flex items-center gap-3 bg-container px-4 py-2">
<SectionCollapsibleToggle />
<Button
type="button"
variant="outline"
size="xs"
onClick={() => refetch()}
disabled={isFetching}
className="flex items-center gap-1.5 h-[2.8rem] px-3 text-[1.2rem] font-normal"
title="최신 데이터 새로고침"
>
<RefreshCw className={cn("size-3.5", isFetching && "animate-spin")} />
<span>새로 고침</span>
</Button>
</div>
<div className="space-y-4">
<ProgressStatusSection
columns={PROGRESS_STATUS_COLUMNS}
sttsChgHstryList={sttsChgHstryList}
elaprDocNo={detDto.instlSncnDocNo}
/>
<BasicInfoSection det={detDto} />
<ContactInfoSection det={detDto} />
<ElaprDocPrgrsStatus elaprDocNo={detDto.instlSncnDocNo} />
<BusinessScaleSection bizSclList={bizSclList} />
<BusinessCostSection bizCstList={bizCstList} />
<AttachmentsViewSection
esntlFileList={esntlFileList ?? []}
etcFileList={etcFileList ?? []}
/>
<TechReviewOpinionSection
ref={techReviewOpinionRef}
rvwList={rvwList}
editableReviewStep={editableReviewStep}
atrzSeComCdList={atrzSeComCdList}
isAtrzLoading={needsAtrzResolve && isRvwLoginUserLoading}
/>
<SupplementDemandSection splmntDmndList={splmntDmndList} />
<FloatingActionButtons
data={data}
docSeComCd="INSTL"
instlSncnDocNo={detDto.instlSncnDocNo}
prgrsSttsComCd={detDto.prgrsSttsComCd || ""}
dtlInstCd={userInfo?.dtlInstCd}
atrzSeComCdList={atrzSeComCdList}
isRvwLoginUserLoading={needsAtrzResolve && isRvwLoginUserLoading}
checklistFormState={checklistFormState}
techReviewOpinionRef={techReviewOpinionRef}
opnrNm={userInfo?.userName}
/>
</div>
</SectionCollapsibleGroup>
</div>
);
}
export { InstlViewPage };
@@ -8,6 +8,7 @@ import { SectionCollapsible } from "@/shared/ui";
import { Field, FieldError, FieldGroup, FieldLabel, FieldSet } from "@/shared/ui/field";
import { findCodeLabel, useCodeList } from "@/entities/code";
import { type BnsApplyData, BnsPickerDialog } from "@/widgets/bns-picker";
import { PreviousPlanLookupButton } from "@/widgets/previous-plan-lookup";
import {
Button,
Checkbox,
@@ -27,9 +28,9 @@ function BasicInfoFormSection() {
const { control, watch, setValue } = useFormContext<InstlSncnRegistFormData>();
const [isPickerOpen, setIsPickerOpen] = useState(false);
const taskSeComCd = watch("instlSncnMng.taskSeComCd");
const localGovLabel = [watch("instlSncnMng.sido"), watch("instlSncnMng.gugun")]
.filter(Boolean)
.join(" ");
const sido = watch("instlSncnMng.sido");
const gugun = watch("instlSncnMng.gugun");
const localGovLabel = [sido, gugun].filter(Boolean).join(" ");
function handleBnsSelect(data: BnsApplyData) {
setValue("instlSncnMng.bizNm", data.bizNm);
@@ -53,6 +54,7 @@ function BasicInfoFormSection() {
return (
<SectionCollapsible title="기본 정보">
<PreviousPlanLookupButton sidoNm={sido} gugunNm={gugun} />
<Table caption="기본 정보 입력으로 사업명, 사업기간, 사업내용, 구분, 신청 지자체, 사업구분, 시설종류, 재이용용도 정보 제공" variant="secondary">
<colgroup>
<col width="15%" />
@@ -2,6 +2,7 @@
import { SectionCollapsible } from "@/shared/ui";
import type { InstlSncnMngResponseDto } from "@/entities/instl-sncn";
import { PreviousPlanLookupButton } from "@/widgets/previous-plan-lookup";
import { Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
interface BasicInfoSectionProps {
@@ -11,6 +12,7 @@ interface BasicInfoSectionProps {
function BasicInfoSection({ det }: BasicInfoSectionProps) {
return (
<SectionCollapsible title="기본 정보">
<PreviousPlanLookupButton sidoNm={det.sido} gugunNm={det.gugun} />
<Table caption="기본 정보로 사업명, 사업기간, 사업내용, 구분, 신청지자체, 사업구분 정보 제공" variant="secondary">
<colgroup>
<col width="15%" />
@@ -1,350 +1,350 @@
"use client";
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
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 { AttachmentDownloadLink, attachService } from "@/entities/attach";
import type { InstlSncnRvwOpnnResponseDto, ReviewStepCode } from "@/entities/instl-sncn";
import {
Button,
RadioGroup,
RadioGroupItem,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Textarea,
} from "@common/components";
import type { ReviewOpinionFormData } from "../../lib/review-opinion-utils";
import { REVIEW_STEP_LABELS } from "../../model/view-types";
const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
interface TechReviewOpinionSectionProps {
rvwList: InstlSncnRvwOpnnResponseDto[];
editableReviewStep?: ReviewStepCode;
atrzSeComCdList?: string[];
isAtrzLoading?: boolean;
}
export interface TechReviewOpinionSectionHandle {
getFormData: () => ReviewOpinionFormData | null;
resetForm: () => void;
}
interface TechReviewOpinionTableProps {
items: InstlSncnRvwOpnnResponseDto[];
}
interface UploadedOpinionFile {
fileName: string;
fileKey: string;
atflId?: string;
atflCd?: string;
}
const EMPTY_FORM_DATA: ReviewOpinionFormData = {
rvwRsltCn: "",
rvwRsltAnmlyYn: "",
atchFileGroupId: "",
opnrSeComCd: "",
};
function createRvwOpnnUploadKey(file: File) {
return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
}
function hasFormContent(formData: ReviewOpinionFormData) {
return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
}
const TechReviewOpinionSection = forwardRef<
TechReviewOpinionSectionHandle,
TechReviewOpinionSectionProps
>(function TechReviewOpinionSection(
{ rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false },
ref,
) {
const canWriteKecoOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
const [formData, setFormData] = useState<ReviewOpinionFormData>({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null);
useEffect(() => {
setFormData((prev) => ({
...prev,
opnrSeComCd: editableReviewStep ?? "",
}));
}, [editableReviewStep]);
const { uploadFileAsync, isUploading, uploads } = useTus({
onSuccess: (atflCd, atflId, _file, fileKey) => {
if (!atflCd || !atflId) return;
setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd }));
setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
},
onError: (_error, _file, fileKey) => {
setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
},
});
useImperativeHandle(
ref,
() => ({
getFormData: () => {
if (!editableReviewStep || !hasFormContent(formData)) {
return null;
}
if (editableReviewStep === "KECO" && !canWriteKecoOpinion) {
return null;
}
return {
...formData,
opnrSeComCd: editableReviewStep,
};
},
resetForm: () => {
setFormData({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
setUploadedFile(null);
},
}),
[canWriteKecoOpinion, editableReviewStep, formData],
);
const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV");
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
const hasEnvSection = envRvwList.length > 0 || editableReviewStep === "ENV";
const hasKecoSection =
kecoRvwList.length > 0 || (editableReviewStep === "KECO" && canWriteKecoOpinion);
if (!hasEnvSection && !hasKecoSection) {
return null;
}
async function handleFileUpload(files: FileList | null) {
if (!files || files.length === 0) return;
const file = files[0];
const fileKey = createRvwOpnnUploadKey(file);
const existingAtflCd = formData.atchFileGroupId;
setUploadedFile({ fileName: file.name, fileKey });
try {
await uploadFileAsync(file, {
fileKey,
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
});
} catch {
// onError callback handles cleanup
}
}
async function handleFileRemove() {
if (!uploadedFile) return;
uploads[uploadedFile.fileKey]?.abort();
if (uploadedFile.atflId) {
try {
await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
} catch (error) {
console.error("검토의견 첨부파일 삭제 실패:", error);
}
}
setUploadedFile(null);
setFormData((prev) => ({ ...prev, atchFileGroupId: "" }));
}
function renderOpinionForm(reviewStep: ReviewStepCode) {
if (editableReviewStep !== reviewStep) return null;
if (reviewStep === "KECO" && !canWriteKecoOpinion) return null;
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
return (
<div className="space-y-2">
<h4 className="text-xl font-semibold">검토의견 작성</h4>
<Table caption="검토의견 작성으로 의견내용, 검토결과, 첨부파일 정보 제공" variant="secondary">
<TableHeader>
<TableRow>
<TableHead scope="row" className="w-[12rem]">의견내용</TableHead>
<TableCell>
<Textarea
value={formData.rvwRsltCn}
onChange={(event) =>
setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value }))
}
placeholder="의견내용을 입력하세요"
rows={4}
className="min-h-[8rem] !text-xl"
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead scope="row">검토결과</TableHead>
<TableCell>
<FieldSet>
<RadioGroup
value={formData.rvwRsltAnmlyYn}
onValueChange={(value) =>
setFormData((prev) => ({
...prev,
rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"],
}))
}
>
<div className="flex flex-wrap gap-4">
<FieldLabel
htmlFor={`rvw-opnn-normal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} />
<span className="font-normal">정상</span>
</Field>
</FieldLabel>
<FieldLabel
htmlFor={`rvw-opnn-abnormal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} />
<span className="font-normal">이상</span>
</Field>
</FieldLabel>
</div>
</RadioGroup>
</FieldSet>
</TableCell>
</TableRow>
<TableRow>
<TableHead scope="row">첨부파일</TableHead>
<TableCell>
{uploadedFile ? (
<div className="flex items-center gap-2">
<TextShimmer className="text-xl" active={uploading}>
{uploadedFile.fileName}
</TextShimmer>
<Button
size="icon"
onClick={(e) => {
e.preventDefault();
void handleFileRemove();
}}
disabled={uploading}
className="size-8 !bg-destructive"
>
<i className="diveicon di-x text-xs" aria-label="삭제" />
</Button>
</div>
) : (
<label>
<input
type="file"
className="hidden"
onChange={(event) => {
void handleFileUpload(event.target.files);
event.target.value = "";
}}
/>
<Button size="sm" variant="outline" type="button" asChild>
<span>파일 선택</span>
</Button>
</label>
)}
</TableCell>
</TableRow>
</TableHeader>
</Table>
</div>
);
}
return (
<>
{hasEnvSection ? (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}>
<div className="space-y-6">
{envRvwList.length > 0 ? <TechReviewOpinionTable items={envRvwList} /> : null}
{renderOpinionForm("ENV")}
</div>
</SectionCollapsible>
) : null}
{hasKecoSection ? (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
<div className="space-y-6">
{kecoRvwList.length > 0 ? <TechReviewOpinionTable items={kecoRvwList} /> : null}
{renderOpinionForm("KECO")}
</div>
</SectionCollapsible>
) : null}
</>
);
});
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
return (
<div className="overflow-x-auto">
<Table caption="검토의견 목록으로 의견자명, 의견내용, 검토결과, 첨부파일, 등록일시 정보 제공" variant="secondary">
<colgroup>
<col width="10%" />
<col width="55%" />
<col width="5%" />
<col width="20%" />
<col width="10%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>의견자명</TableHead>
<TableHead>의견내용</TableHead>
<TableHead>검토결과</TableHead>
<TableHead>첨부파일</TableHead>
<TableHead>등록일시</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={`${item.instlSncnDocNo}_${item.rvwOpnnSn}`}>
<TableCell align="center">{item.opnrNm}</TableCell>
<TableCell align="left">{item.rvwRsltCn || "-"}</TableCell>
<TableCell align="center">
{item.rvwRsltAnmlyYn === "Y" ? "이상" : item.rvwRsltAnmlyYn === "N" ? "정상" : "-"}
</TableCell>
<TableCell align="center">
{item.atchFileInfo?.atflCd ? (
<AttachmentDownloadLink
atflCd={item.atchFileInfo.atflCd}
fileName={item.atchFileInfo.orgnlFileNm || "다운로드"}
/>
) : (
"-"
)}
</TableCell>
<TableCell align="center">
{item.regDt ? dayjs(item.regDt).format("YYYY-MM-DD HH:mm:ss") : "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
export { TechReviewOpinionSection };
"use client";
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { getDefaultFileKey, useTus, useUserStore } 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 {
instlSncnKeys,
type InstlSncnRvwOpnnResponseDto,
instlSncnRvwOpnnService,
type ReviewStepCode,
} from "@/entities/instl-sncn";
import { RvwOpnnListTable } from "@/widgets/pbadms-sprt";
import {
Button,
RadioGroup,
RadioGroupItem,
Table,
TableCell,
TableHead,
TableHeader,
TableRow,
Textarea,
} from "@common/components";
import type { ReviewOpinionFormData } from "../../lib/review-opinion-utils";
import { REVIEW_STEP_LABELS } from "../../model/view-types";
const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
interface TechReviewOpinionSectionProps {
rvwList: InstlSncnRvwOpnnResponseDto[];
editableReviewStep?: ReviewStepCode;
atrzSeComCdList?: string[];
isAtrzLoading?: boolean;
}
export interface TechReviewOpinionSectionHandle {
getFormData: () => ReviewOpinionFormData | null;
resetForm: () => void;
}
interface TechReviewOpinionTableProps {
items: InstlSncnRvwOpnnResponseDto[];
}
interface UploadedOpinionFile {
fileName: string;
fileKey: string;
atflId?: string;
atflCd?: string;
}
const EMPTY_FORM_DATA: ReviewOpinionFormData = {
rvwRsltCn: "",
rvwRsltAnmlyYn: "",
atchFileGroupId: "",
opnrSeComCd: "",
};
function createRvwOpnnUploadKey(file: File) {
return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
}
function hasFormContent(formData: ReviewOpinionFormData) {
return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
}
const TechReviewOpinionSection = forwardRef<
TechReviewOpinionSectionHandle,
TechReviewOpinionSectionProps
>(function TechReviewOpinionSection(
{ rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false },
ref,
) {
const canWriteKecoOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
const [formData, setFormData] = useState<ReviewOpinionFormData>({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null);
useEffect(() => {
setFormData((prev) => ({
...prev,
opnrSeComCd: editableReviewStep ?? "",
}));
}, [editableReviewStep]);
const { uploadFileAsync, isUploading, uploads } = useTus({
onSuccess: (atflCd, atflId, _file, fileKey) => {
if (!atflCd || !atflId) return;
setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd }));
setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
},
onError: (_error, _file, fileKey) => {
setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
},
});
useImperativeHandle(
ref,
() => ({
getFormData: () => {
if (!editableReviewStep || !hasFormContent(formData)) {
return null;
}
if (editableReviewStep === "KECO" && !canWriteKecoOpinion) {
return null;
}
return {
...formData,
opnrSeComCd: editableReviewStep,
};
},
resetForm: () => {
setFormData({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
setUploadedFile(null);
},
}),
[canWriteKecoOpinion, editableReviewStep, formData],
);
const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV");
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
const hasEnvSection = envRvwList.length > 0 || editableReviewStep === "ENV";
const hasKecoSection =
kecoRvwList.length > 0 || (editableReviewStep === "KECO" && canWriteKecoOpinion);
if (!hasEnvSection && !hasKecoSection) {
return null;
}
async function handleFileUpload(files: FileList | null) {
if (!files || files.length === 0) return;
const file = files[0];
const fileKey = createRvwOpnnUploadKey(file);
const existingAtflCd = formData.atchFileGroupId;
setUploadedFile({ fileName: file.name, fileKey });
try {
await uploadFileAsync(file, {
fileKey,
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
});
} catch {
// onError callback handles cleanup
}
}
async function handleFileRemove() {
if (!uploadedFile) return;
uploads[uploadedFile.fileKey]?.abort();
if (uploadedFile.atflId) {
try {
await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
} catch (error) {
console.error("검토의견 첨부파일 삭제 실패:", error);
}
}
setUploadedFile(null);
setFormData((prev) => ({ ...prev, atchFileGroupId: "" }));
}
function isReviewTurn(reviewStep: ReviewStepCode) {
if (editableReviewStep !== reviewStep) return false;
if (reviewStep === "KECO" && !canWriteKecoOpinion) return false;
return true;
}
function renderOpinionForm(reviewStep: ReviewStepCode) {
if (!isReviewTurn(reviewStep)) return null;
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
return (
<div className="space-y-2">
<h4 className="text-xl font-semibold">검토의견 작성</h4>
<Table
caption="검토의견 작성으로 의견내용, 검토결과, 첨부파일 정보 제공"
variant="secondary"
>
<TableHeader>
<TableRow>
<TableHead scope="row" className="w-[12rem]">
의견내용
</TableHead>
<TableCell>
<Textarea
value={formData.rvwRsltCn}
onChange={(event) =>
setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value }))
}
placeholder="의견내용을 입력하세요"
rows={4}
className="min-h-[8rem] !text-xl"
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead scope="row">검토결과</TableHead>
<TableCell>
<FieldSet>
<RadioGroup
value={formData.rvwRsltAnmlyYn}
onValueChange={(value) =>
setFormData((prev) => ({
...prev,
rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"],
}))
}
>
<div className="flex flex-wrap gap-4">
<FieldLabel
htmlFor={`rvw-opnn-normal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} />
<span className="font-normal">정상</span>
</Field>
</FieldLabel>
<FieldLabel
htmlFor={`rvw-opnn-abnormal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} />
<span className="font-normal">이상</span>
</Field>
</FieldLabel>
</div>
</RadioGroup>
</FieldSet>
</TableCell>
</TableRow>
<TableRow>
<TableHead scope="row">첨부파일</TableHead>
<TableCell>
{uploadedFile ? (
<div className="flex items-center gap-2">
<TextShimmer className="text-xl" active={uploading}>
{uploadedFile.fileName}
</TextShimmer>
<Button
size="icon"
onClick={(e) => {
e.preventDefault();
void handleFileRemove();
}}
disabled={uploading}
className="size-8 !bg-destructive"
>
<i className="diveicon di-x text-xs" aria-label="삭제" />
</Button>
</div>
) : (
<label>
<input
type="file"
className="hidden"
onChange={(event) => {
void handleFileUpload(event.target.files);
event.target.value = "";
}}
/>
<Button size="sm" variant="outline" type="button" asChild>
<span>파일 선택</span>
</Button>
</label>
)}
</TableCell>
</TableRow>
</TableHeader>
</Table>
</div>
);
}
return (
<>
{hasEnvSection ? (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}>
<div className="space-y-6">
{envRvwList.length > 0 ? (
<TechReviewOpinionTable items={envRvwList} />
) : null}
{renderOpinionForm("ENV")}
</div>
</SectionCollapsible>
) : null}
{hasKecoSection ? (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
<div className="space-y-6">
{kecoRvwList.length > 0 ? (
<TechReviewOpinionTable items={kecoRvwList} />
) : null}
{renderOpinionForm("KECO")}
</div>
</SectionCollapsible>
) : null}
</>
);
});
function getRvwOpnnIds(item: InstlSncnRvwOpnnResponseDto) {
if (!item.instlSncnDocNo || item.rvwOpnnSn == null) {
throw new Error("검토의견 식별값이 없습니다.");
}
return [item.instlSncnDocNo, item.rvwOpnnSn] as const;
}
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
const queryClient = useQueryClient();
const currentUserUuid = useUserStore((state) => state.userInfo?.userUnqId);
return (
<RvwOpnnListTable
items={items}
getRowKey={(item) => `${item.instlSncnDocNo}_${item.rvwOpnnSn}`}
currentUserUuid={currentUserUuid}
onUpdate={async (item, body) => {
await instlSncnRvwOpnnService.update(...getRvwOpnnIds(item), body);
await queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
}}
onDelete={async (item) => {
await instlSncnRvwOpnnService.delete(...getRvwOpnnIds(item));
await queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
}}
/>
);
}
export { TechReviewOpinionSection };
@@ -9,6 +9,7 @@ export const RVW_MNPW_BLOCKS: RvwMnpwBlockConfig[] = [
{ id: "receiver", title: "접수", atrzSeComCd: "ATRZ01" },
{ id: "reviewer", title: "검토", atrzSeComCd: "ATRZ02" },
{ id: "approver", title: "승인", atrzSeComCd: "ATRZ03" },
{ id: "merger", title: "병합", atrzSeComCd: "ATRZ04" },
{ id: "final", title: "최종승인", atrzSeComCd: "ATRZ99" },
];
@@ -40,6 +40,7 @@ function RvwMnpwAsmtPanel({ selectedPerson }: RvwMnpwAsmtPanelProps) {
<Skeleton className="h-[8rem]" />
<Skeleton className="h-[8rem]" />
<Skeleton className="h-[8rem]" />
<Skeleton className="h-[8rem]" />
</div>
) : pageQuery.isError ? (
<p className="py-8 text-center text-lg text-muted-foreground">
@@ -201,7 +201,7 @@ export function mapDetailToFormData(data: SewerBscPlanDetResponseDto): SewerBscP
cklstSn: item.cklstSn,
cklstArtclSn: item.cklstArtclSn,
cklstRvwSn: item.cklstRvwSn,
rvwStpComCd: item.rvwStpComCd,
rvwStpComCd: item.rvwStpComCd || "LOCAL",
selected: mapSelectedValue(item.rvwRsltAnmlyYn),
picRvwCn: item.picRvwCn,
})) ?? [],
@@ -251,6 +251,7 @@ export function buildApplyPayload(
sewerBscPlanTmplntList: data.sewerBscPlanTmplntList,
cklstResultList: data.cklstResultList.map((item) => ({
...item,
rvwStpComCd: item.rvwStpComCd || "LOCAL",
selected: item.selected ?? undefined,
})),
esntlAtchFileList: data.esntlAtchFileList,
+235 -235
View File
@@ -1,235 +1,235 @@
"use client";
import { Fragment } from "react";
import { createColumnHelper } from "@tanstack/react-table";
import { dayjs } from "@/shared/lib";
import type { SewerBscPlanResponseDto } from "@/entities/sewer-bsc-plan";
import { Badge } from "@common/components";
const columnHelper = createColumnHelper<SewerBscPlanResponseDto>();
const MAIN_STEP_LABELS = {
apply: "기본계획 신청",
envoReview: "기본계획 검토",
techReview: "기술검토",
reply: "검토결과 회신",
} as const;
export const PROGRESS_STATUS_COLUMNS = [
{ label: "기본계획 신청 (시군)", sttsComCd: "LE" },
{ label: "기본계획 검토", sttsComCd: "ER" },
{ label: "기술검토", sttsComCd: "TE" },
{ label: "기술검토 회신", sttsComCd: "TP" },
{ label: "횐경청 검토 결과 회신", sttsComCd: "EP" },
];
type BadgeState = "completed" | "current" | "pending";
interface ProgressBadge {
label: string;
state: BadgeState;
}
function buildProgressBadges(prgrsSttsComCd: string, techRplyHstryYn: "Y" | "N"): ProgressBadge[] {
if (prgrsSttsComCd === "SV") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "ST") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "current" },
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "LE") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "current" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "ER" || prgrsSttsComCd === "TE" || prgrsSttsComCd === "SL") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{ label: MAIN_STEP_LABELS.techReview, state: "current" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "TP") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{
label: MAIN_STEP_LABELS.techReview,
state: techRplyHstryYn === "Y" ? "completed" : "pending",
},
{ label: MAIN_STEP_LABELS.reply, state: "current" },
];
}
if (prgrsSttsComCd === "EP") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{
label: MAIN_STEP_LABELS.techReview,
state: techRplyHstryYn === "Y" ? "completed" : "pending",
},
{ label: MAIN_STEP_LABELS.reply, state: "completed" },
];
}
return [
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
function getProgressStatusBadge(prgrsSttsComCd?: string | null) {
if (prgrsSttsComCd === "SL") {
return <Badge color="orange">보완요청</Badge>;
}
return null;
}
function getBadgeColor(state: BadgeState) {
switch (state) {
case "completed":
return "green";
case "current":
return "blue";
case "pending":
return "lightGray";
}
}
function getBadgeStateText(state: BadgeState) {
switch (state) {
case "completed":
return "완료";
case "current":
return "진행중";
case "pending":
return "대기";
}
}
/** 단계명을 2줄로 분리: "설치인가 신청" → ["설치인가", "신청"], "기술검토" → ["기술검토", ""] */
function splitStepLabel(label: string) {
const [title = "", ...rest] = label.split(" ");
return { title, detail: rest.join(" ") };
}
function formatOptional(value: string | undefined) {
return value?.trim() ? value : "-";
}
interface ProgressStepBadgeProps {
prgrsSttsComCd: string;
techRplyHstryYn: "Y" | "N";
}
function ProgressStepBadge({ prgrsSttsComCd, techRplyHstryYn }: ProgressStepBadgeProps) {
const badges = buildProgressBadges(prgrsSttsComCd, techRplyHstryYn);
return (
<div className="flex flex-wrap items-center justify-start">
{badges.map((badge, index) => (
<Fragment key={`${badge.label}-${index}`}>
{index > 0 && <span className="mx-2">&gt;</span>}
{/* 1줄: 단계명 / 2줄: 세부단계 + 상태 (예: 설치인가 / 신청완료, 기술검토 / 완료) */}
<Badge
color={getBadgeColor(badge.state)}
className="flex-col justify-center !px-[20px] py-1 text-center leading-tight"
>
<span>{splitStepLabel(badge.label).title}</span>
<span>
{splitStepLabel(badge.label).detail}
{getBadgeStateText(badge.state)}
</span>
</Badge>
</Fragment>
))}
</div>
);
}
export function createSewerBscPlanColumns() {
return [
columnHelper.accessor("sido", {
header: "시도",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("gugun", {
header: "시군구",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("sewerBscPlanNm", {
header: "기본계획명",
meta: { align: "left" },
cell: ({ row, getValue }) => (
<>
{getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())}
</>
),
}),
columnHelper.accessor("sewerBscPlanSeCdNm", {
header: "계획구분",
meta: { align: "center" },
cell: ({ row }) =>
formatOptional(row.original.sewerBscPlanSeCdNm || row.original.sewerBscPlanSeComCd),
}),
columnHelper.accessor("taskSeCdNm", {
header: "업무구분",
meta: { align: "center" },
cell: ({ row }) => formatOptional(row.original.taskSeCdNm || row.original.taskSeComCd),
}),
columnHelper.accessor("rgtrNm", {
header: "담당자",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("envoSeCdNm", {
header: "해당기관",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.display({
id: "progress",
header: "진행상황",
meta: { align: "center" },
cell: ({ row }) => (
<ProgressStepBadge
prgrsSttsComCd={row.original.prgrsSttsComCd || ""}
techRplyHstryYn={row.original.techRplyHstryYn || "N"}
/>
),
}),
columnHelper.accessor("regDt", {
header: "등록일",
meta: { align: "center" },
cell: ({ getValue }) => {
const value = getValue();
return value ? dayjs(value).format("YYYY-MM-DD") : "-";
},
}),
];
}
"use client";
import { Fragment } from "react";
import { createColumnHelper } from "@tanstack/react-table";
import { dayjs } from "@/shared/lib";
import type { SewerBscPlanResponseDto } from "@/entities/sewer-bsc-plan";
import { Badge } from "@common/components";
const columnHelper = createColumnHelper<SewerBscPlanResponseDto>();
const MAIN_STEP_LABELS = {
apply: "기본계획 신청",
envoReview: "기본계획 검토",
techReview: "기술검토",
reply: "검토결과 회신",
} as const;
export const PROGRESS_STATUS_COLUMNS = [
{ label: "기본계획 신청 (시군)", sttsComCd: "LE" },
{ label: "기본계획 검토", sttsComCd: "ER" },
{ label: "기술검토", sttsComCd: "TE" },
{ label: "기술검토 회신", sttsComCd: "TP" },
{ label: "횐경청 검토 결과 회신", sttsComCd: "EP" },
];
type BadgeState = "completed" | "current" | "pending";
interface ProgressBadge {
label: string;
state: BadgeState;
}
function buildProgressBadges(prgrsSttsComCd: string, techRplyHstryYn: "Y" | "N"): ProgressBadge[] {
if (prgrsSttsComCd === "SV") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "ST") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "current" },
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "LE") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "current" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "ER" || prgrsSttsComCd === "TE" || prgrsSttsComCd === "SL") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{ label: MAIN_STEP_LABELS.techReview, state: "current" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "TP") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{
label: MAIN_STEP_LABELS.techReview,
state: techRplyHstryYn === "Y" ? "completed" : "pending",
},
{ label: MAIN_STEP_LABELS.reply, state: "current" },
];
}
if (prgrsSttsComCd === "EP") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{
label: MAIN_STEP_LABELS.techReview,
state: techRplyHstryYn === "Y" ? "completed" : "pending",
},
{ label: MAIN_STEP_LABELS.reply, state: "completed" },
];
}
return [
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
function getProgressStatusBadge(prgrsSttsComCd?: string | null) {
if (prgrsSttsComCd === "SL") {
return <Badge color="orange">보완요청</Badge>;
}
return null;
}
function getBadgeColor(state: BadgeState) {
switch (state) {
case "completed":
return "green";
case "current":
return "blue";
case "pending":
return "lightGray";
}
}
function getBadgeStateText(state: BadgeState) {
switch (state) {
case "completed":
return "완료";
case "current":
return "진행중";
case "pending":
return "대기";
}
}
/** 단계명을 2줄로 분리: "설치인가 신청" → ["설치인가", "신청"], "기술검토" → ["기술검토", ""] */
function splitStepLabel(label: string) {
const [title = "", ...rest] = label.split(" ");
return { title, detail: rest.join(" ") };
}
function formatOptional(value: string | undefined) {
return value?.trim() ? value : "-";
}
interface ProgressStepBadgeProps {
prgrsSttsComCd: string;
techRplyHstryYn: "Y" | "N";
}
function ProgressStepBadge({ prgrsSttsComCd, techRplyHstryYn }: ProgressStepBadgeProps) {
const badges = buildProgressBadges(prgrsSttsComCd, techRplyHstryYn);
return (
<div className="flex flex-wrap items-center justify-center">
{badges.map((badge, index) => (
<Fragment key={`${badge.label}-${index}`}>
{index > 0 && <span className="mx-2">&gt;</span>}
{/* 1줄: 단계명 / 2줄: 세부단계 + 상태 (예: 설치인가 / 신청완료, 기술검토 / 완료) */}
<Badge
color={getBadgeColor(badge.state)}
className="w-[100px] flex-col justify-center !px-0 py-1 text-center leading-tight"
>
<span>{splitStepLabel(badge.label).title}</span>
<span>
{splitStepLabel(badge.label).detail}
{getBadgeStateText(badge.state)}
</span>
</Badge>
</Fragment>
))}
</div>
);
}
export function createSewerBscPlanColumns() {
return [
columnHelper.accessor("sido", {
header: "시도",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("gugun", {
header: "시군구",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("sewerBscPlanNm", {
header: "기본계획명",
meta: { align: "left" },
cell: ({ row, getValue }) => (
<>
{getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())}
</>
),
}),
columnHelper.accessor("sewerBscPlanSeCdNm", {
header: "계획구분",
meta: { align: "center" },
cell: ({ row }) =>
formatOptional(row.original.sewerBscPlanSeCdNm || row.original.sewerBscPlanSeComCd),
}),
columnHelper.accessor("taskSeCdNm", {
header: "업무구분",
meta: { align: "center" },
cell: ({ row }) => formatOptional(row.original.taskSeCdNm || row.original.taskSeComCd),
}),
columnHelper.accessor("rgtrNm", {
header: "담당자",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("envoSeCdNm", {
header: "해당기관",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.display({
id: "progress",
header: "진행상황",
meta: { align: "center" },
cell: ({ row }) => (
<ProgressStepBadge
prgrsSttsComCd={row.original.prgrsSttsComCd || ""}
techRplyHstryYn={row.original.techRplyHstryYn || "N"}
/>
),
}),
columnHelper.accessor("regDt", {
header: "등록일",
meta: { align: "center" },
cell: ({ getValue }) => {
const value = getValue();
return value ? dayjs(value).format("YYYY-MM-DD") : "-";
},
}),
];
}
@@ -1,15 +1,13 @@
"use client";
import { useState } from "react";
import { useFormContext, useWatch } from "react-hook-form";
import dynamic from "next/dynamic";
import { NON_NEGATIVE_INPUT_PROPS, sanitizeNumberInput } from "@/shared/lib";
import { SectionCollapsible } from "@/shared/ui";
import { Field } from "@/shared/ui/field";
import { truncateDecimals } from "@/widgets/pbadms-sprt";
import { PreviousPlanLookupButton } from "@/widgets/previous-plan-lookup";
import {
Button,
Input,
Table,
TableBody,
@@ -21,14 +19,6 @@ import {
import type { SewerBscPlanRegistFormData } from "../../lib/sewer-bsc-plan-regist-schema";
const PreviousPlanLookupDialog = dynamic(
() =>
import("../previous-plan-lookup-dialog").then((mod) => ({
default: mod.PreviousPlanLookupDialog,
})),
{ ssr: false },
);
const OTLN_ROWS = [
{ key: "sewerPlanPpltnCnt" as const, label: "계획인구", decimals: 0 },
{ key: "sewerPrcsPpltnCnt" as const, label: "처리인구", decimals: 0 },
@@ -37,7 +27,6 @@ const OTLN_ROWS = [
function OutlineFormSection() {
const { control, setValue, getValues } = useFormContext<SewerBscPlanRegistFormData>();
const [isLookupOpen, setIsLookupOpen] = useState(false);
const otlnList = useWatch({ control, name: "sewerBscPlanOtlnList" }) ?? [];
const frlvlGoalYr = useWatch({ control, name: "sewerBscPlanDtl.frlvlGoalYr" });
@@ -74,11 +63,7 @@ function OutlineFormSection() {
return (
<SectionCollapsible title="사업개요">
<div className="mb-2 flex justify-end px-2">
<Button type="button" color="white" onClick={() => setIsLookupOpen(true)}>
이전기본계획조회
</Button>
</div>
<PreviousPlanLookupButton sidoNm={sido} gugunNm={gugun} />
<Table caption="사업개요 목록으로 구분, 1단계, 2단계, 3단계, 4단계 정보 제공" variant="secondary">
<colgroup>
<col width="12%" />
@@ -126,13 +111,6 @@ function OutlineFormSection() {
))}
</TableBody>
</Table>
<PreviousPlanLookupDialog
open={isLookupOpen}
onOpenChange={setIsLookupOpen}
initialSidoNm={sido}
initialGugunNm={gugun}
/>
</SectionCollapsible>
);
}
@@ -6,11 +6,14 @@ import type {
SewerBscPlanOtlnResponseDto,
} from "@/entities/sewer-bsc-plan";
import { fmtNumber } from "@/widgets/pbadms-sprt";
import { PreviousPlanLookupButton } from "@/widgets/previous-plan-lookup";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
interface OutlineSectionProps {
otlnList: SewerBscPlanOtlnResponseDto[];
dtl?: SewerBscPlanDtlResponseDto;
sido?: string | null;
gugun?: string | null;
}
const OTLN_ROWS = [
@@ -19,7 +22,7 @@ const OTLN_ROWS = [
{ key: "sewerPsplyRt" as const, label: "하수도보급율" },
];
function OutlineSection({ otlnList, dtl }: OutlineSectionProps) {
function OutlineSection({ otlnList, dtl, sido, gugun }: OutlineSectionProps) {
const stepHeaders = [
{ planStpNo: 1, label: "1단계", year: dtl?.frlvlGoalYr },
{ planStpNo: 2, label: "2단계", year: dtl?.sclvlGoalYr },
@@ -29,6 +32,7 @@ function OutlineSection({ otlnList, dtl }: OutlineSectionProps) {
return (
<SectionCollapsible title="사업개요">
<PreviousPlanLookupButton sidoNm={sido} gugunNm={gugun} />
<Table caption="사업개요 목록으로 구분, 1단계, 2단계, 3단계, 4단계 정보 제공" variant="secondary">
<colgroup>
<col width="12%" />
@@ -1,357 +1,354 @@
"use client";
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
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 { AttachmentDownloadLink, attachService } from "@/entities/attach";
import type { SewerBscPlanRvwOpnnResponseDto } from "@/entities/sewer-bsc-plan";
import {
Button,
RadioGroup,
RadioGroupItem,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Textarea,
} from "@common/components";
import type {
ReviewOpinionFormData,
ReviewOpinionGetFormDataResult,
} from "../../lib/review-opinion-utils";
import type { ReviewStepCode } from "../../model/view-types";
import { REVIEW_STEP_LABELS } from "../../model/view-types";
const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
interface TechReviewOpinionSectionProps {
rvwList: SewerBscPlanRvwOpnnResponseDto[];
editableReviewStep?: ReviewStepCode;
atrzSeComCdList?: string[];
isAtrzLoading?: boolean;
}
export interface TechReviewOpinionSectionHandle {
getFormData: () => ReviewOpinionGetFormDataResult | null;
resetForm: () => void;
}
interface TechReviewOpinionTableProps {
items: SewerBscPlanRvwOpnnResponseDto[];
}
interface UploadedOpinionFile {
fileName: string;
fileKey: string;
atflId?: string;
atflCd?: string;
}
const EMPTY_FORM_DATA: ReviewOpinionFormData = {
rvwRsltCn: "",
rvwRsltAnmlyYn: "",
atchFileGroupId: "",
opnrSeComCd: "",
};
function createRvwOpnnUploadKey(file: File) {
return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
}
function hasFormContent(formData: ReviewOpinionFormData) {
return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
}
const TechReviewOpinionSection = forwardRef<
TechReviewOpinionSectionHandle,
TechReviewOpinionSectionProps
>(function TechReviewOpinionSection(
{ rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false },
ref,
) {
const canWriteKecoOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
const [formData, setFormData] = useState<ReviewOpinionFormData>({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null);
useEffect(() => {
setFormData((prev) => ({
...prev,
opnrSeComCd: editableReviewStep ?? "",
}));
}, [editableReviewStep]);
const { uploadFileAsync, isUploading, uploads } = useTus({
onSuccess: (atflCd, atflId, _file, fileKey) => {
if (!atflCd || !atflId) return;
setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd }));
setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
},
onError: (_error, _file, fileKey) => {
setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
},
});
useImperativeHandle(
ref,
() => ({
getFormData: () => {
if (!editableReviewStep || !hasFormContent(formData)) {
return null;
}
if (editableReviewStep === "KECO" && !canWriteKecoOpinion) {
return null;
}
return {
formData: {
...formData,
opnrSeComCd: editableReviewStep,
},
rvwList,
};
},
resetForm: () => {
setFormData({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
setUploadedFile(null);
},
}),
[canWriteKecoOpinion, editableReviewStep, formData, rvwList],
);
const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV");
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
const hasEnvSection = envRvwList.length > 0 || editableReviewStep === "ENV";
const hasKecoSection =
kecoRvwList.length > 0 || (editableReviewStep === "KECO" && canWriteKecoOpinion);
if (!hasEnvSection && !hasKecoSection) {
return null;
}
async function handleFileUpload(files: FileList | null) {
if (!files || files.length === 0) return;
const file = files[0];
const fileKey = createRvwOpnnUploadKey(file);
const existingAtflCd = formData.atchFileGroupId;
setUploadedFile({ fileName: file.name, fileKey });
try {
await uploadFileAsync(file, {
fileKey,
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
});
} catch {
// onError callback handles cleanup
}
}
async function handleFileRemove() {
if (!uploadedFile) return;
uploads[uploadedFile.fileKey]?.abort();
if (uploadedFile.atflId) {
try {
await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
} catch (error) {
console.error("검토의견 첨부파일 삭제 실패:", error);
}
}
setUploadedFile(null);
setFormData((prev) => ({ ...prev, atchFileGroupId: "" }));
}
function renderOpinionForm(reviewStep: ReviewStepCode) {
if (editableReviewStep !== reviewStep) return null;
if (reviewStep === "KECO" && !canWriteKecoOpinion) return null;
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
return (
<div className="space-y-2">
<h4 className="text-xl font-semibold">검토의견 작성</h4>
<Table caption="검토의견 작성으로 의견내용, 검토결과, 첨부파일 정보 제공" variant="secondary">
<TableHeader>
<TableRow>
<TableHead className="w-[12rem]">의견내용</TableHead>
<TableCell>
<Textarea
value={formData.rvwRsltCn}
onChange={(event) =>
setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value }))
}
placeholder="의견내용을 입력하세요"
rows={4}
className="min-h-[8rem] !text-xl"
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>검토결과</TableHead>
<TableCell>
<FieldSet>
<RadioGroup
value={formData.rvwRsltAnmlyYn}
onValueChange={(value) =>
setFormData((prev) => ({
...prev,
rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"],
}))
}
>
<div className="flex flex-wrap gap-4">
<FieldLabel
htmlFor={`rvw-opnn-normal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} />
<span className="font-normal">정상</span>
</Field>
</FieldLabel>
<FieldLabel
htmlFor={`rvw-opnn-abnormal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} />
<span className="font-normal">이상</span>
</Field>
</FieldLabel>
</div>
</RadioGroup>
</FieldSet>
</TableCell>
</TableRow>
<TableRow>
<TableHead>첨부파일</TableHead>
<TableCell>
{uploadedFile ? (
<div className="flex items-center gap-2">
<TextShimmer className="text-xl" active={uploading}>
{uploadedFile.fileName}
</TextShimmer>
<Button
size="icon"
onClick={(e) => {
e.preventDefault();
void handleFileRemove();
}}
disabled={uploading}
className="size-8 !bg-destructive"
>
<i className="diveicon di-x text-xs" aria-label="삭제" />
</Button>
</div>
) : (
<label>
<input
type="file"
className="hidden"
onChange={(event) => {
void handleFileUpload(event.target.files);
event.target.value = "";
}}
/>
<Button size="sm" variant="outline" type="button" asChild>
<span>파일 선택</span>
</Button>
</label>
)}
</TableCell>
</TableRow>
</TableHeader>
</Table>
</div>
);
}
return (
<>
{hasEnvSection ? (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}>
<div className="space-y-6">
{envRvwList.length > 0 ? <TechReviewOpinionTable items={envRvwList} /> : null}
{renderOpinionForm("ENV")}
</div>
</SectionCollapsible>
) : null}
{hasKecoSection ? (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
<div className="space-y-6">
{kecoRvwList.length > 0 ? <TechReviewOpinionTable items={kecoRvwList} /> : null}
{renderOpinionForm("KECO")}
</div>
</SectionCollapsible>
) : null}
</>
);
});
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
return (
<div className="overflow-x-auto">
<Table caption="검토의견 목록으로 의견자명, 의견내용, 검토결과, 첨부파일, 등록일시 정보 제공" variant="secondary">
<colgroup>
<col width="10%" />
<col width="55%" />
<col width="5%" />
<col width="20%" />
<col width="10%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>의견자명</TableHead>
<TableHead>의견내용</TableHead>
<TableHead>검토결과</TableHead>
<TableHead>첨부파일</TableHead>
<TableHead>등록일시</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={`${item.sewerBscPlanDocNo}_${item.rvwOpnnSn}`}>
<TableCell align="center">{item.opnrNm}</TableCell>
<TableCell align="left">{item.rvwRsltCn || "-"}</TableCell>
<TableCell align="center">
{item.rvwRsltAnmlyYn === "Y" ? "이상" : item.rvwRsltAnmlyYn === "N" ? "정상" : "-"}
</TableCell>
<TableCell align="center">
{item.atchFileInfo?.atflCd ? (
<AttachmentDownloadLink
atflCd={item.atchFileInfo.atflCd}
fileName={item.atchFileInfo.orgnlFileNm || "다운로드"}
/>
) : (
"-"
)}
</TableCell>
<TableCell align="center">
{item.regDt ? dayjs(item.regDt).format("YYYY-MM-DD HH:mm:ss") : "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
export { TechReviewOpinionSection };
"use client";
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { getDefaultFileKey, useTus, useUserStore } 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 {
sewerBscPlanKeys,
type SewerBscPlanRvwOpnnResponseDto,
sewerBscPlanRvwOpnnService,
} from "@/entities/sewer-bsc-plan";
import { RvwOpnnListTable } from "@/widgets/pbadms-sprt";
import {
Button,
RadioGroup,
RadioGroupItem,
Table,
TableCell,
TableHead,
TableHeader,
TableRow,
Textarea,
} from "@common/components";
import type {
ReviewOpinionFormData,
ReviewOpinionGetFormDataResult,
} from "../../lib/review-opinion-utils";
import type { ReviewStepCode } from "../../model/view-types";
import { REVIEW_STEP_LABELS } from "../../model/view-types";
const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
interface TechReviewOpinionSectionProps {
rvwList: SewerBscPlanRvwOpnnResponseDto[];
editableReviewStep?: ReviewStepCode;
atrzSeComCdList?: string[];
isAtrzLoading?: boolean;
}
export interface TechReviewOpinionSectionHandle {
getFormData: () => ReviewOpinionGetFormDataResult | null;
resetForm: () => void;
}
interface TechReviewOpinionTableProps {
items: SewerBscPlanRvwOpnnResponseDto[];
}
interface UploadedOpinionFile {
fileName: string;
fileKey: string;
atflId?: string;
atflCd?: string;
}
const EMPTY_FORM_DATA: ReviewOpinionFormData = {
rvwRsltCn: "",
rvwRsltAnmlyYn: "",
atchFileGroupId: "",
opnrSeComCd: "",
};
function createRvwOpnnUploadKey(file: File) {
return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
}
function hasFormContent(formData: ReviewOpinionFormData) {
return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
}
const TechReviewOpinionSection = forwardRef<
TechReviewOpinionSectionHandle,
TechReviewOpinionSectionProps
>(function TechReviewOpinionSection(
{ rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false },
ref,
) {
const canWriteKecoOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
const [formData, setFormData] = useState<ReviewOpinionFormData>({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null);
useEffect(() => {
setFormData((prev) => ({
...prev,
opnrSeComCd: editableReviewStep ?? "",
}));
}, [editableReviewStep]);
const { uploadFileAsync, isUploading, uploads } = useTus({
onSuccess: (atflCd, atflId, _file, fileKey) => {
if (!atflCd || !atflId) return;
setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd }));
setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
},
onError: (_error, _file, fileKey) => {
setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
},
});
useImperativeHandle(
ref,
() => ({
getFormData: () => {
if (!editableReviewStep || !hasFormContent(formData)) {
return null;
}
if (editableReviewStep === "KECO" && !canWriteKecoOpinion) {
return null;
}
return {
formData: {
...formData,
opnrSeComCd: editableReviewStep,
},
rvwList,
};
},
resetForm: () => {
setFormData({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
setUploadedFile(null);
},
}),
[canWriteKecoOpinion, editableReviewStep, formData, rvwList],
);
const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV");
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
const hasEnvSection = envRvwList.length > 0 || editableReviewStep === "ENV";
const hasKecoSection =
kecoRvwList.length > 0 || (editableReviewStep === "KECO" && canWriteKecoOpinion);
if (!hasEnvSection && !hasKecoSection) {
return null;
}
async function handleFileUpload(files: FileList | null) {
if (!files || files.length === 0) return;
const file = files[0];
const fileKey = createRvwOpnnUploadKey(file);
const existingAtflCd = formData.atchFileGroupId;
setUploadedFile({ fileName: file.name, fileKey });
try {
await uploadFileAsync(file, {
fileKey,
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
});
} catch {
// onError callback handles cleanup
}
}
async function handleFileRemove() {
if (!uploadedFile) return;
uploads[uploadedFile.fileKey]?.abort();
if (uploadedFile.atflId) {
try {
await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
} catch (error) {
console.error("검토의견 첨부파일 삭제 실패:", error);
}
}
setUploadedFile(null);
setFormData((prev) => ({ ...prev, atchFileGroupId: "" }));
}
function isReviewTurn(reviewStep: ReviewStepCode) {
if (editableReviewStep !== reviewStep) return false;
if (reviewStep === "KECO" && !canWriteKecoOpinion) return false;
return true;
}
function renderOpinionForm(reviewStep: ReviewStepCode) {
if (!isReviewTurn(reviewStep)) return null;
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
return (
<div className="space-y-2">
<h4 className="text-xl font-semibold">검토의견 작성</h4>
<Table
caption="검토의견 작성으로 의견내용, 검토결과, 첨부파일 정보 제공"
variant="secondary"
>
<TableHeader>
<TableRow>
<TableHead className="w-[12rem]">의견내용</TableHead>
<TableCell>
<Textarea
value={formData.rvwRsltCn}
onChange={(event) =>
setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value }))
}
placeholder="의견내용을 입력하세요"
rows={4}
className="min-h-[8rem] !text-xl"
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>검토결과</TableHead>
<TableCell>
<FieldSet>
<RadioGroup
value={formData.rvwRsltAnmlyYn}
onValueChange={(value) =>
setFormData((prev) => ({
...prev,
rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"],
}))
}
>
<div className="flex flex-wrap gap-4">
<FieldLabel
htmlFor={`rvw-opnn-normal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} />
<span className="font-normal">정상</span>
</Field>
</FieldLabel>
<FieldLabel
htmlFor={`rvw-opnn-abnormal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} />
<span className="font-normal">이상</span>
</Field>
</FieldLabel>
</div>
</RadioGroup>
</FieldSet>
</TableCell>
</TableRow>
<TableRow>
<TableHead>첨부파일</TableHead>
<TableCell>
{uploadedFile ? (
<div className="flex items-center gap-2">
<TextShimmer className="text-xl" active={uploading}>
{uploadedFile.fileName}
</TextShimmer>
<Button
size="icon"
onClick={(e) => {
e.preventDefault();
void handleFileRemove();
}}
disabled={uploading}
className="size-8 !bg-destructive"
>
<i className="diveicon di-x text-xs" aria-label="삭제" />
</Button>
</div>
) : (
<label>
<input
type="file"
className="hidden"
onChange={(event) => {
void handleFileUpload(event.target.files);
event.target.value = "";
}}
/>
<Button size="sm" variant="outline" type="button" asChild>
<span>파일 선택</span>
</Button>
</label>
)}
</TableCell>
</TableRow>
</TableHeader>
</Table>
</div>
);
}
return (
<>
{hasEnvSection ? (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}>
<div className="space-y-6">
{envRvwList.length > 0 ? (
<TechReviewOpinionTable items={envRvwList} />
) : null}
{renderOpinionForm("ENV")}
</div>
</SectionCollapsible>
) : null}
{hasKecoSection ? (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
<div className="space-y-6">
{kecoRvwList.length > 0 ? (
<TechReviewOpinionTable items={kecoRvwList} />
) : null}
{renderOpinionForm("KECO")}
</div>
</SectionCollapsible>
) : null}
</>
);
});
function getRvwOpnnIds(item: SewerBscPlanRvwOpnnResponseDto) {
if (!item.sewerBscPlanDocNo || item.chgCycl == null || item.rvwOpnnSn == null) {
throw new Error("검토의견 식별값이 없습니다.");
}
return [item.sewerBscPlanDocNo, item.chgCycl, item.rvwOpnnSn] as const;
}
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
const queryClient = useQueryClient();
const currentUserUuid = useUserStore((state) => state.userInfo?.userUnqId);
return (
<RvwOpnnListTable
items={items}
getRowKey={(item) => `${item.sewerBscPlanDocNo}_${item.rvwOpnnSn}`}
currentUserUuid={currentUserUuid}
onUpdate={async (item, body) => {
await sewerBscPlanRvwOpnnService.update(...getRvwOpnnIds(item), body);
await queryClient.invalidateQueries({ queryKey: sewerBscPlanKeys.all });
}}
onDelete={async (item) => {
await sewerBscPlanRvwOpnnService.delete(...getRvwOpnnIds(item));
await queryClient.invalidateQueries({ queryKey: sewerBscPlanKeys.all });
}}
/>
);
}
export { TechReviewOpinionSection };
@@ -2,6 +2,8 @@
import { useMemo, useRef } from "react";
import { RefreshCw } from "lucide-react";
import { useUserStore } from "@/shared/lib";
import { SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
import { ElaprDocPrgrsStatus } from "@/entities/elapr";
@@ -12,7 +14,7 @@ import {
ProgressStatusSection,
SupplementDemandSection,
} from "@/widgets/pbadms-sprt";
import { Skeleton } from "@common/components";
import { Button, cn, Skeleton } from "@common/components";
import { needsRoleUser01AtrzResolve } from "../lib/button-config";
import { getEditableReviewStep } from "../lib/review-step-utils";
@@ -41,7 +43,7 @@ function SewerBscPlanViewPage({ id, propChgCycl }: SewerBscPlanViewPageProps) {
const userInfo = useUserStore((state) => state.userInfo);
const techReviewOpinionRef = useRef<TechReviewOpinionSectionHandle>(null);
const chgCycl = propChgCycl ?? 1;
const { data, isLoading, isError } = useSewerBscPlanDetails(id, chgCycl);
const { data, isLoading, isError, refetch, isFetching } = useSewerBscPlanDetails(id, chgCycl);
const { formState: checklistFormState, setFormState: setChecklistFormState } =
useChecklistFormState(data?.cklstResultRows ?? EMPTY_CKLST_RESULT_LIST);
@@ -102,8 +104,20 @@ function SewerBscPlanViewPage({ id, propChgCycl }: SewerBscPlanViewPageProps) {
return (
<div className="mt-4">
<SectionCollapsibleGroup>
<div className="sticky top-[6rem] z-30 bg-container px-4 py-2">
<div className="sticky top-[6rem] z-30 flex items-center gap-3 bg-container px-4 py-2">
<SectionCollapsibleToggle />
<Button
type="button"
variant="outline"
size="xs"
onClick={() => refetch()}
disabled={isFetching}
className="flex items-center gap-1.5 h-[2.8rem] px-3 text-[1.2rem] font-normal"
title="최신 데이터 새로고침"
>
<RefreshCw className={cn("size-3.5", isFetching && "animate-spin")} />
<span>새로 고침</span>
</Button>
</div>
<div className="space-y-4">
<ProgressStatusSection
@@ -114,7 +128,12 @@ function SewerBscPlanViewPage({ id, propChgCycl }: SewerBscPlanViewPageProps) {
<BasicInfoSection det={detDto} dtl={activeDtlDto} />
<ContactInfoSection dtl={activeDtlDto} />
<ElaprDocPrgrsStatus elaprDocNo={detDto.sewerBscPlanDocNo} />
<OutlineSection otlnList={otlnList} dtl={activeDtlDto} />
<OutlineSection
otlnList={otlnList}
dtl={activeDtlDto}
sido={detDto.sido}
gugun={detDto.gugun}
/>
<BizCstMatrixSection bizCstList={bizCstList} dtl={activeDtlDto} />
<FnrsCstSection fnrsCstList={fnrsCstList} dtl={activeDtlDto} />
<TmplntSection tmplntList={tmplntList} det={detDto} dtl={activeDtlDto} />
+4
View File
@@ -12,11 +12,13 @@ export const ENDPOINT = {
RVW_MNPW: "/as-service/api/v1/rvw-mnpw",
BNS_IF: "/as-service/api/v1/if",
INSTL_SNCN: "/as-service/api/v1/instl-sncn",
INSTL_SNCN_RVW_OPNN: "/as-service/api/v1/instl-sncn-rvw-opnn",
CKLST: "/as-service/api/v1/cklst",
CKLST_ARTCL: "/as-service/api/v1/cklst-artcl",
CKLST_RVW: "/as-service/api/v1/cklst-rvw",
ADSTDSTR: "/as-service/api/v1/adstdstr",
FNRS_CNSLTN_MNG: "/as-service/api/v1/fnrs-cnsltn-mng",
FNRS_CNSLTN_RVW_OPNN: "/as-service/api/v1/fnrs-cnsltn-rvw-opnn",
FNRS_SHAR_SE: "/as-service/api/v1/fnrs-shar-se-mng",
DESIGN_CNSTN: "/as-service/api/v1/design-cnstn",
DESIGN_CNSTN_BIZ_CST: "/as-service/api/v1/design-cnstn-biz-cst",
@@ -24,6 +26,7 @@ export const ENDPOINT = {
DESIGN_CNSTN_ESNTL_ATCH_FILE: "/as-service/api/v1/design-cnstn-esntl-atch-file",
DESIGN_CNSTN_RVW_OPNN: "/as-service/api/v1/design-cnstn-rvw-opnn",
SEWER_BSC_PLAN: "/as-service/api/v1/sewer-bsc-plans",
SEWER_BSC_PLAN_RVW_OPNNS: "/as-service/api/v1/sewer-bsc-plan-rvw-opnns",
DASHBOARD: "/as-service/api/v1/dashboard",
DCRAG_TO: "/as-service/api/v1/dcrag-to",
AI_AUTO_PRCS_MNG: "/as-service/api/v1/ai-auto-prcs-mng",
@@ -42,6 +45,7 @@ export const ENDPOINT = {
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",
ELAPR_BRANCH: "/as-elapr-service/api/v1/elapr-branch",
},
CMS_SERVICE: {
CODE: "/cms-service/api/v1/code/cache",
+1
View File
@@ -46,6 +46,7 @@ export type {
PbadmsMngDetResponseBase,
RvwOpnnResponseExtension,
RvwOpnnSaveBase,
RvwOpnnUpdateRequestDto,
SplmntDmndResponseExtension,
SplmntDmndSaveBase,
} from "./pbadms-sprt";
+17
View File
@@ -6,6 +6,8 @@ export interface DocPrgrsUpdateRequestDto {
docSeComCd: string;
/** 진행상태공통코드 */
prgrsSttsComCd: string;
/** 작성자 UUID */
rgtrUuid?: string;
}
/** 공종 트리와 사업규모(수량) 조합 DTO */
@@ -149,9 +151,13 @@ export interface ElaprDocPrgrsClientResponseDto {
atrzDt?: string | null;
jbpsSeNm?: string;
jbgdSeNm?: string;
aprvrDeptNm?: string;
ognzNm?: string;
dcarbYn?: string;
elaprCycl?: number;
branchId?: string;
branchSeCd?: string;
branchNm?: string;
}
/** 문서 상태 변경 이력 응답 DTO */
@@ -231,11 +237,22 @@ export interface PbadmsApplySaveHeader {
rvwStpCd?: string;
}
/** 검토의견 수정 공통 요청 DTO */
export interface RvwOpnnUpdateRequestDto {
opnrSeComCd?: string;
opnrNm?: string;
rvwRsltCn?: string;
rvwRsltAnmlyYn?: string;
atchFileGroupId?: string;
}
/** 검토의견 응답 공통 확장 필드 */
export interface RvwOpnnResponseExtension {
opnrSeCdNm?: string;
atchFileInfo?: AtflMngClientResponseDto;
regDt?: string;
rgtr?: string;
rgtrNm?: string;
}
/** 보완요구 응답 공통 확장 필드 */
@@ -1 +1,5 @@
export { HangulGiangi, type HangulGiangiProps } from "./ui/hangul-giangi";
export { getHwpCtrl } from "./lib/get-hwp-ctrl";
export { buildBridgeSrc } from "./lib/build-bridge-src";
export { DEFAULT_BRIDGE_URL } from "./lib/hangul-giangi-constants";
export type { HwpCtrl } from "./model/hwp-ctrl";
@@ -0,0 +1,20 @@
import { WEBHYP_CTRL_URL } from "@/shared/config";
import { DEFAULT_BRIDGE_URL } from "./hangul-giangi-constants";
/**
* 웹한글 기안기 브리지 HTML(/webhwpctrl/index.html)에
* 한컴 컨트롤 서버 baseurl 쿼리 파라미터를 추가한 iframe src URL을 생성합니다.
*/
export function buildBridgeSrc(
bridgeUrl: string = DEFAULT_BRIDGE_URL,
ctrlBaseUrl: string = WEBHYP_CTRL_URL,
): string {
const base = ctrlBaseUrl.replace(/\/$/, "");
const url = new URL(
bridgeUrl,
typeof window !== "undefined" ? window.location.origin : "http://localhost",
);
url.searchParams.set("baseurl", base);
if (bridgeUrl.startsWith("/")) return `${url.pathname}${url.search}`;
return url.toString();
}

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