Compare commits
68
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3711c2609 | ||
|
|
957fe4637e | ||
|
|
4e9bca25ef | ||
|
|
23d62ab5e6 | ||
|
|
bedefd7e2e | ||
|
|
56688eb1a6 | ||
|
|
ee0049722f | ||
|
|
74934d4559 | ||
|
|
027f88da6c | ||
|
|
175964b652 | ||
|
|
eddb1b24d6 | ||
|
|
cf2cc11167 | ||
|
|
9e71f529e7 | ||
|
|
4f4f5112b5 | ||
|
|
6dc2965209 | ||
|
|
6d9db4389d | ||
|
|
b5eceadee8 | ||
|
|
b74f2820da | ||
|
|
c69c1aa855 | ||
|
|
d0f2e41874 | ||
|
|
4be56402d8 | ||
|
|
9f0e03c151 | ||
|
|
471a431af5 | ||
|
|
26d8410649 | ||
|
|
8403ad556c | ||
|
|
d26c15f857 | ||
|
|
86ed664e3e | ||
|
|
7df6f40bc4 | ||
|
|
690724bc74 | ||
|
|
08bdbe79b5 | ||
|
|
177f4653c8 | ||
|
|
cc4acb3916 | ||
|
|
a105f395dd | ||
|
|
a06e4f46cf | ||
|
|
07ae3f7711 | ||
|
|
46c4634868 | ||
|
|
0ce35d4c38 | ||
|
|
a5ebc12580 | ||
|
|
270f6e366f | ||
|
|
acf215c1bf | ||
|
|
f220904742 | ||
|
|
dc74c07ef4 | ||
|
|
36b11d6b00 | ||
|
|
9a77e9c52a | ||
|
|
42dc2dc027 | ||
|
|
14803b2625 | ||
|
|
79f70d8b4b | ||
|
|
7911cff96a | ||
|
|
1a5180551e | ||
|
|
fe501965bf | ||
|
|
fb82cadc82 | ||
|
|
f5b6effeb3 | ||
|
|
63c2901ae3 | ||
|
|
e697881581 | ||
|
|
0356aef7b2 | ||
|
|
f709dc99f6 | ||
|
|
dd4ee1dcb5 | ||
|
|
6c71bccc27 | ||
|
|
728a66bac2 | ||
|
|
c850b9ce1e | ||
|
|
9a33663f1e | ||
|
|
791bc20e8e | ||
|
|
b088918cd9 | ||
|
|
3f2938df1c | ||
|
|
fed1383ced | ||
|
|
c39d1f433a | ||
|
|
91ff80a255 | ||
|
|
7f10e20a41 |
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
# turbo
|
# turbo
|
||||||
.turbo/
|
.turbo/
|
||||||
.vscode/**/*
|
**/.vscode/
|
||||||
|
*.code-workspace
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|||||||
Vendored
+119
-11
@@ -1,7 +1,5 @@
|
|||||||
{
|
{
|
||||||
// IntelliSense를 사용하여 가능한 특성에 대해 알아보세요.
|
|
||||||
// 기존 특성에 대한 설명을 보려면 가리킵니다.
|
|
||||||
// 자세한 내용을 보려면 https://go.microsoft.com/fwlink/?linkid=830387을(를) 방문하세요.
|
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
@@ -14,13 +12,91 @@
|
|||||||
"dev:as"
|
"dev:as"
|
||||||
],
|
],
|
||||||
"skipFiles": [
|
"skipFiles": [
|
||||||
"<node_internals>/**"
|
"<node_internals>/**",
|
||||||
|
"**/node_modules/**"
|
||||||
|
],
|
||||||
|
"resolveSourceMapLocations": [
|
||||||
|
"${workspaceFolder}/**",
|
||||||
|
"!**/node_modules/**"
|
||||||
],
|
],
|
||||||
"cwd": "${workspaceFolder}",
|
"cwd": "${workspaceFolder}",
|
||||||
"console": "integratedTerminal",
|
"console": "integratedTerminal",
|
||||||
"sourceMaps": true,
|
"sourceMaps": true,
|
||||||
"env": {
|
"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"
|
"dev:cs"
|
||||||
],
|
],
|
||||||
"skipFiles": [
|
"skipFiles": [
|
||||||
"<node_internals>/**"
|
"<node_internals>/**",
|
||||||
|
"**/node_modules/**"
|
||||||
|
],
|
||||||
|
"resolveSourceMapLocations": [
|
||||||
|
"${workspaceFolder}/**",
|
||||||
|
"!**/node_modules/**"
|
||||||
],
|
],
|
||||||
"cwd": "${workspaceFolder}",
|
"cwd": "${workspaceFolder}",
|
||||||
"console": "integratedTerminal",
|
"console": "integratedTerminal",
|
||||||
"sourceMaps": true,
|
"sourceMaps": true,
|
||||||
"env": {
|
"env": {
|
||||||
"NODE_OPTIONS": "--inspect"
|
"NODE_OPTIONS": "--inspect --max-old-space-size=8192"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -49,7 +130,8 @@
|
|||||||
"port": 9229,
|
"port": 9229,
|
||||||
"restart": true,
|
"restart": true,
|
||||||
"skipFiles": [
|
"skipFiles": [
|
||||||
"<node_internals>/**"
|
"<node_internals>/**",
|
||||||
|
"**/node_modules/**"
|
||||||
],
|
],
|
||||||
"sourceMaps": true,
|
"sourceMaps": true,
|
||||||
"resolveSourceMapLocations": [
|
"resolveSourceMapLocations": [
|
||||||
@@ -63,7 +145,15 @@
|
|||||||
"name": "dev:cs 클라이언트 디버그 (Chrome)",
|
"name": "dev:cs 클라이언트 디버그 (Chrome)",
|
||||||
"url": "http://localhost:3000",
|
"url": "http://localhost:3000",
|
||||||
"webRoot": "${workspaceFolder}/apps/cs",
|
"webRoot": "${workspaceFolder}/apps/cs",
|
||||||
"sourceMaps": true
|
"skipFiles": [
|
||||||
|
"<node_internals>/**",
|
||||||
|
"**/node_modules/**"
|
||||||
|
],
|
||||||
|
"sourceMaps": true,
|
||||||
|
"resolveSourceMapLocations": [
|
||||||
|
"${workspaceFolder}/apps/cs/**",
|
||||||
|
"!**/node_modules/**"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "node",
|
"type": "node",
|
||||||
@@ -75,13 +165,31 @@
|
|||||||
"dev:st"
|
"dev:st"
|
||||||
],
|
],
|
||||||
"skipFiles": [
|
"skipFiles": [
|
||||||
"<node_internals>/**"
|
"<node_internals>/**",
|
||||||
|
"**/node_modules/**"
|
||||||
|
],
|
||||||
|
"resolveSourceMapLocations": [
|
||||||
|
"${workspaceFolder}/**",
|
||||||
|
"!**/node_modules/**"
|
||||||
],
|
],
|
||||||
"cwd": "${workspaceFolder}",
|
"cwd": "${workspaceFolder}",
|
||||||
"console": "integratedTerminal",
|
"console": "integratedTerminal",
|
||||||
"sourceMaps": true,
|
"sourceMaps": true,
|
||||||
"env": {
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
Vendored
+4
-1
@@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
"chat.tools.terminal.autoApprove": {
|
"chat.tools.terminal.autoApprove": {
|
||||||
"pnpm": true
|
"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] 필수첨부파일 목록 │
|
│ [영역 B] 필수첨부파일 목록 │
|
||||||
│ ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ │
|
│ ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ │
|
||||||
│ │ 필수첨부파일아이디 | 문서구분 | 필수첨부파일명 | 필수첨부여부 | 비고 | 등록일시 | 수정일시 | 관리 │
|
│ │ 필수첨부파일아이디 | 문서구분 | 필수첨부파일명 | 필수첨부여부 | 필수파일검토내용 | 비고 | 관리 │ │
|
||||||
│ ├────────────────────────────────────────────────────────────────────────────────────────────────┤ │
|
│ ├────────────────────────────────────────────────────────────────────────────────────────────────┤ │
|
||||||
│ │ EAF-0001 | 설치인가 | 사업계획서 | 예 | 서명본 첨부 | 2026-04-22 10:10:00 | — | [수정][삭제] │ │
|
│ │ EAF-0001 | 설치인가 | 사업계획서 | 예 | 사업비 내역 검토 | 서명본 첨부 | [수정][삭제] │ │
|
||||||
│ │ EAF-0002 | 기본계획 | 변경사유서 | 예 | — | 2026-04-22 11:00:00 | — | [수정][삭제] │ │
|
│ │ EAF-0002 | 기본계획 | 변경사유서 | 예 | 변경 전후 비교표 | — | [수정][삭제] │ │
|
||||||
│ └────────────────────────────────────────────────────────────────────────────────────────────────┘ │
|
│ └────────────────────────────────────────────────────────────────────────────────────────────────┘ │
|
||||||
└──────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
@@ -47,7 +47,9 @@
|
|||||||
│ 필수첨부파일아이디(자동발급) [EAF-0004] │
|
│ 필수첨부파일아이디(자동발급) [EAF-0004] │
|
||||||
│ 문서구분 [설치인가 ▼] │
|
│ 문서구분 [설치인가 ▼] │
|
||||||
│ 필수첨부파일명 [예: 사업계획서] │
|
│ 필수첨부파일명 [예: 사업계획서] │
|
||||||
|
│ 양식파일 [파일 선택] │
|
||||||
│ 필수첨부여부 [ ] 예 [ ] 아니오 │
|
│ 필수첨부여부 [ ] 예 [ ] 아니오 │
|
||||||
|
│ 필수파일검토내용 [검토내용을 입력하세요]│
|
||||||
│ 비고 [비고를 입력하세요] │
|
│ 비고 [비고를 입력하세요] │
|
||||||
├──────────────────────────────────────────────────────┤
|
├──────────────────────────────────────────────────────┤
|
||||||
│ [취소] [저장] │
|
│ [취소] [저장] │
|
||||||
@@ -139,16 +141,16 @@ END
|
|||||||
|
|
||||||
### 5-2. 그리드 컬럼
|
### 5-2. 그리드 컬럼
|
||||||
|
|
||||||
| 컬럼명 | Field | 타입 | 비고 |
|
| 컬럼명 | Field | 타입 | 비고 |
|
||||||
| ------------------ | ------------------- | ------ | ------------------------------------- |
|
| ------------------ | -------------------- | ------ | ------------------------------------- |
|
||||||
| 필수첨부파일아이디 | `esntlAtchFileId` | string | 예: `EAF-0001`, 클라이언트 생성/입력 |
|
| 필수첨부파일아이디 | `esntlAtchFileId` | string | 예: `EAF-0001`, 클라이언트 생성/입력 |
|
||||||
| 문서구분 | `docSeComCd` | string | 코드명 표시 — 공통코드 (TBD 상위코드) |
|
| 문서구분 | `docSeComCd` | string | 코드명 표시 — 공통코드 (TBD 상위코드) |
|
||||||
| 필수첨부파일명 | `esntlAtchFileNm` | string | |
|
| 필수첨부파일명 | `esntlAtchFileNm` | string | |
|
||||||
| 필수첨부여부 | `esntlAtchYn` | string | `Y`→예, `N`→아니오 |
|
| 필수첨부여부 | `esntlAtchYn` | string | `Y`→예, `N`→아니오 |
|
||||||
| 비고 | `esntlAtchFileRmrk` | string | 없으면 `—` 표시 |
|
| 필수파일검토내용 | `esntlAtchFileRvwCn` | string | 없으면 `—` 표시, 최대 2줄 clamp |
|
||||||
| 등록일시 | `regDt` | string | `yyyy-MM-dd HH:mm:ss` |
|
| 비고 | `esntlAtchFileRmrk` | string | 없으면 `—` 표시 |
|
||||||
| 수정일시 | `mdfcnDt` | string | 미수정 시 빈 값 또는 `—` |
|
| 양식 내려받기 | `atchFileGroupId` | string | 양식파일 다운로드 버튼 |
|
||||||
| 관리 | — | — | 행별 **[수정] · [삭제]** |
|
| 관리 | — | — | 행별 **[수정] · [삭제]** |
|
||||||
|
|
||||||
- 행 클릭 시 별도 상세 화면 없음. 편집은 **[수정]** 버튼으로 다이얼로그 C를 연다.
|
- 행 클릭 시 별도 상세 화면 없음. 편집은 **[수정]** 버튼으로 다이얼로그 C를 연다.
|
||||||
|
|
||||||
@@ -167,13 +169,15 @@ END
|
|||||||
|
|
||||||
### 6-1. 폼 필드
|
### 6-1. 폼 필드
|
||||||
|
|
||||||
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
|
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
|
||||||
| ------------------ | ---------- | ---- | ------ | ------------------- | -------------------------------- |
|
| ------------------ | ------------------- | ---- | ------ | -------------------- | ---------------------------------- |
|
||||||
| 필수첨부파일아이디 | (Readonly) | Y | — | `esntlAtchFileId` | 등록 시 미노출 |
|
| 필수첨부파일아이디 | (Readonly) | Y | — | `esntlAtchFileId` | 등록 시 미노출 |
|
||||||
| 문서구분 | CodeSelect | Y | — | `docSeComCd` | 예: 설치인가, 기본계획 |
|
| 문서구분 | CodeSelect | Y | — | `docSeComCd` | 예: 설치인가, 기본계획 |
|
||||||
| 필수첨부파일명 | Text | Y | — | `esntlAtchFileNm` | placeholder: "예: 사업계획서" |
|
| 필수첨부파일명 | Text | Y | — | `esntlAtchFileNm` | placeholder: "예: 사업계획서" |
|
||||||
| 필수첨부여부 | RadioGroup | Y | Y | `esntlAtchYn` | Y/N, UI 표시: 예/아니오 |
|
| 양식파일 | AttachmentFileField | N | — | `atchFileGroupId` | TUS 파일 업로드 |
|
||||||
| 비고 | Textarea | N | — | `esntlAtchFileRmrk` | placeholder: "비고를 입력하세요" |
|
| 필수첨부여부 | RadioGroup | Y | Y | `esntlAtchYn` | Y/N, UI 표시: 예/아니오 |
|
||||||
|
| 필수파일검토내용 | Textarea | N | — | `esntlAtchFileRvwCn` | placeholder: "검토내용을 입력하세요"|
|
||||||
|
| 비고 | Textarea | N | — | `esntlAtchFileRmrk` | placeholder: "비고를 입력하세요" |
|
||||||
|
|
||||||
### 6-2. 버튼
|
### 6-2. 버튼
|
||||||
|
|
||||||
@@ -210,28 +214,32 @@ END
|
|||||||
|
|
||||||
**응답 DTO**
|
**응답 DTO**
|
||||||
|
|
||||||
| Field | 타입 | 설명 |
|
| Field | 타입 | 설명 |
|
||||||
| ------------------- | ------- | -------------------------------- |
|
| -------------------- | -------------------------- | -------------------------------- |
|
||||||
| `esntlAtchFileId` | string | 필수첨부파일아이디 |
|
| `esntlAtchFileId` | string | 필수첨부파일아이디 |
|
||||||
| `docSeComCd` | string | 문서구분공통코드 |
|
| `docSeComCd` | string | 문서구분공통코드 |
|
||||||
| `esntlAtchFileNm` | string | 필수첨부파일명 |
|
| `esntlAtchFileNm` | string | 필수첨부파일명 |
|
||||||
| `esntlAtchYn` | string | 필수첨부여부 (Y/N) |
|
| `esntlAtchYn` | string | 필수첨부여부 (Y/N) |
|
||||||
| `esntlAtchFileRmrk` | string? | 필수첨부파일비고 |
|
| `esntlAtchFileRvwCn` | string? | 필수첨부파일검토내용 |
|
||||||
| `rgtr` | string? | 등록자 UUID |
|
| `esntlAtchFileRmrk` | string? | 필수첨부파일비고 |
|
||||||
| `rgtrNm` | string? | 등록자명 |
|
| `atchFileGroupId` | string? | 첨부파일그룹아이디 (양식파일) |
|
||||||
| `mdfr` | string? | 수정자 UUID |
|
| `atchFileList` | AtflMngClientResponseDto[]?| 첨부파일 목록 |
|
||||||
| `regDt` | string? | 등록일시 (`yyyy-MM-dd HH:mm:ss`) |
|
| `rgtr` | string? | 등록자 UUID |
|
||||||
| `mdfcnDt` | string? | 수정일시 |
|
| `rgtrNm` | string? | 등록자명 |
|
||||||
|
| `mdfr` | string? | 수정자 UUID |
|
||||||
|
| `regDt` | string? | 등록일시 (`yyyy-MM-dd HH:mm:ss`) |
|
||||||
|
| `mdfcnDt` | string? | 수정일시 |
|
||||||
|
|
||||||
**저장 Request**
|
**저장 Request**
|
||||||
|
|
||||||
| Field | 필수 | 설명 |
|
| Field | 필수 | 설명 |
|
||||||
| ------------------- | ---- | ------------------ |
|
| -------------------- | ---- | -------------------- |
|
||||||
| `esntlAtchFileId` | Y | 필수첨부파일아이디 |
|
| `docSeComCd` | Y | 문서구분 |
|
||||||
| `docSeComCd` | N | 문서구분 |
|
| `esntlAtchFileNm` | N | 필수첨부파일명 |
|
||||||
| `esntlAtchFileNm` | N | 필수첨부파일명 |
|
| `esntlAtchYn` | N | 필수첨부여부 |
|
||||||
| `esntlAtchYn` | N | 필수첨부여부 |
|
| `esntlAtchFileRvwCn` | N | 필수첨부파일검토내용 |
|
||||||
| `esntlAtchFileRmrk` | N | 비고 |
|
| `esntlAtchFileRmrk` | N | 비고 |
|
||||||
|
| `atchFileGroupId` | N | 첨부파일그룹아이디 |
|
||||||
|
|
||||||
**검색 Query**
|
**검색 Query**
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3003",
|
"dev": "next dev -p 3004",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start -p 3003",
|
"start": "next start -p 3004",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"lint:fsd": "steiger ."
|
"lint:fsd": "steiger ."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
DesignCnstnApplySaveRequestDto,
|
||||||
|
DesignCnstnMngSaveRequestDto,
|
||||||
|
DesignCnstnRvwOpnnSaveRequestDto,
|
||||||
|
} from "../model/design-cnstn-apply-payload-schema";
|
||||||
import type {
|
import type {
|
||||||
DesignCnstnBizCstSaveRequestDto,
|
DesignCnstnBizCstSaveRequestDto,
|
||||||
DesignCnstnBizCstSearchDto,
|
DesignCnstnBizCstSearchDto,
|
||||||
@@ -15,11 +20,6 @@ import type {
|
|||||||
DesignCnstnEsntlAtchFileSearchDto,
|
DesignCnstnEsntlAtchFileSearchDto,
|
||||||
DesignCnstnEsntlAtchFileUpdateRequestDto,
|
DesignCnstnEsntlAtchFileUpdateRequestDto,
|
||||||
} from "../model/design-cnstn-esntl-atch-file-interface";
|
} from "../model/design-cnstn-esntl-atch-file-interface";
|
||||||
import type {
|
|
||||||
DesignCnstnApplySaveRequestDto,
|
|
||||||
DesignCnstnMngSaveRequestDto,
|
|
||||||
DesignCnstnRvwOpnnSaveRequestDto,
|
|
||||||
} from "../model/design-cnstn-apply-payload-schema";
|
|
||||||
import type {
|
import type {
|
||||||
DesignCnstnMngSearchDto,
|
DesignCnstnMngSearchDto,
|
||||||
DesignCnstnMngUpdateRequestDto,
|
DesignCnstnMngUpdateRequestDto,
|
||||||
@@ -83,6 +83,8 @@ export function useDesignCnstnMng(id: string | null | undefined) {
|
|||||||
queryKey: designCnstnKeys.detail(id!),
|
queryKey: designCnstnKeys.detail(id!),
|
||||||
queryFn: () => designCnstnMngService.findById(id!),
|
queryFn: () => designCnstnMngService.findById(id!),
|
||||||
enabled: Boolean(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,
|
||||||
|
};
|
||||||
@@ -23,6 +23,7 @@ import type {
|
|||||||
} from "../model/elapr-tmplt-interface";
|
} from "../model/elapr-tmplt-interface";
|
||||||
|
|
||||||
import { elaprAplnService } from "./elapr-apln-service";
|
import { elaprAplnService } from "./elapr-apln-service";
|
||||||
|
import { elaprBranchService } from "./elapr-branch-service";
|
||||||
import { elaprDmndService } from "./elapr-dmnd-service";
|
import { elaprDmndService } from "./elapr-dmnd-service";
|
||||||
import { elaprPrgrsSttsService } from "./elapr-prgrs-stts-service";
|
import { elaprPrgrsSttsService } from "./elapr-prgrs-stts-service";
|
||||||
import { elaprTmpltService } from "./elapr-tmplt-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 elaprAplnBaseKey = [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN] as const;
|
||||||
const elaprDmndBaseKey = [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND] as const;
|
const elaprDmndBaseKey = [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND] as const;
|
||||||
const elaprPrgrsSttsBaseKey = [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_PRGRS_STTS] 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>) {
|
function invalidateElaprPrgrsSttsQueries(queryClient: ReturnType<typeof useQueryClient>) {
|
||||||
queryClient.invalidateQueries({ queryKey: elaprPrgrsSttsBaseKey });
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export { elaprAplnService } from "./api/elapr-apln-service";
|
export { elaprAplnService } from "./api/elapr-apln-service";
|
||||||
|
export { elaprBranchService } from "./api/elapr-branch-service";
|
||||||
export { elaprDmndService } from "./api/elapr-dmnd-service";
|
export { elaprDmndService } from "./api/elapr-dmnd-service";
|
||||||
export { elaprPrgrsSttsService } from "./api/elapr-prgrs-stts-service";
|
export { elaprPrgrsSttsService } from "./api/elapr-prgrs-stts-service";
|
||||||
export { elaprTmpltService } from "./api/elapr-tmplt-service";
|
export { elaprTmpltService } from "./api/elapr-tmplt-service";
|
||||||
@@ -9,6 +10,7 @@ export {
|
|||||||
useDeleteElaprTmplt,
|
useDeleteElaprTmplt,
|
||||||
useElaprApln,
|
useElaprApln,
|
||||||
useElaprAplnPage,
|
useElaprAplnPage,
|
||||||
|
useElaprBranchesByDocNo,
|
||||||
useElaprDmnd,
|
useElaprDmnd,
|
||||||
useElaprDmndPage,
|
useElaprDmndPage,
|
||||||
useElaprDocPrgrsByDocNo,
|
useElaprDocPrgrsByDocNo,
|
||||||
@@ -59,6 +61,11 @@ export {
|
|||||||
buildElaprPrgrsSttsListFromAplnRows,
|
buildElaprPrgrsSttsListFromAplnRows,
|
||||||
} from "./lib/dmnd-save-utils";
|
} from "./lib/dmnd-save-utils";
|
||||||
export { mapDocPrgrsToAplnRows } from "./lib/doc-prgrs-to-apln-rows";
|
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 type { ElaprPrgrsSttsRow } from "./lib/prgrs-stts-save-utils";
|
||||||
export {
|
export {
|
||||||
assignPrgrsSttsAtrzSeqNumbers,
|
assignPrgrsSttsAtrzSeqNumbers,
|
||||||
@@ -91,6 +98,14 @@ export type {
|
|||||||
ElaprDmndService,
|
ElaprDmndService,
|
||||||
ElaprDocPrgrsResponseDto,
|
ElaprDocPrgrsResponseDto,
|
||||||
} from "./model/elapr-dmnd-interface";
|
} from "./model/elapr-dmnd-interface";
|
||||||
|
export type {
|
||||||
|
AtrzSttsComCd,
|
||||||
|
BranchSeCd,
|
||||||
|
BranchSttsCd,
|
||||||
|
ElaprBranchDto,
|
||||||
|
ElaprForkJoinDataMap,
|
||||||
|
ElaprForkJoinStepDto,
|
||||||
|
} from "./model/elapr-forkjoin-types";
|
||||||
export type {
|
export type {
|
||||||
ElaprApproverChangeRequestDto,
|
ElaprApproverChangeRequestDto,
|
||||||
ElaprApproverInsertRequestDto,
|
ElaprApproverInsertRequestDto,
|
||||||
@@ -115,11 +130,17 @@ export type { ReviewerOption } from "./model/reviewer-option";
|
|||||||
export { AprvrPickerDialog } from "./ui/aprvr-picker-dialog";
|
export { AprvrPickerDialog } from "./ui/aprvr-picker-dialog";
|
||||||
export { ElaprAplnPickerGrid } from "./ui/elapr-apln-picker-grid";
|
export { ElaprAplnPickerGrid } from "./ui/elapr-apln-picker-grid";
|
||||||
export { ElaprApprovalDialog } from "./ui/elapr-approval-dialog";
|
export { ElaprApprovalDialog } from "./ui/elapr-approval-dialog";
|
||||||
|
export { ElaprDocPrgrsForkJoinStep } from "./ui/elapr-doc-prgrs-forkjoin-step";
|
||||||
export {
|
export {
|
||||||
type ElaprDocPrgrsPickerConfirmPayload,
|
type ElaprDocPrgrsPickerConfirmPayload,
|
||||||
ElaprDocPrgrsPickerDialog,
|
ElaprDocPrgrsPickerDialog,
|
||||||
} from "./ui/elapr-doc-prgrs-picker-dialog";
|
} 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 { ElaprDocPrgrsStatus } from "./ui/elapr-doc-prgrs-status";
|
||||||
|
export { ElaprForkJoinPickerGrid } from "./ui/elapr-forkjoin-picker-grid";
|
||||||
export {
|
export {
|
||||||
type ElaprTmpltPickerConfirmPayload,
|
type ElaprTmpltPickerConfirmPayload,
|
||||||
ElaprTmpltPickerDialog,
|
ElaprTmpltPickerDialog,
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ export function hasCurrentUserPendingApprovalRequest(
|
|||||||
): boolean {
|
): boolean {
|
||||||
if (!currentUserUuid) return false;
|
if (!currentUserUuid) return false;
|
||||||
|
|
||||||
|
const normalizedUserUuid = currentUserUuid.toLowerCase();
|
||||||
return list.some(
|
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 {
|
): ElaprDocPrgrsResponseDto | undefined {
|
||||||
if (!currentUserUuid) return undefined;
|
if (!currentUserUuid) return undefined;
|
||||||
|
|
||||||
|
const normalizedUserUuid = currentUserUuid.toLowerCase();
|
||||||
return list?.find(
|
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,
|
aprvrUuid: string | undefined,
|
||||||
atrzSeComCd: string | undefined,
|
atrzSeComCd: string | undefined,
|
||||||
atrzSeqNo: number | undefined,
|
atrzSeqNo: number | undefined,
|
||||||
|
branchId?: string,
|
||||||
|
branchSeCd?: string,
|
||||||
|
aprvrDeptNm?: string,
|
||||||
): ElaprPrgrsSttsSaveRequestDto {
|
): ElaprPrgrsSttsSaveRequestDto {
|
||||||
if (!aprvrUuid?.trim()) {
|
if (!aprvrUuid?.trim()) {
|
||||||
throw new Error("결재자가 지정되지 않은 행이 있습니다.");
|
throw new Error("결재자가 지정되지 않은 행이 있습니다.");
|
||||||
@@ -23,6 +26,9 @@ function toPrgrsSttsItem(
|
|||||||
aprvrUuid,
|
aprvrUuid,
|
||||||
atrzSeComCd,
|
atrzSeComCd,
|
||||||
atrzSeqNo,
|
atrzSeqNo,
|
||||||
|
branchId,
|
||||||
|
branchSeCd,
|
||||||
|
aprvrDeptNm,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +44,13 @@ export function buildElaprPrgrsSttsListFromAplnRows(
|
|||||||
rows: ElaprAplnRow[],
|
rows: ElaprAplnRow[],
|
||||||
): ElaprPrgrsSttsSaveRequestDto[] {
|
): ElaprPrgrsSttsSaveRequestDto[] {
|
||||||
return getVisibleRows(rows).map((row) =>
|
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;
|
atrzSeComCd?: string;
|
||||||
atrzSeqNo?: number;
|
atrzSeqNo?: number;
|
||||||
dcarbYn?: string;
|
dcarbYn?: string;
|
||||||
|
branchId?: string;
|
||||||
|
branchSeCd?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 결재선 그리드 로컬 행 — 진행상태 API 응답 + UI 보조 필드 */
|
/** 결재선 그리드 로컬 행 — 진행상태 API 응답 + UI 보조 필드 */
|
||||||
@@ -20,6 +22,9 @@ export interface ElaprPrgrsSttsRow {
|
|||||||
rowKey: string;
|
rowKey: string;
|
||||||
elaprPrgrsId?: string;
|
elaprPrgrsId?: string;
|
||||||
elaprDmndId?: string;
|
elaprDmndId?: string;
|
||||||
|
branchId?: string;
|
||||||
|
branchSeCd?: string;
|
||||||
|
branchNm?: string;
|
||||||
aprvrUuid?: string;
|
aprvrUuid?: string;
|
||||||
aprvrNm?: string;
|
aprvrNm?: string;
|
||||||
atrzSeComCd?: string;
|
atrzSeComCd?: string;
|
||||||
@@ -52,8 +57,22 @@ function getVisibleRows(rows: ElaprPrgrsSttsRow[]): ElaprPrgrsSttsRow[] {
|
|||||||
return rows.filter((row) => !row.isDeleted);
|
return rows.filter((row) => !row.isDeleted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 그리드 행 순서(1-based)로 결재순서번호 일괄 채번 */
|
/** 그리드 행 순서(1-based)로 결재순서번호 채번 (브랜치가 분기된 Fork-Join의 경우 각 브랜치별 독립 순번 채번) */
|
||||||
export function assignPrgrsSttsAtrzSeqNumbers(rows: ElaprPrgrsSttsRow[]): ElaprPrgrsSttsRow[] {
|
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 }));
|
return rows.map((row, index) => ({ ...row, atrzSeqNo: index + 1 }));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +111,7 @@ function rowDiffersFromServer(row: ElaprPrgrsSttsRow, serverRow: PrgrsSttsServer
|
|||||||
return (
|
return (
|
||||||
row.aprvrUuid !== serverRow.aprvrUuid ||
|
row.aprvrUuid !== serverRow.aprvrUuid ||
|
||||||
row.atrzSeComCd !== serverRow.atrzSeComCd ||
|
row.atrzSeComCd !== serverRow.atrzSeComCd ||
|
||||||
row.atrzSeqNo !== serverRow.atrzSeqNo ||
|
Number(row.atrzSeqNo) !== Number(serverRow.atrzSeqNo) ||
|
||||||
(row.dcarbYn ?? "N") !== (serverRow.dcarbYn ?? "N")
|
(row.dcarbYn ?? "N") !== (serverRow.dcarbYn ?? "N")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ function toRow(item: ElaprDocPrgrsClientResponseDto): ElaprPrgrsSttsRow {
|
|||||||
rowKey: item.elaprPrgrsId ?? createDraftRowKey(),
|
rowKey: item.elaprPrgrsId ?? createDraftRowKey(),
|
||||||
elaprPrgrsId: item.elaprPrgrsId,
|
elaprPrgrsId: item.elaprPrgrsId,
|
||||||
elaprDmndId: item.elaprDmndId,
|
elaprDmndId: item.elaprDmndId,
|
||||||
|
branchId: item.branchId,
|
||||||
|
branchSeCd: item.branchSeCd,
|
||||||
|
branchNm: item.branchNm,
|
||||||
aprvrUuid: item.aprvrUuid,
|
aprvrUuid: item.aprvrUuid,
|
||||||
aprvrNm: item.aprvrNm,
|
aprvrNm: item.aprvrNm,
|
||||||
atrzSeComCd: item.atrzSeComCd,
|
atrzSeComCd: item.atrzSeComCd,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { BranchSeCd } from "./elapr-forkjoin-types";
|
||||||
import type { ElaprApln } from "./elapr-apln-interface";
|
import type { ElaprApln } from "./elapr-apln-interface";
|
||||||
|
|
||||||
/** 그리드 로컬 행 — API 응답 + UI 보조 필드 */
|
/** 그리드 로컬 행 — API 응답 + UI 보조 필드 */
|
||||||
@@ -7,6 +8,11 @@ export interface ElaprAplnRow extends Omit<ElaprApln, "aplnId"> {
|
|||||||
aplnId?: string;
|
aplnId?: string;
|
||||||
/** 결재자 표시명 — 응답 DTO 또는 선택 시 로컬 유지 */
|
/** 결재자 표시명 — 응답 DTO 또는 선택 시 로컬 유지 */
|
||||||
aprvrNm?: string;
|
aprvrNm?: string;
|
||||||
|
/** 소속 브랜치 ID 및 구분코드 */
|
||||||
|
branchId?: string;
|
||||||
|
branchSeCd?: BranchSeCd;
|
||||||
|
aprvrDeptNm?: string;
|
||||||
|
dcarbYn?: string;
|
||||||
isNew?: boolean;
|
isNew?: boolean;
|
||||||
isDirty?: boolean;
|
isDirty?: boolean;
|
||||||
/** 소프트 삭제 — 최종 저장 시 mode:D */
|
/** 소프트 삭제 — 최종 저장 시 mode:D */
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ElaprDocPrgrsClientResponseDto, PageableParams, PageResponse } from "@/shared/model";
|
import type { ElaprDocPrgrsClientResponseDto, PageableParams, PageResponse } from "@/shared/model";
|
||||||
|
|
||||||
|
import type { ElaprBranchDto } from "./elapr-forkjoin-types";
|
||||||
import type { ElaprPrgrsSttsSaveRequestDto } from "./elapr-prgrs-stts-interface";
|
import type { ElaprPrgrsSttsSaveRequestDto } from "./elapr-prgrs-stts-interface";
|
||||||
|
|
||||||
/** 백엔드 ElaprDmndSaveRequestDto */
|
/** 백엔드 ElaprDmndSaveRequestDto */
|
||||||
@@ -7,6 +8,9 @@ export interface ElaprDmndSaveRequestDto {
|
|||||||
elaprDocNo: string;
|
elaprDocNo: string;
|
||||||
dmndTtl: string;
|
dmndTtl: string;
|
||||||
dmndCn: string;
|
dmndCn: string;
|
||||||
|
taskSeComCd?: string | null;
|
||||||
|
forkJoinSeCd?: string | null;
|
||||||
|
crntStageNo?: number | null;
|
||||||
elaprCycl?: number | null;
|
elaprCycl?: number | null;
|
||||||
docPrgrsSttsComCd?: string | null;
|
docPrgrsSttsComCd?: string | null;
|
||||||
elaprPrgrsSttsList?: ElaprPrgrsSttsSaveRequestDto[];
|
elaprPrgrsSttsList?: ElaprPrgrsSttsSaveRequestDto[];
|
||||||
@@ -19,8 +23,13 @@ export interface ElaprDmndResponseDto {
|
|||||||
elaprDocNo: string;
|
elaprDocNo: string;
|
||||||
dmndTtl: string;
|
dmndTtl: string;
|
||||||
dmndCn: string;
|
dmndCn: string;
|
||||||
|
taskSeComCd?: string;
|
||||||
|
forkJoinSeCd?: string;
|
||||||
|
crntStageNo?: number;
|
||||||
|
elaprBranchList?: ElaprBranchDto[];
|
||||||
atrzSttsComCd: string;
|
atrzSttsComCd: string;
|
||||||
atrzSttsComNm?: string;
|
atrzSttsComNm?: string;
|
||||||
|
elaprCycl?: number;
|
||||||
dmndDt?: string;
|
dmndDt?: string;
|
||||||
updtDt?: 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 {
|
export interface ElaprPrgrsSttsResponseDto {
|
||||||
elaprPrgrsId: string;
|
elaprPrgrsId: string;
|
||||||
elaprDmndId?: string;
|
elaprDmndId?: string;
|
||||||
|
branchId?: string;
|
||||||
|
branchSeCd?: string;
|
||||||
|
aprvrDeptNm?: string;
|
||||||
aprvrUuid?: string;
|
aprvrUuid?: string;
|
||||||
atrzSeComCd?: string;
|
atrzSeComCd?: string;
|
||||||
atrzSeComNm?: string;
|
atrzSeComNm?: string;
|
||||||
@@ -30,6 +33,9 @@ export interface ElaprPrgrsSttsSaveRequestDto {
|
|||||||
mode?: ElaprPrgrsSttsSaveMode;
|
mode?: ElaprPrgrsSttsSaveMode;
|
||||||
elaprPrgrsId?: string;
|
elaprPrgrsId?: string;
|
||||||
elaprDmndId?: string;
|
elaprDmndId?: string;
|
||||||
|
branchId?: string;
|
||||||
|
branchSeCd?: string;
|
||||||
|
aprvrDeptNm?: string;
|
||||||
aprvrUuid?: string;
|
aprvrUuid?: string;
|
||||||
atrzSeComCd?: string;
|
atrzSeComCd?: string;
|
||||||
atrzSeqNo?: number;
|
atrzSeqNo?: number;
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ interface ElaprApprovalDialogProps {
|
|||||||
elaprDocNo: string;
|
elaprDocNo: string;
|
||||||
currentUserUuid: string;
|
currentUserUuid: string;
|
||||||
elaprCycl?: number;
|
elaprCycl?: number;
|
||||||
|
taskSeComCd?: string | null;
|
||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +61,7 @@ function ElaprApprovalDialog({
|
|||||||
elaprDocNo,
|
elaprDocNo,
|
||||||
currentUserUuid,
|
currentUserUuid,
|
||||||
elaprCycl,
|
elaprCycl,
|
||||||
|
taskSeComCd,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}: ElaprApprovalDialogProps) {
|
}: ElaprApprovalDialogProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -73,10 +75,22 @@ function ElaprApprovalDialog({
|
|||||||
elaprCycl,
|
elaprCycl,
|
||||||
structuralSharing: false,
|
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(() => {
|
const cyclDocPrgrsList = useMemo(() => {
|
||||||
if (elaprCycl == null) return elaprDocPrgrsList;
|
if (effectiveCycl == null) return elaprDocPrgrsList;
|
||||||
return elaprDocPrgrsList?.filter((row) => Number(row.elaprCycl) === elaprCycl);
|
return elaprDocPrgrsList?.filter((row) => Number(row.elaprCycl) === effectiveCycl);
|
||||||
}, [elaprCycl, elaprDocPrgrsList]);
|
}, [effectiveCycl, elaprDocPrgrsList]);
|
||||||
|
|
||||||
const elaprDmndId = cyclDocPrgrsList?.[0]?.elaprDmndId;
|
const elaprDmndId = cyclDocPrgrsList?.[0]?.elaprDmndId;
|
||||||
|
|
||||||
@@ -104,7 +118,29 @@ function ElaprApprovalDialog({
|
|||||||
const currentUserPrgrs = findCurrentUserPendingApprovalRequest(cyclDocPrgrsList, currentUserUuid);
|
const currentUserPrgrs = findCurrentUserPendingApprovalRequest(cyclDocPrgrsList, currentUserUuid);
|
||||||
const canApprove = Boolean(currentUserPrgrs?.elaprPrgrsId);
|
const canApprove = Boolean(currentUserPrgrs?.elaprPrgrsId);
|
||||||
const canReject =
|
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 pickingRow = pickingRowKey ? rows.find((r) => r.rowKey === pickingRowKey) : undefined;
|
||||||
const isBusy = isProcessing || savePrgrsSttsMutation.isPending;
|
const isBusy = isProcessing || savePrgrsSttsMutation.isPending;
|
||||||
@@ -239,7 +275,11 @@ function ElaprApprovalDialog({
|
|||||||
atrzPrgrsSttsComCd: "ATRZ_PRGRS_004",
|
atrzPrgrsSttsComCd: "ATRZ_PRGRS_004",
|
||||||
rjctRsn: rjctRsn.trim(),
|
rjctRsn: rjctRsn.trim(),
|
||||||
});
|
});
|
||||||
alert("반려 처리가 완료되었습니다.");
|
alert(
|
||||||
|
isForkJoin
|
||||||
|
? "결재가 반려 처리되었습니다.\n직전 결재자가 '결재 대기중'으로 변경되었습니다. 상대 부서 결재는 정상 진행됩니다."
|
||||||
|
: "반려 처리가 완료되었습니다.",
|
||||||
|
);
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, "findDocPrgrsByDocNo", elaprDocNo],
|
queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, "findDocPrgrsByDocNo", elaprDocNo],
|
||||||
});
|
});
|
||||||
@@ -272,6 +312,42 @@ function ElaprApprovalDialog({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<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>
|
||||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||||
<h4 className="text-xl font-semibold">결재 진행 단계</h4>
|
<h4 className="text-xl font-semibold">결재 진행 단계</h4>
|
||||||
@@ -280,7 +356,7 @@ function ElaprApprovalDialog({
|
|||||||
size="sm"
|
size="sm"
|
||||||
type="button"
|
type="button"
|
||||||
color="white"
|
color="white"
|
||||||
disabled={!elaprDmndId || isEditingLocked || isBusy}
|
disabled={isForkJoin || !elaprDmndId || isEditingLocked || isBusy}
|
||||||
onClick={addRow}
|
onClick={addRow}
|
||||||
>
|
>
|
||||||
결재선 추가
|
결재선 추가
|
||||||
@@ -345,11 +421,11 @@ function ElaprApprovalDialog({
|
|||||||
<SortablePrgrsSttsRow
|
<SortablePrgrsSttsRow
|
||||||
key={row.rowKey}
|
key={row.rowKey}
|
||||||
row={row}
|
row={row}
|
||||||
displaySeqNo={index + 1}
|
displaySeqNo={isForkJoin ? Number(row.atrzSeqNo ?? index + 1) : index + 1}
|
||||||
isEditing={editingRowKey === row.rowKey}
|
isEditing={editingRowKey === row.rowKey}
|
||||||
isLocked={isEditingLocked}
|
isLocked={isEditingLocked}
|
||||||
dragDisabled={
|
dragDisabled={
|
||||||
isEditingLocked || !isPrgrsSttsRowEditable(row) || !row.atrzSeComCd
|
isForkJoin || isEditingLocked || !isPrgrsSttsRowEditable(row) || !row.atrzSeComCd
|
||||||
}
|
}
|
||||||
onStartEdit={() => startEdit(row.rowKey)}
|
onStartEdit={() => startEdit(row.rowKey)}
|
||||||
onSave={() => handleRowSave(row.rowKey)}
|
onSave={() => handleRowSave(row.rowKey)}
|
||||||
@@ -372,6 +448,17 @@ function ElaprApprovalDialog({
|
|||||||
|
|
||||||
{canApprove && (
|
{canApprove && (
|
||||||
<div>
|
<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>이전(직전) 결재자만 '결재 대기중(ATRZ_PRGRS_002)'으로 변경되어 보완 및 재상신 기회가 부여됩니다.</li>
|
||||||
|
<li>동시에 진행 중인 상대방 부서 결재는 중단되지 않고 독립 정상 진행됩니다.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<h4 className="mb-3 text-xl font-semibold">반려 의견</h4>
|
<h4 className="mb-3 text-xl font-semibold">반려 의견</h4>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={rjctRsn}
|
value={rjctRsn}
|
||||||
@@ -392,7 +479,7 @@ function ElaprApprovalDialog({
|
|||||||
{canApprove && (
|
{canApprove && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button onClick={handleApprove} disabled={isBusy}>
|
<Button onClick={handleApprove} disabled={isBusy}>
|
||||||
결재
|
{approveButtonLabel}
|
||||||
</Button>
|
</Button>
|
||||||
{canReject && (
|
{canReject && (
|
||||||
<Button color="red" onClick={handleReject} disabled={isBusy}>
|
<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 { useElaprDocPrgrsByDocNo } from "../api/queries";
|
||||||
import { getApproverStatus, sortByAtrzSeqNo } from "../lib/approval-utils";
|
import { getApproverStatus, sortByAtrzSeqNo } from "../lib/approval-utils";
|
||||||
|
|
||||||
|
import { ElaprDocPrgrsForkJoinStep } from "./elapr-doc-prgrs-forkjoin-step";
|
||||||
|
|
||||||
const REJECT_STATUS_CD = "ATRZ_PRGRS_004";
|
const REJECT_STATUS_CD = "ATRZ_PRGRS_004";
|
||||||
|
|
||||||
interface ElaprDocPrgrsStatusProps {
|
interface ElaprDocPrgrsStatusProps {
|
||||||
elaprDocNo: string | null | undefined;
|
elaprDocNo: string | null | undefined;
|
||||||
|
taskSeComCd?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ElaprDocPrgrsStep({ item }: { item: ElaprDocPrgrsClientResponseDto }) {
|
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 userInfo = useUserStore((state) => state.userInfo);
|
||||||
const { data, isLoading } = useElaprDocPrgrsByDocNo(elaprDocNo);
|
const { data, isLoading } = useElaprDocPrgrsByDocNo(elaprDocNo);
|
||||||
const [activeCycl, setActiveCycl] = useState<"1" | "2">("1");
|
const [activeCycl, setActiveCycl] = useState<"1" | "2">("1");
|
||||||
|
|
||||||
const { cycl1Steps, cycl2Steps, hasCycl2 } = useMemo(() => {
|
const { cycl1Steps, cycl2Steps, hasCycl2, isForkJoin1, isForkJoin2 } = useMemo(() => {
|
||||||
const rows = data ?? [];
|
const rows = data ?? [];
|
||||||
const cycl1 = sortByAtrzSeqNo(rows.filter((item) => Number(item.elaprCycl ?? 1) !== 2));
|
const cycl1 = sortByAtrzSeqNo(rows.filter((item) => Number(item.elaprCycl ?? 1) !== 2));
|
||||||
const cycl2 = sortByAtrzSeqNo(rows.filter((item) => Number(item.elaprCycl) === 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 {
|
return {
|
||||||
cycl1Steps: cycl1,
|
cycl1Steps: cycl1,
|
||||||
cycl2Steps: cycl2,
|
cycl2Steps: cycl2,
|
||||||
hasCycl2: cycl2.length > 0,
|
hasCycl2: cycl2.length > 0,
|
||||||
|
isForkJoin1: checkForkJoin(cycl1),
|
||||||
|
isForkJoin2: checkForkJoin(cycl2),
|
||||||
};
|
};
|
||||||
}, [data]);
|
}, [data, taskSeComCd]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveCycl(hasCycl2 ? "2" : "1");
|
setActiveCycl(hasCycl2 ? "2" : "1");
|
||||||
@@ -138,23 +153,33 @@ function ElaprDocPrgrsStatus({ elaprDocNo }: ElaprDocPrgrsStatusProps) {
|
|||||||
>
|
>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="1" className="!px-6 !py-3 text-xl">
|
<TabsTrigger value="1" className="!px-6 !py-3 text-xl">
|
||||||
재원협의
|
1차 실무협의 (기술검토)
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="2" className="!px-6 !py-3 text-xl">
|
<TabsTrigger value="2" className="!px-6 !py-3 text-xl">
|
||||||
실무협의서
|
2차 조치결과 검토 (종결)
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="1" className="mt-2 border-none p-0 shadow-none">
|
<TabsContent value="1" className="mt-2 border-none p-0 shadow-none">
|
||||||
<div className="-mx-2">
|
<div className="-mx-2">
|
||||||
<ElaprDocPrgrsStepList steps={cycl1Steps} />
|
{isForkJoin1 ? (
|
||||||
|
<ElaprDocPrgrsForkJoinStep steps={cycl1Steps} />
|
||||||
|
) : (
|
||||||
|
<ElaprDocPrgrsStepList steps={cycl1Steps} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="2" className="mt-2 border-none p-0 shadow-none">
|
<TabsContent value="2" className="mt-2 border-none p-0 shadow-none">
|
||||||
<div className="-mx-2">
|
<div className="-mx-2">
|
||||||
<ElaprDocPrgrsStepList steps={cycl2Steps} />
|
{isForkJoin2 ? (
|
||||||
|
<ElaprDocPrgrsForkJoinStep steps={cycl2Steps} />
|
||||||
|
) : (
|
||||||
|
<ElaprDocPrgrsStepList steps={cycl2Steps} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
) : isForkJoin1 ? (
|
||||||
|
<ElaprDocPrgrsForkJoinStep steps={cycl1Steps} />
|
||||||
) : (
|
) : (
|
||||||
<ElaprDocPrgrsStepList 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 type { ElaprTmplt } from "../model/elapr-tmplt-interface";
|
||||||
|
|
||||||
import { ElaprAplnPickerGrid } from "./elapr-apln-picker-grid";
|
import { ElaprAplnPickerGrid } from "./elapr-apln-picker-grid";
|
||||||
|
import { ElaprForkJoinPickerGrid } from "./elapr-forkjoin-picker-grid";
|
||||||
import { ElaprTmpltSelectTable } from "./elapr-tmplt-select-table";
|
import { ElaprTmpltSelectTable } from "./elapr-tmplt-select-table";
|
||||||
|
|
||||||
const PAGE_SIZE = 100;
|
const PAGE_SIZE = 100;
|
||||||
@@ -37,6 +38,7 @@ interface ElaprTmpltPickerDialogProps {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
docSeComCd: string;
|
docSeComCd: string;
|
||||||
|
taskSeComCd?: string | null;
|
||||||
onSelect?: (tmplt: ElaprTmplt) => void;
|
onSelect?: (tmplt: ElaprTmplt) => void;
|
||||||
onConfirm?: (payload: ElaprTmpltPickerConfirmPayload) => void;
|
onConfirm?: (payload: ElaprTmpltPickerConfirmPayload) => void;
|
||||||
}
|
}
|
||||||
@@ -45,6 +47,7 @@ function ElaprTmpltPickerDialog({
|
|||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
docSeComCd,
|
docSeComCd,
|
||||||
|
taskSeComCd,
|
||||||
onSelect,
|
onSelect,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
}: ElaprTmpltPickerDialogProps) {
|
}: ElaprTmpltPickerDialogProps) {
|
||||||
@@ -53,6 +56,8 @@ function ElaprTmpltPickerDialog({
|
|||||||
const [aplnRows, setAplnRows] = useState<ElaprAplnRow[]>([]);
|
const [aplnRows, setAplnRows] = useState<ElaprAplnRow[]>([]);
|
||||||
const [isEditingLocked, setIsEditingLocked] = useState(false);
|
const [isEditingLocked, setIsEditingLocked] = useState(false);
|
||||||
|
|
||||||
|
const isForkJoinMode = docSeComCd === "FNRS" && taskSeComCd === "SF_SP";
|
||||||
|
|
||||||
const tmpltQuery = useElaprTmpltPage({
|
const tmpltQuery = useElaprTmpltPage({
|
||||||
page: 0,
|
page: 0,
|
||||||
size: PAGE_SIZE,
|
size: PAGE_SIZE,
|
||||||
@@ -70,7 +75,9 @@ function ElaprTmpltPickerDialog({
|
|||||||
|
|
||||||
function handleSelectTmplt(tmplt: ElaprTmplt | null) {
|
function handleSelectTmplt(tmplt: ElaprTmplt | null) {
|
||||||
setSelectedTmplt(tmplt);
|
setSelectedTmplt(tmplt);
|
||||||
setAplnRows([]);
|
if (!isForkJoinMode) {
|
||||||
|
setAplnRows([]);
|
||||||
|
}
|
||||||
setIsEditingLocked(false);
|
setIsEditingLocked(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,11 +91,110 @@ function ElaprTmpltPickerDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleConfirm() {
|
function handleConfirm() {
|
||||||
if (!selectedTmplt) {
|
if (!selectedTmplt && !isForkJoinMode) {
|
||||||
alert("결재서식을 선택해 주세요.");
|
alert("결재서식을 선택해 주세요.");
|
||||||
return;
|
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);
|
const rowsError = validateAllRowsForSave(aplnRows, isEditingLocked);
|
||||||
if (rowsError) {
|
if (rowsError) {
|
||||||
alert(rowsError);
|
alert(rowsError);
|
||||||
@@ -116,14 +222,14 @@ function ElaprTmpltPickerDialog({
|
|||||||
const visibleRows = getVisibleRows(aplnRows);
|
const visibleRows = getVisibleRows(aplnRows);
|
||||||
|
|
||||||
const payload: ElaprTmpltPickerConfirmPayload = {
|
const payload: ElaprTmpltPickerConfirmPayload = {
|
||||||
tmplt: selectedTmplt,
|
tmplt: selectedTmplt!,
|
||||||
aplnRows: visibleRows,
|
aplnRows: visibleRows,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (onConfirm) {
|
if (onConfirm) {
|
||||||
onConfirm(payload);
|
onConfirm(payload);
|
||||||
} else {
|
} else {
|
||||||
onSelect?.(selectedTmplt);
|
onSelect?.(selectedTmplt!);
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelectedTmplt(null);
|
setSelectedTmplt(null);
|
||||||
@@ -134,9 +240,16 @@ function ElaprTmpltPickerDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<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>
|
<DialogHeader>
|
||||||
<DialogTitle>결재선 선택</DialogTitle>
|
<DialogTitle>
|
||||||
|
{isForkJoinMode
|
||||||
|
? "결재선 선택 (복합사업 처리시설+하수관로 Fork-Join 3트랙)"
|
||||||
|
: "결재선 선택"}
|
||||||
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex gap-6">
|
<div className="flex gap-6">
|
||||||
@@ -145,11 +258,19 @@ function ElaprTmpltPickerDialog({
|
|||||||
selectedTmpltId={selectedTmplt?.elaprTmpltId ?? null}
|
selectedTmpltId={selectedTmplt?.elaprTmpltId ?? null}
|
||||||
onSelectTmplt={handleSelectTmplt}
|
onSelectTmplt={handleSelectTmplt}
|
||||||
/>
|
/>
|
||||||
<ElaprAplnPickerGrid
|
|
||||||
elaprTmpltId={selectedTmplt?.elaprTmpltId ?? null}
|
{isForkJoinMode ? (
|
||||||
onRowsChange={handleRowsChange}
|
<ElaprForkJoinPickerGrid
|
||||||
onEditingLockedChange={handleEditingLockedChange}
|
onRowsChange={handleRowsChange}
|
||||||
/>
|
onEditingLockedChange={handleEditingLockedChange}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ElaprAplnPickerGrid
|
||||||
|
elaprTmpltId={selectedTmplt?.elaprTmpltId ?? null}
|
||||||
|
onRowsChange={handleRowsChange}
|
||||||
|
onEditingLockedChange={handleEditingLockedChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@@ -158,7 +279,7 @@ function ElaprTmpltPickerDialog({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!selectedTmplt || isEditingLocked}
|
disabled={(!selectedTmplt && !isForkJoinMode) || isEditingLocked}
|
||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
>
|
>
|
||||||
확인
|
확인
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export interface EsntlAtchFileMng {
|
|||||||
esntlAtchYn: "Y" | "N";
|
esntlAtchYn: "Y" | "N";
|
||||||
/** 필수첨부파일비고 */
|
/** 필수첨부파일비고 */
|
||||||
esntlAtchFileRmrk?: string;
|
esntlAtchFileRmrk?: string;
|
||||||
|
/** 필수첨부파일검토내용 */
|
||||||
|
esntlAtchFileRvwCn?: string;
|
||||||
/** 첨부파일그룹아이디 (양식파일 그룹) */
|
/** 첨부파일그룹아이디 (양식파일 그룹) */
|
||||||
atchFileGroupId?: string;
|
atchFileGroupId?: string;
|
||||||
/** 첨부파일 목록 (양식파일) */
|
/** 첨부파일 목록 (양식파일) */
|
||||||
@@ -77,6 +79,8 @@ export interface EsntlAtchFileMngSaveRequest {
|
|||||||
esntlAtchYn?: "Y" | "N";
|
esntlAtchYn?: "Y" | "N";
|
||||||
/** 필수첨부파일비고 */
|
/** 필수첨부파일비고 */
|
||||||
esntlAtchFileRmrk?: string;
|
esntlAtchFileRmrk?: string;
|
||||||
|
/** 필수첨부파일검토내용 */
|
||||||
|
esntlAtchFileRvwCn?: string;
|
||||||
/** 첨부파일그룹아이디 (양식파일 그룹) */
|
/** 첨부파일그룹아이디 (양식파일 그룹) */
|
||||||
atchFileGroupId?: string;
|
atchFileGroupId?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,10 @@ async function changePrgrsSttsComCd(body: DocPrgrsUpdateRequestDto): Promise<voi
|
|||||||
await client.post<void>(`${BASE}/doc/updatePrgrsStts`, body);
|
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 = {
|
export const fnrsCnsltnMngService = {
|
||||||
findPage,
|
findPage,
|
||||||
findById,
|
findById,
|
||||||
@@ -112,4 +116,5 @@ export const fnrsCnsltnMngService = {
|
|||||||
remove,
|
remove,
|
||||||
findFileVrfcResults,
|
findFileVrfcResults,
|
||||||
changePrgrsSttsComCd,
|
changePrgrsSttsComCd,
|
||||||
|
rejectPrgrsStts,
|
||||||
} satisfies FnrsCnsltnMngService;
|
} satisfies FnrsCnsltnMngService;
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export function useFnrsCnsltnMng(fnrsCnsltnDocNo: string | null | undefined) {
|
|||||||
queryKey: fnrsCnsltnKeys.detail(fnrsCnsltnDocNo!),
|
queryKey: fnrsCnsltnKeys.detail(fnrsCnsltnDocNo!),
|
||||||
queryFn: () => fnrsCnsltnMngService.findById(fnrsCnsltnDocNo!),
|
queryFn: () => fnrsCnsltnMngService.findById(fnrsCnsltnDocNo!),
|
||||||
enabled: Boolean(fnrsCnsltnDocNo),
|
enabled: Boolean(fnrsCnsltnDocNo),
|
||||||
|
staleTime: 0,
|
||||||
|
refetchOnMount: "always",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +45,8 @@ export function useFnrsCnsltnFileVrfcResults(fnrsCnsltnDocNo: string | null | un
|
|||||||
queryKey: fnrsCnsltnKeys.fileVrfcResults(fnrsCnsltnDocNo!),
|
queryKey: fnrsCnsltnKeys.fileVrfcResults(fnrsCnsltnDocNo!),
|
||||||
queryFn: () => fnrsCnsltnMngService.findFileVrfcResults(fnrsCnsltnDocNo!),
|
queryFn: () => fnrsCnsltnMngService.findFileVrfcResults(fnrsCnsltnDocNo!),
|
||||||
enabled: Boolean(fnrsCnsltnDocNo),
|
enabled: Boolean(fnrsCnsltnDocNo),
|
||||||
|
staleTime: 0,
|
||||||
|
refetchOnMount: "always",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ export const fnrsSharItemSchema = z.object({
|
|||||||
export const fnrsCnsltnApplySaveSchema = z.object({
|
export const fnrsCnsltnApplySaveSchema = z.object({
|
||||||
techReViewSaveCd: z.string().nullish(),
|
techReViewSaveCd: z.string().nullish(),
|
||||||
rvwStpCd: z.string().nullish(),
|
rvwStpCd: z.string().nullish(),
|
||||||
|
branchSeCd: z.string().nullish(),
|
||||||
fnrsCnsltnMng: fnrsCnsltnMngSaveSchema,
|
fnrsCnsltnMng: fnrsCnsltnMngSaveSchema,
|
||||||
bzScaleList: z.array(fnrsCnsltnBzScaleItemSchema).nullish(),
|
bzScaleList: z.array(fnrsCnsltnBzScaleItemSchema).nullish(),
|
||||||
bizCstList: z.array(fnrsCnsltnBizCstItemSchema).nullish(),
|
bizCstList: z.array(fnrsCnsltnBizCstItemSchema).nullish(),
|
||||||
|
|||||||
@@ -145,4 +145,5 @@ export interface FnrsCnsltnMngService {
|
|||||||
remove(fnrsCnsltnDocNo: string): Promise<void>;
|
remove(fnrsCnsltnDocNo: string): Promise<void>;
|
||||||
findFileVrfcResults(fnrsCnsltnDocNo: string): Promise<FnrsCnsltnFileVrfcRsltResponseDto[]>;
|
findFileVrfcResults(fnrsCnsltnDocNo: string): Promise<FnrsCnsltnFileVrfcRsltResponseDto[]>;
|
||||||
changePrgrsSttsComCd(body: DocPrgrsUpdateRequestDto): Promise<void>;
|
changePrgrsSttsComCd(body: DocPrgrsUpdateRequestDto): Promise<void>;
|
||||||
|
rejectPrgrsStts(body: DocPrgrsUpdateRequestDto): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,10 @@ async function changePrgrsSttsComCd(payload: DocPrgrsUpdateRequestDto): Promise<
|
|||||||
await client.post(`${BASE}/doc/updatePrgrsStts`, payload);
|
await client.post(`${BASE}/doc/updatePrgrsStts`, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function rejectPrgrsStts(payload: DocPrgrsUpdateRequestDto): Promise<void> {
|
||||||
|
await client.post(`${BASE}/doc/rejectPrgrsStts`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
export const instlSncnService = {
|
export const instlSncnService = {
|
||||||
getPage,
|
getPage,
|
||||||
getById,
|
getById,
|
||||||
@@ -102,6 +106,7 @@ export const instlSncnService = {
|
|||||||
submitReqSupplement,
|
submitReqSupplement,
|
||||||
remove,
|
remove,
|
||||||
changePrgrsSttsComCd,
|
changePrgrsSttsComCd,
|
||||||
|
rejectPrgrsStts,
|
||||||
} satisfies InstlSncnMngService;
|
} satisfies InstlSncnMngService;
|
||||||
|
|
||||||
// TODO: st-anls-mst service 로 분리
|
// TODO: st-anls-mst service 로 분리
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export function useInstlSncnDetail(id: string) {
|
|||||||
queryKey: instlSncnKeys.detail(id),
|
queryKey: instlSncnKeys.detail(id),
|
||||||
queryFn: () => instlSncnService.getById(id),
|
queryFn: () => instlSncnService.getById(id),
|
||||||
enabled: Boolean(id),
|
enabled: Boolean(id),
|
||||||
|
staleTime: 0,
|
||||||
|
refetchOnMount: "always",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -131,4 +131,5 @@ export interface InstlSncnMngService {
|
|||||||
submitReqSupplement: (id: string, payload: InstlSncnApplySaveRequestDto) => Promise<string>;
|
submitReqSupplement: (id: string, payload: InstlSncnApplySaveRequestDto) => Promise<string>;
|
||||||
remove: (id: string) => Promise<void>;
|
remove: (id: string) => Promise<void>;
|
||||||
changePrgrsSttsComCd: (payload: DocPrgrsUpdateRequestDto) => Promise<void>;
|
changePrgrsSttsComCd: (payload: DocPrgrsUpdateRequestDto) => Promise<void>;
|
||||||
|
rejectPrgrsStts: (payload: DocPrgrsUpdateRequestDto) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-3
@@ -11,27 +11,67 @@ const BASE = ENDPOINT.AS_SERVICE.PWRK_CNSLTN_DOC_CRT_RSLT;
|
|||||||
|
|
||||||
async function findLatestByCrtDmndUuid(
|
async function findLatestByCrtDmndUuid(
|
||||||
crtDmndUuid: string,
|
crtDmndUuid: string,
|
||||||
|
branchSeCd?: string,
|
||||||
): Promise<PwrkCnsltnDocCrtRsltResponseDto> {
|
): Promise<PwrkCnsltnDocCrtRsltResponseDto> {
|
||||||
const response = await client.get<PwrkCnsltnDocCrtRsltResponseDto>(
|
const response = await client.get<PwrkCnsltnDocCrtRsltResponseDto>(
|
||||||
`${BASE}/${crtDmndUuid}/latest`,
|
`${BASE}/${crtDmndUuid}/latest`,
|
||||||
|
{
|
||||||
|
params: branchSeCd ? { branchSeCd } : undefined,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
return response.data;
|
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`, {
|
return client.get<Blob>(`${BASE}/${crtDmndUuid}/webhwp/open`, {
|
||||||
|
params: branchSeCd ? { branchSeCd } : undefined,
|
||||||
responseType: "blob",
|
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`, {
|
return client.get<Blob>(`${BASE}/${crtDmndUuid}/${verSn}/download`, {
|
||||||
|
params: branchSeCd ? { branchSeCd } : undefined,
|
||||||
responseType: "blob",
|
responseType: "blob",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadLatest(crtDmndUuid: string) {
|
async function downloadLatest(crtDmndUuid: string, branchSeCd?: string) {
|
||||||
return client.get<Blob>(`${BASE}/${crtDmndUuid}/download`, {
|
return client.get<Blob>(`${BASE}/${crtDmndUuid}/download`, {
|
||||||
|
params: branchSeCd ? { branchSeCd } : undefined,
|
||||||
responseType: "blob",
|
responseType: "blob",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -47,6 +87,9 @@ async function saveMultipart(
|
|||||||
if (saveDto.atchFileGroupId) {
|
if (saveDto.atchFileGroupId) {
|
||||||
formData.append("atchFileGroupId", saveDto.atchFileGroupId);
|
formData.append("atchFileGroupId", saveDto.atchFileGroupId);
|
||||||
}
|
}
|
||||||
|
if (saveDto.branchSeCd) {
|
||||||
|
formData.append("branchSeCd", saveDto.branchSeCd);
|
||||||
|
}
|
||||||
if (file) {
|
if (file) {
|
||||||
const resolvedFileName = fileName ?? (file instanceof File ? file.name : "document.hwpx");
|
const resolvedFileName = fileName ?? (file instanceof File ? file.name : "document.hwpx");
|
||||||
formData.append("file", file, resolvedFileName);
|
formData.append("file", file, resolvedFileName);
|
||||||
@@ -73,7 +116,9 @@ async function updateMultipart(
|
|||||||
|
|
||||||
export const pwrkCnsltnDocCrtRsltService = {
|
export const pwrkCnsltnDocCrtRsltService = {
|
||||||
findLatestByCrtDmndUuid,
|
findLatestByCrtDmndUuid,
|
||||||
|
findListByCrtDmndUuid,
|
||||||
openWebHwpLatest,
|
openWebHwpLatest,
|
||||||
|
openWebHwpByVer,
|
||||||
download,
|
download,
|
||||||
downloadLatest,
|
downloadLatest,
|
||||||
saveMultipart,
|
saveMultipart,
|
||||||
|
|||||||
+22
-4
@@ -5,6 +5,7 @@ import type { PwrkCnsltnDocCrtRsltSaveRequestDto } from "./pwrk-cnsltn-doc-crt-r
|
|||||||
export interface PwrkCnsltnDocCrtRsltResponseDto {
|
export interface PwrkCnsltnDocCrtRsltResponseDto {
|
||||||
crtDmndUuid: string;
|
crtDmndUuid: string;
|
||||||
verSn: number;
|
verSn: number;
|
||||||
|
branchSeCd?: string;
|
||||||
rgtrUuid?: string;
|
rgtrUuid?: string;
|
||||||
rgtrNm?: string;
|
rgtrNm?: string;
|
||||||
orgnlOtptDocCn: null;
|
orgnlOtptDocCn: null;
|
||||||
@@ -14,15 +15,32 @@ export interface PwrkCnsltnDocCrtRsltResponseDto {
|
|||||||
/** 백엔드 PwrkCnsltnDocCrtRsltUpdateRequestDto (모두 optional) */
|
/** 백엔드 PwrkCnsltnDocCrtRsltUpdateRequestDto (모두 optional) */
|
||||||
export interface PwrkCnsltnDocCrtRsltUpdateRequestDto {
|
export interface PwrkCnsltnDocCrtRsltUpdateRequestDto {
|
||||||
rgtrUuid?: string;
|
rgtrUuid?: string;
|
||||||
|
branchSeCd?: string;
|
||||||
orgnlOtptDocCn?: string;
|
orgnlOtptDocCn?: string;
|
||||||
crtCmptnDt?: string;
|
crtCmptnDt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PwrkCnsltnDocCrtRsltService {
|
export interface PwrkCnsltnDocCrtRsltService {
|
||||||
findLatestByCrtDmndUuid(crtDmndUuid: string): Promise<PwrkCnsltnDocCrtRsltResponseDto>;
|
findLatestByCrtDmndUuid(
|
||||||
openWebHwpLatest(crtDmndUuid: string): Promise<AxiosResponse<Blob>>;
|
crtDmndUuid: string,
|
||||||
download(crtDmndUuid: string, verSn: number): Promise<AxiosResponse<Blob>>;
|
branchSeCd?: string,
|
||||||
downloadLatest(crtDmndUuid: string): Promise<AxiosResponse<Blob>>;
|
): 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(
|
saveMultipart(
|
||||||
saveDto: PwrkCnsltnDocCrtRsltSaveRequestDto,
|
saveDto: PwrkCnsltnDocCrtRsltSaveRequestDto,
|
||||||
file?: Blob | File,
|
file?: Blob | File,
|
||||||
|
|||||||
+1
@@ -5,6 +5,7 @@ export const pwrkCnsltnDocCrtRsltSaveRequestSchema = z.object({
|
|||||||
crtDmndUuid: z.string().uuid(),
|
crtDmndUuid: z.string().uuid(),
|
||||||
pwrkCnsltnDocNo: z.string().min(1).max(100),
|
pwrkCnsltnDocNo: z.string().min(1).max(100),
|
||||||
atchFileGroupId: z.string().max(30).nullish(),
|
atchFileGroupId: z.string().max(30).nullish(),
|
||||||
|
branchSeCd: z.string().max(20).nullish(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type PwrkCnsltnDocCrtRsltSaveRequestDto = z.infer<
|
export type PwrkCnsltnDocCrtRsltSaveRequestDto = z.infer<
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export function useSewerBscPlan(sewerBscPlanDocNo: string | null | undefined) {
|
|||||||
queryKey: sewerBscPlanKeys.detail(sewerBscPlanDocNo!),
|
queryKey: sewerBscPlanKeys.detail(sewerBscPlanDocNo!),
|
||||||
queryFn: () => sewerBscPlanService.findById(sewerBscPlanDocNo!),
|
queryFn: () => sewerBscPlanService.findById(sewerBscPlanDocNo!),
|
||||||
enabled: Boolean(sewerBscPlanDocNo),
|
enabled: Boolean(sewerBscPlanDocNo),
|
||||||
|
staleTime: 0,
|
||||||
|
refetchOnMount: "always",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +46,8 @@ export function useSewerBscPlanDetails(
|
|||||||
queryKey: sewerBscPlanKeys.details(sewerBscPlanDocNo!, chgCycl ?? undefined),
|
queryKey: sewerBscPlanKeys.details(sewerBscPlanDocNo!, chgCycl ?? undefined),
|
||||||
queryFn: () => sewerBscPlanService.findDetails(sewerBscPlanDocNo!, chgCycl!),
|
queryFn: () => sewerBscPlanService.findDetails(sewerBscPlanDocNo!, chgCycl!),
|
||||||
enabled: Boolean(sewerBscPlanDocNo) && chgCycl != null,
|
enabled: Boolean(sewerBscPlanDocNo) && chgCycl != null,
|
||||||
|
staleTime: 0,
|
||||||
|
refetchOnMount: "always",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,15 @@
|
|||||||
|
|
||||||
import { useMemo, useRef } from "react";
|
import { useMemo, useRef } from "react";
|
||||||
|
|
||||||
|
import { RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
import { useUserStore } from "@/shared/lib";
|
import { useUserStore } from "@/shared/lib";
|
||||||
import { SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
|
import { SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
|
||||||
import { useDesignCnstnMng } from "@/entities/design-cnstn";
|
import { useDesignCnstnMng } from "@/entities/design-cnstn";
|
||||||
import { ElaprDocPrgrsStatus } from "@/entities/elapr";
|
import { ElaprDocPrgrsStatus } from "@/entities/elapr";
|
||||||
import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw";
|
import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw";
|
||||||
import { AttachmentsViewSection, ProgressStatusSection } from "@/widgets/pbadms-sprt";
|
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 { needsRoleUser01AtrzResolve } from "../lib/button-config";
|
||||||
import { getEditableReviewStep } from "../lib/review-step-utils";
|
import { getEditableReviewStep } from "../lib/review-step-utils";
|
||||||
@@ -30,7 +32,7 @@ interface DesignViewPageProps {
|
|||||||
function DesignViewPage({ id }: DesignViewPageProps) {
|
function DesignViewPage({ id }: DesignViewPageProps) {
|
||||||
const userInfo = useUserStore((state) => state.userInfo);
|
const userInfo = useUserStore((state) => state.userInfo);
|
||||||
const techReviewOpinionRef = useRef<TechReviewOpinionSectionHandle>(null);
|
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 prgrsSttsComCd = data?.detDto?.prgrsSttsComCd || "";
|
||||||
const needsAtrzResolve = needsRoleUser01AtrzResolve(userInfo?.dtlInstCd, prgrsSttsComCd);
|
const needsAtrzResolve = needsRoleUser01AtrzResolve(userInfo?.dtlInstCd, prgrsSttsComCd);
|
||||||
@@ -83,8 +85,20 @@ function DesignViewPage({ id }: DesignViewPageProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<SectionCollapsibleGroup>
|
<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 />
|
<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>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<ProgressStatusSection
|
<ProgressStatusSection
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ export interface TechReviewOpinionSectionHandle {
|
|||||||
|
|
||||||
interface TechReviewOpinionTableProps {
|
interface TechReviewOpinionTableProps {
|
||||||
items: DesignCnstnRvwOpnnResponseDto[];
|
items: DesignCnstnRvwOpnnResponseDto[];
|
||||||
isReviewTurn: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UploadedOpinionFile {
|
interface UploadedOpinionFile {
|
||||||
@@ -296,7 +295,7 @@ const TechReviewOpinionSection = forwardRef<
|
|||||||
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
|
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{kecoRvwList.length > 0 ? (
|
{kecoRvwList.length > 0 ? (
|
||||||
<TechReviewOpinionTable items={kecoRvwList} isReviewTurn={isReviewTurn("KECO")} />
|
<TechReviewOpinionTable items={kecoRvwList} />
|
||||||
) : null}
|
) : null}
|
||||||
{renderOpinionForm("KECO")}
|
{renderOpinionForm("KECO")}
|
||||||
</div>
|
</div>
|
||||||
@@ -311,7 +310,7 @@ function getRvwOpnnIds(item: DesignCnstnRvwOpnnResponseDto) {
|
|||||||
return [item.designCnstnDocNo, item.rvwOpnnSn] as const;
|
return [item.designCnstnDocNo, item.rvwOpnnSn] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TechReviewOpinionTable({ items, isReviewTurn }: TechReviewOpinionTableProps) {
|
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const currentUserUuid = useUserStore((state) => state.userInfo?.userUnqId);
|
const currentUserUuid = useUserStore((state) => state.userInfo?.userUnqId);
|
||||||
|
|
||||||
@@ -320,7 +319,6 @@ function TechReviewOpinionTable({ items, isReviewTurn }: TechReviewOpinionTableP
|
|||||||
items={items}
|
items={items}
|
||||||
getRowKey={(item) => `${item.designCnstnDocNo}_${item.rvwOpnnSn}`}
|
getRowKey={(item) => `${item.designCnstnDocNo}_${item.rvwOpnnSn}`}
|
||||||
currentUserUuid={currentUserUuid}
|
currentUserUuid={currentUserUuid}
|
||||||
isReviewTurn={isReviewTurn}
|
|
||||||
onUpdate={async (item, body) => {
|
onUpdate={async (item, body) => {
|
||||||
await designCnstnRvwOpnnService.update(...getRvwOpnnIds(item), body);
|
await designCnstnRvwOpnnService.update(...getRvwOpnnIds(item), body);
|
||||||
await queryClient.invalidateQueries({ queryKey: designCnstnKeys.all });
|
await queryClient.invalidateQueries({ queryKey: designCnstnKeys.all });
|
||||||
|
|||||||
@@ -51,6 +51,19 @@ export function createEsntlAtchFileColumns({
|
|||||||
meta: { align: "center" },
|
meta: { align: "center" },
|
||||||
cell: ({ getValue }) => formatYn(getValue()),
|
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", {
|
columnHelper.accessor("esntlAtchFileRmrk", {
|
||||||
header: "비고",
|
header: "비고",
|
||||||
meta: { align: "center" },
|
meta: { align: "center" },
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export interface EsntlAtchFileFormValues {
|
|||||||
docSeComCd: string;
|
docSeComCd: string;
|
||||||
esntlAtchFileNm: string;
|
esntlAtchFileNm: string;
|
||||||
esntlAtchYn: "Y" | "N";
|
esntlAtchYn: "Y" | "N";
|
||||||
|
esntlAtchFileRvwCn: string;
|
||||||
esntlAtchFileRmrk: string;
|
esntlAtchFileRmrk: string;
|
||||||
atchFileGroupId: string;
|
atchFileGroupId: string;
|
||||||
}
|
}
|
||||||
@@ -68,6 +69,7 @@ function EsntlAtchFileFormDialog({
|
|||||||
docSeComCd: "",
|
docSeComCd: "",
|
||||||
esntlAtchFileNm: "",
|
esntlAtchFileNm: "",
|
||||||
esntlAtchYn: "Y",
|
esntlAtchYn: "Y",
|
||||||
|
esntlAtchFileRvwCn: "",
|
||||||
esntlAtchFileRmrk: "",
|
esntlAtchFileRmrk: "",
|
||||||
atchFileGroupId: "",
|
atchFileGroupId: "",
|
||||||
},
|
},
|
||||||
@@ -115,6 +117,7 @@ function EsntlAtchFileFormDialog({
|
|||||||
docSeComCd: initial.docSeComCd ?? "",
|
docSeComCd: initial.docSeComCd ?? "",
|
||||||
esntlAtchFileNm: initial.esntlAtchFileNm ?? "",
|
esntlAtchFileNm: initial.esntlAtchFileNm ?? "",
|
||||||
esntlAtchYn: initial.esntlAtchYn ?? "Y",
|
esntlAtchYn: initial.esntlAtchYn ?? "Y",
|
||||||
|
esntlAtchFileRvwCn: initial.esntlAtchFileRvwCn ?? "",
|
||||||
esntlAtchFileRmrk: initial.esntlAtchFileRmrk ?? "",
|
esntlAtchFileRmrk: initial.esntlAtchFileRmrk ?? "",
|
||||||
atchFileGroupId: initial.atchFileGroupId ?? "",
|
atchFileGroupId: initial.atchFileGroupId ?? "",
|
||||||
});
|
});
|
||||||
@@ -127,6 +130,7 @@ function EsntlAtchFileFormDialog({
|
|||||||
docSeComCd: "",
|
docSeComCd: "",
|
||||||
esntlAtchFileNm: "",
|
esntlAtchFileNm: "",
|
||||||
esntlAtchYn: "Y",
|
esntlAtchYn: "Y",
|
||||||
|
esntlAtchFileRvwCn: "",
|
||||||
esntlAtchFileRmrk: "",
|
esntlAtchFileRmrk: "",
|
||||||
atchFileGroupId: "",
|
atchFileGroupId: "",
|
||||||
});
|
});
|
||||||
@@ -249,6 +253,17 @@ function EsntlAtchFileFormDialog({
|
|||||||
</div>
|
</div>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</div>
|
</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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="esntlAtchFileRmrk">비고</Label>
|
<Label htmlFor="esntlAtchFileRmrk">비고</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ function EsntlAtchFileList({
|
|||||||
manualPagination: true,
|
manualPagination: true,
|
||||||
},
|
},
|
||||||
dataTableOptions: {
|
dataTableOptions: {
|
||||||
caption: "필수첨부파일 목록으로 필수첨부파일아이디, 문서구분, 필수첨부파일명, 필수첨부여부, 비고, 양식 내려받기, 관리 정보 제공",
|
caption: "필수첨부파일 목록으로 필수첨부파일아이디, 문서구분, 필수첨부파일명, 필수첨부여부, 필수파일검토내용, 비고, 양식 내려받기, 관리 정보 제공",
|
||||||
viewPagination: true,
|
viewPagination: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -112,6 +112,7 @@ function EsntlAtchFileList({
|
|||||||
docSeComCd: values.docSeComCd,
|
docSeComCd: values.docSeComCd,
|
||||||
esntlAtchFileNm: values.esntlAtchFileNm,
|
esntlAtchFileNm: values.esntlAtchFileNm,
|
||||||
esntlAtchYn: values.esntlAtchYn,
|
esntlAtchYn: values.esntlAtchYn,
|
||||||
|
esntlAtchFileRvwCn: values.esntlAtchFileRvwCn || undefined,
|
||||||
esntlAtchFileRmrk: values.esntlAtchFileRmrk || undefined,
|
esntlAtchFileRmrk: values.esntlAtchFileRmrk || undefined,
|
||||||
atchFileGroupId: values.atchFileGroupId || 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[] {
|
function resolveButtonConfigs(actions: FnrsCnsltnViewButtonAction[]): FnrsCnsltnViewButtonAction[] {
|
||||||
return actions.map((action) => FNRS_CNSLTN_VIEW_BUTTON_ACTION[action]);
|
return actions.map((action) => FNRS_CNSLTN_VIEW_BUTTON_ACTION[action]);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GetButtonConfigsOptions {
|
export interface GetButtonConfigsOptions {
|
||||||
hasPendingApprovalRequest?: boolean;
|
hasPendingApprovalRequest?: boolean;
|
||||||
hasCompletedDraft?: boolean;
|
hasCompletedDraft?: boolean;
|
||||||
canRetryFileVrfc?: boolean;
|
canRetryFileVrfc?: boolean;
|
||||||
canRetryDraftGen?: boolean;
|
canRetryDraftGen?: boolean;
|
||||||
canRetryCostVrfc?: 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>([
|
const PENDING_APPROVAL_ACTIONS = new Set<FnrsCnsltnViewButtonAction>([
|
||||||
@@ -214,7 +347,35 @@ function resolveRoleUser01AtrzButtonActions(
|
|||||||
atrzSeComCdList: string[],
|
atrzSeComCdList: string[],
|
||||||
options?: GetButtonConfigsOptions,
|
options?: GetButtonConfigsOptions,
|
||||||
): FnrsCnsltnViewButtonAction[] {
|
): 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 [];
|
if (!atrzStatusMap || atrzSeComCdList.length === 0) return [];
|
||||||
|
|
||||||
const seen = new Set<FnrsCnsltnViewButtonAction>();
|
const seen = new Set<FnrsCnsltnViewButtonAction>();
|
||||||
@@ -232,6 +393,11 @@ function resolveRoleUser01AtrzButtonActions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return actions.filter((action) => {
|
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);
|
const needsPendingApproval = PENDING_APPROVAL_ACTIONS.has(action);
|
||||||
if (needsPendingApproval && options?.hasPendingApprovalRequest === false) return false;
|
if (needsPendingApproval && options?.hasPendingApprovalRequest === false) return false;
|
||||||
|
|
||||||
@@ -249,12 +415,12 @@ function resolveRoleUser01AtrzButtonActions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (options?.hasCompletedDraft != null) {
|
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) {
|
if (action === FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng) {
|
||||||
return options.hasCompletedDraft;
|
return options.hasCompletedDraft;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (prgrsSttsComCd === "TE") {
|
if (effectiveStatus === "TE") {
|
||||||
if (action === FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRsltInq) {
|
if (action === FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRsltInq) {
|
||||||
return options.hasCompletedDraft;
|
return options.hasCompletedDraft;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { BranchSeCd } from "@/entities/elapr";
|
||||||
import type { CklstResultItem, CklstRvwRsltTreeDto } from "@/entities/fnrs-cnsltn";
|
import type { CklstResultItem, CklstRvwRsltTreeDto } from "@/entities/fnrs-cnsltn";
|
||||||
|
|
||||||
import {
|
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 [];
|
if (!taskSeComCd) return [];
|
||||||
return taskSeComCd.split(",").filter(Boolean);
|
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,
|
cklstSn: item.cklstSn,
|
||||||
cklstArtclSn: item.cklstArtclSn,
|
cklstArtclSn: item.cklstArtclSn,
|
||||||
cklstRvwSn: item.cklstRvwSn,
|
cklstRvwSn: item.cklstRvwSn,
|
||||||
rvwStpComCd: item.rvwStpComCd,
|
rvwStpComCd: item.rvwStpComCd || "LOCAL",
|
||||||
selected: mapSelectedValue(item.rvwRsltAnmlyYn),
|
selected: mapSelectedValue(item.rvwRsltAnmlyYn),
|
||||||
picRvwCn: item.picRvwCn,
|
picRvwCn: item.picRvwCn,
|
||||||
})) ?? [],
|
})) ?? [],
|
||||||
@@ -183,6 +183,7 @@ export function buildFormPayload(
|
|||||||
fnrsSharList: toFnrsSharSaveList(withTotals),
|
fnrsSharList: toFnrsSharSaveList(withTotals),
|
||||||
cklstResultList: data.cklstResultList.map((item) => ({
|
cklstResultList: data.cklstResultList.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
|
rvwStpComCd: item.rvwStpComCd || "LOCAL",
|
||||||
selected: item.selected ?? undefined,
|
selected: item.selected ?? undefined,
|
||||||
})),
|
})),
|
||||||
esntlAtchFileList: data.esntlAtchFileList,
|
esntlAtchFileList: data.esntlAtchFileList,
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export function buildSupplementReqPayload(
|
|||||||
data: FnrsCnsltnMngDetResponseDto,
|
data: FnrsCnsltnMngDetResponseDto,
|
||||||
payload: SplmntDmndDialogConfirmPayload,
|
payload: SplmntDmndDialogConfirmPayload,
|
||||||
checklistFormState: ChecklistFormState,
|
checklistFormState: ChecklistFormState,
|
||||||
|
branchSeCd?: string,
|
||||||
): FnrsCnsltnApplySaveRequestDto {
|
): FnrsCnsltnApplySaveRequestDto {
|
||||||
const existingItems: FnrsCnsltnSplmntDmndItem[] = (data.splmntDmndList ?? []).map((item) =>
|
const existingItems: FnrsCnsltnSplmntDmndItem[] = (data.splmntDmndList ?? []).map((item) =>
|
||||||
toSplmntDmndSaveItem(item),
|
toSplmntDmndSaveItem(item),
|
||||||
@@ -111,6 +112,7 @@ export function buildSupplementReqPayload(
|
|||||||
return fnrsCnsltnApplySaveSchema.parse({
|
return fnrsCnsltnApplySaveSchema.parse({
|
||||||
...buildSavePayload(data, checklistFormState, "LOCAL", "SL"),
|
...buildSavePayload(data, checklistFormState, "LOCAL", "SL"),
|
||||||
splmntDmndList: [...existingItems, ...newItems],
|
splmntDmndList: [...existingItems, ...newItems],
|
||||||
|
branchSeCd,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,11 +20,14 @@ import {
|
|||||||
ElaprApprovalDialog,
|
ElaprApprovalDialog,
|
||||||
type ElaprDmndSaveRequestDto,
|
type ElaprDmndSaveRequestDto,
|
||||||
elaprDmndService,
|
elaprDmndService,
|
||||||
|
type ElaprDocPrgrsForkJoinPickerConfirmPayload,
|
||||||
|
ElaprDocPrgrsForkJoinPickerDialog,
|
||||||
type ElaprDocPrgrsPickerConfirmPayload,
|
type ElaprDocPrgrsPickerConfirmPayload,
|
||||||
ElaprDocPrgrsPickerDialog,
|
ElaprDocPrgrsPickerDialog,
|
||||||
type ElaprTmpltPickerConfirmPayload,
|
type ElaprTmpltPickerConfirmPayload,
|
||||||
ElaprTmpltPickerDialog,
|
ElaprTmpltPickerDialog,
|
||||||
hasCurrentUserPendingApprovalRequest,
|
hasCurrentUserPendingApprovalRequest,
|
||||||
|
useElaprBranchesByDocNo,
|
||||||
useElaprDocPrgrsByDocNo,
|
useElaprDocPrgrsByDocNo,
|
||||||
} from "@/entities/elapr";
|
} from "@/entities/elapr";
|
||||||
import {
|
import {
|
||||||
@@ -54,7 +57,11 @@ import {
|
|||||||
getButtonConfigs,
|
getButtonConfigs,
|
||||||
needsRoleUser01AtrzResolve,
|
needsRoleUser01AtrzResolve,
|
||||||
} from "../../lib/button-config";
|
} from "../../lib/button-config";
|
||||||
import { computeReviewStepChecklistProgress } from "../../lib/checklist-utils";
|
import type { BranchSeCd } from "@/entities/elapr";
|
||||||
|
import {
|
||||||
|
computeReviewStepChecklistProgress,
|
||||||
|
validateChecklistFormStateByBranch,
|
||||||
|
} from "../../lib/checklist-utils";
|
||||||
import {
|
import {
|
||||||
buildFileSplmntReqPayload,
|
buildFileSplmntReqPayload,
|
||||||
buildRvwRsltSplmntReqPayload,
|
buildRvwRsltSplmntReqPayload,
|
||||||
@@ -63,6 +70,10 @@ import {
|
|||||||
buildTechReviewReplyPayload,
|
buildTechReviewReplyPayload,
|
||||||
toSplmntDmndSaveItem,
|
toSplmntDmndSaveItem,
|
||||||
} from "../../lib/save-payload-utils";
|
} from "../../lib/save-payload-utils";
|
||||||
|
import {
|
||||||
|
isForkJoinTask,
|
||||||
|
normalizeTaskSeComCdForElapr,
|
||||||
|
} from "../../lib/fnrs-cnsltn-regist-schema";
|
||||||
import { type ChecklistFormState } from "../../model/view-types";
|
import { type ChecklistFormState } from "../../model/view-types";
|
||||||
import type { TechReviewOpinionSectionHandle } from "../sections/tech-review-opinion-section";
|
import type { TechReviewOpinionSectionHandle } from "../sections/tech-review-opinion-section";
|
||||||
|
|
||||||
@@ -96,6 +107,7 @@ interface FloatingActionButtonsProps {
|
|||||||
type ActiveDialog =
|
type ActiveDialog =
|
||||||
| "elaprTmplt"
|
| "elaprTmplt"
|
||||||
| "elaprDocPrgrs"
|
| "elaprDocPrgrs"
|
||||||
|
| "elaprDocPrgrsForkJoin"
|
||||||
| "rjct"
|
| "rjct"
|
||||||
| "fileSplmnt"
|
| "fileSplmnt"
|
||||||
| "elaprApproval"
|
| "elaprApproval"
|
||||||
@@ -128,8 +140,13 @@ function FloatingActionButtons({
|
|||||||
const docNo = data.detDto?.fnrsCnsltnDocNo ?? fnrsCnsltnDocNo;
|
const docNo = data.detDto?.fnrsCnsltnDocNo ?? fnrsCnsltnDocNo;
|
||||||
const isRegister = data.detDto?.rgtr === userInfo?.userUnqId;
|
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 needsAtrzResolve = needsRoleUser01AtrzResolve(dtlInstCd, prgrsSttsComCd);
|
||||||
const needsElaprPrgrsCheck = needsAtrzResolve && ["TE", "RK"].includes(prgrsSttsComCd);
|
const needsElaprPrgrsCheck =
|
||||||
|
needsAtrzResolve &&
|
||||||
|
(["TE", "RK"].includes(prgrsSttsComCd) || (isForkJoin && prgrsSttsComCd === "SL"));
|
||||||
const needsPwrkDraftCheck =
|
const needsPwrkDraftCheck =
|
||||||
dtlInstCd === "ROLE_USER01" && ["SL", "TE", "RK"].includes(prgrsSttsComCd);
|
dtlInstCd === "ROLE_USER01" && ["SL", "TE", "RK"].includes(prgrsSttsComCd);
|
||||||
const needsPwrkForRb = dtlInstCd === "ROLE_USER05" && prgrsSttsComCd === "RB";
|
const needsPwrkForRb = dtlInstCd === "ROLE_USER05" && prgrsSttsComCd === "RB";
|
||||||
@@ -140,6 +157,10 @@ function FloatingActionButtons({
|
|||||||
const { data: elaprDocPrgrsList = [], isLoading: isElaprDocPrgrsLoading } =
|
const { data: elaprDocPrgrsList = [], isLoading: isElaprDocPrgrsLoading } =
|
||||||
useElaprDocPrgrsByDocNo(needsElaprPrgrsCheck ? docNo : null);
|
useElaprDocPrgrsByDocNo(needsElaprPrgrsCheck ? docNo : null);
|
||||||
|
|
||||||
|
const { data: elaprBranchList = [], isLoading: isElaprBranchLoading } = useElaprBranchesByDocNo(
|
||||||
|
isForkJoin && (needsElaprPrgrsCheck || prgrsSttsComCd === "SL") ? docNo : null,
|
||||||
|
);
|
||||||
|
|
||||||
const { data: pwrkCnsltnData, isLoading: isPwrkCnsltnLoading } = usePwrkCnsltnMngByUpDocNo(
|
const { data: pwrkCnsltnData, isLoading: isPwrkCnsltnLoading } = usePwrkCnsltnMngByUpDocNo(
|
||||||
docNo,
|
docNo,
|
||||||
needsPwrkLookup,
|
needsPwrkLookup,
|
||||||
@@ -171,55 +192,167 @@ function FloatingActionButtons({
|
|||||||
draftGenLatest?.pwrkCnsltnDocNo ??
|
draftGenLatest?.pwrkCnsltnDocNo ??
|
||||||
costVrfcLatest?.pwrkCnsltnDocNo;
|
costVrfcLatest?.pwrkCnsltnDocNo;
|
||||||
|
|
||||||
|
const isSecondApproval = useMemo(() => {
|
||||||
|
return elaprDocPrgrsList.some((row) => Number(row.elaprCycl) === 2);
|
||||||
|
}, [elaprDocPrgrsList]);
|
||||||
|
|
||||||
const elaprPrgrsForPending =
|
const elaprPrgrsForPending =
|
||||||
prgrsSttsComCd === "RK"
|
isSecondApproval || prgrsSttsComCd === "RK"
|
||||||
? elaprDocPrgrsList.filter((row) => Number(row.elaprCycl) === 2)
|
? elaprDocPrgrsList.filter((row) => Number(row.elaprCycl) === 2)
|
||||||
: elaprDocPrgrsList;
|
: elaprDocPrgrsList;
|
||||||
const hasPendingApprovalRequest = hasCurrentUserPendingApprovalRequest(
|
const hasPendingApprovalRequest = hasCurrentUserPendingApprovalRequest(
|
||||||
elaprPrgrsForPending,
|
elaprPrgrsForPending,
|
||||||
userInfo?.userUnqId,
|
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 =
|
const viewOnlyPwrkCnsltnDoc =
|
||||||
prgrsSttsComCd === "SL" ||
|
(prgrsSttsComCd === "SL" && dtlInstCd !== "ROLE_USER05" && !isBranchInProgress) ||
|
||||||
(prgrsSttsComCd === "RB" && dtlInstCd !== "ROLE_USER05") ||
|
(prgrsSttsComCd === "RB" && dtlInstCd !== "ROLE_USER05") ||
|
||||||
(prgrsSttsComCd === "RK" && dtlInstCd === "ROLE_USER03");
|
(prgrsSttsComCd === "RK" && dtlInstCd === "ROLE_USER03");
|
||||||
|
|
||||||
const actionButtons = useMemo(() => {
|
const actionButtons = useMemo(() => {
|
||||||
if (needsAtrzResolve && isRvwLoginUserLoading) return [];
|
if (needsAtrzResolve && isRvwLoginUserLoading) return [];
|
||||||
if (needsElaprPrgrsCheck && isElaprDocPrgrsLoading) return [];
|
if (needsElaprPrgrsCheck && isElaprDocPrgrsLoading) return [];
|
||||||
|
if (isForkJoin && isElaprBranchLoading) return [];
|
||||||
if (needsPwrkDraftCheck && isPwrkCnsltnLoading) return [];
|
if (needsPwrkDraftCheck && isPwrkCnsltnLoading) return [];
|
||||||
if (needsAiAutoLatest && isAiAutoLatestLoading) return [];
|
if (needsAiAutoLatest && isAiAutoLatestLoading) return [];
|
||||||
return getButtonConfigs(dtlInstCd, prgrsSttsComCd, atrzSeComCdList, {
|
return getButtonConfigs(dtlInstCd, prgrsSttsComCd, effectiveAtrzSeComCdList, {
|
||||||
hasPendingApprovalRequest: needsElaprPrgrsCheck ? hasPendingApprovalRequest : undefined,
|
hasPendingApprovalRequest: needsElaprPrgrsCheck ? hasPendingApprovalRequest : undefined,
|
||||||
hasCompletedDraft: needsPwrkDraftCheck ? hasCompletedDraft : undefined,
|
hasCompletedDraft: needsPwrkDraftCheck ? hasCompletedDraft : undefined,
|
||||||
canRetryFileVrfc: needsAiAutoLatest ? canRetryFileVrfc : undefined,
|
canRetryFileVrfc: needsAiAutoLatest ? canRetryFileVrfc : undefined,
|
||||||
canRetryDraftGen: needsAiAutoLatest ? canRetryDraftGen : undefined,
|
canRetryDraftGen: needsAiAutoLatest ? canRetryDraftGen : undefined,
|
||||||
canRetryCostVrfc: needsAiAutoLatest ? canRetryCostVrfc : undefined,
|
canRetryCostVrfc: needsAiAutoLatest ? canRetryCostVrfc : undefined,
|
||||||
|
isForkJoin,
|
||||||
|
branchSeCd: userBranchSeCd,
|
||||||
|
branchSttsCd: userBranchSttsCd,
|
||||||
|
isSecondApproval,
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
atrzSeComCdList,
|
|
||||||
canRetryCostVrfc,
|
canRetryCostVrfc,
|
||||||
canRetryDraftGen,
|
canRetryDraftGen,
|
||||||
canRetryFileVrfc,
|
canRetryFileVrfc,
|
||||||
dtlInstCd,
|
dtlInstCd,
|
||||||
|
effectiveAtrzSeComCdList,
|
||||||
hasCompletedDraft,
|
hasCompletedDraft,
|
||||||
hasPendingApprovalRequest,
|
hasPendingApprovalRequest,
|
||||||
isAiAutoLatestLoading,
|
isAiAutoLatestLoading,
|
||||||
|
isElaprBranchLoading,
|
||||||
isElaprDocPrgrsLoading,
|
isElaprDocPrgrsLoading,
|
||||||
|
isForkJoin,
|
||||||
isPwrkCnsltnLoading,
|
isPwrkCnsltnLoading,
|
||||||
isRvwLoginUserLoading,
|
isRvwLoginUserLoading,
|
||||||
|
isSecondApproval,
|
||||||
needsAiAutoLatest,
|
needsAiAutoLatest,
|
||||||
needsAtrzResolve,
|
needsAtrzResolve,
|
||||||
needsElaprPrgrsCheck,
|
needsElaprPrgrsCheck,
|
||||||
needsPwrkDraftCheck,
|
needsPwrkDraftCheck,
|
||||||
prgrsSttsComCd,
|
prgrsSttsComCd,
|
||||||
|
userBranchSeCd,
|
||||||
|
userBranchSttsCd,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const canEdit =
|
const canEdit =
|
||||||
isRegister && actionButtons.some((button) => button === FNRS_CNSLTN_VIEW_BUTTON_ACTION.strg);
|
isRegister && actionButtons.some((button) => button === FNRS_CNSLTN_VIEW_BUTTON_ACTION.strg);
|
||||||
const canDmndSplmntInPwrkCnsltnDoc =
|
const canDmndSplmntInPwrkCnsltnDoc =
|
||||||
prgrsSttsComCd === "RK" &&
|
prgrsSttsComCd === "RK" &&
|
||||||
atrzSeComCdList.includes("ATRZ02") &&
|
effectiveAtrzSeComCdList.includes("ATRZ02") &&
|
||||||
actionButtons.some((button) => button === FNRS_CNSLTN_VIEW_BUTTON_ACTION.dmndSplmnt);
|
actionButtons.some((button) => button === FNRS_CNSLTN_VIEW_BUTTON_ACTION.dmndSplmnt);
|
||||||
|
|
||||||
function handleGoToList() {
|
function handleGoToList() {
|
||||||
@@ -256,6 +389,9 @@ function FloatingActionButtons({
|
|||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: fnrsCnsltnKeys.detail(fnrsCnsltnDocNo),
|
queryKey: fnrsCnsltnKeys.detail(fnrsCnsltnDocNo),
|
||||||
});
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_BRANCH],
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
let errorMessage = "";
|
let errorMessage = "";
|
||||||
@@ -326,6 +462,7 @@ function FloatingActionButtons({
|
|||||||
docNo,
|
docNo,
|
||||||
docSeComCd,
|
docSeComCd,
|
||||||
prgrsSttsComCd: "LE",
|
prgrsSttsComCd: "LE",
|
||||||
|
rgtrUuid: userInfo?.userUnqId,
|
||||||
};
|
};
|
||||||
await executeAction({
|
await executeAction({
|
||||||
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.envoSbmsn,
|
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.envoSbmsn,
|
||||||
@@ -335,10 +472,14 @@ function FloatingActionButtons({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleRjctClick() {
|
async function handleRjctClick() {
|
||||||
|
if (!(await confirm("해당 신청 건을 반려하시겠습니까?"))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const payload: DocPrgrsUpdateRequestDto = {
|
const payload: DocPrgrsUpdateRequestDto = {
|
||||||
docNo,
|
docNo,
|
||||||
docSeComCd,
|
docSeComCd,
|
||||||
prgrsSttsComCd: "SV",
|
prgrsSttsComCd: "SV",
|
||||||
|
rgtrUuid: userInfo?.userUnqId,
|
||||||
};
|
};
|
||||||
await executeAction({
|
await executeAction({
|
||||||
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.rjct,
|
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.rjct,
|
||||||
@@ -352,6 +493,7 @@ function FloatingActionButtons({
|
|||||||
docNo,
|
docNo,
|
||||||
docSeComCd,
|
docSeComCd,
|
||||||
prgrsSttsComCd: "TA",
|
prgrsSttsComCd: "TA",
|
||||||
|
rgtrUuid: userInfo?.userUnqId,
|
||||||
};
|
};
|
||||||
await executeAction({
|
await executeAction({
|
||||||
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.sncnAprv,
|
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.sncnAprv,
|
||||||
@@ -449,6 +591,9 @@ function FloatingActionButtons({
|
|||||||
elaprDocNo: docNo,
|
elaprDocNo: docNo,
|
||||||
dmndTtl: data.detDto?.bizNm ?? "",
|
dmndTtl: data.detDto?.bizNm ?? "",
|
||||||
dmndCn: data.detDto?.bizCn ?? "",
|
dmndCn: data.detDto?.bizCn ?? "",
|
||||||
|
taskSeComCd: normalizedTaskSeComCd || undefined,
|
||||||
|
forkJoinSeCd: isForkJoin ? "FORK_JOIN" : "NONE",
|
||||||
|
crntStageNo: isForkJoin ? 2 : 1,
|
||||||
elaprCycl: 1,
|
elaprCycl: 1,
|
||||||
docPrgrsSttsComCd: "AC",
|
docPrgrsSttsComCd: "AC",
|
||||||
elaprPrgrsSttsList: buildElaprPrgrsSttsListFromAplnRows(aplnRows),
|
elaprPrgrsSttsList: buildElaprPrgrsSttsListFromAplnRows(aplnRows),
|
||||||
@@ -463,11 +608,16 @@ function FloatingActionButtons({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleElaprDocPrgrsConfirm({ aplnRows }: ElaprDocPrgrsPickerConfirmPayload) {
|
async function handleElaprDocPrgrsConfirm({
|
||||||
|
aplnRows,
|
||||||
|
}: ElaprDocPrgrsPickerConfirmPayload | ElaprDocPrgrsForkJoinPickerConfirmPayload) {
|
||||||
const payload: ElaprDmndSaveRequestDto = {
|
const payload: ElaprDmndSaveRequestDto = {
|
||||||
elaprDocNo: docNo,
|
elaprDocNo: docNo,
|
||||||
dmndTtl: data.detDto?.bizNm ?? "",
|
dmndTtl: data.detDto?.bizNm ?? "",
|
||||||
dmndCn: data.detDto?.bizCn ?? "",
|
dmndCn: data.detDto?.bizCn ?? "",
|
||||||
|
taskSeComCd: normalizedTaskSeComCd || undefined,
|
||||||
|
forkJoinSeCd: isForkJoin ? "FORK_JOIN" : "NONE",
|
||||||
|
crntStageNo: isForkJoin ? 2 : 1,
|
||||||
elaprCycl: 2,
|
elaprCycl: 2,
|
||||||
docPrgrsSttsComCd: "RK",
|
docPrgrsSttsComCd: "RK",
|
||||||
elaprPrgrsSttsList: buildElaprPrgrsSttsListFromAplnRows(aplnRows),
|
elaprPrgrsSttsList: buildElaprPrgrsSttsListFromAplnRows(aplnRows),
|
||||||
@@ -483,11 +633,13 @@ function FloatingActionButtons({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleRvwStrgClick() {
|
async function handleRvwStrgClick() {
|
||||||
|
const targetPrgrsStts =
|
||||||
|
isSecondApproval || prgrsSttsComCd === "RK" ? "RK" : prgrsSttsComCd === "SL" ? "SL" : "TE";
|
||||||
const payload = buildTechReviewReplyPayload(
|
const payload = buildTechReviewReplyPayload(
|
||||||
data,
|
data,
|
||||||
checklistFormState,
|
checklistFormState,
|
||||||
"KECO",
|
"KECO",
|
||||||
"TE",
|
targetPrgrsStts,
|
||||||
"SV",
|
"SV",
|
||||||
getReviewOpinionFormData(),
|
getReviewOpinionFormData(),
|
||||||
opnrNm,
|
opnrNm,
|
||||||
@@ -501,26 +653,53 @@ function FloatingActionButtons({
|
|||||||
techReviewOpinionRef?.current?.resetForm();
|
techReviewOpinionRef?.current?.resetForm();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRvwCmptnClick() {
|
function handleApprovalOpen() {
|
||||||
if (prgrsSttsComCd === "TE") {
|
const isTeStep = prgrsSttsComCd === "TE" || (isForkJoin && prgrsSttsComCd === "SL");
|
||||||
const { percent } = computeReviewStepChecklistProgress(
|
if (isTeStep && (userBranchSeCd || userPendingBranchSeCd)) {
|
||||||
data.cklstResultList ?? [],
|
const branchCd = (userBranchSeCd as BranchSeCd) || (userPendingBranchSeCd as BranchSeCd) || (isForkJoin ? "SP" : "MAIN");
|
||||||
checklistFormState.results,
|
const validation = validateChecklistFormStateByBranch({
|
||||||
"KECO",
|
rows: data.cklstResultList ?? [],
|
||||||
);
|
formState: checklistFormState,
|
||||||
|
reviewStep: "KECO",
|
||||||
|
approverBranchSeCd: branchCd,
|
||||||
|
docTaskSeComCd: normalizedTaskSeComCd || data.detDto?.taskSeComCd || "MAIN",
|
||||||
|
});
|
||||||
|
|
||||||
if (percent !== 100) {
|
if (!validation.isValid) {
|
||||||
alert("모든 체크리스트 항목을 체크해야 검토완료가 가능합니다.");
|
alert(validation.errorMessage || "체크리스트 항목을 모두 검토해야 결재가 가능합니다.");
|
||||||
return;
|
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 = {
|
const payload = {
|
||||||
...buildTechReviewReplyPayload(
|
...buildTechReviewReplyPayload(
|
||||||
data,
|
data,
|
||||||
checklistFormState,
|
checklistFormState,
|
||||||
"KECO",
|
"KECO",
|
||||||
prgrsSttsComCd,
|
targetPrgrsStts,
|
||||||
"SV",
|
"SV",
|
||||||
getReviewOpinionFormData(),
|
getReviewOpinionFormData(),
|
||||||
opnrNm,
|
opnrNm,
|
||||||
@@ -539,7 +718,8 @@ function FloatingActionButtons({
|
|||||||
async function handleDmndSplmntConfirm(payload: SplmntDmndDialogConfirmPayload) {
|
async function handleDmndSplmntConfirm(payload: SplmntDmndDialogConfirmPayload) {
|
||||||
setIsDmndSplmntDialogOpen(false);
|
setIsDmndSplmntDialogOpen(false);
|
||||||
setActiveDialog(null);
|
setActiveDialog(null);
|
||||||
const requestPayload = buildSupplementReqPayload(data, payload, checklistFormState);
|
const branchSeCd = userBranchSeCd ?? userPendingBranchSeCd;
|
||||||
|
const requestPayload = buildSupplementReqPayload(data, payload, checklistFormState, branchSeCd);
|
||||||
await executeAction({
|
await executeAction({
|
||||||
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.dmndSplmnt,
|
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.dmndSplmnt,
|
||||||
request: requestPayload,
|
request: requestPayload,
|
||||||
@@ -563,7 +743,11 @@ function FloatingActionButtons({
|
|||||||
|
|
||||||
async function handleRvwRsltSplmntConfirm(selected: PwrkCnsltnCnsltnResponseDto[]) {
|
async function handleRvwRsltSplmntConfirm(selected: PwrkCnsltnCnsltnResponseDto[]) {
|
||||||
setActiveDialog(null);
|
setActiveDialog(null);
|
||||||
const requestPayload = buildRvwRsltSplmntReqPayload(data, selected, checklistFormState);
|
const branchSeCd = userBranchSeCd ?? userPendingBranchSeCd;
|
||||||
|
const requestPayload = {
|
||||||
|
...buildRvwRsltSplmntReqPayload(data, selected, checklistFormState),
|
||||||
|
branchSeCd,
|
||||||
|
};
|
||||||
await executeAction({
|
await executeAction({
|
||||||
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.dmndSplmnt,
|
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.dmndSplmnt,
|
||||||
request: requestPayload,
|
request: requestPayload,
|
||||||
@@ -738,7 +922,13 @@ function FloatingActionButtons({
|
|||||||
!isRagIndexingInProgress(prgrsSttsComCd, data.ragIndexingInfo) && (
|
!isRagIndexingInProgress(prgrsSttsComCd, data.ragIndexingInfo) && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setActiveDialog(prgrsSttsComCd === "EV" ? "elaprDocPrgrs" : "elaprTmplt")
|
setActiveDialog(
|
||||||
|
prgrsSttsComCd === "EV"
|
||||||
|
? isForkJoin
|
||||||
|
? "elaprDocPrgrsForkJoin"
|
||||||
|
: "elaprDocPrgrs"
|
||||||
|
: "elaprTmplt",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
>
|
>
|
||||||
@@ -750,11 +940,16 @@ function FloatingActionButtons({
|
|||||||
조치 완료
|
조치 완료
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRsltInq) && (
|
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRsltInq) &&
|
||||||
<Button color="sky" onClick={() => setActiveDialog("rvwRsltInq")} disabled={isBusy}>
|
!isSecondApproval &&
|
||||||
검토 결과 조회
|
!(
|
||||||
</Button>
|
isForkJoin &&
|
||||||
)}
|
(userPendingBranchSeCd === "TOTAL_AGG" || userPendingAtrzSeComCd === "ATRZ04")
|
||||||
|
) && (
|
||||||
|
<Button color="sky" onClick={() => setActiveDialog("rvwRsltInq")} disabled={isBusy}>
|
||||||
|
검토 결과 조회
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwStrg) && (
|
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwStrg) && (
|
||||||
<Button onClick={handleRvwStrgClick} disabled={isBusy}>
|
<Button onClick={handleRvwStrgClick} disabled={isBusy}>
|
||||||
저장
|
저장
|
||||||
@@ -774,16 +969,33 @@ function FloatingActionButtons({
|
|||||||
보완 요청
|
보완 요청
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzAprv) && (
|
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzAprv) &&
|
||||||
<Button onClick={() => setActiveDialog("elaprApproval")} disabled={isBusy}>
|
(!isForkJoin ||
|
||||||
결재(승인)
|
(userPendingBranchSeCd === "TOTAL_AGG" && userPendingAtrzSeComCd === "ATRZ04")) && (
|
||||||
</Button>
|
<Button onClick={handleApprovalOpen} disabled={isBusy}>
|
||||||
)}
|
{isForkJoin ? "결재 (병합)" : "결재(승인)"}
|
||||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzRply) && (
|
</Button>
|
||||||
<Button onClick={() => setActiveDialog("elaprApproval")} disabled={isBusy}>
|
)}
|
||||||
결재(회신)
|
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzRply) &&
|
||||||
</Button>
|
(!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>
|
</div>
|
||||||
@@ -795,6 +1007,7 @@ function FloatingActionButtons({
|
|||||||
open={activeDialog === "elaprTmplt"}
|
open={activeDialog === "elaprTmplt"}
|
||||||
onClose={() => setActiveDialog(null)}
|
onClose={() => setActiveDialog(null)}
|
||||||
docSeComCd={docSeComCd}
|
docSeComCd={docSeComCd}
|
||||||
|
taskSeComCd={normalizedTaskSeComCd || undefined}
|
||||||
onConfirm={handleElaprTmpltConfirm}
|
onConfirm={handleElaprTmpltConfirm}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -805,6 +1018,13 @@ function FloatingActionButtons({
|
|||||||
onConfirm={handleElaprDocPrgrsConfirm}
|
onConfirm={handleElaprDocPrgrsConfirm}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ElaprDocPrgrsForkJoinPickerDialog
|
||||||
|
open={activeDialog === "elaprDocPrgrsForkJoin"}
|
||||||
|
elaprDocNo={docNo}
|
||||||
|
onClose={() => setActiveDialog(null)}
|
||||||
|
onConfirm={handleElaprDocPrgrsConfirm}
|
||||||
|
/>
|
||||||
|
|
||||||
<SplmntDmndDialog
|
<SplmntDmndDialog
|
||||||
splmntArtclUpCd="SPLMNTARTCL"
|
splmntArtclUpCd="SPLMNTARTCL"
|
||||||
open={isDmndSplmntDialogOpen}
|
open={isDmndSplmntDialogOpen}
|
||||||
@@ -826,21 +1046,36 @@ function FloatingActionButtons({
|
|||||||
onOpenChange={(open) => setActiveDialog(open ? "elaprApproval" : null)}
|
onOpenChange={(open) => setActiveDialog(open ? "elaprApproval" : null)}
|
||||||
elaprDocNo={data.detDto?.fnrsCnsltnDocNo ?? fnrsCnsltnDocNo}
|
elaprDocNo={data.detDto?.fnrsCnsltnDocNo ?? fnrsCnsltnDocNo}
|
||||||
currentUserUuid={userInfo?.userUnqId ?? ""}
|
currentUserUuid={userInfo?.userUnqId ?? ""}
|
||||||
elaprCycl={prgrsSttsComCd === "RK" ? 2 : undefined}
|
elaprCycl={
|
||||||
|
userPendingPrgrs?.elaprCycl != null
|
||||||
|
? Number(userPendingPrgrs.elaprCycl)
|
||||||
|
: isSecondApproval || prgrsSttsComCd === "RK"
|
||||||
|
? 2
|
||||||
|
: 1
|
||||||
|
}
|
||||||
|
taskSeComCd={normalizedTaskSeComCd || undefined}
|
||||||
onSuccess={() => {
|
onSuccess={() => {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: fnrsCnsltnKeys.detail(fnrsCnsltnDocNo),
|
queryKey: fnrsCnsltnKeys.detail(fnrsCnsltnDocNo),
|
||||||
});
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_BRANCH],
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PwrkCnsltnDocMngDialog
|
<PwrkCnsltnDocMngDialog
|
||||||
open={activeDialog === "pwrkCnsltnDocMng"}
|
open={activeDialog === "pwrkCnsltnDocMng"}
|
||||||
fnrsCnsltnDocNo={docNo}
|
fnrsCnsltnDocNo={docNo}
|
||||||
atrzSeComCdList={atrzSeComCdList}
|
atrzSeComCdList={effectiveAtrzSeComCdList}
|
||||||
prgrsSttsComCd={prgrsSttsComCd}
|
prgrsSttsComCd={prgrsSttsComCd}
|
||||||
|
branchSeCd={userBranchSeCd ?? userPendingBranchSeCd}
|
||||||
|
isForkJoin={isForkJoin}
|
||||||
|
userRole={dtlInstCd}
|
||||||
|
supplementDmndBranch={supplementDmndBranch}
|
||||||
canSave={
|
canSave={
|
||||||
(needsElaprPrgrsCheck && !hasPendingApprovalRequest) || viewOnlyPwrkCnsltnDoc
|
(needsElaprPrgrsCheck && !hasPendingApprovalRequest && !isBranchInProgress) ||
|
||||||
|
viewOnlyPwrkCnsltnDoc
|
||||||
? false
|
? false
|
||||||
: null
|
: 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(
|
async function handleTechReview(
|
||||||
context: ActionHandlerContext<FnrsCnsltnApplySaveRequestDto>,
|
context: ActionHandlerContext<FnrsCnsltnApplySaveRequestDto>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -982,7 +1228,7 @@ const buttonActionHandlers: Record<FnrsCnsltnViewButtonAction, ActionHandler> =
|
|||||||
aply: handleSaveApply,
|
aply: handleSaveApply,
|
||||||
del: handleDel,
|
del: handleDel,
|
||||||
envoSbmsn: handleChangePrgrsSttsComCd,
|
envoSbmsn: handleChangePrgrsSttsComCd,
|
||||||
rjct: handleChangePrgrsSttsComCd,
|
rjct: handleRejectPrgrsStts,
|
||||||
sncnAprv: handleChangePrgrsSttsComCd,
|
sncnAprv: handleChangePrgrsSttsComCd,
|
||||||
rvwRply: handleTechReview,
|
rvwRply: handleTechReview,
|
||||||
rvwOpnnRply: handleTechReview,
|
rvwOpnnRply: handleTechReview,
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ import {
|
|||||||
interface PwrkCnsltnDocEvlDialogProps {
|
interface PwrkCnsltnDocEvlDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
pwrkCnsltnDocNo: string;
|
pwrkCnsltnDocNo: string;
|
||||||
/** 평가 저장 성공 시 호출 (이후 웹기안기 문서 저장이 진행된다) */
|
/** 평가 저장 성공 시 호출 */
|
||||||
onSaved: () => void;
|
onSaved?: () => void;
|
||||||
/** 평가 없이 닫을 때 호출 (웹기안기 문서는 저장되지 않는다) */
|
/** 평가 없이 닫을 때 호출 */
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ function PwrkCnsltnDocEvlDialog({
|
|||||||
try {
|
try {
|
||||||
const payload = buildPwrkCnsltnDocEvlSavePayload(pwrkCnsltnDocNo, values);
|
const payload = buildPwrkCnsltnDocEvlSavePayload(pwrkCnsltnDocNo, values);
|
||||||
await saveEvl(payload);
|
await saveEvl(payload);
|
||||||
onSaved();
|
onSaved?.();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("AI 초안 만족도 평가 등록 실패:", error);
|
console.error("AI 초안 만족도 평가 등록 실패:", error);
|
||||||
alert("AI 초안 만족도 평가 등록에 실패했습니다.");
|
alert("AI 초안 만족도 평가 등록에 실패했습니다.");
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -16,12 +16,14 @@ interface PwrkCnsltnDocPjtcoAjmtTableProps {
|
|||||||
rows: PwrkCnsltnDocPjtcoAjmtMngResponseDto[];
|
rows: PwrkCnsltnDocPjtcoAjmtMngResponseDto[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isError: boolean;
|
isError: boolean;
|
||||||
|
emptyMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PwrkCnsltnDocPjtcoAjmtTable({
|
function PwrkCnsltnDocPjtcoAjmtTable({
|
||||||
rows,
|
rows,
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
|
emptyMessage = "금액조정 검토의견이 0건(변동 없음)입니다.",
|
||||||
}: PwrkCnsltnDocPjtcoAjmtTableProps) {
|
}: PwrkCnsltnDocPjtcoAjmtTableProps) {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -64,7 +66,7 @@ function PwrkCnsltnDocPjtcoAjmtTable({
|
|||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={3} className="py-10 text-center text-muted-foreground">
|
<TableCell colSpan={3} className="py-10 text-center text-muted-foreground">
|
||||||
조회된 사업비조정 항목이 없습니다.
|
{emptyMessage}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { Button } from "@common/components";
|
|||||||
import {
|
import {
|
||||||
type FnrsCnsltnRegistFormData,
|
type FnrsCnsltnRegistFormData,
|
||||||
fnrsCnsltnRegistSchema,
|
fnrsCnsltnRegistSchema,
|
||||||
|
isForkJoinTask,
|
||||||
} from "../lib/fnrs-cnsltn-regist-schema";
|
} from "../lib/fnrs-cnsltn-regist-schema";
|
||||||
import {
|
import {
|
||||||
buildFormPayload,
|
buildFormPayload,
|
||||||
@@ -349,7 +350,10 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
|
|||||||
<PwrkCnsltnDocMngDialog
|
<PwrkCnsltnDocMngDialog
|
||||||
open={isPwrkCnsltnDocMngOpen}
|
open={isPwrkCnsltnDocMngOpen}
|
||||||
fnrsCnsltnDocNo={fnrsCnsltnDocNo}
|
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)}
|
onClose={() => setIsPwrkCnsltnDocMngOpen(false)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { useMemo, useRef } from "react";
|
import { useMemo, useRef } from "react";
|
||||||
|
|
||||||
|
import { RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
import { useUserStore } from "@/shared/lib";
|
import { useUserStore } from "@/shared/lib";
|
||||||
import { SectionCollapsibleGroup, SectionCollapsibleToggle, TextShimmer } from "@/shared/ui";
|
import { SectionCollapsibleGroup, SectionCollapsibleToggle, TextShimmer } from "@/shared/ui";
|
||||||
import { ElaprDocPrgrsStatus } from "@/entities/elapr";
|
import { ElaprDocPrgrsStatus } from "@/entities/elapr";
|
||||||
@@ -12,7 +14,7 @@ import {
|
|||||||
ProgressStatusSection,
|
ProgressStatusSection,
|
||||||
SupplementDemandSection,
|
SupplementDemandSection,
|
||||||
} from "@/widgets/pbadms-sprt";
|
} from "@/widgets/pbadms-sprt";
|
||||||
import { Skeleton } from "@common/components";
|
import { Button, cn, Skeleton } from "@common/components";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getAiPendingMessage,
|
getAiPendingMessage,
|
||||||
@@ -20,6 +22,7 @@ import {
|
|||||||
isRagIndexingInProgress,
|
isRagIndexingInProgress,
|
||||||
} from "../lib/ai-pending-utils";
|
} from "../lib/ai-pending-utils";
|
||||||
import { needsRoleUser01AtrzResolve } from "../lib/button-config";
|
import { needsRoleUser01AtrzResolve } from "../lib/button-config";
|
||||||
|
import { normalizeTaskSeComCdForElapr } from "../lib/fnrs-cnsltn-regist-schema";
|
||||||
import { getEditableReviewStep } from "../lib/review-step-utils";
|
import { getEditableReviewStep } from "../lib/review-step-utils";
|
||||||
import { EMPTY_CKLST_RESULT_LIST, useChecklistFormState } from "../lib/use-checklist-form-state";
|
import { EMPTY_CKLST_RESULT_LIST, useChecklistFormState } from "../lib/use-checklist-form-state";
|
||||||
import { PROGRESS_STATUS_COLUMNS } from "../model/columns";
|
import { PROGRESS_STATUS_COLUMNS } from "../model/columns";
|
||||||
@@ -43,7 +46,7 @@ interface FnrsViewPageProps {
|
|||||||
function FnrsViewPage({ id }: FnrsViewPageProps) {
|
function FnrsViewPage({ id }: FnrsViewPageProps) {
|
||||||
const userInfo = useUserStore((state) => state.userInfo);
|
const userInfo = useUserStore((state) => state.userInfo);
|
||||||
const techReviewOpinionRef = useRef<TechReviewOpinionSectionHandle>(null);
|
const techReviewOpinionRef = useRef<TechReviewOpinionSectionHandle>(null);
|
||||||
const { data, isLoading, isError } = useFnrsCnsltnMng(id);
|
const { data, isLoading, isError, refetch, isFetching } = useFnrsCnsltnMng(id);
|
||||||
const { formState: checklistFormState, setFormState: setChecklistFormState } =
|
const { formState: checklistFormState, setFormState: setChecklistFormState } =
|
||||||
useChecklistFormState(data?.cklstResultList ?? EMPTY_CKLST_RESULT_LIST);
|
useChecklistFormState(data?.cklstResultList ?? EMPTY_CKLST_RESULT_LIST);
|
||||||
|
|
||||||
@@ -108,19 +111,33 @@ function FnrsViewPage({ id }: FnrsViewPageProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<SectionCollapsibleGroup>
|
<SectionCollapsibleGroup>
|
||||||
<div className="sticky top-[6rem] z-30 flex items-center gap-4 bg-container px-4 py-2">
|
<div className="sticky top-[6rem] z-30 flex items-center justify-between bg-container px-4 py-2">
|
||||||
<SectionCollapsibleToggle />
|
<div className="flex items-center gap-3">
|
||||||
{showPendingBanner && (
|
<SectionCollapsibleToggle />
|
||||||
<div className="flex items-center gap-2">
|
<Button
|
||||||
<span className="relative flex size-3">
|
type="button"
|
||||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75"></span>
|
variant="outline"
|
||||||
<span className="relative inline-flex size-3 rounded-full bg-primary"></span>
|
size="xs"
|
||||||
</span>
|
onClick={() => refetch()}
|
||||||
<TextShimmer active className="text-xl">
|
disabled={isFetching}
|
||||||
{pendingMessage}
|
className="flex items-center gap-1.5 h-[2.8rem] px-3 text-[1.2rem] font-normal"
|
||||||
</TextShimmer>
|
title="최신 데이터 새로고침"
|
||||||
</div>
|
>
|
||||||
)}
|
<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>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<ProgressStatusSection
|
<ProgressStatusSection
|
||||||
@@ -130,7 +147,10 @@ function FnrsViewPage({ id }: FnrsViewPageProps) {
|
|||||||
/>
|
/>
|
||||||
<BasicInfoSection det={detDto} />
|
<BasicInfoSection det={detDto} />
|
||||||
<ContactInfoSection det={detDto} />
|
<ContactInfoSection det={detDto} />
|
||||||
<ElaprDocPrgrsStatus elaprDocNo={detDto.fnrsCnsltnDocNo} />
|
<ElaprDocPrgrsStatus
|
||||||
|
elaprDocNo={detDto.fnrsCnsltnDocNo}
|
||||||
|
taskSeComCd={normalizeTaskSeComCdForElapr(detDto.taskSeComCd)}
|
||||||
|
/>
|
||||||
<BusinessScaleSection bizSclList={bizSclList} />
|
<BusinessScaleSection bizSclList={bizSclList} />
|
||||||
<BusinessCostSection bizCstList={bizCstList} />
|
<BusinessCostSection bizCstList={bizCstList} />
|
||||||
<FnrsSharSection fnrsSharList={fnrsSharList} />
|
<FnrsSharSection fnrsSharList={fnrsSharList} />
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ export interface TechReviewOpinionSectionHandle {
|
|||||||
|
|
||||||
interface TechReviewOpinionTableProps {
|
interface TechReviewOpinionTableProps {
|
||||||
items: FnrsCnsltnRvwOpnnResponseDto[];
|
items: FnrsCnsltnRvwOpnnResponseDto[];
|
||||||
isReviewTurn: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UploadedOpinionFile {
|
interface UploadedOpinionFile {
|
||||||
@@ -292,7 +291,7 @@ const TechReviewOpinionSection = forwardRef<
|
|||||||
<SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}>
|
<SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{envRvwList.length > 0 ? (
|
{envRvwList.length > 0 ? (
|
||||||
<TechReviewOpinionTable items={envRvwList} isReviewTurn={isReviewTurn("ENV")} />
|
<TechReviewOpinionTable items={envRvwList} />
|
||||||
) : null}
|
) : null}
|
||||||
{renderOpinionForm("ENV")}
|
{renderOpinionForm("ENV")}
|
||||||
</div>
|
</div>
|
||||||
@@ -302,7 +301,7 @@ const TechReviewOpinionSection = forwardRef<
|
|||||||
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
|
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{kecoRvwList.length > 0 ? (
|
{kecoRvwList.length > 0 ? (
|
||||||
<TechReviewOpinionTable items={kecoRvwList} isReviewTurn={isReviewTurn("KECO")} />
|
<TechReviewOpinionTable items={kecoRvwList} />
|
||||||
) : null}
|
) : null}
|
||||||
{/*
|
{/*
|
||||||
재원협의에서 환경공단 검토의견은 자동 생성
|
재원협의에서 환경공단 검토의견은 자동 생성
|
||||||
@@ -322,7 +321,7 @@ function getRvwOpnnIds(item: FnrsCnsltnRvwOpnnResponseDto) {
|
|||||||
return [item.fnrsCnsltnDocNo, item.rvwOpnnSn] as const;
|
return [item.fnrsCnsltnDocNo, item.rvwOpnnSn] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TechReviewOpinionTable({ items, isReviewTurn }: TechReviewOpinionTableProps) {
|
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const currentUserUuid = useUserStore((state) => state.userInfo?.userUnqId);
|
const currentUserUuid = useUserStore((state) => state.userInfo?.userUnqId);
|
||||||
|
|
||||||
@@ -331,7 +330,6 @@ function TechReviewOpinionTable({ items, isReviewTurn }: TechReviewOpinionTableP
|
|||||||
items={items}
|
items={items}
|
||||||
getRowKey={(item) => `${item.fnrsCnsltnDocNo}_${item.rvwOpnnSn}`}
|
getRowKey={(item) => `${item.fnrsCnsltnDocNo}_${item.rvwOpnnSn}`}
|
||||||
currentUserUuid={currentUserUuid}
|
currentUserUuid={currentUserUuid}
|
||||||
isReviewTurn={isReviewTurn}
|
|
||||||
onUpdate={async (item, body) => {
|
onUpdate={async (item, body) => {
|
||||||
await fnrsCnsltnRvwOpnnService.update(...getRvwOpnnIds(item), body);
|
await fnrsCnsltnRvwOpnnService.update(...getRvwOpnnIds(item), body);
|
||||||
await queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
|
await queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ export const INSTL_SNCN_VIEW_BUTTON_ACTION = {
|
|||||||
aply: "aply",
|
aply: "aply",
|
||||||
del: "del",
|
del: "del",
|
||||||
instlSncnAncmnt: "instlSncnAncmnt",
|
instlSncnAncmnt: "instlSncnAncmnt",
|
||||||
envoSbmsn: "envoSbmsn",
|
|
||||||
rjct: "rjct",
|
rjct: "rjct",
|
||||||
sncnAprv: "sncnAprv",
|
sncnAprv: "sncnAprv",
|
||||||
rvwRply: "rvwRply",
|
rvwRply: "rvwRply",
|
||||||
@@ -33,14 +32,16 @@ const ROLE_STATUS_BUTTON_ACTIONS: Record<string, Record<string, InstlSncnViewBut
|
|||||||
],
|
],
|
||||||
TA: [INSTL_SNCN_VIEW_BUTTON_ACTION.instlSncnAncmnt],
|
TA: [INSTL_SNCN_VIEW_BUTTON_ACTION.instlSncnAncmnt],
|
||||||
},
|
},
|
||||||
ROLE_USER04: {
|
// 환경청 단계 제거: 기존 환경청(LE·TP) 기능을 광역자치단체가 수행
|
||||||
ST: [INSTL_SNCN_VIEW_BUTTON_ACTION.envoSbmsn, INSTL_SNCN_VIEW_BUTTON_ACTION.rjct],
|
ROLE_USER04: {
|
||||||
EP: [INSTL_SNCN_VIEW_BUTTON_ACTION.sncnAprv],
|
ST: [
|
||||||
},
|
INSTL_SNCN_VIEW_BUTTON_ACTION.rvwRply,
|
||||||
ROLE_USER03: {
|
INSTL_SNCN_VIEW_BUTTON_ACTION.rqstTechRvw,
|
||||||
LE: [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],
|
],
|
||||||
},
|
TP: [INSTL_SNCN_VIEW_BUTTON_ACTION.rvwRply],
|
||||||
|
EP: [INSTL_SNCN_VIEW_BUTTON_ACTION.sncnAprv],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const ROLE_USER01_ATRZ_STATUS_CODES = ["ER", "TE"] as const;
|
const ROLE_USER01_ATRZ_STATUS_CODES = ["ER", "TE"] as const;
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function mapDetailToFormData(data: InstlSncnMngDetResponse): InstlSncnReg
|
|||||||
cklstSn: item.cklstSn,
|
cklstSn: item.cklstSn,
|
||||||
cklstArtclSn: item.cklstArtclSn,
|
cklstArtclSn: item.cklstArtclSn,
|
||||||
cklstRvwSn: item.cklstRvwSn,
|
cklstRvwSn: item.cklstRvwSn,
|
||||||
rvwStpComCd: item.rvwStpComCd,
|
rvwStpComCd: item.rvwStpComCd || "LOCAL",
|
||||||
selected: mapSelectedValue(item.rvwRsltAnmlyYn),
|
selected: mapSelectedValue(item.rvwRsltAnmlyYn),
|
||||||
picRvwCn: item.picRvwCn,
|
picRvwCn: item.picRvwCn,
|
||||||
})) ?? [],
|
})) ?? [],
|
||||||
@@ -143,6 +143,7 @@ export function buildFormPayload(
|
|||||||
bizCstList: data.bizCstList,
|
bizCstList: data.bizCstList,
|
||||||
cklstResultList: data.cklstResultList.map((item) => ({
|
cklstResultList: data.cklstResultList.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
|
rvwStpComCd: item.rvwStpComCd || "LOCAL",
|
||||||
selected: item.selected ?? undefined,
|
selected: item.selected ?? undefined,
|
||||||
})),
|
})),
|
||||||
esntlAtchFileList: data.esntlAtchFileList,
|
esntlAtchFileList: data.esntlAtchFileList,
|
||||||
|
|||||||
@@ -55,16 +55,8 @@ export const instlSncnRegistSchema = z.object({
|
|||||||
tstmDesignAmt: z.number(),
|
tstmDesignAmt: z.number(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
cklstResultList: z.array(
|
// 설치인가 화면에서 체크리스트 미사용: 숨겨진 값 때문에 신청·저장 검증이 막히지 않도록 필수값을 두지 않음
|
||||||
instlSncnCklstResultItemSchema.extend({
|
cklstResultList: z.array(instlSncnCklstResultItemSchema),
|
||||||
cklstSn: z.number(),
|
|
||||||
cklstArtclSn: z.number(),
|
|
||||||
cklstRvwSn: z.number(),
|
|
||||||
rvwStpComCd: z.string(),
|
|
||||||
selected: z.enum(["Y", "N"]).nullish(),
|
|
||||||
picRvwCn: z.string().nullish(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
esntlAtchFileList: z.array(
|
esntlAtchFileList: z.array(
|
||||||
instlSncnEsntlAtchFileItemSchema.extend({
|
instlSncnEsntlAtchFileItemSchema.extend({
|
||||||
esntlAtchFileId: z.string(),
|
esntlAtchFileId: z.string(),
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function getEditableReviewStep(
|
|||||||
dtlInstCd?: string,
|
dtlInstCd?: string,
|
||||||
prgrsSttsComCd?: string,
|
prgrsSttsComCd?: string,
|
||||||
): ReviewStepCode | undefined {
|
): ReviewStepCode | undefined {
|
||||||
if (dtlInstCd === "ROLE_USER03" && (prgrsSttsComCd === "LE" || prgrsSttsComCd === "TP")) {
|
if (dtlInstCd === "ROLE_USER04" && (prgrsSttsComCd === "ST" || prgrsSttsComCd === "TP")) {
|
||||||
return "ENV";
|
return "ENV";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,13 +50,13 @@ export const REVIEW_STEP_CODES: ReviewStepCode[] = ["LOCAL", "ENV", "KECO"];
|
|||||||
|
|
||||||
export const CHECKLIST_TAB_LABELS: Record<ReviewStepCode, string> = {
|
export const CHECKLIST_TAB_LABELS: Record<ReviewStepCode, string> = {
|
||||||
LOCAL: "자가체크",
|
LOCAL: "자가체크",
|
||||||
ENV: "환경청검토",
|
ENV: "광역자치단체검토",
|
||||||
KECO: "기술검토",
|
KECO: "기술검토",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const REVIEW_STEP_LABELS: Record<ReviewStepCode, string> = {
|
export const REVIEW_STEP_LABELS: Record<ReviewStepCode, string> = {
|
||||||
LOCAL: "지자체",
|
LOCAL: "지자체",
|
||||||
ENV: "환경청",
|
ENV: "광역자치단체",
|
||||||
KECO: "환경공단",
|
KECO: "환경공단",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ export const INSTL_PROGRESS_ORDER = [
|
|||||||
|
|
||||||
export const ROLE_TO_REVIEW_STEP: Record<string, ReviewStepCode> = {
|
export const ROLE_TO_REVIEW_STEP: Record<string, ReviewStepCode> = {
|
||||||
ROLE_USER05: "LOCAL",
|
ROLE_USER05: "LOCAL",
|
||||||
ROLE_USER03: "ENV",
|
ROLE_USER04: "ENV",
|
||||||
ROLE_USER01: "KECO",
|
ROLE_USER01: "KECO",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ import {
|
|||||||
type InstlSncnViewButtonAction,
|
type InstlSncnViewButtonAction,
|
||||||
needsRoleUser01AtrzResolve,
|
needsRoleUser01AtrzResolve,
|
||||||
} from "../../lib/button-config";
|
} from "../../lib/button-config";
|
||||||
import { computeReviewStepChecklistProgress } from "../../lib/checklist-utils";
|
|
||||||
import {
|
import {
|
||||||
buildSavePayload,
|
buildSavePayload,
|
||||||
buildSupplementReqPayload,
|
buildSupplementReqPayload,
|
||||||
@@ -179,11 +178,6 @@ function FloatingActionButtons({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleAplyClick() {
|
async function handleAplyClick() {
|
||||||
const { percent } = computeReviewStepChecklistProgress(
|
|
||||||
data.cklstResultList ?? [],
|
|
||||||
checklistFormState.results,
|
|
||||||
"LOCAL",
|
|
||||||
);
|
|
||||||
const payload: InstlSncnApplySaveRequestDto = buildSavePayload(
|
const payload: InstlSncnApplySaveRequestDto = buildSavePayload(
|
||||||
data,
|
data,
|
||||||
checklistFormState,
|
checklistFormState,
|
||||||
@@ -191,11 +185,6 @@ function FloatingActionButtons({
|
|||||||
"ST",
|
"ST",
|
||||||
);
|
);
|
||||||
|
|
||||||
if (percent !== 100) {
|
|
||||||
alert("모든 체크리스트 항목을 체크해야 신청이 가능합니다.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await executeAction({
|
await executeAction({
|
||||||
action: INSTL_SNCN_VIEW_BUTTON_ACTION.aply,
|
action: INSTL_SNCN_VIEW_BUTTON_ACTION.aply,
|
||||||
request: payload,
|
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() {
|
async function handleRjctClick() {
|
||||||
|
if (!(await confirm("해당 신청 건을 반려하시겠습니까?"))) return;
|
||||||
|
|
||||||
const payload: DocPrgrsUpdateRequestDto = {
|
const payload: DocPrgrsUpdateRequestDto = {
|
||||||
docNo,
|
docNo,
|
||||||
docSeComCd,
|
docSeComCd,
|
||||||
prgrsSttsComCd: "SV",
|
prgrsSttsComCd: "SV",
|
||||||
|
rgtrUuid: userInfo?.userUnqId,
|
||||||
};
|
};
|
||||||
await executeAction({
|
await executeAction({
|
||||||
action: INSTL_SNCN_VIEW_BUTTON_ACTION.rjct,
|
action: INSTL_SNCN_VIEW_BUTTON_ACTION.rjct,
|
||||||
@@ -284,16 +260,6 @@ function FloatingActionButtons({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleRvwRplyClick() {
|
async function handleRvwRplyClick() {
|
||||||
const { percent } = computeReviewStepChecklistProgress(
|
|
||||||
data.cklstResultList ?? [],
|
|
||||||
checklistFormState.results,
|
|
||||||
"ENV",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (percent !== 100) {
|
|
||||||
alert("모든 체크리스트 항목을 체크해야 검토결과 회신이 가능합니다.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = buildTechReviewReplyPayload(
|
const payload = buildTechReviewReplyPayload(
|
||||||
data,
|
data,
|
||||||
@@ -369,16 +335,6 @@ function FloatingActionButtons({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleRvwCmptnClick() {
|
async function handleRvwCmptnClick() {
|
||||||
const { percent } = computeReviewStepChecklistProgress(
|
|
||||||
data.cklstResultList ?? [],
|
|
||||||
checklistFormState.results,
|
|
||||||
"KECO",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (percent !== 100) {
|
|
||||||
alert("모든 체크리스트 항목을 체크해야 검토완료가 가능합니다.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
...buildTechReviewReplyPayload(
|
...buildTechReviewReplyPayload(
|
||||||
@@ -447,11 +403,6 @@ function FloatingActionButtons({
|
|||||||
설치인가 고시
|
설치인가 고시
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.envoSbmsn) && (
|
|
||||||
<Button onClick={handleEnvoSbmsnClick} disabled={isProcessing}>
|
|
||||||
환경청제출
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.rjct) && (
|
{shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.rjct) && (
|
||||||
<Button color="red" onClick={handleRjctClick} disabled={isProcessing}>
|
<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(
|
async function handleTechReview(
|
||||||
context: ActionHandlerContext<InstlSncnApplySaveRequestDto>,
|
context: ActionHandlerContext<InstlSncnApplySaveRequestDto>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -618,8 +580,7 @@ const buttonActionHandlers: Record<InstlSncnViewButtonAction, ActionHandler> = {
|
|||||||
aply: handleSaveApply,
|
aply: handleSaveApply,
|
||||||
del: handleDel,
|
del: handleDel,
|
||||||
instlSncnAncmnt: handleChangePrgrsSttsComCd,
|
instlSncnAncmnt: handleChangePrgrsSttsComCd,
|
||||||
envoSbmsn: handleChangePrgrsSttsComCd,
|
rjct: handleRejectPrgrsStts,
|
||||||
rjct: handleChangePrgrsSttsComCd,
|
|
||||||
sncnAprv: handleChangePrgrsSttsComCd,
|
sncnAprv: handleChangePrgrsSttsComCd,
|
||||||
rvwRply: handleTechReview,
|
rvwRply: handleTechReview,
|
||||||
rqstTechRvw: handleChangePrgrsSttsComCd,
|
rqstTechRvw: handleChangePrgrsSttsComCd,
|
||||||
|
|||||||
@@ -20,9 +20,7 @@ import {
|
|||||||
AttachmentsFormSection,
|
AttachmentsFormSection,
|
||||||
BusinessCostFormSection,
|
BusinessCostFormSection,
|
||||||
BusinessScaleFormSection,
|
BusinessScaleFormSection,
|
||||||
ChecklistFormSection,
|
|
||||||
ContactInfoFormSection,
|
ContactInfoFormSection,
|
||||||
isFormChecklistComplete,
|
|
||||||
isFormEsntlAtchComplete,
|
isFormEsntlAtchComplete,
|
||||||
PrivacyConsentSection,
|
PrivacyConsentSection,
|
||||||
ProgressStatusSection,
|
ProgressStatusSection,
|
||||||
@@ -154,11 +152,6 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isFormChecklistComplete(data.cklstResultList)) {
|
|
||||||
alert("모든 체크리스트 항목을 체크해야 신청이 가능합니다.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isFormEsntlAtchComplete(essentialFiles, data.esntlAtchFileList)) {
|
if (!isFormEsntlAtchComplete(essentialFiles, data.esntlAtchFileList)) {
|
||||||
alert("필수 첨부파일을 모두 등록해야 신청이 가능합니다.");
|
alert("필수 첨부파일을 모두 등록해야 신청이 가능합니다.");
|
||||||
return;
|
return;
|
||||||
@@ -228,7 +221,6 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
|
|||||||
<PrivacyConsentSection />
|
<PrivacyConsentSection />
|
||||||
<BusinessScaleFormSection />
|
<BusinessScaleFormSection />
|
||||||
<BusinessCostFormSection />
|
<BusinessCostFormSection />
|
||||||
<ChecklistFormSection docSeComCd="INSTL" fieldPrefix="instlSncnMng" />
|
|
||||||
<AttachmentsFormSection
|
<AttachmentsFormSection
|
||||||
docSeComCd="INSTL"
|
docSeComCd="INSTL"
|
||||||
fieldPrefix="instlSncnMng"
|
fieldPrefix="instlSncnMng"
|
||||||
|
|||||||
@@ -1,149 +1,155 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useRef } from "react";
|
import { useMemo, useRef } from "react";
|
||||||
|
|
||||||
import { useUserStore } from "@/shared/lib";
|
import { RefreshCw } from "lucide-react";
|
||||||
import { SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
|
|
||||||
import { ElaprDocPrgrsStatus } from "@/entities/elapr";
|
import { useUserStore } from "@/shared/lib";
|
||||||
import { useInstlSncnDetail } from "@/entities/instl-sncn";
|
import { SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
|
||||||
import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw";
|
import { ElaprDocPrgrsStatus } from "@/entities/elapr";
|
||||||
import {
|
import { useInstlSncnDetail } from "@/entities/instl-sncn";
|
||||||
AttachmentsViewSection,
|
import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw";
|
||||||
ProgressStatusSection,
|
import {
|
||||||
SupplementDemandSection,
|
AttachmentsViewSection,
|
||||||
} from "@/widgets/pbadms-sprt";
|
ProgressStatusSection,
|
||||||
import { Skeleton } from "@common/components";
|
SupplementDemandSection,
|
||||||
|
} from "@/widgets/pbadms-sprt";
|
||||||
import { needsRoleUser01AtrzResolve } from "../lib/button-config";
|
import { Button, cn, Skeleton } from "@common/components";
|
||||||
import { getEditableReviewStep } from "../lib/review-step-utils";
|
|
||||||
import { EMPTY_CKLST_RESULT_LIST, useChecklistFormState } from "../lib/use-checklist-form-state";
|
import { needsRoleUser01AtrzResolve } from "../lib/button-config";
|
||||||
import { PROGRESS_STATUS_COLUMNS } from "../model/columns";
|
import { getEditableReviewStep } from "../lib/review-step-utils";
|
||||||
|
import { EMPTY_CKLST_RESULT_LIST, useChecklistFormState } from "../lib/use-checklist-form-state";
|
||||||
import { FloatingActionButtons } from "./components/floating-action-buttons";
|
import { PROGRESS_STATUS_COLUMNS } from "../model/columns";
|
||||||
import { BasicInfoSection } from "./sections/basic-info-section";
|
|
||||||
import { BusinessCostSection } from "./sections/business-cost-section";
|
import { FloatingActionButtons } from "./components/floating-action-buttons";
|
||||||
import { BusinessScaleSection } from "./sections/business-scale-section";
|
import { BasicInfoSection } from "./sections/basic-info-section";
|
||||||
import { ChecklistSection } from "./sections/checklist-section";
|
import { BusinessCostSection } from "./sections/business-cost-section";
|
||||||
import { ContactInfoSection } from "./sections/contact-info-section";
|
import { BusinessScaleSection } from "./sections/business-scale-section";
|
||||||
import {
|
import { ContactInfoSection } from "./sections/contact-info-section";
|
||||||
TechReviewOpinionSection,
|
import {
|
||||||
type TechReviewOpinionSectionHandle,
|
TechReviewOpinionSection,
|
||||||
} from "./sections/tech-review-opinion-section";
|
type TechReviewOpinionSectionHandle,
|
||||||
|
} from "./sections/tech-review-opinion-section";
|
||||||
interface InstlViewPageProps {
|
|
||||||
id: string;
|
interface InstlViewPageProps {
|
||||||
}
|
id: string;
|
||||||
|
}
|
||||||
function InstlViewPage({ id }: InstlViewPageProps) {
|
|
||||||
const userInfo = useUserStore((state) => state.userInfo);
|
function InstlViewPage({ id }: InstlViewPageProps) {
|
||||||
const techReviewOpinionRef = useRef<TechReviewOpinionSectionHandle>(null);
|
const userInfo = useUserStore((state) => state.userInfo);
|
||||||
const { data, isLoading, isError } = useInstlSncnDetail(id);
|
const techReviewOpinionRef = useRef<TechReviewOpinionSectionHandle>(null);
|
||||||
const { formState: checklistFormState, setFormState: setChecklistFormState } =
|
const { data, isLoading, isError, refetch, isFetching } = useInstlSncnDetail(id);
|
||||||
useChecklistFormState(data?.cklstResultList ?? EMPTY_CKLST_RESULT_LIST);
|
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(
|
const prgrsSttsComCd = data?.detDto?.prgrsSttsComCd || "";
|
||||||
{},
|
const needsAtrzResolve = needsRoleUser01AtrzResolve(userInfo?.dtlInstCd, prgrsSttsComCd);
|
||||||
{ enabled: needsAtrzResolve },
|
const { data: rvwLoginUserData, isLoading: isRvwLoginUserLoading } = useRvwMnpwMngLoginUserList(
|
||||||
);
|
{},
|
||||||
const atrzSeComCdList = useMemo(
|
{ enabled: needsAtrzResolve },
|
||||||
() => rvwLoginUserData?.content?.map((item) => item.atrzSeComCd) ?? [],
|
);
|
||||||
[rvwLoginUserData],
|
const atrzSeComCdList = useMemo(
|
||||||
);
|
() => rvwLoginUserData?.content?.map((item) => item.atrzSeComCd) ?? [],
|
||||||
|
[rvwLoginUserData],
|
||||||
if (isLoading) {
|
);
|
||||||
return (
|
|
||||||
<div className="mt-6 flex flex-col gap-4 px-6">
|
if (isLoading) {
|
||||||
<div className="flex flex-col gap-2">
|
return (
|
||||||
<Skeleton className="h-[4rem] rounded-xl" />
|
<div className="mt-6 flex flex-col gap-4 px-6">
|
||||||
<Skeleton className="h-[16rem] rounded-xl" />
|
<div className="flex flex-col gap-2">
|
||||||
</div>
|
<Skeleton className="h-[4rem] rounded-xl" />
|
||||||
<div className="flex flex-col gap-2">
|
<Skeleton className="h-[16rem] rounded-xl" />
|
||||||
<Skeleton className="h-[4rem] rounded-xl" />
|
</div>
|
||||||
<Skeleton className="h-[16rem] rounded-xl" />
|
<div className="flex flex-col gap-2">
|
||||||
</div>
|
<Skeleton className="h-[4rem] rounded-xl" />
|
||||||
<div className="flex flex-col gap-2">
|
<Skeleton className="h-[16rem] rounded-xl" />
|
||||||
<Skeleton className="h-[4rem] rounded-xl" />
|
</div>
|
||||||
<Skeleton className="h-[32rem] rounded-xl" />
|
<div className="flex flex-col gap-2">
|
||||||
</div>
|
<Skeleton className="h-[4rem] rounded-xl" />
|
||||||
</div>
|
<Skeleton className="h-[32rem] rounded-xl" />
|
||||||
);
|
</div>
|
||||||
}
|
</div>
|
||||||
|
);
|
||||||
if (isError || !data) {
|
}
|
||||||
return <div className="mt-6 text-[1.4rem]">상세 정보를 찾을 수 없습니다.</div>;
|
|
||||||
}
|
if (isError || !data) {
|
||||||
|
return <div className="mt-6 text-[1.4rem]">상세 정보를 찾을 수 없습니다.</div>;
|
||||||
const {
|
}
|
||||||
detDto,
|
|
||||||
bizSclList,
|
const {
|
||||||
bizCstList,
|
detDto,
|
||||||
cklstResultList = [],
|
bizSclList,
|
||||||
esntlFileList,
|
bizCstList,
|
||||||
etcFileList,
|
esntlFileList,
|
||||||
rvwList = [],
|
etcFileList,
|
||||||
splmntDmndList = [],
|
rvwList = [],
|
||||||
sttsChgHstryList = [],
|
splmntDmndList = [],
|
||||||
} = data;
|
sttsChgHstryList = [],
|
||||||
|
} = data;
|
||||||
const editableReviewStep = getEditableReviewStep(
|
|
||||||
userInfo?.dtlInstCd,
|
const editableReviewStep = getEditableReviewStep(
|
||||||
detDto.prgrsSttsComCd || "",
|
userInfo?.dtlInstCd,
|
||||||
);
|
detDto.prgrsSttsComCd || "",
|
||||||
|
);
|
||||||
return (
|
|
||||||
<div className="mt-4">
|
return (
|
||||||
<SectionCollapsibleGroup>
|
<div className="mt-4">
|
||||||
<div className="sticky top-[6rem] z-30 bg-container px-4 py-2">
|
<SectionCollapsibleGroup>
|
||||||
<SectionCollapsibleToggle />
|
<div className="sticky top-[6rem] z-30 flex items-center gap-3 bg-container px-4 py-2">
|
||||||
</div>
|
<SectionCollapsibleToggle />
|
||||||
<div className="space-y-4">
|
<Button
|
||||||
<ProgressStatusSection
|
type="button"
|
||||||
columns={PROGRESS_STATUS_COLUMNS}
|
variant="outline"
|
||||||
sttsChgHstryList={sttsChgHstryList}
|
size="xs"
|
||||||
elaprDocNo={detDto.instlSncnDocNo}
|
onClick={() => refetch()}
|
||||||
/>
|
disabled={isFetching}
|
||||||
<BasicInfoSection det={detDto} />
|
className="flex items-center gap-1.5 h-[2.8rem] px-3 text-[1.2rem] font-normal"
|
||||||
<ContactInfoSection det={detDto} />
|
title="최신 데이터 새로고침"
|
||||||
<ElaprDocPrgrsStatus elaprDocNo={detDto.instlSncnDocNo} />
|
>
|
||||||
<BusinessScaleSection bizSclList={bizSclList} />
|
<RefreshCw className={cn("size-3.5", isFetching && "animate-spin")} />
|
||||||
<BusinessCostSection bizCstList={bizCstList} />
|
<span>새로 고침</span>
|
||||||
<ChecklistSection
|
</Button>
|
||||||
cklstResultList={cklstResultList}
|
</div>
|
||||||
prgrsSttsComCd={detDto.prgrsSttsComCd || ""}
|
<div className="space-y-4">
|
||||||
userRoleCode={userInfo?.dtlInstCd}
|
<ProgressStatusSection
|
||||||
formState={checklistFormState}
|
columns={PROGRESS_STATUS_COLUMNS}
|
||||||
setFormState={setChecklistFormState}
|
sttsChgHstryList={sttsChgHstryList}
|
||||||
/>
|
elaprDocNo={detDto.instlSncnDocNo}
|
||||||
<AttachmentsViewSection
|
/>
|
||||||
esntlFileList={esntlFileList ?? []}
|
<BasicInfoSection det={detDto} />
|
||||||
etcFileList={etcFileList ?? []}
|
<ContactInfoSection det={detDto} />
|
||||||
/>
|
<ElaprDocPrgrsStatus elaprDocNo={detDto.instlSncnDocNo} />
|
||||||
<TechReviewOpinionSection
|
<BusinessScaleSection bizSclList={bizSclList} />
|
||||||
ref={techReviewOpinionRef}
|
<BusinessCostSection bizCstList={bizCstList} />
|
||||||
rvwList={rvwList}
|
<AttachmentsViewSection
|
||||||
editableReviewStep={editableReviewStep}
|
esntlFileList={esntlFileList ?? []}
|
||||||
atrzSeComCdList={atrzSeComCdList}
|
etcFileList={etcFileList ?? []}
|
||||||
isAtrzLoading={needsAtrzResolve && isRvwLoginUserLoading}
|
/>
|
||||||
/>
|
<TechReviewOpinionSection
|
||||||
<SupplementDemandSection splmntDmndList={splmntDmndList} />
|
ref={techReviewOpinionRef}
|
||||||
<FloatingActionButtons
|
rvwList={rvwList}
|
||||||
data={data}
|
editableReviewStep={editableReviewStep}
|
||||||
docSeComCd="INSTL"
|
atrzSeComCdList={atrzSeComCdList}
|
||||||
instlSncnDocNo={detDto.instlSncnDocNo}
|
isAtrzLoading={needsAtrzResolve && isRvwLoginUserLoading}
|
||||||
prgrsSttsComCd={detDto.prgrsSttsComCd || ""}
|
/>
|
||||||
dtlInstCd={userInfo?.dtlInstCd}
|
<SupplementDemandSection splmntDmndList={splmntDmndList} />
|
||||||
atrzSeComCdList={atrzSeComCdList}
|
<FloatingActionButtons
|
||||||
isRvwLoginUserLoading={needsAtrzResolve && isRvwLoginUserLoading}
|
data={data}
|
||||||
checklistFormState={checklistFormState}
|
docSeComCd="INSTL"
|
||||||
techReviewOpinionRef={techReviewOpinionRef}
|
instlSncnDocNo={detDto.instlSncnDocNo}
|
||||||
opnrNm={userInfo?.userName}
|
prgrsSttsComCd={detDto.prgrsSttsComCd || ""}
|
||||||
/>
|
dtlInstCd={userInfo?.dtlInstCd}
|
||||||
</div>
|
atrzSeComCdList={atrzSeComCdList}
|
||||||
</SectionCollapsibleGroup>
|
isRvwLoginUserLoading={needsAtrzResolve && isRvwLoginUserLoading}
|
||||||
</div>
|
checklistFormState={checklistFormState}
|
||||||
);
|
techReviewOpinionRef={techReviewOpinionRef}
|
||||||
}
|
opnrNm={userInfo?.userName}
|
||||||
|
/>
|
||||||
export { InstlViewPage };
|
</div>
|
||||||
|
</SectionCollapsibleGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { InstlViewPage };
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ export interface TechReviewOpinionSectionHandle {
|
|||||||
|
|
||||||
interface TechReviewOpinionTableProps {
|
interface TechReviewOpinionTableProps {
|
||||||
items: InstlSncnRvwOpnnResponseDto[];
|
items: InstlSncnRvwOpnnResponseDto[];
|
||||||
isReviewTurn: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UploadedOpinionFile {
|
interface UploadedOpinionFile {
|
||||||
@@ -300,7 +299,7 @@ const TechReviewOpinionSection = forwardRef<
|
|||||||
<SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}>
|
<SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{envRvwList.length > 0 ? (
|
{envRvwList.length > 0 ? (
|
||||||
<TechReviewOpinionTable items={envRvwList} isReviewTurn={isReviewTurn("ENV")} />
|
<TechReviewOpinionTable items={envRvwList} />
|
||||||
) : null}
|
) : null}
|
||||||
{renderOpinionForm("ENV")}
|
{renderOpinionForm("ENV")}
|
||||||
</div>
|
</div>
|
||||||
@@ -310,7 +309,7 @@ const TechReviewOpinionSection = forwardRef<
|
|||||||
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
|
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{kecoRvwList.length > 0 ? (
|
{kecoRvwList.length > 0 ? (
|
||||||
<TechReviewOpinionTable items={kecoRvwList} isReviewTurn={isReviewTurn("KECO")} />
|
<TechReviewOpinionTable items={kecoRvwList} />
|
||||||
) : null}
|
) : null}
|
||||||
{renderOpinionForm("KECO")}
|
{renderOpinionForm("KECO")}
|
||||||
</div>
|
</div>
|
||||||
@@ -327,7 +326,7 @@ function getRvwOpnnIds(item: InstlSncnRvwOpnnResponseDto) {
|
|||||||
return [item.instlSncnDocNo, item.rvwOpnnSn] as const;
|
return [item.instlSncnDocNo, item.rvwOpnnSn] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TechReviewOpinionTable({ items, isReviewTurn }: TechReviewOpinionTableProps) {
|
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const currentUserUuid = useUserStore((state) => state.userInfo?.userUnqId);
|
const currentUserUuid = useUserStore((state) => state.userInfo?.userUnqId);
|
||||||
|
|
||||||
@@ -336,7 +335,6 @@ function TechReviewOpinionTable({ items, isReviewTurn }: TechReviewOpinionTableP
|
|||||||
items={items}
|
items={items}
|
||||||
getRowKey={(item) => `${item.instlSncnDocNo}_${item.rvwOpnnSn}`}
|
getRowKey={(item) => `${item.instlSncnDocNo}_${item.rvwOpnnSn}`}
|
||||||
currentUserUuid={currentUserUuid}
|
currentUserUuid={currentUserUuid}
|
||||||
isReviewTurn={isReviewTurn}
|
|
||||||
onUpdate={async (item, body) => {
|
onUpdate={async (item, body) => {
|
||||||
await instlSncnRvwOpnnService.update(...getRvwOpnnIds(item), body);
|
await instlSncnRvwOpnnService.update(...getRvwOpnnIds(item), body);
|
||||||
await queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
|
await queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const RVW_MNPW_BLOCKS: RvwMnpwBlockConfig[] = [
|
|||||||
{ id: "receiver", title: "접수", atrzSeComCd: "ATRZ01" },
|
{ id: "receiver", title: "접수", atrzSeComCd: "ATRZ01" },
|
||||||
{ id: "reviewer", title: "검토", atrzSeComCd: "ATRZ02" },
|
{ id: "reviewer", title: "검토", atrzSeComCd: "ATRZ02" },
|
||||||
{ id: "approver", title: "승인", atrzSeComCd: "ATRZ03" },
|
{ id: "approver", title: "승인", atrzSeComCd: "ATRZ03" },
|
||||||
|
{ id: "merger", title: "병합", atrzSeComCd: "ATRZ04" },
|
||||||
{ id: "final", title: "최종승인", atrzSeComCd: "ATRZ99" },
|
{ 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]" />
|
||||||
<Skeleton className="h-[8rem]" />
|
<Skeleton className="h-[8rem]" />
|
||||||
|
<Skeleton className="h-[8rem]" />
|
||||||
</div>
|
</div>
|
||||||
) : pageQuery.isError ? (
|
) : pageQuery.isError ? (
|
||||||
<p className="py-8 text-center text-lg text-muted-foreground">
|
<p className="py-8 text-center text-lg text-muted-foreground">
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ export function mapDetailToFormData(data: SewerBscPlanDetResponseDto): SewerBscP
|
|||||||
cklstSn: item.cklstSn,
|
cklstSn: item.cklstSn,
|
||||||
cklstArtclSn: item.cklstArtclSn,
|
cklstArtclSn: item.cklstArtclSn,
|
||||||
cklstRvwSn: item.cklstRvwSn,
|
cklstRvwSn: item.cklstRvwSn,
|
||||||
rvwStpComCd: item.rvwStpComCd,
|
rvwStpComCd: item.rvwStpComCd || "LOCAL",
|
||||||
selected: mapSelectedValue(item.rvwRsltAnmlyYn),
|
selected: mapSelectedValue(item.rvwRsltAnmlyYn),
|
||||||
picRvwCn: item.picRvwCn,
|
picRvwCn: item.picRvwCn,
|
||||||
})) ?? [],
|
})) ?? [],
|
||||||
@@ -251,6 +251,7 @@ export function buildApplyPayload(
|
|||||||
sewerBscPlanTmplntList: data.sewerBscPlanTmplntList,
|
sewerBscPlanTmplntList: data.sewerBscPlanTmplntList,
|
||||||
cklstResultList: data.cklstResultList.map((item) => ({
|
cklstResultList: data.cklstResultList.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
|
rvwStpComCd: item.rvwStpComCd || "LOCAL",
|
||||||
selected: item.selected ?? undefined,
|
selected: item.selected ?? undefined,
|
||||||
})),
|
})),
|
||||||
esntlAtchFileList: data.esntlAtchFileList,
|
esntlAtchFileList: data.esntlAtchFileList,
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ export interface TechReviewOpinionSectionHandle {
|
|||||||
|
|
||||||
interface TechReviewOpinionTableProps {
|
interface TechReviewOpinionTableProps {
|
||||||
items: SewerBscPlanRvwOpnnResponseDto[];
|
items: SewerBscPlanRvwOpnnResponseDto[];
|
||||||
isReviewTurn: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UploadedOpinionFile {
|
interface UploadedOpinionFile {
|
||||||
@@ -304,7 +303,7 @@ const TechReviewOpinionSection = forwardRef<
|
|||||||
<SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}>
|
<SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{envRvwList.length > 0 ? (
|
{envRvwList.length > 0 ? (
|
||||||
<TechReviewOpinionTable items={envRvwList} isReviewTurn={isReviewTurn("ENV")} />
|
<TechReviewOpinionTable items={envRvwList} />
|
||||||
) : null}
|
) : null}
|
||||||
{renderOpinionForm("ENV")}
|
{renderOpinionForm("ENV")}
|
||||||
</div>
|
</div>
|
||||||
@@ -314,7 +313,7 @@ const TechReviewOpinionSection = forwardRef<
|
|||||||
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
|
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{kecoRvwList.length > 0 ? (
|
{kecoRvwList.length > 0 ? (
|
||||||
<TechReviewOpinionTable items={kecoRvwList} isReviewTurn={isReviewTurn("KECO")} />
|
<TechReviewOpinionTable items={kecoRvwList} />
|
||||||
) : null}
|
) : null}
|
||||||
{renderOpinionForm("KECO")}
|
{renderOpinionForm("KECO")}
|
||||||
</div>
|
</div>
|
||||||
@@ -331,7 +330,7 @@ function getRvwOpnnIds(item: SewerBscPlanRvwOpnnResponseDto) {
|
|||||||
return [item.sewerBscPlanDocNo, item.chgCycl, item.rvwOpnnSn] as const;
|
return [item.sewerBscPlanDocNo, item.chgCycl, item.rvwOpnnSn] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TechReviewOpinionTable({ items, isReviewTurn }: TechReviewOpinionTableProps) {
|
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const currentUserUuid = useUserStore((state) => state.userInfo?.userUnqId);
|
const currentUserUuid = useUserStore((state) => state.userInfo?.userUnqId);
|
||||||
|
|
||||||
@@ -340,7 +339,6 @@ function TechReviewOpinionTable({ items, isReviewTurn }: TechReviewOpinionTableP
|
|||||||
items={items}
|
items={items}
|
||||||
getRowKey={(item) => `${item.sewerBscPlanDocNo}_${item.rvwOpnnSn}`}
|
getRowKey={(item) => `${item.sewerBscPlanDocNo}_${item.rvwOpnnSn}`}
|
||||||
currentUserUuid={currentUserUuid}
|
currentUserUuid={currentUserUuid}
|
||||||
isReviewTurn={isReviewTurn}
|
|
||||||
onUpdate={async (item, body) => {
|
onUpdate={async (item, body) => {
|
||||||
await sewerBscPlanRvwOpnnService.update(...getRvwOpnnIds(item), body);
|
await sewerBscPlanRvwOpnnService.update(...getRvwOpnnIds(item), body);
|
||||||
await queryClient.invalidateQueries({ queryKey: sewerBscPlanKeys.all });
|
await queryClient.invalidateQueries({ queryKey: sewerBscPlanKeys.all });
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { useMemo, useRef } from "react";
|
import { useMemo, useRef } from "react";
|
||||||
|
|
||||||
|
import { RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
import { useUserStore } from "@/shared/lib";
|
import { useUserStore } from "@/shared/lib";
|
||||||
import { SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
|
import { SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
|
||||||
import { ElaprDocPrgrsStatus } from "@/entities/elapr";
|
import { ElaprDocPrgrsStatus } from "@/entities/elapr";
|
||||||
@@ -12,7 +14,7 @@ import {
|
|||||||
ProgressStatusSection,
|
ProgressStatusSection,
|
||||||
SupplementDemandSection,
|
SupplementDemandSection,
|
||||||
} from "@/widgets/pbadms-sprt";
|
} from "@/widgets/pbadms-sprt";
|
||||||
import { Skeleton } from "@common/components";
|
import { Button, cn, Skeleton } from "@common/components";
|
||||||
|
|
||||||
import { needsRoleUser01AtrzResolve } from "../lib/button-config";
|
import { needsRoleUser01AtrzResolve } from "../lib/button-config";
|
||||||
import { getEditableReviewStep } from "../lib/review-step-utils";
|
import { getEditableReviewStep } from "../lib/review-step-utils";
|
||||||
@@ -41,7 +43,7 @@ function SewerBscPlanViewPage({ id, propChgCycl }: SewerBscPlanViewPageProps) {
|
|||||||
const userInfo = useUserStore((state) => state.userInfo);
|
const userInfo = useUserStore((state) => state.userInfo);
|
||||||
const techReviewOpinionRef = useRef<TechReviewOpinionSectionHandle>(null);
|
const techReviewOpinionRef = useRef<TechReviewOpinionSectionHandle>(null);
|
||||||
const chgCycl = propChgCycl ?? 1;
|
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 } =
|
const { formState: checklistFormState, setFormState: setChecklistFormState } =
|
||||||
useChecklistFormState(data?.cklstResultRows ?? EMPTY_CKLST_RESULT_LIST);
|
useChecklistFormState(data?.cklstResultRows ?? EMPTY_CKLST_RESULT_LIST);
|
||||||
|
|
||||||
@@ -102,8 +104,20 @@ function SewerBscPlanViewPage({ id, propChgCycl }: SewerBscPlanViewPageProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<SectionCollapsibleGroup>
|
<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 />
|
<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>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<ProgressStatusSection
|
<ProgressStatusSection
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export const ENDPOINT = {
|
|||||||
ELAPR_APLN: "/as-elapr-service/api/v1/elapr-apln",
|
ELAPR_APLN: "/as-elapr-service/api/v1/elapr-apln",
|
||||||
ELAPR_DMND: "/as-elapr-service/api/v1/elapr-dmnd",
|
ELAPR_DMND: "/as-elapr-service/api/v1/elapr-dmnd",
|
||||||
ELAPR_PRGRS_STTS: "/as-elapr-service/api/v1/elapr-prgrs-stts",
|
ELAPR_PRGRS_STTS: "/as-elapr-service/api/v1/elapr-prgrs-stts",
|
||||||
|
ELAPR_BRANCH: "/as-elapr-service/api/v1/elapr-branch",
|
||||||
},
|
},
|
||||||
CMS_SERVICE: {
|
CMS_SERVICE: {
|
||||||
CODE: "/cms-service/api/v1/code/cache",
|
CODE: "/cms-service/api/v1/code/cache",
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export interface DocPrgrsUpdateRequestDto {
|
|||||||
docSeComCd: string;
|
docSeComCd: string;
|
||||||
/** 진행상태공통코드 */
|
/** 진행상태공통코드 */
|
||||||
prgrsSttsComCd: string;
|
prgrsSttsComCd: string;
|
||||||
|
/** 작성자 UUID */
|
||||||
|
rgtrUuid?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 공종 트리와 사업규모(수량) 조합 DTO */
|
/** 공종 트리와 사업규모(수량) 조합 DTO */
|
||||||
@@ -149,9 +151,13 @@ export interface ElaprDocPrgrsClientResponseDto {
|
|||||||
atrzDt?: string | null;
|
atrzDt?: string | null;
|
||||||
jbpsSeNm?: string;
|
jbpsSeNm?: string;
|
||||||
jbgdSeNm?: string;
|
jbgdSeNm?: string;
|
||||||
|
aprvrDeptNm?: string;
|
||||||
ognzNm?: string;
|
ognzNm?: string;
|
||||||
dcarbYn?: string;
|
dcarbYn?: string;
|
||||||
elaprCycl?: number;
|
elaprCycl?: number;
|
||||||
|
branchId?: string;
|
||||||
|
branchSeCd?: string;
|
||||||
|
branchNm?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 문서 상태 변경 이력 응답 DTO */
|
/** 문서 상태 변경 이력 응답 DTO */
|
||||||
|
|||||||
@@ -1 +1,5 @@
|
|||||||
export { HangulGiangi, type HangulGiangiProps } from "./ui/hangul-giangi";
|
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();
|
||||||
|
}
|
||||||
@@ -59,6 +59,7 @@ export interface HwpCtrl {
|
|||||||
callbackUserData?: unknown,
|
callbackUserData?: unknown,
|
||||||
) => boolean;
|
) => boolean;
|
||||||
ShowHorizontalScroll: (show: boolean) => boolean;
|
ShowHorizontalScroll: (show: boolean) => boolean;
|
||||||
|
IsModified?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HwpCtrlFrameWindow {
|
export interface HwpCtrlFrameWindow {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ interface HangulGiangiToolbarProps {
|
|||||||
onDownload: () => void;
|
onDownload: () => void;
|
||||||
onFullscreen: () => void;
|
onFullscreen: () => void;
|
||||||
onDmndSplmnt?: () => void;
|
onDmndSplmnt?: () => void;
|
||||||
|
rightSlot?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function HangulGiangiToolbar({
|
function HangulGiangiToolbar({
|
||||||
@@ -26,11 +27,13 @@ function HangulGiangiToolbar({
|
|||||||
onDownload,
|
onDownload,
|
||||||
onFullscreen,
|
onFullscreen,
|
||||||
onDmndSplmnt,
|
onDmndSplmnt,
|
||||||
|
rightSlot,
|
||||||
}: HangulGiangiToolbarProps) {
|
}: HangulGiangiToolbarProps) {
|
||||||
const openInputId = useId();
|
const openInputId = useId();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-nowrap items-center gap-2 border-b border-border bg-background p-3">
|
<div className="flex w-full flex-wrap items-center justify-between gap-2 border-b border-border bg-background p-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{WEBHYP_DEV_MODE && (
|
{WEBHYP_DEV_MODE && (
|
||||||
<label
|
<label
|
||||||
htmlFor={openInputId}
|
htmlFor={openInputId}
|
||||||
@@ -87,6 +90,8 @@ function HangulGiangiToolbar({
|
|||||||
보완요청
|
보완요청
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
{rightSlot && <div className="flex items-center gap-2">{rightSlot}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { WEBHYP_CTRL_URL } from "@/shared/config";
|
import { WEBHYP_CTRL_URL } from "@/shared/config";
|
||||||
import { cn } from "@common/components";
|
import { cn } from "@common/components";
|
||||||
|
|
||||||
|
import { buildBridgeSrc } from "../lib/build-bridge-src";
|
||||||
import { getHwpCtrl } from "../lib/get-hwp-ctrl";
|
import { getHwpCtrl } from "../lib/get-hwp-ctrl";
|
||||||
import {
|
import {
|
||||||
DEFAULT_BRIDGE_URL,
|
DEFAULT_BRIDGE_URL,
|
||||||
@@ -26,7 +27,9 @@ function HangulGiangi({
|
|||||||
bridgeUrl = DEFAULT_BRIDGE_URL,
|
bridgeUrl = DEFAULT_BRIDGE_URL,
|
||||||
ctrlBaseUrl = WEBHYP_CTRL_URL,
|
ctrlBaseUrl = WEBHYP_CTRL_URL,
|
||||||
className,
|
className,
|
||||||
|
iframeClassName,
|
||||||
onReady,
|
onReady,
|
||||||
|
rightSlot,
|
||||||
}: HangulGiangiProps) {
|
}: HangulGiangiProps) {
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
const openedDocumentKeyRef = useRef<string | null>(null);
|
const openedDocumentKeyRef = useRef<string | null>(null);
|
||||||
@@ -208,6 +211,7 @@ function HangulGiangi({
|
|||||||
onDownload={handleDownload}
|
onDownload={handleDownload}
|
||||||
onFullscreen={handleFullscreen}
|
onFullscreen={handleFullscreen}
|
||||||
onDmndSplmnt={onDmndSplmnt}
|
onDmndSplmnt={onDmndSplmnt}
|
||||||
|
rightSlot={rightSlot}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<iframe
|
<iframe
|
||||||
@@ -216,7 +220,10 @@ function HangulGiangi({
|
|||||||
name={HWP_CTRL_IFRAME_NAME}
|
name={HWP_CTRL_IFRAME_NAME}
|
||||||
title="웹한글 기안기"
|
title="웹한글 기안기"
|
||||||
src={iframeSrc}
|
src={iframeSrc}
|
||||||
className="fullscreen:h-screen fullscreen:min-h-0 fullscreen:w-screen h-[64vh] min-h-[40rem] w-full border-0 bg-background"
|
className={cn(
|
||||||
|
"fullscreen:h-screen fullscreen:min-h-0 fullscreen:w-screen w-full border-0 bg-background",
|
||||||
|
iframeClassName ?? "h-[64vh] min-h-[40rem]",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -234,16 +241,6 @@ function resolveHwpCtrl(iframe: HTMLIFrameElement | null, isCtrlReady: boolean):
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildBridgeSrc(bridgeUrl: string, ctrlBaseUrl: string): 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveSaveAsDocumentBlob(result: HwpCtrlSaveAsCallbackResult): Promise<Blob> {
|
async function resolveSaveAsDocumentBlob(result: HwpCtrlSaveAsCallbackResult): Promise<Blob> {
|
||||||
const downloadCandidate = result.downloadUrl ?? result.path;
|
const downloadCandidate = result.downloadUrl ?? result.path;
|
||||||
@@ -323,7 +320,9 @@ interface HangulGiangiProps {
|
|||||||
bridgeUrl?: string;
|
bridgeUrl?: string;
|
||||||
ctrlBaseUrl?: string;
|
ctrlBaseUrl?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
iframeClassName?: string;
|
||||||
onReady?: () => void;
|
onReady?: () => void;
|
||||||
|
rightSlot?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { HangulGiangi };
|
export { HangulGiangi };
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export { buildCnstpScaleItems, recalculateBzScaleTotals } from "./lib/business-s
|
|||||||
export { canEditPbadmsDoc, type CanEditPbadmsDocParams } from "./lib/can-edit-pbadms-doc";
|
export { canEditPbadmsDoc, type CanEditPbadmsDocParams } from "./lib/can-edit-pbadms-doc";
|
||||||
export {
|
export {
|
||||||
buildChecklistTabs,
|
buildChecklistTabs,
|
||||||
|
isBranchChecklistComplete,
|
||||||
isFormChecklistComplete,
|
isFormChecklistComplete,
|
||||||
normalizeTaskSeComCdArray,
|
normalizeTaskSeComCdArray,
|
||||||
syncCklstResultListFromTree,
|
syncCklstResultListFromTree,
|
||||||
|
|||||||
@@ -118,16 +118,19 @@ export function syncCklstResultListFromTree(
|
|||||||
|
|
||||||
const next = orderedSelectable.map((node) => {
|
const next = orderedSelectable.map((node) => {
|
||||||
const existing = prevByRvwSn.get(node.cklstRvwSn);
|
const existing = prevByRvwSn.get(node.cklstRvwSn);
|
||||||
return (
|
return existing
|
||||||
existing ?? {
|
? {
|
||||||
cklstSn: node.cklstSn,
|
...existing,
|
||||||
cklstArtclSn: node.cklstArtclSn,
|
rvwStpComCd: existing.rvwStpComCd || "LOCAL",
|
||||||
cklstRvwSn: node.cklstRvwSn,
|
}
|
||||||
rvwStpComCd: "LOCAL",
|
: {
|
||||||
selected: null,
|
cklstSn: node.cklstSn,
|
||||||
picRvwCn: null,
|
cklstArtclSn: node.cklstArtclSn,
|
||||||
}
|
cklstRvwSn: node.cklstRvwSn,
|
||||||
);
|
rvwStpComCd: "LOCAL",
|
||||||
|
selected: null,
|
||||||
|
picRvwCn: null,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isSameCklstResultList(prev, next)) return prev;
|
if (isSameCklstResultList(prev, next)) return prev;
|
||||||
@@ -138,3 +141,32 @@ export function isFormChecklistComplete(cklstResultList: CklstResultListItem[]):
|
|||||||
if (cklstResultList.length === 0) return true;
|
if (cklstResultList.length === 0) return true;
|
||||||
return cklstResultList.every((item) => item.selected === "Y" || item.selected === "N");
|
return cklstResultList.every((item) => item.selected === "Y" || item.selected === "N");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 결재자 브랜치별 체크리스트 완료 여부 검증 (병렬 결재 지원)
|
||||||
|
* - branchSeCd가 주어지면 해당 브랜치의 항목만 필수 검증 (SP 또는 SF)
|
||||||
|
* - branchSeCd가 없거나 TOTAL_AGG, MAIN 등인 경우 전체 항목 검증
|
||||||
|
*/
|
||||||
|
export function isBranchChecklistComplete(
|
||||||
|
cklstResultList: CklstResultListItem[],
|
||||||
|
cklstTree: CklstTreeNode[],
|
||||||
|
branchSeCd?: string | null,
|
||||||
|
): boolean {
|
||||||
|
if (cklstResultList.length === 0) return true;
|
||||||
|
if (!branchSeCd || branchSeCd === "TOTAL_AGG" || branchSeCd === "MAIN") {
|
||||||
|
return isFormChecklistComplete(cklstResultList);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetRvwSns = new Set(
|
||||||
|
cklstTree
|
||||||
|
.filter((node) => node.taskSeComCd === branchSeCd && node.bchckUseYn === "Y")
|
||||||
|
.map((node) => node.cklstRvwSn),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (targetRvwSns.size === 0) return true;
|
||||||
|
|
||||||
|
return cklstResultList
|
||||||
|
.filter((item) => targetRvwSns.has(item.cklstRvwSn))
|
||||||
|
.every((item) => item.selected === "Y" || item.selected === "N");
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,8 +45,6 @@ interface RvwOpnnListTableProps<T extends RvwOpnnListItem> {
|
|||||||
getRowKey: (item: T) => string;
|
getRowKey: (item: T) => string;
|
||||||
/** 로그인 사용자 UUID — 검토의견 등록자(rgtr)와 같을 때만 수정·삭제 버튼 노출 */
|
/** 로그인 사용자 UUID — 검토의견 등록자(rgtr)와 같을 때만 수정·삭제 버튼 노출 */
|
||||||
currentUserUuid?: string;
|
currentUserUuid?: string;
|
||||||
/** 현재 검토 단계가 로그인 사용자 차례인지 여부 — false면 등록자라도 수정·삭제 불가 */
|
|
||||||
isReviewTurn?: boolean;
|
|
||||||
onUpdate: (item: T, body: RvwOpnnUpdateRequestDto) => Promise<void>;
|
onUpdate: (item: T, body: RvwOpnnUpdateRequestDto) => Promise<void>;
|
||||||
onDelete: (item: T) => Promise<void>;
|
onDelete: (item: T) => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -76,7 +74,6 @@ function RvwOpnnListTable<T extends RvwOpnnListItem>({
|
|||||||
items,
|
items,
|
||||||
getRowKey,
|
getRowKey,
|
||||||
currentUserUuid,
|
currentUserUuid,
|
||||||
isReviewTurn = false,
|
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: RvwOpnnListTableProps<T>) {
|
}: RvwOpnnListTableProps<T>) {
|
||||||
@@ -102,7 +99,7 @@ function RvwOpnnListTable<T extends RvwOpnnListItem>({
|
|||||||
const editFileUploading = editFile ? (isUploading[editFile.fileKey] ?? false) : false;
|
const editFileUploading = editFile ? (isUploading[editFile.fileKey] ?? false) : false;
|
||||||
|
|
||||||
function canModify(item: T) {
|
function canModify(item: T) {
|
||||||
return isReviewTurn && Boolean(currentUserUuid) && item.rgtr === currentUserUuid;
|
return Boolean(currentUserUuid) && item.rgtr === currentUserUuid;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function discardNewFile(file: EditFile | null) {
|
async function discardNewFile(file: EditFile | null) {
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
|
|||||||
#NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr/openapi/Gettile.do?apiKey=04trYP9_xwLAfALjwZ-B8g&Service=WMTS&Request=GetTile&Version=1.0.0&Format=image/png&layer=korean_map&style=korean&tilematrixset=EPSG:5179&TileMatrix={z}&TileCol={x}&TileRow={y}
|
#NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr/openapi/Gettile.do?apiKey=04trYP9_xwLAfALjwZ-B8g&Service=WMTS&Request=GetTile&Version=1.0.0&Format=image/png&layer=korean_map&style=korean&tilematrixset=EPSG:5179&TileMatrix={z}&TileCol={x}&TileRow={y}
|
||||||
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
|
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
|
||||||
NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y}
|
NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y}
|
||||||
SERVER_API_URL = http://dev.onasys.co.kr:8000
|
SERVER_API_URL = https://apigateway.onasys.co.kr
|
||||||
SEWERAGE_API_URL = http://localhost:8883
|
SEWERAGE_API_URL = http://localhost:8883
|
||||||
# SEWERAGE_API_URL = http://con.mind-one.co.kr:11083
|
# SEWERAGE_API_URL = http://con.mind-one.co.kr:11083
|
||||||
DC_URL = https://apigateway.apps.rsonesoft.com
|
DC_URL = https://apigateway.apps.rsonesoft.com
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ NEXT_PUBLIC_ACCESS_TOKEN = access-token
|
|||||||
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
|
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
|
||||||
NEXT_PUBLIC_PROXY_HOST = http://con.mind-one.co.kr:11302
|
NEXT_PUBLIC_PROXY_HOST = http://con.mind-one.co.kr:11302
|
||||||
NEXT_PUBLIC_SUB_DOMAIN = http://con.mind-one.co.kr:13000
|
NEXT_PUBLIC_SUB_DOMAIN = http://con.mind-one.co.kr:13000
|
||||||
SERVER_API_URL = http://dev.onasys.co.kr:8000
|
SERVER_API_URL = https://apigateway.onasys.co.kr
|
||||||
NEXT_PUBLIC_GEOSERVER_URL = https://hasudo.geo.rsonesoft.com/geoserver
|
NEXT_PUBLIC_GEOSERVER_URL = https://hasudo.geo.rsonesoft.com/geoserver
|
||||||
NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
|
NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
|
||||||
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
|
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ $commonDocsRoot = Join-Path $beRepoRoot 'docs'
|
|||||||
|
|
||||||
## 작업 원칙
|
## 작업 원칙
|
||||||
|
|
||||||
|
- `sn` 프론트엔드 엑셀 다운로드 기능을 신규 개발하거나 수정할 때는 `src/shared/util/download-excel-workbook.ts` 재사용을 먼저 검토한다. 세부 기준은 [컴포넌트정책의 공통 유틸 재사용 기준](docs/기준문서/004-컴포넌트정책.md#공통-유틸-재사용-기준)을 따른다. 이 코드 경로는 `FE_APP_ROOT` 기준이다.
|
||||||
- 일반 목록·모든 팝업은 공통 AGENTS.md의 서버 페이징·지도·Excel 계약을 따른다. 지도 전체 표출에 현재 그리드 페이지 배열을 재사용하지 않는다. Excel은 적용조건 전체를 사용하고 대량 원시 자료는 서버 생성으로 분리한다. 화면 페이징/페이징 없음 예외는 사유와 검증 범위를 메뉴 > 화면명별로 기록한다.
|
- 일반 목록·모든 팝업은 공통 AGENTS.md의 서버 페이징·지도·Excel 계약을 따른다. 지도 전체 표출에 현재 그리드 페이지 배열을 재사용하지 않는다. Excel은 적용조건 전체를 사용하고 대량 원시 자료는 서버 생성으로 분리한다. 화면 페이징/페이징 없음 예외는 사유와 검증 범위를 메뉴 > 화면명별로 기록한다.
|
||||||
|
|
||||||
1. 모든 변경은 원본 요구사항정의서와 화면설계서, 2차 요구사항, 기능 명세 또는 승인된 작업 문서를 근거로 한다.
|
1. 모든 변경은 원본 요구사항정의서와 화면설계서, 2차 요구사항, 기능 명세 또는 승인된 작업 문서를 근거로 한다.
|
||||||
|
|||||||
+3
-2
@@ -66,7 +66,8 @@ export const normalizeAgedPipeStatusByCtpvFilters = (
|
|||||||
filters: Partial<AgedPipeStatusByCtpvFilters>,
|
filters: Partial<AgedPipeStatusByCtpvFilters>,
|
||||||
): AgedPipeStatusByCtpvFilters => {
|
): AgedPipeStatusByCtpvFilters => {
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
let startYear = Math.trunc(Number(filters.startYear ?? currentYear - 2));
|
// 최초 진입·초기화는 당해연도 1년 조회로 시작하며 직접 선택한 범위는 유지한다.
|
||||||
|
let startYear = Math.trunc(Number(filters.startYear ?? currentYear));
|
||||||
let endYear = Math.trunc(Number(filters.endYear ?? currentYear));
|
let endYear = Math.trunc(Number(filters.endYear ?? currentYear));
|
||||||
let elapsedYears = Math.trunc(
|
let elapsedYears = Math.trunc(
|
||||||
Number(
|
Number(
|
||||||
@@ -74,7 +75,7 @@ export const normalizeAgedPipeStatusByCtpvFilters = (
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!Number.isFinite(startYear)) startYear = currentYear - 2;
|
if (!Number.isFinite(startYear)) startYear = currentYear;
|
||||||
if (!Number.isFinite(endYear)) endYear = currentYear;
|
if (!Number.isFinite(endYear)) endYear = currentYear;
|
||||||
if (
|
if (
|
||||||
!Number.isFinite(elapsedYears) ||
|
!Number.isFinite(elapsedYears) ||
|
||||||
|
|||||||
@@ -10,9 +10,7 @@ import {
|
|||||||
ChartColumnBig,
|
ChartColumnBig,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
PanelsTopLeft,
|
PanelsTopLeft,
|
||||||
PencilRuler,
|
|
||||||
Search,
|
Search,
|
||||||
Settings2,
|
|
||||||
TriangleAlert,
|
TriangleAlert,
|
||||||
Waves,
|
Waves,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -28,8 +26,6 @@ import { getRawJson } from "@/shared/api/sewerage-api";
|
|||||||
type Props = {
|
type Props = {
|
||||||
onOpenPopup: (popupKey: WindowPopupKey) => Window | null;
|
onOpenPopup: (popupKey: WindowPopupKey) => Window | null;
|
||||||
onOpenInfoSearch: () => void;
|
onOpenInfoSearch: () => void;
|
||||||
onToggleGisEdit?: () => void;
|
|
||||||
isGisEditActive?: boolean;
|
|
||||||
isInfoSearchActive?: boolean;
|
isInfoSearchActive?: boolean;
|
||||||
initialActiveKey?: WindowPopupKey | null;
|
initialActiveKey?: WindowPopupKey | null;
|
||||||
};
|
};
|
||||||
@@ -89,16 +85,6 @@ const ANALYSIS_POPUP_ITEMS: Array<{
|
|||||||
label: windowPopupDefinitions.agedPipeStatusByCtpvPopup.title,
|
label: windowPopupDefinitions.agedPipeStatusByCtpvPopup.title,
|
||||||
icon: ChartColumnBig,
|
icon: ChartColumnBig,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: "gisEditPopup",
|
|
||||||
label: windowPopupDefinitions.gisEditPopup.title,
|
|
||||||
icon: PencilRuler,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "gisManagementPopup",
|
|
||||||
label: windowPopupDefinitions.gisManagementPopup.title,
|
|
||||||
icon: Settings2,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "flowRiskPopup",
|
key: "flowRiskPopup",
|
||||||
label: windowPopupDefinitions.flowRiskPopup.title,
|
label: windowPopupDefinitions.flowRiskPopup.title,
|
||||||
@@ -119,8 +105,6 @@ const ANALYSIS_POPUP_ITEMS: Array<{
|
|||||||
export default function AnalysisPopupMenu({
|
export default function AnalysisPopupMenu({
|
||||||
onOpenPopup,
|
onOpenPopup,
|
||||||
onOpenInfoSearch,
|
onOpenInfoSearch,
|
||||||
onToggleGisEdit,
|
|
||||||
isGisEditActive = false,
|
|
||||||
isInfoSearchActive = false,
|
isInfoSearchActive = false,
|
||||||
initialActiveKey = null,
|
initialActiveKey = null,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@@ -155,12 +139,6 @@ export default function AnalysisPopupMenu({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isGisEditActive && activeKey === "gisEditPopup") {
|
|
||||||
setActiveKey(null);
|
|
||||||
}
|
|
||||||
}, [activeKey, isGisEditActive]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isInfoSearchActive && activeKey === "infoSearch") {
|
if (!isInfoSearchActive && activeKey === "infoSearch") {
|
||||||
setActiveKey(null);
|
setActiveKey(null);
|
||||||
@@ -214,10 +192,7 @@ export default function AnalysisPopupMenu({
|
|||||||
<div className="flex flex-col gap-[3px] p-[6px]">
|
<div className="flex flex-col gap-[3px] p-[6px]">
|
||||||
{ANALYSIS_POPUP_ITEMS.map((item) => {
|
{ANALYSIS_POPUP_ITEMS.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
const isActive =
|
const isActive = activeKey === item.key;
|
||||||
item.key === "gisEditPopup"
|
|
||||||
? isGisEditActive
|
|
||||||
: activeKey === item.key;
|
|
||||||
const label =
|
const label =
|
||||||
item.key === "fldngPredcPrst"
|
item.key === "fldngPredcPrst"
|
||||||
? `침수예측현황(${seriousPredictionCount}건)`
|
? `침수예측현황(${seriousPredictionCount}건)`
|
||||||
@@ -233,12 +208,6 @@ export default function AnalysisPopupMenu({
|
|||||||
: "border-transparent bg-background text-foreground hover:border-border hover:bg-accent hover:text-primary"
|
: "border-transparent bg-background text-foreground hover:border-border hover:bg-accent hover:text-primary"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (item.key === "gisEditPopup" && onToggleGisEdit) {
|
|
||||||
setActiveKey(item.key);
|
|
||||||
onToggleGisEdit();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setActiveKey(item.key);
|
setActiveKey(item.key);
|
||||||
|
|
||||||
if (item.key === "infoSearch") {
|
if (item.key === "infoSearch") {
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ const normalizeControlNode = (
|
|||||||
...node,
|
...node,
|
||||||
lyrSn: nodeId,
|
lyrSn: nodeId,
|
||||||
id: nodeId,
|
id: nodeId,
|
||||||
// DB에 저장된 과거 명칭과 무관하게 조사관리 표준 화면 명칭으로 표시한다.
|
// DB에 저장된 과거 명칭과 무관하게 조사사업 표준 화면 명칭으로 표시한다.
|
||||||
lyrNm: fallbackName,
|
lyrNm: fallbackName,
|
||||||
upperLyrSn: upperId,
|
upperLyrSn: upperId,
|
||||||
upId: upperId,
|
upId: upperId,
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export const useExmnResultMap = (options: Options) => {
|
|||||||
[gisApp, mapRef],
|
[gisApp, mapRef],
|
||||||
);
|
);
|
||||||
|
|
||||||
/** 지역 검색값 없이 전체를 요청하고, 선택 진입 context는 위치 이동 범위 조회에만 사용한다. */
|
/** 일반 진입은 전체, 조사관리 지도보기는 선택 사업·처리분구만 조회해 겹친 타 사업 선택을 방지한다. */
|
||||||
const loadLayer = useCallback(
|
const loadLayer = useCallback(
|
||||||
async (job: ExmnMapJobType, filters: ExmnMapFilters) => {
|
async (job: ExmnMapJobType, filters: ExmnMapFilters) => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
@@ -109,7 +109,9 @@ export const useExmnResultMap = (options: Options) => {
|
|||||||
const response = await getExmnMapLayer(
|
const response = await getExmnMapLayer(
|
||||||
job,
|
job,
|
||||||
filters,
|
filters,
|
||||||
{},
|
entry.exmnBizId && entry.trtdstId
|
||||||
|
? { exmnBizId: entry.exmnBizId, trtdstId: entry.trtdstId }
|
||||||
|
: {},
|
||||||
controller.signal,
|
controller.signal,
|
||||||
);
|
);
|
||||||
if (!active()) return;
|
if (!active()) return;
|
||||||
|
|||||||
+4
-4
@@ -5,7 +5,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { SelectBox } from "@common/business_components/ui";
|
import { SelectBox } from "@common/business_components/ui";
|
||||||
import { FLDNG_HSTRY_MIN_YEAR, type FldngHstryYearRange } from "@/entities/flood-history/model/flood-history-types";
|
import { FLDNG_HSTRY_MIN_YEAR, FLDNG_HSTRY_MAX_YEAR, type FldngHstryYearRange } from "@/entities/flood-history/model/flood-history-types";
|
||||||
import { normalizeFldngHstryYearRange } from "@/entities/flood-history/lib/flood-history-utils";
|
import { normalizeFldngHstryYearRange } from "@/entities/flood-history/lib/flood-history-utils";
|
||||||
import LayerSettingCard from "../layer-setting/layer-setting-card";
|
import LayerSettingCard from "../layer-setting/layer-setting-card";
|
||||||
|
|
||||||
@@ -26,10 +26,10 @@ export default function FldngHstryLayerSettingPanel({
|
|||||||
onClose,
|
onClose,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const normalizedRange = normalizeFldngHstryYearRange(yearRange);
|
const normalizedRange = normalizeFldngHstryYearRange(yearRange);
|
||||||
const currentYear = new Date().getFullYear();
|
// 제공 자료의 최대 연도부터 최소 연도까지 선택 목록을 구성한다.
|
||||||
const yearOptions = Array.from(
|
const yearOptions = Array.from(
|
||||||
{ length: currentYear - FLDNG_HSTRY_MIN_YEAR + 1 },
|
{ length: FLDNG_HSTRY_MAX_YEAR - FLDNG_HSTRY_MIN_YEAR + 1 },
|
||||||
(_, index) => String(currentYear - index),
|
(_, index) => String(FLDNG_HSTRY_MAX_YEAR - index),
|
||||||
);
|
);
|
||||||
const startYearOptions = yearOptions
|
const startYearOptions = yearOptions
|
||||||
.filter((year) => Number(year) <= Number(normalizedRange.endYear))
|
.filter((year) => Number(year) <= Number(normalizedRange.endYear))
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useAlertStore } from "@/shared/lib";
|
|||||||
import useStore from "@/entities/reference-data/model/reference-data-store";
|
import useStore from "@/entities/reference-data/model/reference-data-store";
|
||||||
import LoadingOverlay from "@/components/ui/loading-bar";
|
import LoadingOverlay from "@/components/ui/loading-bar";
|
||||||
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
|
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
|
||||||
|
import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note";
|
||||||
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
|
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
|
||||||
import { FLDNG_HSTRY_LAYER_TYPE, FLDNG_HSTRY_DEFAULT_FILTERS, FLDNG_HSTRY_LIST_HEADLIST, FLDNG_HSTRY_PAGE_SIZE, FLDNG_HSTRY_ROW_SELECT_MESSAGE, type FldngHstryApiItem, type FldngHstryFilters, type FldngHstryTableRow } from "@/entities/flood-history/model/flood-history-types";
|
import { FLDNG_HSTRY_LAYER_TYPE, FLDNG_HSTRY_DEFAULT_FILTERS, FLDNG_HSTRY_LIST_HEADLIST, FLDNG_HSTRY_PAGE_SIZE, FLDNG_HSTRY_ROW_SELECT_MESSAGE, type FldngHstryApiItem, type FldngHstryFilters, type FldngHstryTableRow } from "@/entities/flood-history/model/flood-history-types";
|
||||||
import { attachFldngHstryGeometry, buildFldngHstryListQueryString, buildFldngHstryYearOptions, normalizeFldngHstryFilters, toFldngHstryTableRows } from "@/entities/flood-history/lib/flood-history-utils";
|
import { attachFldngHstryGeometry, buildFldngHstryListQueryString, buildFldngHstryYearOptions, normalizeFldngHstryFilters, toFldngHstryTableRows } from "@/entities/flood-history/lib/flood-history-utils";
|
||||||
@@ -421,9 +422,13 @@ export default function FldngHstryPopupPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="rounded-sm bg-secondary px-4 py-3 text-[1.2rem] text-muted-foreground">
|
{/* 기존 행 클릭 안내에 인수테스트 요구(자료 원천 표시) 출처 문구를 글머리표로 더한다. */}
|
||||||
행을 클릭하면 메인 지도에서 해당 침수흔적을 표시합니다.
|
<PopupFooterNote
|
||||||
</div>
|
items={[
|
||||||
|
"행을 클릭하면 메인 지도에서 해당 침수흔적을 표시합니다.",
|
||||||
|
"출처 : 행정안전부 재난보험과(2002년~2025년)",
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</StandardPopupLayout>
|
</StandardPopupLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-7
@@ -11,6 +11,7 @@ import { getData } from "@/shared/api/sewerage-api";
|
|||||||
import LoadingOverlay from "@/components/ui/loading-bar";
|
import LoadingOverlay from "@/components/ui/loading-bar";
|
||||||
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types";
|
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types";
|
||||||
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
|
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
|
||||||
|
import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note";
|
||||||
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
|
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
|
||||||
import useStore from "@/entities/reference-data/model/reference-data-store";
|
import useStore from "@/entities/reference-data/model/reference-data-store";
|
||||||
import {
|
import {
|
||||||
@@ -226,7 +227,12 @@ export default function FloodDngrMapPopupPage() {
|
|||||||
const response = await getFloodDngrMapJson(
|
const response = await getFloodDngrMapJson(
|
||||||
`/sewerage-service/api/v1/fldng/floodDngrMap/group/extent?${params.toString()}`,
|
`/sewerage-service/api/v1/fldng/floodDngrMap/group/extent?${params.toString()}`,
|
||||||
);
|
);
|
||||||
const payload = response as FloodDngrMapExtent;
|
const payload = response as FloodDngrMapExtent | null | undefined;
|
||||||
|
// 성공 응답의 null·생략된 data는 위치 부재이다. 좌표 접근과 지도 메시지를 중단한다.
|
||||||
|
if (payload == null) {
|
||||||
|
setErrorMessage("위치 정보가 없습니다");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const extent = [
|
const extent = [
|
||||||
payload?.minX,
|
payload?.minX,
|
||||||
payload?.minY,
|
payload?.minY,
|
||||||
@@ -237,7 +243,7 @@ export default function FloodDngrMapPopupPage() {
|
|||||||
extent.length !== 4 ||
|
extent.length !== 4 ||
|
||||||
extent.some((value) => !Number.isFinite(value))
|
extent.some((value) => !Number.isFinite(value))
|
||||||
) {
|
) {
|
||||||
throw new Error("해당 도시침수지도 피처의 위치 정보가 없습니다.");
|
throw new Error("도시침수지도 위치 응답의 좌표가 올바르지 않습니다.");
|
||||||
}
|
}
|
||||||
window.opener?.postMessage(
|
window.opener?.postMessage(
|
||||||
{
|
{
|
||||||
@@ -253,11 +259,9 @@ export default function FloodDngrMapPopupPage() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("도시침수지도 위치 이동 중 오류:", error);
|
console.error("도시침수지도 위치 이동 중 오류:", error);
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error && error.message.includes("404")
|
error instanceof Error
|
||||||
? "해당 도시침수지도 데이터에 위치 정보가 없어 이동할 수 없습니다."
|
? error.message
|
||||||
: error instanceof Error
|
: "도시침수지도 위치 이동 중 오류가 발생했습니다.";
|
||||||
? error.message
|
|
||||||
: "도시침수지도 위치 이동 중 오류가 발생했습니다.";
|
|
||||||
setErrorMessage(message);
|
setErrorMessage(message);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -395,6 +399,11 @@ export default function FloodDngrMapPopupPage() {
|
|||||||
onRowClick={(row) => void handleRowClick(row)}
|
onRowClick={(row) => void handleRowClick(row)}
|
||||||
rowId={(row, index) => `${row.sggCd}-${row.sbmrgFrqExpln}-${index}`}
|
rowId={(row, index) => `${row.sggCd}-${row.sbmrgFrqExpln}-${index}`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 인수테스트 요구: 팝업 자료의 원천을 목록 아래에 표시한다. */}
|
||||||
|
<PopupFooterNote
|
||||||
|
items={["출처 : 한강홍수통제소, 행정구역별 도시침수지도(2025.09)"]}
|
||||||
|
/>
|
||||||
</StandardPopupLayout>
|
</StandardPopupLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import type {
|
|||||||
|
|
||||||
export const useGisEdit = (treeData: any[]) => {
|
export const useGisEdit = (treeData: any[]) => {
|
||||||
const editableLayers = useMemo(() => collectEditableLayers(treeData), [treeData]);
|
const editableLayers = useMemo(() => collectEditableLayers(treeData), [treeData]);
|
||||||
// GIS 편집 모드 on/off는 우측 분석 메뉴의 GIS 편집 버튼과 직접 연결된다.
|
// GIS 편집 모드 on/off는 지도 우측 GIS 편집 아이콘과 직접 연결된다.
|
||||||
const [isEditMode, setIsEditMode] = useState(false);
|
const [isEditMode, setIsEditMode] = useState(false);
|
||||||
// 현재 선택 레이어를 기준으로 허용 도구와 저장 payload의 layerId를 결정한다.
|
// 현재 선택 레이어를 기준으로 허용 도구와 저장 payload의 layerId를 결정한다.
|
||||||
const [selectedLayerId, setSelectedLayerId] = useState("tb_ms_swage_ppl");
|
const [selectedLayerId, setSelectedLayerId] = useState("tb_ms_swage_ppl");
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { PencilRuler, Settings2 } from "lucide-react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isGisEditActive: boolean;
|
||||||
|
onToggleGisEdit: () => void;
|
||||||
|
onOpenGisManagement: () => Window | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 기존 지도 도구와 같은 크기로 GIS 버튼의 기본·활성·키보드 포커스를 표시한다. */
|
||||||
|
const getButtonClass = (isActive: boolean) =>
|
||||||
|
`flex h-[40px] w-[40px] items-center justify-center p-0 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary ${
|
||||||
|
isActive
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-background text-label hover:bg-primary hover:text-primary-foreground"
|
||||||
|
}`;
|
||||||
|
|
||||||
|
/** 분석도구 메뉴와 독립적으로 GIS 편집 모드와 관리 팝업의 진입 버튼을 제공한다. */
|
||||||
|
export default function GisToolButtons({
|
||||||
|
isGisEditActive,
|
||||||
|
onToggleGisEdit,
|
||||||
|
onOpenGisManagement,
|
||||||
|
}: Props) {
|
||||||
|
// 관리 팝업이 닫히면 버튼의 활성 표시도 해제한다.
|
||||||
|
const [isGisManagementActive, setIsGisManagementActive] = useState(false);
|
||||||
|
// 렌더링과 무관한 감지 타이머를 보관해 재클릭·화면 이탈 시 중복 실행을 방지한다.
|
||||||
|
const closeWatcherRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (closeWatcherRef.current !== null) {
|
||||||
|
window.clearInterval(closeWatcherRef.current);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** 기존 팝업 실행 함수를 호출하고 차단·닫힘 상태를 버튼에 반영한다. */
|
||||||
|
const handleOpenGisManagement = () => {
|
||||||
|
if (closeWatcherRef.current !== null) {
|
||||||
|
window.clearInterval(closeWatcherRef.current);
|
||||||
|
closeWatcherRef.current = null;
|
||||||
|
}
|
||||||
|
const popup = onOpenGisManagement();
|
||||||
|
setIsGisManagementActive(Boolean(popup && !popup.closed));
|
||||||
|
if (!popup || popup.closed) return;
|
||||||
|
|
||||||
|
closeWatcherRef.current = window.setInterval(() => {
|
||||||
|
if (!popup.closed) return;
|
||||||
|
if (closeWatcherRef.current !== null) {
|
||||||
|
window.clearInterval(closeWatcherRef.current);
|
||||||
|
closeWatcherRef.current = null;
|
||||||
|
}
|
||||||
|
setIsGisManagementActive(false);
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-label="GIS 도구"
|
||||||
|
className="absolute right-[16px] top-[222.67px] z-[45] flex w-[40px] flex-col overflow-hidden rounded-[3px] bg-background shadow-[0_1px_2px_rgba(60,64,67,0.3),0_1px_3px_1px_rgba(60,64,67,0.15)]"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="GIS 편집"
|
||||||
|
aria-label="GIS 편집"
|
||||||
|
aria-pressed={isGisEditActive}
|
||||||
|
onClick={onToggleGisEdit}
|
||||||
|
className={`${getButtonClass(isGisEditActive)} border-b border-[#eeeeee]`}
|
||||||
|
>
|
||||||
|
<PencilRuler className="h-[20px] w-[20px]" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="GIS 관리"
|
||||||
|
aria-label="GIS 관리"
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
className={getButtonClass(isGisManagementActive)}
|
||||||
|
onClick={handleOpenGisManagement}
|
||||||
|
>
|
||||||
|
<Settings2 className="h-[20px] w-[20px]" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import { useUserStore } from "@/shared/lib/use-user-store";
|
|||||||
import { openWindowPopup } from "@/features/window-popup/lib/open-window-popup";
|
import { openWindowPopup } from "@/features/window-popup/lib/open-window-popup";
|
||||||
import { windowPopupDefinitions } from "@/features/window-popup/model/popup-definitions";
|
import { windowPopupDefinitions } from "@/features/window-popup/model/popup-definitions";
|
||||||
import PipeAnalysisSummaryTable from "./pipe-analysis-summary-table";
|
import PipeAnalysisSummaryTable from "./pipe-analysis-summary-table";
|
||||||
|
import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note";
|
||||||
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
|
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
|
||||||
import {
|
import {
|
||||||
PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS,
|
PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS,
|
||||||
@@ -522,6 +523,13 @@ export default function PipeAnalysisPopupPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 인수테스트 요구: 관로분석의 기반 자료(원천)를 목록 아래에 표시한다. */}
|
||||||
|
<PopupFooterNote
|
||||||
|
items={[
|
||||||
|
"국토교통부로부터 제공받은 GIS 관로정보(2025년)를 기반으로 분석하고 있음",
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</StandardPopupLayout>
|
</StandardPopupLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { StandardPopupLayout } from "@common/business_components";
|
|||||||
import { getRawJson } from "@/shared/api/sewerage-api";
|
import { getRawJson } from "@/shared/api/sewerage-api";
|
||||||
import LoadingOverlay from "@/components/ui/loading-bar";
|
import LoadingOverlay from "@/components/ui/loading-bar";
|
||||||
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
|
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
|
||||||
|
import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note";
|
||||||
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
|
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
|
||||||
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types";
|
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types";
|
||||||
import { useAlertStore } from "@/shared/lib";
|
import { useAlertStore } from "@/shared/lib";
|
||||||
@@ -309,6 +310,11 @@ export default function SbmrgDngrMapPopupPage() {
|
|||||||
].join(":")
|
].join(":")
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 인수테스트 요구: 팝업 자료의 원천을 목록 아래에 표시한다. */}
|
||||||
|
<PopupFooterNote
|
||||||
|
items={["출처 : 하수도정비 중점관리지역 20개소 과업 산출물"]}
|
||||||
|
/>
|
||||||
</StandardPopupLayout>
|
</StandardPopupLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/** 지반침하 상세의 표시값과 다운로드 시점 관로 조회 결과를 두 시트로 저장한다. */
|
||||||
|
import { getRawJson } from "@/shared/api/sewerage-api";
|
||||||
|
import {
|
||||||
|
downloadExcelWorkbook,
|
||||||
|
type ExcelCellValue,
|
||||||
|
type ExcelSheetSpec,
|
||||||
|
} from "@/shared/util/download-excel-workbook";
|
||||||
|
import type { SinkholeDetail, SinkholeNearbyPipe } from "./sinkhole-types";
|
||||||
|
|
||||||
|
type DownloadOptions = {
|
||||||
|
detail: SinkholeDetail;
|
||||||
|
radiusMeters: number;
|
||||||
|
summaryEntries: Array<[string, string]>;
|
||||||
|
getPipeUseName: (pipe: SinkholeNearbyPipe) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 식별자는 문자열을 유지하고 결측 숫자는 0으로 바꾸지 않는다. */
|
||||||
|
const numberCell = (value: unknown): ExcelCellValue => {
|
||||||
|
if (value == null || String(value).trim() === "") return "-";
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isFinite(number) ? number : String(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 화면과 같은 순서의 발생정보 및 반경 내 관로 목록 시트 명세를 만든다. */
|
||||||
|
export const buildSinkholeDetailSheets = (
|
||||||
|
options: DownloadOptions,
|
||||||
|
pipes: SinkholeNearbyPipe[],
|
||||||
|
noCoordinates: boolean,
|
||||||
|
): ExcelSheetSpec[] => {
|
||||||
|
const pipeRows: ExcelCellValue[][] = pipes.map((pipe, index) => [
|
||||||
|
index + 1,
|
||||||
|
String(pipe.pipeId ?? "").trim() || "-",
|
||||||
|
options.getPipeUseName(pipe),
|
||||||
|
numberCell(pipe.elapsedYears),
|
||||||
|
numberCell(pipe.distanceMeters),
|
||||||
|
]);
|
||||||
|
if (pipeRows.length === 0)
|
||||||
|
pipeRows.push([
|
||||||
|
noCoordinates
|
||||||
|
? "좌표가 없어 반경 내 하수관로를 조회할 수 없습니다."
|
||||||
|
: "반경 내 하수관로 정보가 없습니다.",
|
||||||
|
]);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "지반침하정보",
|
||||||
|
rows: [["항목", "내용"], ...options.summaryEntries],
|
||||||
|
columnWidths: [22, 75],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "하수관로목록",
|
||||||
|
rows: [
|
||||||
|
["사고번호", String(options.detail.acdntNo)],
|
||||||
|
["조회 반경(m)", options.radiusMeters],
|
||||||
|
["조회 기준", "기존 반경 조회 기준, 최대 200건 (가까운 거리순)"],
|
||||||
|
[],
|
||||||
|
["NO", "관로 ID", "용도", "경과년도(년)", "거리(m)"],
|
||||||
|
...pipeRows,
|
||||||
|
],
|
||||||
|
columnWidths: [12, 36, 24, 20, 18],
|
||||||
|
autoFilterRange: `A5:E${5 + pipes.length}`,
|
||||||
|
mergeRanges: ["B3:E3", ...(pipes.length === 0 ? ["A6:E6"] : [])],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 기존 반경 API를 다시 조회하여 실패를 빈 목록으로 내보내지 않는다. */
|
||||||
|
export const downloadSinkholeDetailExcel = async (options: DownloadOptions) => {
|
||||||
|
// 비동기 재조회 동안 호출자의 상세·반경·표시 행이 변경되어도 클릭 시점을 유지한다.
|
||||||
|
const snapshot: DownloadOptions = {
|
||||||
|
...options,
|
||||||
|
detail: { ...options.detail },
|
||||||
|
summaryEntries: options.summaryEntries.map(([title, value]) => [
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
const { detail, radiusMeters } = snapshot;
|
||||||
|
const noCoordinates = String(detail.coordStatus ?? "").trim() !== "SUCCESS";
|
||||||
|
let pipes: SinkholeNearbyPipe[] = [];
|
||||||
|
if (!noCoordinates) {
|
||||||
|
const hasPoint =
|
||||||
|
detail.x != null &&
|
||||||
|
detail.y != null &&
|
||||||
|
String(detail.x).trim() !== "" &&
|
||||||
|
String(detail.y).trim() !== "" &&
|
||||||
|
Number.isFinite(Number(detail.x)) &&
|
||||||
|
Number.isFinite(Number(detail.y));
|
||||||
|
const params = new URLSearchParams({ radiusMeters: String(radiusMeters) });
|
||||||
|
if (hasPoint) {
|
||||||
|
params.set("x", String(detail.x));
|
||||||
|
params.set("y", String(detail.y));
|
||||||
|
}
|
||||||
|
const path = hasPoint
|
||||||
|
? "nearbyPipesByPoint"
|
||||||
|
: `${encodeURIComponent(detail.acdntNo)}/nearbyPipes`;
|
||||||
|
const raw = await getRawJson(
|
||||||
|
`/sewerage-service/api/v1/sinkholes/${path}?${params}`,
|
||||||
|
);
|
||||||
|
const response = raw?.data ?? raw;
|
||||||
|
if (response?.coordStatus !== "SUCCESS")
|
||||||
|
throw new Error(
|
||||||
|
"관로 조회 좌표를 확인할 수 없습니다. 상세정보를 다시 조회해 주세요.",
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!Array.isArray(response?.items) ||
|
||||||
|
response.items.some(
|
||||||
|
(item: unknown) =>
|
||||||
|
!item ||
|
||||||
|
typeof item !== "object" ||
|
||||||
|
Array.isArray(item) ||
|
||||||
|
!("pipeId" in item) ||
|
||||||
|
!("fid" in item),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
"관로 조회 응답이 올바르지 않습니다. 다시 시도해 주세요.",
|
||||||
|
);
|
||||||
|
pipes = response.items;
|
||||||
|
}
|
||||||
|
const now = new Date();
|
||||||
|
const date = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
||||||
|
await downloadExcelWorkbook({
|
||||||
|
fileName: `지반침하정보_${detail.acdntNo}_${date}.xlsx`,
|
||||||
|
sheets: buildSinkholeDetailSheets(snapshot, pipes, noCoordinates),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -4,12 +4,13 @@
|
|||||||
*/
|
*/
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Button } from "@common/components";
|
import { Button } from "@common/components";
|
||||||
import { VerticalTable } from "@/components/ui/vertical-table";
|
import { VerticalTable } from "@/components/ui/vertical-table";
|
||||||
import { getCmsData } from "@/shared/api/external-service-api";
|
import { getCmsData } from "@/shared/api/external-service-api";
|
||||||
import type { SinkholeDetail, SinkholeNearbyPipe } from "./sinkhole-types";
|
import type { SinkholeDetail, SinkholeNearbyPipe } from "./sinkhole-types";
|
||||||
import { useSinkholeCommonCodes } from "./use-sinkhole-common-codes";
|
import { useSinkholeCommonCodes } from "./use-sinkhole-common-codes";
|
||||||
|
import { downloadSinkholeDetailExcel } from "./sinkhole-detail-excel";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
detail: SinkholeDetail;
|
detail: SinkholeDetail;
|
||||||
@@ -81,6 +82,9 @@ export default function SinkholeDetailPanel({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
// 상세의 원천 복구상태 값은 유지하고 CMS에 대응하는 코드명만 표시한다.
|
// 상세의 원천 복구상태 값은 유지하고 CMS에 대응하는 코드명만 표시한다.
|
||||||
const { restoreStatusLabels } = useSinkholeCommonCodes();
|
const { restoreStatusLabels } = useSinkholeCommonCodes();
|
||||||
|
// 파일 생성 중 버튼 표시와 같은 이벤트 루프의 중복 클릭을 함께 제어한다.
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
|
const downloadInProgressRef = useRef(false);
|
||||||
const [pipeUseCodeMap, setPipeUseCodeMap] = useState<Map<string, string>>(
|
const [pipeUseCodeMap, setPipeUseCodeMap] = useState<Map<string, string>>(
|
||||||
new Map(),
|
new Map(),
|
||||||
);
|
);
|
||||||
@@ -127,27 +131,24 @@ export default function SinkholeDetailPanel({
|
|||||||
return { average, oldest };
|
return { average, oldest };
|
||||||
}, [nearbyPipes]);
|
}, [nearbyPipes]);
|
||||||
|
|
||||||
const summaryTableData = useMemo(
|
const summaryTableData = useMemo(() => {
|
||||||
() => {
|
const restoreStatus = String(detail.restoreStatus ?? "").trim();
|
||||||
const restoreStatus = String(detail.restoreStatus ?? "").trim();
|
return [
|
||||||
return [
|
{
|
||||||
{
|
acdntNo: display(detail.acdntNo),
|
||||||
acdntNo: display(detail.acdntNo),
|
ocrnYmd: display(detail.ocrnYmd),
|
||||||
ocrnYmd: display(detail.ocrnYmd),
|
fullAddress: display(detail.fullAddress || detail.daddr),
|
||||||
fullAddress: display(detail.fullAddress || detail.daddr),
|
sizeWidth: display(detail.sizeWidth),
|
||||||
sizeWidth: display(detail.sizeWidth),
|
sizeLength: display(detail.sizeLength),
|
||||||
sizeLength: display(detail.sizeLength),
|
sizeDepth: display(detail.sizeDepth),
|
||||||
sizeDepth: display(detail.sizeDepth),
|
detailCause: display(detail.detailCause),
|
||||||
detailCause: display(detail.detailCause),
|
restoreStatus: display(
|
||||||
restoreStatus: display(
|
restoreStatusLabels[restoreStatus] || restoreStatus,
|
||||||
restoreStatusLabels[restoreStatus] || restoreStatus,
|
),
|
||||||
),
|
restoreCompletedYmd: display(detail.restoreCompletedYmd),
|
||||||
restoreCompletedYmd: display(detail.restoreCompletedYmd),
|
},
|
||||||
},
|
];
|
||||||
];
|
}, [detail, restoreStatusLabels]);
|
||||||
},
|
|
||||||
[detail, restoreStatusLabels],
|
|
||||||
);
|
|
||||||
|
|
||||||
const summaryHeadList = [
|
const summaryHeadList = [
|
||||||
{ id: "acdntNo", title: "사고번호" },
|
{ id: "acdntNo", title: "사고번호" },
|
||||||
@@ -161,6 +162,35 @@ export default function SinkholeDetailPanel({
|
|||||||
{ id: "restoreCompletedYmd", title: "복구완료일자" },
|
{ id: "restoreCompletedYmd", title: "복구완료일자" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** 클릭 시의 상세·반경·표시값을 고정해 탭 전환 중에도 한 사고만 내보낸다. */
|
||||||
|
const handleDownload = async () => {
|
||||||
|
if (downloadInProgressRef.current) return;
|
||||||
|
downloadInProgressRef.current = true;
|
||||||
|
setIsDownloading(true);
|
||||||
|
const summary = summaryTableData[0] as Record<string, string>;
|
||||||
|
try {
|
||||||
|
await downloadSinkholeDetailExcel({
|
||||||
|
detail: { ...detail },
|
||||||
|
radiusMeters,
|
||||||
|
summaryEntries: summaryHeadList.map(({ id, title }) => [
|
||||||
|
title,
|
||||||
|
summary[id] ?? "-",
|
||||||
|
]),
|
||||||
|
getPipeUseName,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("지반침하 상세 엑셀 다운로드 실패:", error);
|
||||||
|
alert(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "엑셀 다운로드 중 오류가 발생했습니다.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
downloadInProgressRef.current = false;
|
||||||
|
setIsDownloading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id="popup"
|
id="popup"
|
||||||
@@ -198,9 +228,20 @@ export default function SinkholeDetailPanel({
|
|||||||
|
|
||||||
<section className="pb-[1.2rem]">
|
<section className="pb-[1.2rem]">
|
||||||
<div className="card-header !block px-[1.2rem]">
|
<div className="card-header !block px-[1.2rem]">
|
||||||
<h3 className="mx-3 whitespace-nowrap text-2xl font-medium text-label">
|
<div className="flex items-center justify-between gap-2">
|
||||||
침하발생 반경내 관로정보
|
<h3 className="ml-3 min-w-0 flex-1 text-2xl font-medium text-label">
|
||||||
</h3>
|
침하발생 반경내 관로정보
|
||||||
|
</h3>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="hover:bg-primary/90 mr-3 shrink-0 bg-primary text-primary-foreground"
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={isDownloading}
|
||||||
|
aria-busy={isDownloading}
|
||||||
|
>
|
||||||
|
{isDownloading ? "다운로드 중..." : "엑셀다운로드"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
{canShowNearby ? (
|
{canShowNearby ? (
|
||||||
<div className="mx-3 mt-2 whitespace-nowrap text-left text-[0.95rem] leading-5 text-muted-foreground">
|
<div className="mx-3 mt-2 whitespace-nowrap text-left text-[0.95rem] leading-5 text-muted-foreground">
|
||||||
<span>반경 {radiusMeters}m · </span>
|
<span>반경 {radiusMeters}m · </span>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user