Merge branch 'main' into feature/0612_sentry_fr_merge
# Conflicts: # apps/as/package.json # pnpm-lock.yaml
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
# 재원협의(fnrs-cnsltn) vs 설치인가(instl-sncn) 차이 분석
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------- | ----------------------------------------------------------------------------- |
|
||||
| 작성일 | 2026-07-09 |
|
||||
| 기준 구현 | `fnrs-cnsltn` (추가 요구사항 반영 완료) |
|
||||
| 비교 대상 | `instl-sncn` |
|
||||
| 목적 | 두 페이지의 동작·구조 차이를 정리하고, 설치인가 정렬 시 이식 대상을 명확히 함 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 요약
|
||||
|
||||
두 모듈은 **동일한 PBADMS(공공행정 지원) 업무 패턴**을 따르는 형제 페이지다.
|
||||
|
||||
공통 흐름:
|
||||
|
||||
```
|
||||
목록 → TaskSe 선택 후 신청 → 저장(SV) → 신청완료(ST)
|
||||
→ 환경청제출(LE) → 기술검토 → 검토결과 회신(EP)
|
||||
→ 인가승인(TA) → 고시(TB)
|
||||
```
|
||||
|
||||
| 구분 | fnrs-cnsltn | instl-sncn |
|
||||
| ------------- | ----------------- | ---------------- |
|
||||
| 업무명 | 재원협의 | 설치인가 |
|
||||
| 문서번호 | `fnrsCnsltnDocNo` | `instlSncnDocNo` |
|
||||
| docSeComCd | `FNRS` | `INSTL` |
|
||||
| 라우트 | `/fnrs-cnsltn/*` | `/instl-sncn/*` |
|
||||
| pages 파일 수 | 39 | 32 |
|
||||
|
||||
**결론:** `fnrs-cnsltn`이 추가 요구사항이 반영된 기준(reference) 구현이고, `instl-sncn`은 동일 골격이지만 보완요청·결재선 편집·등록/수정 폼 완성도 등에서 미반영·TODO·단순화 상태다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 파일/폴더 구조
|
||||
|
||||
### 2-1. fnrs-cnsltn에만 존재
|
||||
|
||||
| 경로 | 역할 |
|
||||
| -------------------------------------------- | ------------------------- |
|
||||
| `ui/sections/fnrs-shar-section.tsx` | 재원분담 조회 섹션 |
|
||||
| `ui/sections/supplement-demand-section.tsx` | 보완요청 이력 섹션 |
|
||||
| `ui/components/sortable-prgrs-stts-row.tsx` | 결재선 DnD 편집 행 |
|
||||
| `ui/components/sortable-apln-picker-row.tsx` | 결재선 템플릿 피커 DnD 행 |
|
||||
| `ui/components/aprvr-picker-dialog.tsx` | 결재자 선택 다이얼로그 |
|
||||
| `ui/components/elapr-apln-picker-grid.tsx` | 결재선 편집 그리드 |
|
||||
| `ui/components/elapr-tmplt-select-table.tsx` | 결재서식 선택 테이블 |
|
||||
| `model/reviewer-option.ts` | 결재자 옵션 타입 |
|
||||
|
||||
### 2-2. instl-sncn에만 존재
|
||||
|
||||
| 경로 | 역할 | 비고 |
|
||||
| -------------------------------------- | ------------------- | ------------------------------------------ |
|
||||
| `ui/sections/history-info-section.tsx` | 등록/수정 이력 표시 | **어디에서도 import되지 않음 (dead code)** |
|
||||
|
||||
### 2-3. 공통 구조
|
||||
|
||||
```
|
||||
index.ts
|
||||
ui/
|
||||
{list,view,form,regist,edit}-page.tsx
|
||||
sections/ (basic-info, contact, scale, cost, checklist, attachments, tech-review)
|
||||
components/ (floating-action-buttons, elapr-approval-dialog, elapr-tmplt-picker-dialog, checklist-task-panel)
|
||||
lib/ button-config, save-payload-utils, form-data-mapper, regist-schema, checklist/review utils
|
||||
model/ columns.tsx, view-types.ts
|
||||
```
|
||||
|
||||
App Router도 동일: `page.tsx`, `regist/page.tsx`, `[id]/page.tsx`, `[id]/edit/page.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 3. 목록 페이지
|
||||
|
||||
### 3-1. 검색 필터
|
||||
|
||||
| 항목 | fnrs-cnsltn | instl-sncn |
|
||||
| ------------------ | ----------------------------------------- | --------------------------------- |
|
||||
| 시도/시군구 API | `@/entities/adstdstr` | 동일 |
|
||||
| 지역 검색 파라미터 | **`admdstCd`** (시군구 우선, 없으면 시도) | **`sidoComCd` + `sggComCd`** 분리 |
|
||||
| "전체" 옵션 | `ALL_SELECT_VALUE = "__ALL__"` SelectItem | 없음 (placeholder만) |
|
||||
| 구분 CodeSelect | `upCd="INSTL"` ※ | `upCd="INSTL"` |
|
||||
| 사업명 | 동일 | 동일 |
|
||||
|
||||
> ※ fnrs 목록의 구분 필터가 `INSTL` 코드를 사용 중이며, TaskSePickerDialog는 `codeUpCd="FNRS"`를 쓴다. **불일치 가능성**이 있어 확인이 필요하다.
|
||||
|
||||
### 3-2. 정렬·페이지네이션
|
||||
|
||||
| 항목 | fnrs-cnsltn | instl-sncn |
|
||||
| ------------------------ | ---------------------- | ------------------------------------- |
|
||||
| 기본 sort (list-page) | `fnrsCnsltnDocNo,desc` | `instlSncnDocNo,desc` |
|
||||
| fallback sort (list.tsx) | `fnrsCnsltnDocNo,desc` | **`regDt,desc`** (list-page와 불일치) |
|
||||
| 페이지 크기 | 10 | 10 |
|
||||
| URL 동기화 | 없음 (`useState` 로컬) | 없음 |
|
||||
|
||||
### 3-3. 테이블 컬럼
|
||||
|
||||
`model/columns.tsx` 로직은 사실상 동일하다. DTO 타입명만 다르다.
|
||||
|
||||
컬럼: 시도, 시군구, 사업명, 업무구분, 담당자, 해당기관, 진행상황, 신청일
|
||||
|
||||
### 3-4. 행 클릭·신청
|
||||
|
||||
| 항목 | fnrs-cnsltn | instl-sncn |
|
||||
| ------------ | ------------------------------------------------------------------ | ------------------ |
|
||||
| 행 클릭 | **조건부 라우팅**: `SV`/`SL` + 등록자 본인 → `/edit`, 그 외 → view | **항상 view** |
|
||||
| useUserStore | 사용 | 미사용 |
|
||||
| 신청 버튼 | TaskSePickerDialog `codeUpCd="FNRS"` | `codeUpCd="INSTL"` |
|
||||
|
||||
```46:54:apps/as/src/pages/fnrs-cnsltn/ui/fnrs-list.tsx
|
||||
const handleRowClick = (row: FnrsCnsltnMngResponseDto) => {
|
||||
const canEdit =
|
||||
(row.prgrsSttsComCd === "SV" || row.prgrsSttsComCd === "SL") &&
|
||||
row.rgtr === userInfo?.userUnqId;
|
||||
|
||||
router.push(
|
||||
canEdit ? `/fnrs-cnsltn/${row.fnrsCnsltnDocNo}/edit` : `/fnrs-cnsltn/${row.fnrsCnsltnDocNo}`,
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 상세(View) 페이지
|
||||
|
||||
### 4-1. 섹션 구성
|
||||
|
||||
| 섹션 | fnrs-cnsltn | instl-sncn |
|
||||
| ------------------------------- | ------------------------------------------ | -------------------------- |
|
||||
| BasicInfo | 환경청구분, 확정예산사업번호, 예산신청연도 | **사업구분(`sncnSeCdNm`)** |
|
||||
| ContactInfo | 동일 패턴 | 동일 |
|
||||
| BusinessScale / Cost | 동일 | 동일 |
|
||||
| **FnrsShar (재원분담)** | **있음** | **없음** (도메인 고유) |
|
||||
| Checklist | 동일 | 동일 |
|
||||
| Attachments | 동일 | 동일 |
|
||||
| **SupplementDemand (보완요청)** | **있음** | **없음** |
|
||||
| TechReviewOpinion | 동일 | 동일 |
|
||||
| HistoryInfo | 없음 | 파일만 존재, view 미연결 |
|
||||
| FloatingActionButtons | `docSeComCd="FNRS"` | `docSeComCd="INSTL"` |
|
||||
|
||||
### 4-2. 데이터 훅
|
||||
|
||||
| 항목 | fnrs-cnsltn | instl-sncn |
|
||||
| --------- | ---------------------------- | -------------------- |
|
||||
| 상세 훅 | `useFnrsCnsltnMng` | `useInstlSncnDetail` |
|
||||
| 에러 판정 | `isError \|\| !data?.detDto` | `isError \|\| !data` |
|
||||
| 로딩 | Skeleton | Skeleton |
|
||||
|
||||
---
|
||||
|
||||
## 5. 등록/수정(Form) 페이지
|
||||
|
||||
### 5-1. 폼 완성도
|
||||
|
||||
| 항목 | fnrs-cnsltn | instl-sncn |
|
||||
| ------------- | --------------------------------- | --------------- |
|
||||
| 저장(SV) | O | O (SV 고정) |
|
||||
| 신청완료(ST) | O (edit 모드) | **없음** |
|
||||
| 삭제 | O (edit 모드, `useConfirm`) | **없음** |
|
||||
| 재원분담 섹션 | `FnrsSharFormSection` + 잔액 검증 | 없음 |
|
||||
| 저장 후 이동 | **`/edit` 유지** | **view로 이동** |
|
||||
| 목록 버튼 | `/fnrs-cnsltn` 고정 | `router.back()` |
|
||||
|
||||
### 5-2. 기본정보 Form 필드
|
||||
|
||||
| 필드 | fnrs | instl |
|
||||
| ----------------- | ---------------- | --------------------------------------- |
|
||||
| taskSe 코드 | `FNRS` | `INSTL` |
|
||||
| 사업구분 | 없음 | **`sncnSeComCd` (SNCNSE 라디오, 필수)** |
|
||||
| BnsPicker 연동 | bizNm ~ bizCn | + **`bgtBizCd`, `bgtAplyYr`** |
|
||||
| 재이용(RE) 조건부 | rtlztnFclt / Usg | 동일 |
|
||||
|
||||
### 5-3. Schema
|
||||
|
||||
| 항목 | fnrs | instl |
|
||||
| ------------------- | ------------------- | -------------- |
|
||||
| 관리 객체 | `fnrsCnsltnMng` | `instlSncnMng` |
|
||||
| 예산 필드 | `cfmtnBgtBizNo` | `bgtBizCd` |
|
||||
| 재원분담 | `fnrsSharList` 배열 | 없음 |
|
||||
| sharAtchFileGroupId | 있음 | 없음 |
|
||||
| privacyConsent | 있음 | 있음 |
|
||||
|
||||
### 5-4. Edit 페이지 래퍼
|
||||
|
||||
| 항목 | fnrs | instl |
|
||||
| ------------------ | ------------------------------- | ------------------------------- |
|
||||
| 로딩 Skeleton | 있음 | **없음** |
|
||||
| 데이터 없음 메시지 | "상세 정보를 찾을 수 없습니다." | "수정 대상을 찾을 수 없습니다." |
|
||||
|
||||
---
|
||||
|
||||
## 6. FloatingActionButtons / 결재 / 보완
|
||||
|
||||
### 6-1. FAB 동작 차이
|
||||
|
||||
공통: 역할×상태 기반 버튼(`button-config.ts`), checklist 진행률 검증, elapr pending approval 체크
|
||||
|
||||
| 기능 | fnrs-cnsltn | instl-sncn |
|
||||
| ------------------------------- | ------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| 보완완료 (`splmntCmptn`) | **`submitSupplement` API 연동** | **`// TODO` stub** |
|
||||
| 보완요청 (`dmndSplmnt`) | **`buildSupplementReqPayload` + `submitReqSupplement`** | **`// TODO` stub** (문자열만 전달) |
|
||||
| 검토완료 (`rvwCmptn`) | `techReViewSaveCd: "SV"` 후 **결재 다이얼로그 오픈** | `techReViewSaveCd: "CP"`, **다이얼로그 없이 즉시 완료** |
|
||||
| 검토자지정 | `buildElaprPrgrsSttsListFromAplnRows(aplnRows)` | `buildElaprPrgrsSttsListFromApln(aplnPage.content)` |
|
||||
| atrzAprv / atrzRply / grndsChck | TODO handler | TODO handler |
|
||||
|
||||
```576:617:apps/as/src/pages/instl-sncn/ui/components/floating-action-buttons.tsx
|
||||
async function handleSplmntCmptn(context: ActionHandlerContext): Promise<void> {
|
||||
// TODO: 보완완료 API 호출
|
||||
context.onSuccess?.();
|
||||
}
|
||||
// ...
|
||||
async function handleDmndSplmnt(context: ActionHandlerContext): Promise<void> {
|
||||
// TODO: 보완요청 API 호출
|
||||
context.onSuccess?.();
|
||||
}
|
||||
```
|
||||
|
||||
### 6-2. ElaprApprovalDialog (가장 큰 UX 격차)
|
||||
|
||||
| 항목 | fnrs (419줄) | instl (231줄) |
|
||||
| ---------------- | ------------------------------------------------------ | -------------------------------- |
|
||||
| 결재선 표시 | DnD sortable 편집 테이블 | **읽기 전용** 테이블 |
|
||||
| 결재선 추가/저장 | **지원** (`usePrgrsSttsRows`, `useSaveElaprPrgrsStts`) | 없음 |
|
||||
| 결재자 변경 | **AprvrPickerDialog** | 없음 |
|
||||
| 전결 체크박스 | **있음** (`finalApprovalYn`) | 없음 |
|
||||
| 반려 사유 | API에 `rjctRsn` 전달 | UI는 있으나 **payload에 미전달** |
|
||||
| 저장 전 검증 | `hasUnsavedPrgrsSttsChanges` | 없음 |
|
||||
|
||||
### 6-3. ElaprTmpltPickerDialog
|
||||
|
||||
| 항목 | fnrs | instl |
|
||||
| --------------- | -------------------------------------------------------------------- | ----------------------- |
|
||||
| UI | 2-pane: 서식 테이블 + **결재선 편집 그리드** | 단일 테이블 + 선택 버튼 |
|
||||
| docSeComCd 필터 | `FNRS` | `INSTL` |
|
||||
| onConfirm | `{ tmplt, aplnRows }` 편집 결과 | `onSelect(tmplt)`만 |
|
||||
| 검증 | `validateAllRowsForSave`, `validateAtrzOrder`, `validateUniqueSeqNo` | 없음 |
|
||||
|
||||
---
|
||||
|
||||
## 7. Entity / API / Hook
|
||||
|
||||
### 7-1. Service 메서드
|
||||
|
||||
| 메서드 | fnrs | instl |
|
||||
| ----------------------- | --------------------------- | --------- |
|
||||
| 목록 | `findPage` | `getPage` |
|
||||
| 상세 | `findById` | `getById` |
|
||||
| saveApply | O | O |
|
||||
| techReview | O | O |
|
||||
| changePrgrsSttsComCd | O | O |
|
||||
| remove | O | O |
|
||||
| **submitSupplement** | **O** (`/submitsupplement`) | **X** |
|
||||
| **submitReqSupplement** | **O** (`/reqsupplement`) | **X** |
|
||||
|
||||
### 7-2. 상세 DTO 확장
|
||||
|
||||
**fnrs 추가 필드**
|
||||
|
||||
- `fnrsSharList`, `sharAtchFileList`
|
||||
- `splmntDmndList` (typed)
|
||||
|
||||
**instl 추가 필드**
|
||||
|
||||
- `sncnSeComCd` / `sncnSeCdNm` (인가구분)
|
||||
- `bgtBizCd` (예산사업코드)
|
||||
- `rtlztnFcltSeComNm`, `rtlztnUsgSeComNm`
|
||||
- `splmntDmndList` 타입은 base에 존재하나 view/API 미연결
|
||||
- `instlSncnRegionService` (시도/시군구 — 목록은 adstdstr 사용, 미활용)
|
||||
|
||||
### 7-3. save-payload-utils
|
||||
|
||||
| 함수 | fnrs | instl |
|
||||
| -------------------------------- | ---------------- | --------- |
|
||||
| buildSavePayload | + `fnrsSharList` | shar 없음 |
|
||||
| buildTechReviewReplyPayload | O | O |
|
||||
| **buildSupplementReqPayload** | **O** | **X** |
|
||||
| **buildSupplementSubmitPayload** | **O** | **X** |
|
||||
|
||||
---
|
||||
|
||||
## 8. 비즈니스 도메인 차이
|
||||
|
||||
| 영역 | fnrs-cnsltn | instl-sncn |
|
||||
| -------------- | ----------------------------------------- | ------------------------ |
|
||||
| 업무 코드 | `FNRS` | `INSTL` |
|
||||
| TaskSe | FNRS 하위 | INSTL 하위 |
|
||||
| 고시 버튼 라벨 | "재원협의 고시" | "설치인가 고시" |
|
||||
| 재원분담 | **핵심 도메인** (`fnrs-shar-se`) | 해당 없음 |
|
||||
| 인가구분 | 없음 | **`SNCNSE` 코드 (필수)** |
|
||||
| 예산 식별 | `cfmtnBgtBizNo` | `bgtBizCd` |
|
||||
| 진행상태 코드 | SV, ST, LE, ER, TE, EP, TA, TB, SL, TP 등 | **동일 세트 공유** |
|
||||
| 역할 매핑 | ROLE_USER05/04/03/01 | 동일 |
|
||||
|
||||
---
|
||||
|
||||
## 9. fnrs-cnsltn에만 있는 기능 (추가 요구사항 후보)
|
||||
|
||||
instl-sncn 정렬 시 **우선 이식 대상**:
|
||||
|
||||
1. 목록 행 클릭 조건부 edit 라우팅 (`SV`/`SL` + 등록자)
|
||||
2. 보완요청 전체 플로우 — view 섹션, payload builder, API, FAB handler
|
||||
3. 보완완료 API 연동 (`submitSupplement`)
|
||||
4. 등록/수정 폼 완성 — 신청완료, 삭제, 저장 후 edit 유지
|
||||
5. 결재선 DnD 편집형 `ElaprApprovalDialog` (+ aprvr-picker, sortable rows)
|
||||
6. 결재선 편집형 `ElaprTmpltPickerDialog` (+ apln grid, validation)
|
||||
7. 검토완료 → 결재 다이얼로그 연계 흐름
|
||||
8. 검색 필터 "전체" 옵션 및 `admdstCd` 통합 방식 (백엔드 API에 맞게)
|
||||
9. Edit 페이지 로딩 Skeleton
|
||||
10. entity에 supplement API/hooks 추가
|
||||
|
||||
---
|
||||
|
||||
## 10. instl-sncn에만 있는 기능 (유지 필요)
|
||||
|
||||
1. **`sncnSeComCd` (사업구분/인가구분)** — 도메인 고유
|
||||
2. **`bgtBizCd` / `bgtAplyYr`** BnsPicker 연동
|
||||
3. **`history-info-section.tsx`** — 구현만 있고 view 미연결 (활성화 또는 삭제 결정 필요)
|
||||
4. entity **`instlSncnRegionService`** — 목록은 adstdstr 사용 중이라 중복/레거시 가능
|
||||
5. **`INSTL_SNCN_REGISTER_SELECTION_KEY`** sessionStorage 키
|
||||
|
||||
---
|
||||
|
||||
## 11. 동일·거의 동일 패턴
|
||||
|
||||
| 영역 | 동일도 |
|
||||
| ----------------------------------------------------- | --------------------- |
|
||||
| App Router 4-route 구조 | 100% |
|
||||
| `button-config.ts` 역할×상태 매트릭스 | ~99% (액션명만 다름) |
|
||||
| `columns.tsx` 진행상황 Badge | ~100% |
|
||||
| checklist / contact / scale / cost / attachments 섹션 | ~95% (import 경로만) |
|
||||
| review-step-utils 로직 | ~100% (상수명만 다름) |
|
||||
| TaskSePickerDialog 신청 플로우 | 100% |
|
||||
| FAB 전체 구조·대부분 handler | ~85% |
|
||||
| checklist-form-state, business-cost-utils | 거의 동일 |
|
||||
| shared widgets (`@/widgets/pbadms-sprt`) | 동일 |
|
||||
|
||||
---
|
||||
|
||||
## 12. instl-sncn 정렬 권장 우선순위
|
||||
|
||||
| 우선순위 | 항목 | 근거 |
|
||||
| -------- | ------------------------------------------------------- | ---------------------------- |
|
||||
| P0 | 보완요청/보완완료 API + UI | fnrs 완전 구현 vs instl TODO |
|
||||
| P0 | ElaprApprovalDialog 고도화 | 결재선 편집·전결·반려사유 |
|
||||
| P0 | ElaprTmpltPickerDialog 2-pane | 검토자지정 품질 |
|
||||
| P1 | Form 신청완료/삭제 + 저장 후 edit 유지 | 등록 UX parity |
|
||||
| P1 | 목록 행 클릭 edit 라우팅 | fnrs 추가 요구사항 |
|
||||
| P1 | 검토완료 → 결재 다이얼로그 연계 | 흐름 통일 |
|
||||
| P2 | sort fallback 불일치 수정 (`regDt` vs `instlSncnDocNo`) | instl 내부 버그성 |
|
||||
| P2 | history-info-section 연결 또는 삭제 | dead code 정리 |
|
||||
| P3 | URL search params 동기화 | 양쪽 모두 미구현 (공통 개선) |
|
||||
|
||||
---
|
||||
|
||||
## 13. 참고 코드 위치
|
||||
|
||||
| 주제 | 경로 |
|
||||
| ---------------------------- | ------------------------------------------------------------ |
|
||||
| 목록 edit 라우팅 (fnrs) | `pages/fnrs-cnsltn/ui/fnrs-list.tsx` |
|
||||
| 목록 항상 view (instl) | `pages/instl-sncn/ui/instl-list.tsx` |
|
||||
| view 추가 섹션 (fnrs) | `pages/fnrs-cnsltn/ui/fnrs-view-page.tsx` |
|
||||
| FAB 보완 TODO (instl) | `pages/instl-sncn/ui/components/floating-action-buttons.tsx` |
|
||||
| 결재 다이얼로그 (fnrs) | `pages/fnrs-cnsltn/ui/components/elapr-approval-dialog.tsx` |
|
||||
| 결재 다이얼로그 (instl) | `pages/instl-sncn/ui/components/elapr-approval-dialog.tsx` |
|
||||
| 폼 완성도 (fnrs) | `pages/fnrs-cnsltn/ui/fnrs-form-page.tsx` |
|
||||
| 폼 단순화 (instl) | `pages/instl-sncn/ui/instl-form-page.tsx` |
|
||||
| supplement API (fnrs entity) | `entities/fnrs-cnsltn/api/fnrs-cnsltn-service.ts` |
|
||||
@@ -20,6 +20,7 @@
|
||||
"@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",
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@opentelemetry/context-zone": "^2.8.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.219.0",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -26,8 +26,10 @@ async function findById(elaprPrgrsId: string): Promise<ElaprPrgrsSttsResponseDto
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function save(body: ElaprPrgrsSttsSaveRequestDto): Promise<ElaprPrgrsSttsResponseDto> {
|
||||
const response = await client.post<ElaprPrgrsSttsResponseDto>(BASE, body);
|
||||
async function save(
|
||||
requestDtos: ElaprPrgrsSttsSaveRequestDto[],
|
||||
): Promise<ElaprPrgrsSttsResponseDto[]> {
|
||||
const response = await client.post<ElaprPrgrsSttsResponseDto[]>(BASE, requestDtos);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -272,7 +272,8 @@ export function useSaveElaprPrgrsStts() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (body: ElaprPrgrsSttsSaveRequestDto) => elaprPrgrsSttsService.save(body),
|
||||
mutationFn: (requestDtos: ElaprPrgrsSttsSaveRequestDto[]) =>
|
||||
elaprPrgrsSttsService.save(requestDtos),
|
||||
onSuccess: () => {
|
||||
invalidateElaprPrgrsSttsQueries(queryClient);
|
||||
},
|
||||
|
||||
@@ -43,16 +43,27 @@ export {
|
||||
validateAtrzOrder,
|
||||
validateUniqueSeqNo,
|
||||
} from "./lib/apln-validators";
|
||||
export {
|
||||
buildElaprPrgrsSttsListFromApln,
|
||||
buildElaprPrgrsSttsListFromAplnRows,
|
||||
} from "./lib/dmnd-save-utils";
|
||||
export {
|
||||
findCurrentUserPendingApprovalRequest,
|
||||
hasCurrentUserPendingApprovalRequest,
|
||||
} from "./lib/approval-request-utils";
|
||||
export {
|
||||
buildElaprPrgrsSttsListFromApln,
|
||||
buildElaprPrgrsSttsListFromAplnRows,
|
||||
} from "./lib/dmnd-save-utils";
|
||||
export type { ElaprPrgrsSttsRow } from "./lib/prgrs-stts-save-utils";
|
||||
export {
|
||||
assignPrgrsSttsAtrzSeqNumbers,
|
||||
buildPrgrsSttsSavePayload,
|
||||
hasUnsavedPrgrsSttsChanges,
|
||||
isPrgrsSttsRowEditable,
|
||||
PRGRS_STTS_WAITING_CD,
|
||||
validateAllPrgrsSttsRows,
|
||||
validatePrgrsSttsRow,
|
||||
} from "./lib/prgrs-stts-save-utils";
|
||||
export { createDraftRowKey, useAplnRows } from "./lib/use-apln-rows";
|
||||
export { useDndSensors } from "./lib/use-dnd-sensors";
|
||||
export { usePrgrsSttsRows } from "./lib/use-prgrs-stts-rows";
|
||||
export {
|
||||
ELAPR_APLN_SAVE_MODE,
|
||||
type ElaprApln,
|
||||
@@ -77,11 +88,13 @@ export type {
|
||||
ElaprApproverInsertRequestDto,
|
||||
ElaprPrgrsSttsMyDocSearchParams,
|
||||
ElaprPrgrsSttsResponseDto,
|
||||
ElaprPrgrsSttsSaveMode,
|
||||
ElaprPrgrsSttsSaveRequestDto,
|
||||
ElaprPrgrsSttsSearchDto,
|
||||
ElaprPrgrsSttsService,
|
||||
ElaprProcessRequestDto,
|
||||
} from "./model/elapr-prgrs-stts-interface";
|
||||
export { ELAPR_PRGRS_STTS_SAVE_MODE } from "./model/elapr-prgrs-stts-interface";
|
||||
export type {
|
||||
ElaprTmplt,
|
||||
ElaprTmpltSaveRequest,
|
||||
|
||||
@@ -3,13 +3,18 @@ import type { ElaprAplnRow } from "../model/elapr-apln-row-types";
|
||||
const ATRZ_RECEIVER_CD = "ATRZ01";
|
||||
const ATRZ_FINAL_CD = "ATRZ99";
|
||||
|
||||
interface AtrzOrderValidateRow {
|
||||
atrzSeComCd?: string;
|
||||
isDeleted?: boolean;
|
||||
}
|
||||
|
||||
export function getVisibleRows(rows: ElaprAplnRow[]): ElaprAplnRow[] {
|
||||
return rows.filter((row) => !row.isDeleted);
|
||||
}
|
||||
|
||||
/** 접수자·최종승인자 위치·중복 검증 (최종 저장 시점) */
|
||||
export function validateAtrzOrder(rows: ElaprAplnRow[]): string | null {
|
||||
const visibleRows = getVisibleRows(rows);
|
||||
export function validateAtrzOrder(rows: AtrzOrderValidateRow[]): string | null {
|
||||
const visibleRows = rows.filter((row) => !row.isDeleted);
|
||||
|
||||
const receiverCount = visibleRows.filter((row) => row.atrzSeComCd === ATRZ_RECEIVER_CD).length;
|
||||
if (receiverCount === 0) {
|
||||
|
||||
@@ -7,19 +7,17 @@ export function hasCurrentUserPendingApprovalRequest(
|
||||
if (!currentUserUuid) return false;
|
||||
|
||||
return list.some(
|
||||
(row) =>
|
||||
row.aprvrUuid === currentUserUuid && row.atrzPrgrsSttsComCd === "ATRZ_PRGRS_002",
|
||||
(row) => row.aprvrUuid === currentUserUuid && row.atrzPrgrsSttsComCd === "ATRZ_PRGRS_002",
|
||||
);
|
||||
}
|
||||
|
||||
export function findCurrentUserPendingApprovalRequest(
|
||||
list: ElaprDocPrgrsResponseDto[],
|
||||
currentUserUuid: string | undefined,
|
||||
list?: ElaprDocPrgrsResponseDto[],
|
||||
currentUserUuid?: string | undefined,
|
||||
): ElaprDocPrgrsResponseDto | undefined {
|
||||
if (!currentUserUuid) return undefined;
|
||||
|
||||
return list.find(
|
||||
(row) =>
|
||||
row.aprvrUuid === currentUserUuid && row.atrzPrgrsSttsComCd === "ATRZ_PRGRS_002",
|
||||
return list?.find(
|
||||
(row) => row.aprvrUuid === currentUserUuid && row.atrzPrgrsSttsComCd === "ATRZ_PRGRS_002",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
ELAPR_PRGRS_STTS_SAVE_MODE,
|
||||
type ElaprPrgrsSttsSaveRequestDto,
|
||||
} from "../model/elapr-prgrs-stts-interface";
|
||||
|
||||
export const PRGRS_STTS_WAITING_CD = "ATRZ_PRGRS_001";
|
||||
|
||||
/** 서버 행 비교용 최소 필드 */
|
||||
export interface PrgrsSttsServerRow {
|
||||
elaprPrgrsId?: string;
|
||||
aprvrUuid?: string;
|
||||
atrzSeComCd?: string;
|
||||
atrzSeqNo?: number;
|
||||
}
|
||||
|
||||
/** 결재선 그리드 로컬 행 — 진행상태 API 응답 + UI 보조 필드 */
|
||||
export interface ElaprPrgrsSttsRow {
|
||||
rowKey: string;
|
||||
elaprPrgrsId?: string;
|
||||
elaprDmndId?: string;
|
||||
aprvrUuid?: string;
|
||||
aprvrNm?: string;
|
||||
atrzSeComCd?: string;
|
||||
atrzSeComNm?: string;
|
||||
atrzSeqNo?: number;
|
||||
atrzPrgrsSttsComCd?: string;
|
||||
atrzPrgrsSttsComNm?: string;
|
||||
rjctRsn?: string | null;
|
||||
ognzNm?: string;
|
||||
jbpsSeNm?: string;
|
||||
jbgdSeNm?: string;
|
||||
isNew?: boolean;
|
||||
isDirty?: boolean;
|
||||
/** 소프트 삭제 — 최종 저장 시 mode:D */
|
||||
isDeleted?: boolean;
|
||||
/** 수정 취소용 스냅샷 */
|
||||
snapshot?: ElaprPrgrsSttsRow;
|
||||
}
|
||||
|
||||
export function isPrgrsSttsRowEditable(row: ElaprPrgrsSttsRow): boolean {
|
||||
return row.isNew === true || row.atrzPrgrsSttsComCd === PRGRS_STTS_WAITING_CD;
|
||||
}
|
||||
|
||||
function getVisibleRows(rows: ElaprPrgrsSttsRow[]): ElaprPrgrsSttsRow[] {
|
||||
return rows.filter((row) => !row.isDeleted);
|
||||
}
|
||||
|
||||
/** 그리드 행 순서(1-based)로 결재순서번호 일괄 채번 */
|
||||
export function assignPrgrsSttsAtrzSeqNumbers(rows: ElaprPrgrsSttsRow[]): ElaprPrgrsSttsRow[] {
|
||||
return rows.map((row, index) => ({ ...row, atrzSeqNo: index + 1 }));
|
||||
}
|
||||
|
||||
export function validatePrgrsSttsRow(row: ElaprPrgrsSttsRow): string | null {
|
||||
if (!row.atrzSeComCd?.trim()) {
|
||||
return "결재구분을 선택해 주세요.";
|
||||
}
|
||||
if (!row.aprvrUuid?.trim()) {
|
||||
return "결재자를 선택해 주세요.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateAllPrgrsSttsRows(
|
||||
rows: ElaprPrgrsSttsRow[],
|
||||
isEditingLocked: boolean,
|
||||
): string | null {
|
||||
if (isEditingLocked) {
|
||||
return "편집 중인 행을 먼저 저장하거나 취소해 주세요.";
|
||||
}
|
||||
|
||||
const visibleRows = getVisibleRows(rows);
|
||||
if (visibleRows.length === 0) {
|
||||
return "저장할 결재선이 없습니다.";
|
||||
}
|
||||
|
||||
for (const row of visibleRows) {
|
||||
const fieldError = validatePrgrsSttsRow(row);
|
||||
if (fieldError) return fieldError;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function rowDiffersFromServer(row: ElaprPrgrsSttsRow, serverRow: PrgrsSttsServerRow): boolean {
|
||||
return (
|
||||
row.aprvrUuid !== serverRow.aprvrUuid ||
|
||||
row.atrzSeComCd !== serverRow.atrzSeComCd ||
|
||||
row.atrzSeqNo !== serverRow.atrzSeqNo
|
||||
);
|
||||
}
|
||||
|
||||
function shouldIncludeUpdate(row: ElaprPrgrsSttsRow, serverRows: PrgrsSttsServerRow[]): boolean {
|
||||
if (row.isDirty) return true;
|
||||
if (!row.elaprPrgrsId) return false;
|
||||
|
||||
const serverRow = serverRows.find((s) => s.elaprPrgrsId === row.elaprPrgrsId);
|
||||
if (!serverRow) return false;
|
||||
|
||||
return rowDiffersFromServer(row, serverRow);
|
||||
}
|
||||
|
||||
export function hasUnsavedPrgrsSttsChanges(
|
||||
rows: ElaprPrgrsSttsRow[],
|
||||
serverRows: PrgrsSttsServerRow[],
|
||||
): boolean {
|
||||
if (rows.some((row) => row.isDeleted && row.elaprPrgrsId)) return true;
|
||||
if (rows.some((row) => row.isNew && !row.isDeleted)) return true;
|
||||
|
||||
const visibleWithSeq = assignPrgrsSttsAtrzSeqNumbers(getVisibleRows(rows));
|
||||
|
||||
for (const row of visibleWithSeq) {
|
||||
if (row.isNew) continue;
|
||||
if (!row.elaprPrgrsId) continue;
|
||||
if (row.isDirty) return true;
|
||||
|
||||
const serverRow = serverRows.find((s) => s.elaprPrgrsId === row.elaprPrgrsId);
|
||||
if (!serverRow) continue;
|
||||
if (rowDiffersFromServer(row, serverRow)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function buildPrgrsSttsSavePayload(
|
||||
rows: ElaprPrgrsSttsRow[],
|
||||
elaprDmndId: string,
|
||||
serverRows: PrgrsSttsServerRow[],
|
||||
): ElaprPrgrsSttsSaveRequestDto[] {
|
||||
const payload: ElaprPrgrsSttsSaveRequestDto[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.isDeleted && row.elaprPrgrsId) {
|
||||
payload.push({
|
||||
mode: ELAPR_PRGRS_STTS_SAVE_MODE.DELETE,
|
||||
elaprPrgrsId: row.elaprPrgrsId,
|
||||
elaprDmndId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const visibleWithSeq = assignPrgrsSttsAtrzSeqNumbers(getVisibleRows(rows));
|
||||
|
||||
for (const row of visibleWithSeq) {
|
||||
if (row.isNew) {
|
||||
payload.push({
|
||||
mode: ELAPR_PRGRS_STTS_SAVE_MODE.CREATE,
|
||||
elaprDmndId,
|
||||
aprvrUuid: row.aprvrUuid,
|
||||
atrzSeComCd: row.atrzSeComCd,
|
||||
atrzSeqNo: row.atrzSeqNo,
|
||||
atrzPrgrsSttsComCd: row.atrzPrgrsSttsComCd,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!row.elaprPrgrsId) continue;
|
||||
|
||||
if (shouldIncludeUpdate(row, serverRows)) {
|
||||
payload.push({
|
||||
mode: ELAPR_PRGRS_STTS_SAVE_MODE.UPDATE,
|
||||
elaprPrgrsId: row.elaprPrgrsId,
|
||||
elaprDmndId,
|
||||
aprvrUuid: row.aprvrUuid,
|
||||
atrzSeComCd: row.atrzSeComCd,
|
||||
atrzSeqNo: row.atrzSeqNo,
|
||||
atrzPrgrsSttsComCd: row.atrzPrgrsSttsComCd,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { arrayMove } from "@dnd-kit/sortable";
|
||||
|
||||
import type { ElaprDocPrgrsClientResponseDto } from "@/shared/model";
|
||||
|
||||
import {
|
||||
assignPrgrsSttsAtrzSeqNumbers,
|
||||
type ElaprPrgrsSttsRow,
|
||||
isPrgrsSttsRowEditable,
|
||||
PRGRS_STTS_WAITING_CD,
|
||||
} from "./prgrs-stts-save-utils";
|
||||
import { createDraftRowKey } from "./use-apln-rows";
|
||||
|
||||
function getVisibleRows(rows: ElaprPrgrsSttsRow[]): ElaprPrgrsSttsRow[] {
|
||||
return rows.filter((row) => !row.isDeleted);
|
||||
}
|
||||
|
||||
function toRow(item: ElaprDocPrgrsClientResponseDto): ElaprPrgrsSttsRow {
|
||||
return {
|
||||
rowKey: item.elaprPrgrsId ?? createDraftRowKey(),
|
||||
elaprPrgrsId: item.elaprPrgrsId,
|
||||
elaprDmndId: item.elaprDmndId,
|
||||
aprvrUuid: item.aprvrUuid,
|
||||
aprvrNm: item.aprvrUuidNm,
|
||||
atrzSeComCd: item.atrzSeComCd,
|
||||
atrzSeComNm: item.atrzSeComNm,
|
||||
atrzSeqNo: item.atrzSeqNo,
|
||||
atrzPrgrsSttsComCd: item.atrzPrgrsSttsComCd,
|
||||
atrzPrgrsSttsComNm: item.atrzPrgrsSttsComNm,
|
||||
rjctRsn: item.rjctRsn,
|
||||
ognzNm: item.ognzNm,
|
||||
jbpsSeNm: item.jbpsSeNm,
|
||||
jbgdSeNm: item.jbgdSeNm,
|
||||
isNew: false,
|
||||
isDirty: false,
|
||||
isDeleted: false,
|
||||
};
|
||||
}
|
||||
|
||||
interface UsePrgrsSttsRowsOptions {
|
||||
serverRows?: ElaprDocPrgrsClientResponseDto[];
|
||||
elaprDmndId?: string | null;
|
||||
}
|
||||
|
||||
export function usePrgrsSttsRows({ serverRows, elaprDmndId }: UsePrgrsSttsRowsOptions) {
|
||||
const [rows, setRows] = useState<ElaprPrgrsSttsRow[]>([]);
|
||||
const [editingRowKey, setEditingRowKey] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setRows((serverRows ?? []).map(toRow));
|
||||
setEditingRowKey(null);
|
||||
}, [serverRows]);
|
||||
|
||||
const isEditingLocked = editingRowKey != null;
|
||||
const visibleRows = getVisibleRows(rows);
|
||||
|
||||
const addRow = useCallback(() => {
|
||||
if (!elaprDmndId || isEditingLocked) return;
|
||||
|
||||
const rowKey = createDraftRowKey();
|
||||
|
||||
setRows((prev) => [
|
||||
...prev,
|
||||
{
|
||||
rowKey,
|
||||
elaprDmndId,
|
||||
atrzSeComCd: "",
|
||||
aprvrUuid: "",
|
||||
aprvrNm: "",
|
||||
atrzPrgrsSttsComCd: PRGRS_STTS_WAITING_CD,
|
||||
isNew: true,
|
||||
isDirty: true,
|
||||
isDeleted: false,
|
||||
},
|
||||
]);
|
||||
setEditingRowKey(rowKey);
|
||||
}, [elaprDmndId, isEditingLocked]);
|
||||
|
||||
const startEdit = useCallback(
|
||||
(rowKey: string) => {
|
||||
if (isEditingLocked) return;
|
||||
|
||||
const target = rows.find((r) => r.rowKey === rowKey);
|
||||
if (!target || !isPrgrsSttsRowEditable(target)) return;
|
||||
|
||||
setEditingRowKey(rowKey);
|
||||
setRows((prev) =>
|
||||
prev.map((row) => (row.rowKey === rowKey ? { ...row, snapshot: { ...row } } : row)),
|
||||
);
|
||||
},
|
||||
[isEditingLocked, rows],
|
||||
);
|
||||
|
||||
const updateRow = useCallback(
|
||||
(rowKey: string, patch: Partial<ElaprPrgrsSttsRow>) => {
|
||||
if (editingRowKey !== rowKey) return;
|
||||
setRows((prev) =>
|
||||
prev.map((row) => (row.rowKey === rowKey ? { ...row, ...patch, isDirty: true } : row)),
|
||||
);
|
||||
},
|
||||
[editingRowKey],
|
||||
);
|
||||
|
||||
const cancelEdit = useCallback(() => {
|
||||
if (!editingRowKey) return;
|
||||
|
||||
setRows((prev) => {
|
||||
const target = prev.find((r) => r.rowKey === editingRowKey);
|
||||
if (target?.isNew) {
|
||||
return prev.filter((r) => r.rowKey !== editingRowKey);
|
||||
}
|
||||
return prev.map((row) => {
|
||||
if (row.rowKey !== editingRowKey) return row;
|
||||
if (row.snapshot) {
|
||||
const {
|
||||
snapshot: _s,
|
||||
isNew: _n,
|
||||
isDirty: _d,
|
||||
isDeleted: _del,
|
||||
...restored
|
||||
} = row.snapshot;
|
||||
return { ...restored, isNew: false, isDirty: false, isDeleted: false };
|
||||
}
|
||||
return { ...row, isDirty: false };
|
||||
});
|
||||
});
|
||||
setEditingRowKey(null);
|
||||
}, [editingRowKey]);
|
||||
|
||||
const commitEdit = useCallback((rowKey: string) => {
|
||||
setRows((prev) =>
|
||||
prev.map((row) => {
|
||||
if (row.rowKey !== rowKey) return row;
|
||||
const { snapshot: _s, ...committed } = row;
|
||||
return { ...committed, isDirty: true };
|
||||
}),
|
||||
);
|
||||
setEditingRowKey(null);
|
||||
}, []);
|
||||
|
||||
const markDeleted = useCallback((rowKey: string) => {
|
||||
setRows((prev) => {
|
||||
const target = prev.find((r) => r.rowKey === rowKey);
|
||||
if (!target || !isPrgrsSttsRowEditable(target)) return prev;
|
||||
|
||||
if (target.isNew) {
|
||||
return prev.filter((r) => r.rowKey !== rowKey);
|
||||
}
|
||||
|
||||
return prev.map((row) =>
|
||||
row.rowKey === rowKey ? { ...row, isDeleted: true, isDirty: true } : row,
|
||||
);
|
||||
});
|
||||
setEditingRowKey((key) => (key === rowKey ? null : key));
|
||||
}, []);
|
||||
|
||||
const reorderRows = useCallback(
|
||||
(activeKey: string, overKey: string): ElaprPrgrsSttsRow[] => {
|
||||
if (isEditingLocked) return [];
|
||||
|
||||
let payloadRows: ElaprPrgrsSttsRow[] = [];
|
||||
|
||||
setRows((prev) => {
|
||||
const visible = getVisibleRows(prev);
|
||||
const activeRow = visible.find((r) => r.rowKey === activeKey);
|
||||
const overRow = visible.find((r) => r.rowKey === overKey);
|
||||
if (!activeRow || !overRow) return prev;
|
||||
if (!isPrgrsSttsRowEditable(activeRow) || !isPrgrsSttsRowEditable(overRow)) return prev;
|
||||
|
||||
const oldIndex = visible.findIndex((r) => r.rowKey === activeKey);
|
||||
const newIndex = visible.findIndex((r) => r.rowKey === overKey);
|
||||
if (oldIndex < 0 || newIndex < 0) return prev;
|
||||
|
||||
const reorderedVisible = assignPrgrsSttsAtrzSeqNumbers(
|
||||
arrayMove(visible, oldIndex, newIndex),
|
||||
).map((row) => (isPrgrsSttsRowEditable(row) ? { ...row, isDirty: true } : row));
|
||||
|
||||
const visibleKeys = new Set(reorderedVisible.map((r) => r.rowKey));
|
||||
const deletedRows = prev.filter((r) => r.isDeleted);
|
||||
const merged = [
|
||||
...reorderedVisible,
|
||||
...deletedRows.filter((r) => !visibleKeys.has(r.rowKey)),
|
||||
];
|
||||
|
||||
payloadRows = reorderedVisible.filter(isPrgrsSttsRowEditable);
|
||||
return merged;
|
||||
});
|
||||
|
||||
return payloadRows;
|
||||
},
|
||||
[isEditingLocked],
|
||||
);
|
||||
|
||||
return {
|
||||
rows,
|
||||
visibleRows,
|
||||
editingRowKey,
|
||||
isEditingLocked,
|
||||
addRow,
|
||||
startEdit,
|
||||
updateRow,
|
||||
cancelEdit,
|
||||
commitEdit,
|
||||
markDeleted,
|
||||
reorderRows,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,15 @@
|
||||
import type { PageableParams, PageResponse } from "@/shared/model";
|
||||
|
||||
export const ELAPR_PRGRS_STTS_SAVE_MODE = {
|
||||
CREATE: "C",
|
||||
READ: "R",
|
||||
UPDATE: "U",
|
||||
DELETE: "D",
|
||||
} as const;
|
||||
|
||||
export type ElaprPrgrsSttsSaveMode =
|
||||
(typeof ELAPR_PRGRS_STTS_SAVE_MODE)[keyof typeof ELAPR_PRGRS_STTS_SAVE_MODE];
|
||||
|
||||
/** 백엔드 ElaprPrgrsSttsResponseDto */
|
||||
export interface ElaprPrgrsSttsResponseDto {
|
||||
elaprPrgrsId: string;
|
||||
@@ -16,10 +26,12 @@ export interface ElaprPrgrsSttsResponseDto {
|
||||
|
||||
/** 백엔드 ElaprPrgrsSttsSaveRequestDto */
|
||||
export interface ElaprPrgrsSttsSaveRequestDto {
|
||||
mode?: ElaprPrgrsSttsSaveMode;
|
||||
elaprPrgrsId?: string;
|
||||
elaprDmndId?: string;
|
||||
aprvrUuid: string;
|
||||
atrzSeComCd: string;
|
||||
atrzSeqNo: number;
|
||||
aprvrUuid?: string;
|
||||
atrzSeComCd?: string;
|
||||
atrzSeqNo?: number;
|
||||
atrzPrgrsSttsComCd?: string;
|
||||
}
|
||||
|
||||
@@ -56,7 +68,7 @@ export interface ElaprApproverInsertRequestDto {
|
||||
export interface ElaprPrgrsSttsService {
|
||||
findAllByDmndId(elaprDmndId?: string): Promise<ElaprPrgrsSttsResponseDto[]>;
|
||||
findById(elaprPrgrsId: string): Promise<ElaprPrgrsSttsResponseDto>;
|
||||
save(body: ElaprPrgrsSttsSaveRequestDto): Promise<ElaprPrgrsSttsResponseDto>;
|
||||
save(requestDtos: ElaprPrgrsSttsSaveRequestDto[]): Promise<ElaprPrgrsSttsResponseDto[]>;
|
||||
process(elaprPrgrsId: string, body: ElaprProcessRequestDto): Promise<ElaprPrgrsSttsResponseDto>;
|
||||
processByDocNoAndAprvrUuid(
|
||||
elaprDocNo: string,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { CklstRvwRsltTreeDto, CklstResultItem } from "@/entities/fnrs-cnsltn";
|
||||
import type { CklstResultItem, CklstRvwRsltTreeDto } from "@/entities/fnrs-cnsltn";
|
||||
|
||||
import {
|
||||
type ChecklistFormState,
|
||||
type ChecklistProgress,
|
||||
type ChecklistRowKey,
|
||||
type ChecklistStepMap,
|
||||
type ReviewStepCode,
|
||||
REVIEW_STEP_CODES,
|
||||
type ReviewStepCode,
|
||||
} from "../model/view-types";
|
||||
|
||||
export function buildChecklistRowKey(
|
||||
@@ -89,7 +89,7 @@ export function computeReviewStepChecklistProgress(
|
||||
const val = results[key];
|
||||
return acc + (val === "Y" || val === "N" ? 1 : 0);
|
||||
}, 0);
|
||||
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
const percent = total > 0 ? Math.round((completed / total) * 100) : 100;
|
||||
return { total, completed, percent };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
FnrsCnsltnMngDetResponseDto,
|
||||
FnrsCnsltnRvwOpnnResponseDto,
|
||||
FnrsCnsltnRvwOpnnSaveRequestDto,
|
||||
} from "@/entities/fnrs-cnsltn";
|
||||
|
||||
@@ -11,35 +12,83 @@ export interface ReviewOpinionFormData {
|
||||
rvwOpnnSn?: number;
|
||||
}
|
||||
|
||||
export interface ReviewOpinionGetFormDataResult {
|
||||
formData: ReviewOpinionFormData;
|
||||
rvwList: FnrsCnsltnRvwOpnnResponseDto[];
|
||||
}
|
||||
|
||||
function hasReviewOpinionContent(formData: ReviewOpinionFormData) {
|
||||
return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
|
||||
}
|
||||
|
||||
function mapServerItemToSaveDto(
|
||||
item: FnrsCnsltnRvwOpnnResponseDto,
|
||||
fnrsCnsltnDocNo: string,
|
||||
): FnrsCnsltnRvwOpnnSaveRequestDto | null {
|
||||
if (item.rvwOpnnSn == null) return null;
|
||||
|
||||
return {
|
||||
fnrsCnsltnDocNo,
|
||||
rvwOpnnSn: item.rvwOpnnSn,
|
||||
opnrSeComCd: item.opnrSeComCd,
|
||||
opnrNm: item.opnrNm,
|
||||
rvwRsltCn: item.rvwRsltCn,
|
||||
rvwRsltAnmlyYn: item.rvwRsltAnmlyYn || undefined,
|
||||
atchFileGroupId: item.atchFileGroupId || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function mapFormDataToSaveDto(
|
||||
formData: ReviewOpinionFormData,
|
||||
fnrsCnsltnDocNo: string,
|
||||
opnrNm?: string,
|
||||
): FnrsCnsltnRvwOpnnSaveRequestDto {
|
||||
const base = {
|
||||
fnrsCnsltnDocNo,
|
||||
opnrSeComCd: formData.opnrSeComCd,
|
||||
opnrNm: opnrNm ?? "",
|
||||
rvwRsltCn: formData.rvwRsltCn,
|
||||
rvwRsltAnmlyYn: formData.rvwRsltAnmlyYn || undefined,
|
||||
atchFileGroupId: formData.atchFileGroupId || undefined,
|
||||
};
|
||||
|
||||
if (formData.rvwOpnnSn != null) {
|
||||
return { ...base, rvwOpnnSn: formData.rvwOpnnSn };
|
||||
}
|
||||
|
||||
return base as FnrsCnsltnRvwOpnnSaveRequestDto;
|
||||
}
|
||||
|
||||
export function toRvwOpnnSaveDto(
|
||||
data: FnrsCnsltnMngDetResponseDto,
|
||||
reviewOpinionFormData: ReviewOpinionFormData | null | undefined,
|
||||
result: ReviewOpinionGetFormDataResult | null | undefined,
|
||||
opnrNm?: string,
|
||||
): FnrsCnsltnRvwOpnnSaveRequestDto[] | undefined {
|
||||
if (!reviewOpinionFormData || !data.detDto) return undefined;
|
||||
if (!result || !data.detDto) return undefined;
|
||||
|
||||
const hasContent =
|
||||
reviewOpinionFormData.rvwRsltCn.trim() ||
|
||||
reviewOpinionFormData.rvwRsltAnmlyYn ||
|
||||
reviewOpinionFormData.atchFileGroupId;
|
||||
const { formData, rvwList } = result;
|
||||
const fnrsCnsltnDocNo = data.detDto.fnrsCnsltnDocNo;
|
||||
|
||||
if (!hasContent) return undefined;
|
||||
const list: FnrsCnsltnRvwOpnnSaveRequestDto[] = rvwList
|
||||
.map((item) => mapServerItemToSaveDto(item, fnrsCnsltnDocNo))
|
||||
.filter((item): item is FnrsCnsltnRvwOpnnSaveRequestDto => item != null);
|
||||
|
||||
const existingSn =
|
||||
reviewOpinionFormData.rvwOpnnSn ??
|
||||
data.rvwList?.find((item) => item.opnrSeComCd === reviewOpinionFormData.opnrSeComCd)
|
||||
?.rvwOpnnSn ??
|
||||
1;
|
||||
if (!hasReviewOpinionContent(formData)) {
|
||||
return list.length > 0 ? list : undefined;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
fnrsCnsltnDocNo: data.detDto.fnrsCnsltnDocNo,
|
||||
rvwOpnnSn: existingSn,
|
||||
opnrSeComCd: reviewOpinionFormData.opnrSeComCd,
|
||||
opnrNm: opnrNm ?? "",
|
||||
rvwRsltCn: reviewOpinionFormData.rvwRsltCn,
|
||||
rvwRsltAnmlyYn: reviewOpinionFormData.rvwRsltAnmlyYn || undefined,
|
||||
atchFileGroupId: reviewOpinionFormData.atchFileGroupId || undefined,
|
||||
},
|
||||
];
|
||||
const formDto = mapFormDataToSaveDto(formData, fnrsCnsltnDocNo, opnrNm);
|
||||
|
||||
if (formData.rvwOpnnSn != null) {
|
||||
const existingIndex = list.findIndex((item) => item.rvwOpnnSn === formData.rvwOpnnSn);
|
||||
if (existingIndex >= 0) {
|
||||
list[existingIndex] = formDto;
|
||||
} else {
|
||||
list.push(formDto);
|
||||
}
|
||||
} else {
|
||||
list.push(formDto);
|
||||
}
|
||||
|
||||
return list.length > 0 ? list : undefined;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { mapFnrsSharDetToFormList, toFnrsSharSaveList } from "@/widgets/pbadms-s
|
||||
import type { ChecklistFormState, ReviewStepCode } from "../model/view-types";
|
||||
|
||||
import { toCklstSaveDto } from "./checklist-utils";
|
||||
import { type ReviewOpinionFormData, toRvwOpnnSaveDto } from "./review-opinion-utils";
|
||||
import { type ReviewOpinionGetFormDataResult, toRvwOpnnSaveDto } from "./review-opinion-utils";
|
||||
|
||||
export function toBzScaleSaveDto(bizSclList: BizSclTreeDto[]): BzScaleItem[] {
|
||||
return bizSclList
|
||||
@@ -88,7 +88,7 @@ export function buildTechReviewReplyPayload(
|
||||
reviewStep: ReviewStepCode,
|
||||
prgrsSttsComCd: string,
|
||||
techReViewSaveCd: string,
|
||||
reviewOpinionFormData?: ReviewOpinionFormData | null,
|
||||
reviewOpinionFormData?: ReviewOpinionGetFormDataResult | null,
|
||||
opnrNm?: string,
|
||||
): FnrsCnsltnApplySaveRequestDto {
|
||||
return {
|
||||
|
||||
@@ -2,31 +2,49 @@
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { closestCenter, DndContext, type DragEndEvent } from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { elaprPrgrsSttsService, findCurrentUserPendingApprovalRequest, useElaprDocPrgrsByDocNo } from "@/entities/elapr";
|
||||
import {
|
||||
buildPrgrsSttsSavePayload,
|
||||
elaprPrgrsSttsService,
|
||||
findCurrentUserPendingApprovalRequest,
|
||||
hasUnsavedPrgrsSttsChanges,
|
||||
isPrgrsSttsRowEditable,
|
||||
useDndSensors,
|
||||
useElaprDocPrgrsByDocNo,
|
||||
usePrgrsSttsRows,
|
||||
useSaveElaprPrgrsStts,
|
||||
validateAllPrgrsSttsRows,
|
||||
validateAtrzOrder,
|
||||
validatePrgrsSttsRow,
|
||||
} from "@/entities/elapr";
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Label,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Textarea,
|
||||
} from "@common/components";
|
||||
|
||||
import { getApproverStatus, sortByAtrzSeqNo } from "../../lib/approval-utils";
|
||||
import type { ReviewerOption } from "../../model/reviewer-option";
|
||||
|
||||
import { AprvrPickerDialog } from "./aprvr-picker-dialog";
|
||||
import { SortablePrgrsSttsRow } from "./sortable-prgrs-stts-row";
|
||||
|
||||
interface ElaprApprovalDialogProps {
|
||||
open: boolean;
|
||||
@@ -36,6 +54,8 @@ interface ElaprApprovalDialogProps {
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const UNSAVED_PRGRS_STTS_MESSAGE = "결재선 변경 이력이 있습니다.\n결재선 저장을 해 주세요.";
|
||||
|
||||
function ElaprApprovalDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -47,12 +67,33 @@ function ElaprApprovalDialog({
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const [rjctRsn, setRjctRsn] = useState("");
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [pickingRowKey, setPickingRowKey] = useState<string | null>(null);
|
||||
const [finalApprovalYn, setFinalApprovalYn] = useState(false);
|
||||
|
||||
const { data: elaprDocPrgrsList = [], isLoading } = useElaprDocPrgrsByDocNo(
|
||||
open ? elaprDocNo : null,
|
||||
);
|
||||
const { data: elaprDocPrgrsList, isLoading } = useElaprDocPrgrsByDocNo(open ? elaprDocNo : null);
|
||||
|
||||
const orderedApprovers = sortByAtrzSeqNo(elaprDocPrgrsList);
|
||||
const elaprDmndId = elaprDocPrgrsList?.[0]?.elaprDmndId;
|
||||
|
||||
const {
|
||||
rows,
|
||||
visibleRows,
|
||||
editingRowKey,
|
||||
isEditingLocked,
|
||||
addRow,
|
||||
startEdit,
|
||||
updateRow,
|
||||
cancelEdit,
|
||||
commitEdit,
|
||||
markDeleted,
|
||||
reorderRows,
|
||||
} = usePrgrsSttsRows({
|
||||
serverRows: elaprDocPrgrsList,
|
||||
elaprDmndId,
|
||||
});
|
||||
|
||||
const savePrgrsSttsMutation = useSaveElaprPrgrsStts();
|
||||
const sensors = useDndSensors();
|
||||
|
||||
const currentUserPrgrs = findCurrentUserPendingApprovalRequest(
|
||||
elaprDocPrgrsList,
|
||||
@@ -60,13 +101,106 @@ function ElaprApprovalDialog({
|
||||
);
|
||||
const canApprove = Boolean(currentUserPrgrs?.elaprPrgrsId);
|
||||
|
||||
const pickingRow = pickingRowKey ? rows.find((r) => r.rowKey === pickingRowKey) : undefined;
|
||||
const isBusy = isProcessing || savePrgrsSttsMutation.isPending;
|
||||
const isPrgrsSttsUnsaved = hasUnsavedPrgrsSttsChanges(rows, elaprDocPrgrsList ?? []);
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
reorderRows(String(active.id), String(over.id));
|
||||
}
|
||||
|
||||
function handleRowSave(rowKey: string) {
|
||||
const row = rows.find((r) => r.rowKey === rowKey);
|
||||
if (!row) return;
|
||||
|
||||
const fieldError = validatePrgrsSttsRow(row);
|
||||
if (fieldError) {
|
||||
alert(fieldError);
|
||||
return;
|
||||
}
|
||||
|
||||
commitEdit(rowKey);
|
||||
}
|
||||
|
||||
function handleReviewerSelect(reviewer: ReviewerOption) {
|
||||
if (!pickingRowKey) return;
|
||||
updateRow(pickingRowKey, {
|
||||
aprvrUuid: reviewer.uuid,
|
||||
aprvrNm: reviewer.name,
|
||||
ognzNm: reviewer.deptNm,
|
||||
});
|
||||
setPickingRowKey(null);
|
||||
}
|
||||
|
||||
function assertPrgrsSttsSaved(): boolean {
|
||||
if (isEditingLocked) {
|
||||
alert(UNSAVED_PRGRS_STTS_MESSAGE);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isPrgrsSttsUnsaved) {
|
||||
alert(UNSAVED_PRGRS_STTS_MESSAGE);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleSavePrgrsStts() {
|
||||
if (!elaprDmndId) {
|
||||
alert("결재 요청 정보가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const validationError = validateAllPrgrsSttsRows(rows, isEditingLocked);
|
||||
if (validationError) {
|
||||
alert(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
const orderError = validateAtrzOrder(rows);
|
||||
if (orderError) {
|
||||
alert(orderError);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = buildPrgrsSttsSavePayload(rows, elaprDmndId, elaprDocPrgrsList ?? []);
|
||||
if (payload.length === 0) {
|
||||
alert("변경된 결재선이 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
await savePrgrsSttsMutation.mutateAsync(payload);
|
||||
alert("결재선이 저장되었습니다.");
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, "findDocPrgrsByDocNo", elaprDocNo],
|
||||
});
|
||||
onSuccess();
|
||||
} catch (error: unknown) {
|
||||
let errorMessage = "결재선 저장 중 오류가 발생했습니다.";
|
||||
if (error instanceof AxiosError) {
|
||||
errorMessage = error.response?.data?.message ?? errorMessage;
|
||||
}
|
||||
alert(errorMessage);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApprove() {
|
||||
if (!currentUserPrgrs?.elaprPrgrsId) return;
|
||||
if (!assertPrgrsSttsSaved()) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
await elaprPrgrsSttsService.process(currentUserPrgrs.elaprPrgrsId, {
|
||||
atrzPrgrsSttsComCd: "ATRZ_PRGRS_003",
|
||||
finalApprovalYn,
|
||||
});
|
||||
alert("결재가 완료되었습니다.");
|
||||
queryClient.invalidateQueries({
|
||||
@@ -93,11 +227,13 @@ function ElaprApprovalDialog({
|
||||
alert("반려 사유를 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
if (!assertPrgrsSttsSaved()) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
await elaprPrgrsSttsService.process(currentUserPrgrs.elaprPrgrsId, {
|
||||
atrzPrgrsSttsComCd: "ATRZ_PRGRS_004",
|
||||
rjctRsn: rjctRsn.trim(),
|
||||
});
|
||||
alert("반려 처리가 완료되었습니다.");
|
||||
queryClient.invalidateQueries({
|
||||
@@ -120,6 +256,7 @@ function ElaprApprovalDialog({
|
||||
|
||||
function handleClose() {
|
||||
setRjctRsn("");
|
||||
setPickingRowKey(null);
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
@@ -132,61 +269,97 @@ function ElaprApprovalDialog({
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="mb-3 text-xl font-semibold">결재 진행 단계</h4>
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<h4 className="text-xl font-semibold">결재 진행 단계</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
color="white"
|
||||
disabled={!elaprDmndId || isEditingLocked || isBusy}
|
||||
onClick={addRow}
|
||||
>
|
||||
결재선 추가
|
||||
</Button>
|
||||
{elaprDmndId && (
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={handleSavePrgrsStts}
|
||||
disabled={!isPrgrsSttsUnsaved || isEditingLocked || isBusy}
|
||||
>
|
||||
결재선 저장
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
) : orderedApprovers.length === 0 ? (
|
||||
<div className="text-center text-gray-500">결재 정보가 없습니다.</div>
|
||||
) : visibleRows.length === 0 ? (
|
||||
<div className="py-8 text-center text-gray-500">
|
||||
{elaprDmndId
|
||||
? "등록된 결재선이 없습니다. [결재선 추가]로 결재자를 등록하세요."
|
||||
: "결재 정보가 없습니다."}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table variant="line">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<Table className="w-full">
|
||||
<colgroup>
|
||||
<col width="8%" />
|
||||
<col width="10%" />
|
||||
<col width="15%" />
|
||||
<col width="35%" />
|
||||
<col width="12%" />
|
||||
<col width="20%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{orderedApprovers.map((row) => (
|
||||
<TableHead key={String(row.elaprPrgrsId ?? row.atrzSeComCd)}>
|
||||
{row.atrzSeComNm ?? row.atrzSeComCd}
|
||||
</TableHead>
|
||||
))}
|
||||
<TableHead className="w-10" />
|
||||
<TableHead>결재순서</TableHead>
|
||||
<TableHead>결재구분</TableHead>
|
||||
<TableHead>결재자</TableHead>
|
||||
<TableHead>결재상태</TableHead>
|
||||
<TableHead>관리</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
{orderedApprovers.map((row) => {
|
||||
const status = row.atrzPrgrsSttsComCd ?? "";
|
||||
const st = getApproverStatus(status);
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
key={String(row.elaprPrgrsId ?? row.atrzSeComCd)}
|
||||
align="center"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-md px-2 py-1 text-base font-semibold",
|
||||
st.badgeClass,
|
||||
)}
|
||||
>
|
||||
{st.label}
|
||||
</span>
|
||||
<span className="font-semibold">{row.aprvrUuidNm ?? "-"}</span>
|
||||
</div>
|
||||
<div className="mt-2 text-base text-gray-500">
|
||||
{row.ognzNm ?? ""}
|
||||
{row.jbpsSeNm || row.jbgdSeNm
|
||||
? ` · ${row.jbpsSeNm ?? ""}${row.jbgdSeNm ? ` ${row.jbgdSeNm}` : ""}`
|
||||
: ""}
|
||||
</div>
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
<SortableContext
|
||||
items={visibleRows.map((r) => r.rowKey)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{visibleRows.map((row, index) => (
|
||||
<SortablePrgrsSttsRow
|
||||
key={row.rowKey}
|
||||
row={row}
|
||||
displaySeqNo={index + 1}
|
||||
isEditing={editingRowKey === row.rowKey}
|
||||
isLocked={isEditingLocked}
|
||||
dragDisabled={
|
||||
isEditingLocked || !isPrgrsSttsRowEditable(row) || !row.atrzSeComCd
|
||||
}
|
||||
onStartEdit={() => startEdit(row.rowKey)}
|
||||
onSave={() => handleRowSave(row.rowKey)}
|
||||
onCancel={cancelEdit}
|
||||
onDelete={() => markDeleted(row.rowKey)}
|
||||
onChange={(patch) => updateRow(row.rowKey, patch)}
|
||||
onPickAprvr={() => {
|
||||
setPickingRowKey(row.rowKey);
|
||||
setPickerOpen(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -205,21 +378,40 @@ function ElaprApprovalDialog({
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" color="white" onClick={handleClose} disabled={isProcessing}>
|
||||
취소
|
||||
</Button>
|
||||
{canApprove && (
|
||||
<>
|
||||
<Button color="red" onClick={handleReject} disabled={isProcessing}>
|
||||
반려
|
||||
</Button>
|
||||
<Button onClick={handleApprove} disabled={isProcessing}>
|
||||
결재
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<Button type="button" color="white" onClick={handleClose} disabled={isBusy}>
|
||||
취소
|
||||
</Button>
|
||||
{canApprove && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="mr-2 flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="all-approve"
|
||||
checked={finalApprovalYn}
|
||||
onCheckedChange={(checked) => setFinalApprovalYn(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="all-approve" className="text-xl">
|
||||
전결
|
||||
</Label>
|
||||
</div>
|
||||
<Button onClick={handleApprove} disabled={isBusy}>
|
||||
결재
|
||||
</Button>
|
||||
<Button color="red" onClick={handleReject} disabled={isBusy}>
|
||||
반려
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
<AprvrPickerDialog
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
atrzSeComCd={pickingRow?.atrzSeComCd ?? ""}
|
||||
onSelect={handleReviewerSelect}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ function FloatingActionButtons({
|
||||
request,
|
||||
data,
|
||||
onSuccess: () => {
|
||||
alert(successMessage ?? "처리가 완료되었습니다.");
|
||||
if (successMessage) alert(successMessage);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...fnrsCnsltnMngBaseKey, "findById", fnrsCnsltnDocNo],
|
||||
});
|
||||
@@ -388,7 +388,7 @@ function FloatingActionButtons({
|
||||
checklistFormState,
|
||||
"KECO",
|
||||
"TE",
|
||||
"CP",
|
||||
"SV",
|
||||
getReviewOpinionFormData(),
|
||||
opnrNm,
|
||||
);
|
||||
@@ -396,9 +396,9 @@ function FloatingActionButtons({
|
||||
await executeAction({
|
||||
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwCmptn,
|
||||
request: payload,
|
||||
successMessage: "검토완료 처리되었습니다.",
|
||||
});
|
||||
techReviewOpinionRef?.current?.resetForm();
|
||||
setActiveDialog("elaprApproval");
|
||||
}
|
||||
|
||||
function handleDmndSplmntClick() {
|
||||
@@ -531,8 +531,6 @@ function FloatingActionButtons({
|
||||
elaprDocNo={data.detDto?.fnrsCnsltnDocNo ?? fnrsCnsltnDocNo}
|
||||
currentUserUuid={userInfo?.userUnqId ?? ""}
|
||||
onSuccess={() => {
|
||||
setActiveDialog(null);
|
||||
alert("결재가 완료되었습니다.");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...fnrsCnsltnMngBaseKey, "findById", fnrsCnsltnDocNo],
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Equal } from "lucide-react";
|
||||
import { MoveVertical } from "lucide-react";
|
||||
|
||||
import { CodeSelect, findCodeLabel, useCodeList } from "@/entities/code";
|
||||
import type { ElaprAplnRow } from "@/entities/elapr";
|
||||
@@ -63,7 +63,7 @@ function SortableAplnPickerRow({
|
||||
{...listeners}
|
||||
aria-label="순서 변경"
|
||||
>
|
||||
<Equal className="!size-6" />
|
||||
<MoveVertical className="!size-6" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell align="center">{row.isNew ? "-" : (row.aplnId ?? "-")}</TableCell>
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { MoveVertical } from "lucide-react";
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import { CodeSelect, findCodeLabel, useCodeList } from "@/entities/code";
|
||||
import { type ElaprPrgrsSttsRow, isPrgrsSttsRowEditable } from "@/entities/elapr";
|
||||
import { Button, cn, TableCell, TableRow } from "@common/components";
|
||||
|
||||
import { getApproverStatus } from "../../lib/approval-utils";
|
||||
|
||||
interface SortablePrgrsSttsRowProps {
|
||||
row: ElaprPrgrsSttsRow;
|
||||
displaySeqNo: number;
|
||||
isEditing: boolean;
|
||||
isLocked: boolean;
|
||||
dragDisabled: boolean;
|
||||
onStartEdit: () => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
onDelete: () => void;
|
||||
onChange: (patch: Partial<ElaprPrgrsSttsRow>) => void;
|
||||
onPickAprvr: () => void;
|
||||
}
|
||||
|
||||
function SortablePrgrsSttsRow({
|
||||
row,
|
||||
displaySeqNo,
|
||||
isEditing,
|
||||
isLocked,
|
||||
dragDisabled,
|
||||
onStartEdit,
|
||||
onSave,
|
||||
onCancel,
|
||||
onDelete,
|
||||
onChange,
|
||||
onPickAprvr,
|
||||
}: SortablePrgrsSttsRowProps) {
|
||||
const { data: atrzSeCodes } = useCodeList("ATRZSE");
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: row.rowKey,
|
||||
disabled: dragDisabled,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.6 : 1,
|
||||
};
|
||||
|
||||
const isEditable = isPrgrsSttsRowEditable(row);
|
||||
const rowDisabled = isLocked && !isEditing;
|
||||
const atrzSeLabel = row.atrzSeComNm || findCodeLabel(atrzSeCodes, row.atrzSeComCd);
|
||||
const status = getApproverStatus(row.atrzPrgrsSttsComCd);
|
||||
const isRejectedRow = row.atrzPrgrsSttsComCd === "ATRZ_PRGRS_004";
|
||||
const rjctRsn = row.rjctRsn?.trim();
|
||||
|
||||
const statusBadge = (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-sm px-3 py-1.5 text-lg font-semibold",
|
||||
status.badgeClass,
|
||||
isRejectedRow && rjctRsn && "cursor-help underline decoration-dotted underline-offset-4",
|
||||
)}
|
||||
>
|
||||
{status.label}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<TableRow ref={setNodeRef} style={style} className={isEditing ? "bg-accent/30" : undefined}>
|
||||
<TableCell align="center" className="w-10">
|
||||
{!dragDisabled && (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
className="cursor-grab !bg-transparent text-muted-foreground disabled:cursor-not-allowed disabled:opacity-40"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label="순서 변경"
|
||||
>
|
||||
<MoveVertical className="!size-6" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">{displaySeqNo}</TableCell>
|
||||
<TableCell align="center">
|
||||
{isEditing ? (
|
||||
<CodeSelect
|
||||
upCd="ATRZSE"
|
||||
className="min-w-[120px]"
|
||||
value={row.atrzSeComCd ?? ""}
|
||||
onValueChange={(v) =>
|
||||
onChange({
|
||||
atrzSeComCd: v,
|
||||
atrzSeComNm: findCodeLabel(atrzSeCodes, v),
|
||||
})
|
||||
}
|
||||
placeholder="결재구분"
|
||||
/>
|
||||
) : (
|
||||
atrzSeLabel || "-"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span>{row.aprvrNm || row.aprvrUuid || "-"}</span>
|
||||
{isEditing && (
|
||||
<Button type="button" size="sm" onClick={onPickAprvr}>
|
||||
선택
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{!isEditing && (row.ognzNm || row.jbpsSeNm || row.jbgdSeNm) && (
|
||||
<div className="text-base text-gray-500">
|
||||
{row.ognzNm ?? ""}
|
||||
{row.jbpsSeNm ? ` · ${row.jbpsSeNm}` : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{isRejectedRow && rjctRsn ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{statusBadge}</TooltipTrigger>
|
||||
<TooltipContent sideOffset={4} className="max-w-md whitespace-pre-wrap">
|
||||
{rjctRsn}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
statusBadge
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<div className="flex justify-center gap-2">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button type="button" size="sm" onClick={onSave}>
|
||||
확인
|
||||
</Button>
|
||||
<Button type="button" size="sm" color="white" onClick={onCancel}>
|
||||
취소
|
||||
</Button>
|
||||
</>
|
||||
) : isEditable ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="white"
|
||||
disabled={rowDisabled}
|
||||
onClick={onStartEdit}
|
||||
>
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="gray"
|
||||
disabled={rowDisabled}
|
||||
onClick={onDelete}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export { SortablePrgrsSttsRow };
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useFnrsCnsltnMng } from "@/entities/fnrs-cnsltn";
|
||||
import { Skeleton } from "@common/components";
|
||||
|
||||
import { FnrsFormPage } from "./fnrs-form-page";
|
||||
|
||||
@@ -12,7 +13,22 @@ function FnrsEditPage({ id }: FnrsEditPageProps) {
|
||||
const { data, isLoading } = useFnrsCnsltnMng(id);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="mt-6 text-[1.4rem]">불러오는 중...</div>;
|
||||
return (
|
||||
<div className="mt-6 space-y-6 px-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-[4rem] w-full" />
|
||||
<Skeleton className="h-[20rem] w-full" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-[4rem] w-full" />
|
||||
<Skeleton className="h-[20rem] w-full" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-[4rem] w-full" />
|
||||
<Skeleton className="h-[20rem] w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data?.detDto) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useMemo, useState } from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
ChecklistFormSection,
|
||||
ContactInfoFormSection,
|
||||
FnrsSharFormSection,
|
||||
isFormChecklistComplete,
|
||||
PrivacyConsentSection,
|
||||
recalculateFnrsSharTotals,
|
||||
toFnrsSharSaveList,
|
||||
@@ -49,6 +51,7 @@ interface FnrsFormPageProps {
|
||||
function FnrsFormPage({ id, mode, initialData, taskSeComCd }: FnrsFormPageProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const { confirm } = useConfirm();
|
||||
const userInfo = useUserStore((state) => state.userInfo);
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
@@ -57,9 +60,7 @@ function FnrsFormPage({ id, mode, initialData, taskSeComCd }: FnrsFormPageProps)
|
||||
const { data: masterSharTree = [] } = useFnrsSharSeMngTree();
|
||||
const { data: acntgTree = [] } = useAcntgSeMngTree();
|
||||
const sharTree =
|
||||
isEditMode && initialData?.fnrsSharList?.length
|
||||
? initialData.fnrsSharList
|
||||
: masterSharTree;
|
||||
isEditMode && initialData?.fnrsSharList?.length ? initialData.fnrsSharList : masterSharTree;
|
||||
const sharItems = useMemo(() => buildFnrsSharItems(sharTree), [sharTree]);
|
||||
const costItems = useMemo(() => buildAcntgCostItems(acntgTree), [acntgTree]);
|
||||
|
||||
@@ -72,47 +73,54 @@ function FnrsFormPage({ id, mode, initialData, taskSeComCd }: FnrsFormPageProps)
|
||||
: getCreateDefaultValues(userInfo, taskSeComCd),
|
||||
});
|
||||
|
||||
async function submitForm(
|
||||
data: FnrsCnsltnRegistFormData,
|
||||
prgrsSttsComCd: "SV" | "ST",
|
||||
): Promise<string | undefined> {
|
||||
const withTotals = recalculateFnrsSharTotals(data.fnrsSharList, sharItems);
|
||||
const validationMessage = validateBizCstSharBalance(
|
||||
data.bizCstList,
|
||||
withTotals,
|
||||
sharItems,
|
||||
costItems,
|
||||
);
|
||||
if (validationMessage) {
|
||||
alert(validationMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const { taskSeComCd: formTaskSeComCd, ...fnrsCnsltnMngRest } = data.fnrsCnsltnMng;
|
||||
|
||||
const payload = {
|
||||
fnrsCnsltnMng: {
|
||||
...fnrsCnsltnMngRest,
|
||||
taskSeComCd: formatTaskSeComCdForApi(formTaskSeComCd),
|
||||
fnrsCnsltnDocNo: isEditMode ? initialData?.detDto?.fnrsCnsltnDocNo : undefined,
|
||||
prgrsSttsComCd,
|
||||
},
|
||||
bzScaleList: data.bzScaleList,
|
||||
bizCstList: data.bizCstList,
|
||||
fnrsSharList: toFnrsSharSaveList(withTotals),
|
||||
cklstResultList: data.cklstResultList.map((item) => ({
|
||||
...item,
|
||||
selected: item.selected ?? undefined,
|
||||
})),
|
||||
esntlAtchFileList: data.esntlAtchFileList,
|
||||
};
|
||||
|
||||
return fnrsCnsltnMngService.saveApply(payload);
|
||||
}
|
||||
|
||||
async function onSave(data: FnrsCnsltnRegistFormData) {
|
||||
if (isSubmitting) return;
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
|
||||
const withTotals = recalculateFnrsSharTotals(data.fnrsSharList, sharItems);
|
||||
const validationMessage = validateBizCstSharBalance(
|
||||
data.bizCstList,
|
||||
withTotals,
|
||||
sharItems,
|
||||
costItems,
|
||||
);
|
||||
if (validationMessage) {
|
||||
alert(validationMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const { taskSeComCd, ...fnrsCnsltnMngRest } = data.fnrsCnsltnMng;
|
||||
|
||||
const payload = {
|
||||
fnrsCnsltnMng: {
|
||||
...fnrsCnsltnMngRest,
|
||||
taskSeComCd: formatTaskSeComCdForApi(taskSeComCd),
|
||||
fnrsCnsltnDocNo: isEditMode ? initialData?.detDto?.fnrsCnsltnDocNo : undefined,
|
||||
prgrsSttsComCd: "SV",
|
||||
},
|
||||
bzScaleList: data.bzScaleList,
|
||||
bizCstList: data.bizCstList,
|
||||
fnrsSharList: toFnrsSharSaveList(withTotals),
|
||||
cklstResultList: data.cklstResultList.map((item) => ({
|
||||
...item,
|
||||
selected: item.selected ?? undefined,
|
||||
})),
|
||||
esntlAtchFileList: data.esntlAtchFileList,
|
||||
};
|
||||
|
||||
const fnrsCnsltnDocNo = await fnrsCnsltnMngService.saveApply(payload);
|
||||
const fnrsCnsltnDocNo = await submitForm(data, "SV");
|
||||
if (!fnrsCnsltnDocNo) return;
|
||||
|
||||
alert("저장되었습니다.");
|
||||
router.push(`/fnrs-cnsltn/${fnrsCnsltnDocNo}`);
|
||||
router.push(`/fnrs-cnsltn/${fnrsCnsltnDocNo}/edit`);
|
||||
if (isEditMode && id) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...fnrsCnsltnMngBaseKey, "findById", id],
|
||||
@@ -126,6 +134,63 @@ function FnrsFormPage({ id, mode, initialData, taskSeComCd }: FnrsFormPageProps)
|
||||
}
|
||||
}
|
||||
|
||||
async function onApply(data: FnrsCnsltnRegistFormData) {
|
||||
if (isSubmitting) return;
|
||||
|
||||
if (!isFormChecklistComplete(data.cklstResultList)) {
|
||||
alert("모든 체크리스트 항목을 체크해야 신청이 가능합니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
const fnrsCnsltnDocNo = await submitForm(data, "ST");
|
||||
if (!fnrsCnsltnDocNo) return;
|
||||
|
||||
alert("신청 완료되었습니다.");
|
||||
router.push(`/fnrs-cnsltn/${fnrsCnsltnDocNo}`);
|
||||
queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey });
|
||||
if (id) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...fnrsCnsltnMngBaseKey, "findById", id],
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("신청 실패:", error);
|
||||
alert("신청 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleApplyClick() {
|
||||
form.handleSubmit(onApply)();
|
||||
}
|
||||
|
||||
async function handleDelClick() {
|
||||
if (isSubmitting) return;
|
||||
|
||||
const docNo = initialData?.detDto?.fnrsCnsltnDocNo;
|
||||
if (!docNo) {
|
||||
alert("문서번호가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await confirm("삭제하시겠습니까?"))) return;
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
await fnrsCnsltnMngService.remove(docNo);
|
||||
alert("삭제되었습니다.");
|
||||
router.push("/fnrs-cnsltn");
|
||||
} catch (error) {
|
||||
console.error("삭제 실패:", error);
|
||||
alert("삭제 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSave)} className="mt-6 space-y-4">
|
||||
@@ -150,10 +215,8 @@ function FnrsFormPage({ id, mode, initialData, taskSeComCd }: FnrsFormPageProps)
|
||||
<div className="mx-4 flex flex-wrap justify-center gap-3 sm:gap-4">
|
||||
<Button
|
||||
color="white"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
router.back();
|
||||
}}
|
||||
type="button"
|
||||
onClick={() => router.push("/fnrs-cnsltn")}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
목록
|
||||
@@ -161,6 +224,21 @@ function FnrsFormPage({ id, mode, initialData, taskSeComCd }: FnrsFormPageProps)
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
저장
|
||||
</Button>
|
||||
{isEditMode && (
|
||||
<>
|
||||
<Button
|
||||
color="green"
|
||||
type="button"
|
||||
onClick={handleApplyClick}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
신청완료
|
||||
</Button>
|
||||
<Button color="red" type="button" onClick={handleDelClick} disabled={isSubmitting}>
|
||||
삭제
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -123,7 +123,7 @@ function FnrsListPage() {
|
||||
<Label htmlFor="taskSeComCd" className="whitespace-nowrap">
|
||||
구분
|
||||
</Label>
|
||||
<CodeSelect upCd="INSTL" value={taskSeComCd} onValueChange={setTaskSeComCd} />
|
||||
<CodeSelect upCd="FNRS" value={taskSeComCd} onValueChange={setTaskSeComCd} />
|
||||
</div>
|
||||
<div className="flex w-[24rem] items-center gap-2">
|
||||
<Label htmlFor="bizNm" className="whitespace-nowrap">
|
||||
|
||||
@@ -5,8 +5,12 @@ import { useRouter } from "next/navigation";
|
||||
|
||||
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
|
||||
|
||||
import { useDataTable } from "@/shared/lib";
|
||||
import { type FnrsCnsltnMngSearchDto, useFnrsCnsltnMngPage } from "@/entities/fnrs-cnsltn";
|
||||
import { useDataTable, useUserStore } from "@/shared/lib";
|
||||
import {
|
||||
FnrsCnsltnMngResponseDto,
|
||||
type FnrsCnsltnMngSearchDto,
|
||||
useFnrsCnsltnMngPage,
|
||||
} from "@/entities/fnrs-cnsltn";
|
||||
import { TaskSePickerDialog } from "@/widgets/pbadms-sprt";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
@@ -20,6 +24,7 @@ interface FnrsListProps {
|
||||
}
|
||||
|
||||
function FnrsList({ searchParams, pagination, onPaginationChange }: FnrsListProps) {
|
||||
const userInfo = useUserStore((state) => state.userInfo);
|
||||
const router = useRouter();
|
||||
const [isTaskSeDialogOpen, setIsTaskSeDialogOpen] = useState(false);
|
||||
|
||||
@@ -38,6 +43,16 @@ function FnrsList({ searchParams, pagination, onPaginationChange }: FnrsListProp
|
||||
const pageQuery = useFnrsCnsltnMngPage(queryParams);
|
||||
const columns = useMemo(() => createFnrsCnsltnColumns(), []);
|
||||
|
||||
const handleRowClick = (row: FnrsCnsltnMngResponseDto) => {
|
||||
const canEdit =
|
||||
(row.prgrsSttsComCd === "SV" || row.prgrsSttsComCd === "SL") &&
|
||||
row.rgtr === userInfo?.userUnqId;
|
||||
|
||||
router.push(
|
||||
canEdit ? `/fnrs-cnsltn/${row.fnrsCnsltnDocNo}/edit` : `/fnrs-cnsltn/${row.fnrsCnsltnDocNo}`,
|
||||
);
|
||||
};
|
||||
|
||||
const { tableComponent } = useDataTable({
|
||||
query: pageQuery,
|
||||
tableOptions: {
|
||||
@@ -52,7 +67,7 @@ function FnrsList({ searchParams, pagination, onPaginationChange }: FnrsListProp
|
||||
},
|
||||
dataTableOptions: {
|
||||
viewPagination: true,
|
||||
onRowClick: (row) => router.push(`/fnrs-cnsltn/${row.fnrsCnsltnDocNo}`),
|
||||
onRowClick: handleRowClick,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -21,7 +21,10 @@ import {
|
||||
Textarea,
|
||||
} from "@common/components";
|
||||
|
||||
import type { ReviewOpinionFormData } from "../../lib/review-opinion-utils";
|
||||
import type {
|
||||
ReviewOpinionFormData,
|
||||
ReviewOpinionGetFormDataResult,
|
||||
} from "../../lib/review-opinion-utils";
|
||||
import type { ReviewStepCode } from "../../model/view-types";
|
||||
import { REVIEW_STEP_LABELS } from "../../model/view-types";
|
||||
|
||||
@@ -33,7 +36,7 @@ interface TechReviewOpinionSectionProps {
|
||||
}
|
||||
|
||||
export interface TechReviewOpinionSectionHandle {
|
||||
getFormData: () => ReviewOpinionFormData | null;
|
||||
getFormData: () => ReviewOpinionGetFormDataResult | null;
|
||||
resetForm: () => void;
|
||||
}
|
||||
|
||||
@@ -101,8 +104,11 @@ const TechReviewOpinionSection = forwardRef<
|
||||
}
|
||||
|
||||
return {
|
||||
...formData,
|
||||
opnrSeComCd: editableReviewStep,
|
||||
formData: {
|
||||
...formData,
|
||||
opnrSeComCd: editableReviewStep,
|
||||
},
|
||||
rvwList,
|
||||
};
|
||||
},
|
||||
resetForm: () => {
|
||||
@@ -113,7 +119,7 @@ const TechReviewOpinionSection = forwardRef<
|
||||
setUploadedFile(null);
|
||||
},
|
||||
}),
|
||||
[editableReviewStep, formData],
|
||||
[editableReviewStep, formData, rvwList],
|
||||
);
|
||||
|
||||
const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV");
|
||||
@@ -168,8 +174,8 @@ const TechReviewOpinionSection = forwardRef<
|
||||
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
|
||||
|
||||
return (
|
||||
<div className="p-2">
|
||||
<h4 className="mb-4 text-xl font-semibold">검토의견 작성</h4>
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xl font-semibold">검토의견 작성</h4>
|
||||
<Table variant="secondary">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -268,14 +274,18 @@ const TechReviewOpinionSection = forwardRef<
|
||||
<>
|
||||
{hasEnvSection ? (
|
||||
<SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}>
|
||||
{envRvwList.length > 0 ? <TechReviewOpinionTable items={envRvwList} /> : null}
|
||||
{renderOpinionForm("ENV")}
|
||||
<div className="space-y-6">
|
||||
{envRvwList.length > 0 ? <TechReviewOpinionTable items={envRvwList} /> : null}
|
||||
{renderOpinionForm("ENV")}
|
||||
</div>
|
||||
</SectionCollapsible>
|
||||
) : null}
|
||||
{hasKecoSection ? (
|
||||
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
|
||||
{kecoRvwList.length > 0 ? <TechReviewOpinionTable items={kecoRvwList} /> : null}
|
||||
{renderOpinionForm("KECO")}
|
||||
<div className="space-y-6">
|
||||
{kecoRvwList.length > 0 ? <TechReviewOpinionTable items={kecoRvwList} /> : null}
|
||||
{renderOpinionForm("KECO")}
|
||||
</div>
|
||||
</SectionCollapsible>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface ReviewerOption {
|
||||
uuid: string;
|
||||
name: string;
|
||||
deptNm: string;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { RvwMnpwMngResponseDto, useRvwMnpwMngPage } from "@/entities/rvw-mnpw";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
import { type ReviewerOption } from "../../model/reviewer-option";
|
||||
|
||||
const PAGE_SIZE = 500;
|
||||
|
||||
interface AprvrPickerDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
atrzSeComCd: string;
|
||||
onSelect: (reviewer: ReviewerOption) => void;
|
||||
}
|
||||
|
||||
function AprvrPickerDialog({ open, onOpenChange, atrzSeComCd, onSelect }: AprvrPickerDialogProps) {
|
||||
const { data, isPending, isError } = useRvwMnpwMngPage(
|
||||
{
|
||||
atrzSeComCd,
|
||||
page: 0,
|
||||
size: PAGE_SIZE,
|
||||
sort: "userUuidNm,asc",
|
||||
},
|
||||
{ enabled: open },
|
||||
);
|
||||
|
||||
const items = data?.content ?? [];
|
||||
|
||||
function fromRvwMnpwMng(item: RvwMnpwMngResponseDto): ReviewerOption {
|
||||
return {
|
||||
uuid: item.userUuid,
|
||||
name: item.userUuidNm ?? "",
|
||||
deptNm: item.ognzNm ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function handleSelect(item: (typeof items)[number]) {
|
||||
onSelect(fromRvwMnpwMng(item));
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[56rem]" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>검토인력 선택</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="h-[36rem] overflow-auto">
|
||||
{isPending ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-[4rem]" />
|
||||
<Skeleton className="h-[16rem]" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<p className="py-8 text-center text-lg text-muted-foreground">
|
||||
검토인력 목록을 불러오지 못했습니다.
|
||||
</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="py-8 text-center text-lg text-muted-foreground">
|
||||
배치된 검토인력이 없습니다.
|
||||
</p>
|
||||
) : (
|
||||
<Table className="w-full">
|
||||
<colgroup>
|
||||
<col width="25%" />
|
||||
<col width="25%" />
|
||||
<col width="15%" />
|
||||
<col width="15%" />
|
||||
<col width="20%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>성명</TableHead>
|
||||
<TableHead>부서</TableHead>
|
||||
<TableHead>직위</TableHead>
|
||||
<TableHead>직급</TableHead>
|
||||
<TableHead>선택</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
<TableRow key={`${item.atrzSeComCd}-${item.ognzSn}-${item.userUuid}`}>
|
||||
<TableCell align="center">{item.userUuidNm || item.userUuid}</TableCell>
|
||||
<TableCell align="center">{item.ognzNm || "-"}</TableCell>
|
||||
<TableCell align="center">{item.jbpsSeNm || "-"}</TableCell>
|
||||
<TableCell align="center">{item.jbgdSeNm || "-"}</TableCell>
|
||||
<TableCell align="center">
|
||||
<Button type="button" size="sm" onClick={() => handleSelect(item)}>
|
||||
선택
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" color="white" onClick={() => onOpenChange(false)}>
|
||||
닫기
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { AprvrPickerDialog };
|
||||
@@ -0,0 +1,206 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, 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 {
|
||||
type ElaprAplnRow,
|
||||
useAplnRows,
|
||||
useDndSensors,
|
||||
useElaprAplnPage,
|
||||
validateAplnRow,
|
||||
} from "@/entities/elapr";
|
||||
import {
|
||||
Button,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
import type { ReviewerOption } from "../../model/reviewer-option";
|
||||
|
||||
import { AprvrPickerDialog } from "./aprvr-picker-dialog";
|
||||
import { SortableAplnPickerRow } from "./sortable-apln-picker-row";
|
||||
|
||||
interface ElaprAplnPickerGridProps {
|
||||
elaprTmpltId: string | null;
|
||||
onRowsChange?: (rows: ElaprAplnRow[]) => void;
|
||||
onEditingLockedChange?: (locked: boolean) => void;
|
||||
}
|
||||
|
||||
function ElaprAplnPickerGrid({
|
||||
elaprTmpltId,
|
||||
onRowsChange,
|
||||
onEditingLockedChange,
|
||||
}: ElaprAplnPickerGridProps) {
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [pickingRowKey, setPickingRowKey] = useState<string | null>(null);
|
||||
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
|
||||
const aplnQuery = useElaprAplnPage({
|
||||
elaprTmpltId: elaprTmpltId ?? undefined,
|
||||
page: 0,
|
||||
size: 200,
|
||||
sort: "atrzSeqNo,asc",
|
||||
});
|
||||
|
||||
const {
|
||||
rows,
|
||||
visibleRows,
|
||||
editingRowKey,
|
||||
isEditingLocked,
|
||||
addRow,
|
||||
startEdit,
|
||||
updateRow,
|
||||
cancelEdit,
|
||||
commitEdit,
|
||||
markDeleted,
|
||||
reorderRows,
|
||||
} = useAplnRows({
|
||||
elaprTmpltId,
|
||||
serverRows: aplnQuery.data?.content,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
onRowsChange?.(rows);
|
||||
}, [rows, onRowsChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onEditingLockedChange?.(isEditingLocked);
|
||||
}, [isEditingLocked, onEditingLockedChange]);
|
||||
|
||||
const sensors = useDndSensors();
|
||||
const isDisabled = !elaprTmpltId;
|
||||
const pickingRow = pickingRowKey ? rows.find((r) => r.rowKey === pickingRowKey) : undefined;
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
reorderRows(String(active.id), String(over.id));
|
||||
}
|
||||
|
||||
function handleRowChange(row: ElaprAplnRow, patch: Partial<ElaprAplnRow>) {
|
||||
updateRow(row.rowKey, patch);
|
||||
}
|
||||
|
||||
function handleRowSave(row: ElaprAplnRow) {
|
||||
const fieldError = validateAplnRow(row);
|
||||
if (fieldError) {
|
||||
alert(fieldError);
|
||||
return;
|
||||
}
|
||||
|
||||
commitEdit(row.rowKey);
|
||||
}
|
||||
|
||||
function handleRowDelete(row: ElaprAplnRow) {
|
||||
markDeleted(row.rowKey);
|
||||
}
|
||||
|
||||
function handleReviewerSelect(reviewer: ReviewerOption) {
|
||||
if (!pickingRowKey) return;
|
||||
updateRow(pickingRowKey, {
|
||||
aprvrUuid: reviewer.uuid,
|
||||
aprvrNm: reviewer.name,
|
||||
});
|
||||
setPickingRowKey(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card card-border mx-0 min-h-[32rem] flex-1 pt-0">
|
||||
<div className="card-header flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="text-2xl font-semibold">결재선 관리</h3>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
color="green"
|
||||
disabled={isDisabled || isEditingLocked}
|
||||
onClick={addRow}
|
||||
>
|
||||
결재선 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{aplnQuery.isPending && elaprTmpltId ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-[4rem]" />
|
||||
<Skeleton className="h-[16rem]" />
|
||||
</div>
|
||||
) : !elaprTmpltId ? (
|
||||
<p className="py-8 text-center text-lg text-muted-foreground">결재서식을 선택하세요.</p>
|
||||
) : visibleRows.length === 0 ? (
|
||||
<p className="py-8 text-center text-lg text-muted-foreground">
|
||||
등록된 결재선이 없습니다. [결재선 추가]로 결재자를 등록하세요.
|
||||
</p>
|
||||
) : (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<Table className="w-full">
|
||||
<colgroup>
|
||||
<col width="10%" />
|
||||
<col width="30%" />
|
||||
<col width="10%" />
|
||||
<col width="10%" />
|
||||
<col width="30%" />
|
||||
<col width="10%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10" />
|
||||
<TableHead>결재선아이디</TableHead>
|
||||
<TableHead>결재순서</TableHead>
|
||||
<TableHead>결재구분</TableHead>
|
||||
<TableHead>결재자</TableHead>
|
||||
<TableHead>관리</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<SortableContext
|
||||
items={visibleRows.map((r) => r.rowKey)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{visibleRows.map((row, index) => (
|
||||
<SortableAplnPickerRow
|
||||
key={row.rowKey}
|
||||
row={row}
|
||||
displaySeqNo={index + 1}
|
||||
isEditing={editingRowKey === row.rowKey}
|
||||
isLocked={isEditingLocked}
|
||||
dragDisabled={isEditingLocked || !row.atrzSeComCd}
|
||||
onStartEdit={() => startEdit(row.rowKey)}
|
||||
onSave={() => handleRowSave(row)}
|
||||
onCancel={cancelEdit}
|
||||
onDelete={() => handleRowDelete(row)}
|
||||
onChange={(patch) => handleRowChange(row, patch)}
|
||||
onPickAprvr={() => {
|
||||
setPickingRowKey(row.rowKey);
|
||||
setPickerOpen(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
<AprvrPickerDialog
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
atrzSeComCd={pickingRow?.atrzSeComCd ?? ""}
|
||||
onSelect={handleReviewerSelect}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export { ElaprAplnPickerGrid };
|
||||
@@ -2,35 +2,49 @@
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { closestCenter, DndContext, type DragEndEvent } from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import {
|
||||
buildPrgrsSttsSavePayload,
|
||||
elaprPrgrsSttsService,
|
||||
findCurrentUserPendingApprovalRequest,
|
||||
hasUnsavedPrgrsSttsChanges,
|
||||
isPrgrsSttsRowEditable,
|
||||
useDndSensors,
|
||||
useElaprDocPrgrsByDocNo,
|
||||
usePrgrsSttsRows,
|
||||
useSaveElaprPrgrsStts,
|
||||
validateAllPrgrsSttsRows,
|
||||
validateAtrzOrder,
|
||||
validatePrgrsSttsRow,
|
||||
} from "@/entities/elapr";
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Label,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Textarea,
|
||||
} from "@common/components";
|
||||
|
||||
import { getApproverStatus, sortByAtrzSeqNo } from "../../lib/approval-utils";
|
||||
import type { ReviewerOption } from "../../model/reviewer-option";
|
||||
|
||||
import { AprvrPickerDialog } from "./aprvr-picker-dialog";
|
||||
import { SortablePrgrsSttsRow } from "./sortable-prgrs-stts-row";
|
||||
|
||||
interface ElaprApprovalDialogProps {
|
||||
open: boolean;
|
||||
@@ -40,6 +54,8 @@ interface ElaprApprovalDialogProps {
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const UNSAVED_PRGRS_STTS_MESSAGE = "결재선 변경 이력이 있습니다.\n결재선 저장을 해 주세요.";
|
||||
|
||||
function ElaprApprovalDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -51,12 +67,33 @@ function ElaprApprovalDialog({
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const [rjctRsn, setRjctRsn] = useState("");
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [pickingRowKey, setPickingRowKey] = useState<string | null>(null);
|
||||
const [finalApprovalYn, setFinalApprovalYn] = useState(false);
|
||||
|
||||
const { data: elaprDocPrgrsList = [], isLoading } = useElaprDocPrgrsByDocNo(
|
||||
open ? elaprDocNo : null,
|
||||
);
|
||||
const { data: elaprDocPrgrsList, isLoading } = useElaprDocPrgrsByDocNo(open ? elaprDocNo : null);
|
||||
|
||||
const orderedApprovers = sortByAtrzSeqNo(elaprDocPrgrsList);
|
||||
const elaprDmndId = elaprDocPrgrsList?.[0]?.elaprDmndId;
|
||||
|
||||
const {
|
||||
rows,
|
||||
visibleRows,
|
||||
editingRowKey,
|
||||
isEditingLocked,
|
||||
addRow,
|
||||
startEdit,
|
||||
updateRow,
|
||||
cancelEdit,
|
||||
commitEdit,
|
||||
markDeleted,
|
||||
reorderRows,
|
||||
} = usePrgrsSttsRows({
|
||||
serverRows: elaprDocPrgrsList,
|
||||
elaprDmndId,
|
||||
});
|
||||
|
||||
const savePrgrsSttsMutation = useSaveElaprPrgrsStts();
|
||||
const sensors = useDndSensors();
|
||||
|
||||
const currentUserPrgrs = findCurrentUserPendingApprovalRequest(
|
||||
elaprDocPrgrsList,
|
||||
@@ -64,13 +101,106 @@ function ElaprApprovalDialog({
|
||||
);
|
||||
const canApprove = Boolean(currentUserPrgrs?.elaprPrgrsId);
|
||||
|
||||
const pickingRow = pickingRowKey ? rows.find((r) => r.rowKey === pickingRowKey) : undefined;
|
||||
const isBusy = isProcessing || savePrgrsSttsMutation.isPending;
|
||||
const isPrgrsSttsUnsaved = hasUnsavedPrgrsSttsChanges(rows, elaprDocPrgrsList ?? []);
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
reorderRows(String(active.id), String(over.id));
|
||||
}
|
||||
|
||||
function handleRowSave(rowKey: string) {
|
||||
const row = rows.find((r) => r.rowKey === rowKey);
|
||||
if (!row) return;
|
||||
|
||||
const fieldError = validatePrgrsSttsRow(row);
|
||||
if (fieldError) {
|
||||
alert(fieldError);
|
||||
return;
|
||||
}
|
||||
|
||||
commitEdit(rowKey);
|
||||
}
|
||||
|
||||
function handleReviewerSelect(reviewer: ReviewerOption) {
|
||||
if (!pickingRowKey) return;
|
||||
updateRow(pickingRowKey, {
|
||||
aprvrUuid: reviewer.uuid,
|
||||
aprvrNm: reviewer.name,
|
||||
ognzNm: reviewer.deptNm,
|
||||
});
|
||||
setPickingRowKey(null);
|
||||
}
|
||||
|
||||
function assertPrgrsSttsSaved(): boolean {
|
||||
if (isEditingLocked) {
|
||||
alert(UNSAVED_PRGRS_STTS_MESSAGE);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isPrgrsSttsUnsaved) {
|
||||
alert(UNSAVED_PRGRS_STTS_MESSAGE);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleSavePrgrsStts() {
|
||||
if (!elaprDmndId) {
|
||||
alert("결재 요청 정보가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const validationError = validateAllPrgrsSttsRows(rows, isEditingLocked);
|
||||
if (validationError) {
|
||||
alert(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
const orderError = validateAtrzOrder(rows);
|
||||
if (orderError) {
|
||||
alert(orderError);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = buildPrgrsSttsSavePayload(rows, elaprDmndId, elaprDocPrgrsList ?? []);
|
||||
if (payload.length === 0) {
|
||||
alert("변경된 결재선이 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
await savePrgrsSttsMutation.mutateAsync(payload);
|
||||
alert("결재선이 저장되었습니다.");
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, "findDocPrgrsByDocNo", elaprDocNo],
|
||||
});
|
||||
onSuccess();
|
||||
} catch (error: unknown) {
|
||||
let errorMessage = "결재선 저장 중 오류가 발생했습니다.";
|
||||
if (error instanceof AxiosError) {
|
||||
errorMessage = error.response?.data?.message ?? errorMessage;
|
||||
}
|
||||
alert(errorMessage);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApprove() {
|
||||
if (!currentUserPrgrs?.elaprPrgrsId) return;
|
||||
if (!assertPrgrsSttsSaved()) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
await elaprPrgrsSttsService.process(currentUserPrgrs.elaprPrgrsId, {
|
||||
atrzPrgrsSttsComCd: "ATRZ_PRGRS_003",
|
||||
finalApprovalYn,
|
||||
});
|
||||
alert("결재가 완료되었습니다.");
|
||||
queryClient.invalidateQueries({
|
||||
@@ -97,11 +227,13 @@ function ElaprApprovalDialog({
|
||||
alert("반려 사유를 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
if (!assertPrgrsSttsSaved()) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
await elaprPrgrsSttsService.process(currentUserPrgrs.elaprPrgrsId, {
|
||||
atrzPrgrsSttsComCd: "ATRZ_PRGRS_004",
|
||||
rjctRsn: rjctRsn.trim(),
|
||||
});
|
||||
alert("반려 처리가 완료되었습니다.");
|
||||
queryClient.invalidateQueries({
|
||||
@@ -124,6 +256,7 @@ function ElaprApprovalDialog({
|
||||
|
||||
function handleClose() {
|
||||
setRjctRsn("");
|
||||
setPickingRowKey(null);
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
@@ -136,61 +269,97 @@ function ElaprApprovalDialog({
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="mb-3 text-xl font-semibold">결재 진행 단계</h4>
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<h4 className="text-xl font-semibold">결재 진행 단계</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
color="white"
|
||||
disabled={!elaprDmndId || isEditingLocked || isBusy}
|
||||
onClick={addRow}
|
||||
>
|
||||
결재선 추가
|
||||
</Button>
|
||||
{elaprDmndId && (
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={handleSavePrgrsStts}
|
||||
disabled={!isPrgrsSttsUnsaved || isEditingLocked || isBusy}
|
||||
>
|
||||
결재선 저장
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
) : orderedApprovers.length === 0 ? (
|
||||
<div className="text-center text-gray-500">결재 정보가 없습니다.</div>
|
||||
) : visibleRows.length === 0 ? (
|
||||
<div className="py-8 text-center text-gray-500">
|
||||
{elaprDmndId
|
||||
? "등록된 결재선이 없습니다. [결재선 추가]로 결재자를 등록하세요."
|
||||
: "결재 정보가 없습니다."}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table variant="line">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<Table className="w-full">
|
||||
<colgroup>
|
||||
<col width="8%" />
|
||||
<col width="10%" />
|
||||
<col width="15%" />
|
||||
<col width="35%" />
|
||||
<col width="12%" />
|
||||
<col width="20%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{orderedApprovers.map((row) => (
|
||||
<TableHead key={String(row.elaprPrgrsId ?? row.atrzSeComCd)}>
|
||||
{row.atrzSeComNm ?? row.atrzSeComCd}
|
||||
</TableHead>
|
||||
))}
|
||||
<TableHead className="w-10" />
|
||||
<TableHead>결재순서</TableHead>
|
||||
<TableHead>결재구분</TableHead>
|
||||
<TableHead>결재자</TableHead>
|
||||
<TableHead>결재상태</TableHead>
|
||||
<TableHead>관리</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
{orderedApprovers.map((row) => {
|
||||
const status = row.atrzPrgrsSttsComCd ?? "";
|
||||
const st = getApproverStatus(status);
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
key={String(row.elaprPrgrsId ?? row.atrzSeComCd)}
|
||||
align="center"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-md px-2 py-1 text-base font-semibold",
|
||||
st.badgeClass,
|
||||
)}
|
||||
>
|
||||
{st.label}
|
||||
</span>
|
||||
<span className="font-semibold">{row.aprvrUuidNm ?? "-"}</span>
|
||||
</div>
|
||||
<div className="mt-2 text-base text-gray-500">
|
||||
{row.ognzNm ?? ""}
|
||||
{row.jbpsSeNm || row.jbgdSeNm
|
||||
? ` · ${row.jbpsSeNm ?? ""}${row.jbgdSeNm ? ` ${row.jbgdSeNm}` : ""}`
|
||||
: ""}
|
||||
</div>
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
<SortableContext
|
||||
items={visibleRows.map((r) => r.rowKey)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{visibleRows.map((row, index) => (
|
||||
<SortablePrgrsSttsRow
|
||||
key={row.rowKey}
|
||||
row={row}
|
||||
displaySeqNo={index + 1}
|
||||
isEditing={editingRowKey === row.rowKey}
|
||||
isLocked={isEditingLocked}
|
||||
dragDisabled={
|
||||
isEditingLocked || !isPrgrsSttsRowEditable(row) || !row.atrzSeComCd
|
||||
}
|
||||
onStartEdit={() => startEdit(row.rowKey)}
|
||||
onSave={() => handleRowSave(row.rowKey)}
|
||||
onCancel={cancelEdit}
|
||||
onDelete={() => markDeleted(row.rowKey)}
|
||||
onChange={(patch) => updateRow(row.rowKey, patch)}
|
||||
onPickAprvr={() => {
|
||||
setPickingRowKey(row.rowKey);
|
||||
setPickerOpen(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -209,21 +378,40 @@ function ElaprApprovalDialog({
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" color="white" onClick={handleClose} disabled={isProcessing}>
|
||||
취소
|
||||
</Button>
|
||||
{canApprove && (
|
||||
<>
|
||||
<Button color="red" onClick={handleReject} disabled={isProcessing}>
|
||||
반려
|
||||
</Button>
|
||||
<Button onClick={handleApprove} disabled={isProcessing}>
|
||||
결재
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<Button type="button" color="white" onClick={handleClose} disabled={isBusy}>
|
||||
취소
|
||||
</Button>
|
||||
{canApprove && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="mr-2 flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="all-approve"
|
||||
checked={finalApprovalYn}
|
||||
onCheckedChange={(checked) => setFinalApprovalYn(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="all-approve" className="text-xl">
|
||||
전결
|
||||
</Label>
|
||||
</div>
|
||||
<Button onClick={handleApprove} disabled={isBusy}>
|
||||
결재
|
||||
</Button>
|
||||
<Button color="red" onClick={handleReject} disabled={isBusy}>
|
||||
반려
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
<AprvrPickerDialog
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
atrzSeComCd={pickingRow?.atrzSeComCd ?? ""}
|
||||
onSelect={handleReviewerSelect}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,137 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { type ElaprTmplt, useElaprAplnPage, useElaprTmpltPage } from "@/entities/elapr";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
interface ElaprTmpltPickerDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (tmplt: ElaprTmplt) => void;
|
||||
}
|
||||
|
||||
function ElaprTmpltPickerDialog({ open, onClose, onSelect }: ElaprTmpltPickerDialogProps) {
|
||||
const { data, isPending, isError } = useElaprTmpltPage({
|
||||
page: 0,
|
||||
size: PAGE_SIZE,
|
||||
sort: "regDt,desc",
|
||||
docSeComCd: "INSTL",
|
||||
});
|
||||
|
||||
const items = data?.content ?? [];
|
||||
|
||||
function handleSelect(tmplt: ElaprTmplt) {
|
||||
onSelect(tmplt);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-[80rem]" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>결재선 선택</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[36rem] overflow-auto">
|
||||
{isPending ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-[4rem]" />
|
||||
<Skeleton className="h-[16rem]" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<p className="py-8 text-center text-lg text-muted-foreground">
|
||||
결재서식 목록을 불러오지 못했습니다.
|
||||
</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="py-8 text-center text-lg text-muted-foreground">
|
||||
선택 가능한 결재서식이 없습니다.
|
||||
</p>
|
||||
) : (
|
||||
<Table className="w-full">
|
||||
<colgroup>
|
||||
<col width="25%" />
|
||||
<col width="25%" />
|
||||
<col width="40%" />
|
||||
<col width="10%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>결재서식명</TableHead>
|
||||
<TableHead>서식 설명</TableHead>
|
||||
<TableHead>검토자</TableHead>
|
||||
<TableHead>선택</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
<TableRow key={item.elaprTmpltId}>
|
||||
<TableCell align="center">{item.elaprTmpltNm}</TableCell>
|
||||
<TableCell align="center">{item.elaprTmpltExpln || "-"}</TableCell>
|
||||
<TableCell align="center">
|
||||
<ElaprAplnCell elaprTmpltId={item.elaprTmpltId} />
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Button type="button" size="sm" onClick={() => handleSelect(item)}>
|
||||
선택
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" color="white" onClick={onClose}>
|
||||
닫기
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ElaprAplnCell({ elaprTmpltId }: { elaprTmpltId: string }) {
|
||||
const { data } = useElaprAplnPage({
|
||||
elaprTmpltId,
|
||||
page: 0,
|
||||
size: PAGE_SIZE,
|
||||
sort: "atrzSeqNo,asc",
|
||||
});
|
||||
|
||||
const items = data?.content ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{items.map((item) => (
|
||||
<Fragment key={item.aplnId}>
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge color="gray">{item.atrzSeComNm}</Badge>
|
||||
{item.aprvrNm}
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { ElaprTmpltPickerDialog };
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import {
|
||||
type ElaprAplnRow,
|
||||
type ElaprTmplt,
|
||||
getVisibleRows,
|
||||
useElaprTmpltPage,
|
||||
validateAllRowsForSave,
|
||||
validateAtrzOrder,
|
||||
validateUniqueSeqNo,
|
||||
} from "@/entities/elapr";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@common/components";
|
||||
|
||||
import { ElaprAplnPickerGrid } from "./elapr-apln-picker-grid";
|
||||
import { ElaprTmpltSelectTable } from "./elapr-tmplt-select-table";
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
interface ElaprTmpltPickerConfirmPayload {
|
||||
tmplt: ElaprTmplt;
|
||||
aplnRows: ElaprAplnRow[];
|
||||
}
|
||||
|
||||
interface ElaprTmpltPickerDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect?: (tmplt: ElaprTmplt) => void;
|
||||
onConfirm?: (payload: ElaprTmpltPickerConfirmPayload) => void;
|
||||
}
|
||||
|
||||
function ElaprTmpltPickerDialog({
|
||||
open,
|
||||
onClose,
|
||||
onSelect,
|
||||
onConfirm,
|
||||
}: ElaprTmpltPickerDialogProps) {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const [selectedTmplt, setSelectedTmplt] = useState<ElaprTmplt | null>(null);
|
||||
const [aplnRows, setAplnRows] = useState<ElaprAplnRow[]>([]);
|
||||
const [isEditingLocked, setIsEditingLocked] = useState(false);
|
||||
|
||||
const tmpltQuery = useElaprTmpltPage({
|
||||
page: 0,
|
||||
size: PAGE_SIZE,
|
||||
sort: "regDt,desc",
|
||||
docSeComCd: "INSTL",
|
||||
});
|
||||
|
||||
const handleRowsChange = useCallback((rows: ElaprAplnRow[]) => {
|
||||
setAplnRows(rows);
|
||||
}, []);
|
||||
|
||||
const handleEditingLockedChange = useCallback((locked: boolean) => {
|
||||
setIsEditingLocked(locked);
|
||||
}, []);
|
||||
|
||||
function handleSelectTmplt(tmplt: ElaprTmplt | null) {
|
||||
setSelectedTmplt(tmplt);
|
||||
setAplnRows([]);
|
||||
setIsEditingLocked(false);
|
||||
}
|
||||
|
||||
function handleOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
setSelectedTmplt(null);
|
||||
setAplnRows([]);
|
||||
setIsEditingLocked(false);
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (!selectedTmplt) {
|
||||
alert("결재서식을 선택해 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const rowsError = validateAllRowsForSave(aplnRows, isEditingLocked);
|
||||
if (rowsError) {
|
||||
alert(rowsError);
|
||||
return;
|
||||
}
|
||||
|
||||
const orderError = validateAtrzOrder(aplnRows);
|
||||
if (orderError) {
|
||||
alert(orderError);
|
||||
return;
|
||||
}
|
||||
|
||||
const seqError = validateUniqueSeqNo(aplnRows);
|
||||
if (seqError) {
|
||||
alert(seqError);
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleRows = getVisibleRows(aplnRows);
|
||||
|
||||
const payload: ElaprTmpltPickerConfirmPayload = {
|
||||
tmplt: selectedTmplt,
|
||||
aplnRows: visibleRows,
|
||||
};
|
||||
|
||||
if (onConfirm) {
|
||||
onConfirm(payload);
|
||||
} else {
|
||||
onSelect?.(selectedTmplt);
|
||||
}
|
||||
|
||||
setSelectedTmplt(null);
|
||||
setAplnRows([]);
|
||||
setIsEditingLocked(false);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-[120rem]" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>결재선 선택</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex gap-6">
|
||||
<ElaprTmpltSelectTable
|
||||
query={tmpltQuery}
|
||||
selectedTmpltId={selectedTmplt?.elaprTmpltId ?? null}
|
||||
onSelectTmplt={handleSelectTmplt}
|
||||
/>
|
||||
<ElaprAplnPickerGrid
|
||||
elaprTmpltId={selectedTmplt?.elaprTmpltId ?? null}
|
||||
onRowsChange={handleRowsChange}
|
||||
onEditingLockedChange={handleEditingLockedChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" color="white" onClick={() => handleOpenChange(false)}>
|
||||
닫기
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!selectedTmplt || isEditingLocked}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
확인
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { ElaprTmpltPickerDialog };
|
||||
export type { ElaprTmpltPickerConfirmPayload };
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type { UseQueryResult } from "@tanstack/react-query";
|
||||
import { createColumnHelper, getCoreRowModel, type RowSelectionState } from "@tanstack/react-table";
|
||||
|
||||
import { useDataTable } from "@/shared/lib";
|
||||
import type { PageResponse } from "@/shared/model";
|
||||
import type { ElaprTmplt } from "@/entities/elapr";
|
||||
import { Skeleton } from "@common/components";
|
||||
|
||||
const columnHelper = createColumnHelper<ElaprTmplt>();
|
||||
|
||||
interface ElaprTmpltSelectTableProps {
|
||||
query: UseQueryResult<PageResponse<ElaprTmplt>>;
|
||||
selectedTmpltId: string | null;
|
||||
onSelectTmplt: (tmplt: ElaprTmplt | null) => void;
|
||||
}
|
||||
|
||||
function ElaprTmpltSelectTable({
|
||||
query,
|
||||
selectedTmpltId,
|
||||
onSelectTmplt,
|
||||
}: ElaprTmpltSelectTableProps) {
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTmpltId) {
|
||||
setRowSelection({ [selectedTmpltId]: true });
|
||||
} else {
|
||||
setRowSelection({});
|
||||
}
|
||||
}, [selectedTmpltId]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
columnHelper.accessor("elaprTmpltId", {
|
||||
header: "전자결재서식아이디",
|
||||
meta: { align: "left" },
|
||||
}),
|
||||
columnHelper.accessor("elaprTmpltNm", {
|
||||
header: "결재서식명",
|
||||
meta: { align: "left" },
|
||||
}),
|
||||
columnHelper.accessor("docSeComNm", {
|
||||
header: "문서구분",
|
||||
cell: ({ getValue, row }) => getValue() ?? row.original.docSeComCd ?? "—",
|
||||
}),
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const { tableComponent } = useDataTable({
|
||||
query,
|
||||
tableOptions: {
|
||||
columns,
|
||||
state: { rowSelection },
|
||||
enableRowSelection: true,
|
||||
enableMultiRowSelection: false,
|
||||
getRowId: (row) => row.elaprTmpltId,
|
||||
onRowSelectionChange: (updater) => {
|
||||
const next = typeof updater === "function" ? updater(rowSelection) : updater;
|
||||
setRowSelection(next);
|
||||
const selectedId = Object.keys(next)[0];
|
||||
if (!selectedId) {
|
||||
onSelectTmplt(null);
|
||||
return;
|
||||
}
|
||||
const selected = query.data?.content.find((t) => t.elaprTmpltId === selectedId);
|
||||
onSelectTmplt(selected ?? null);
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
},
|
||||
dataTableOptions: {
|
||||
onRowClick: (row) => {
|
||||
setRowSelection({ [row.elaprTmpltId]: true });
|
||||
onSelectTmplt(row);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="card card-border mx-0 min-h-[32rem] flex-[0.5] bg-background pt-0">
|
||||
<div className="card-header h-[4.6rem]">
|
||||
<h3 className="text-2xl font-semibold">전자결재서식</h3>
|
||||
</div>
|
||||
{query.isPending && (
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
<Skeleton className="h-[4rem]" />
|
||||
<Skeleton className="h-[16rem]" />
|
||||
</div>
|
||||
)}
|
||||
{query.isError && (
|
||||
<p className="py-8 text-center text-lg text-muted-foreground">
|
||||
결재서식 목록을 불러오지 못했습니다.
|
||||
</p>
|
||||
)}
|
||||
{!query.isPending && !query.isError && tableComponent}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export { ElaprTmpltSelectTable };
|
||||
@@ -10,12 +10,10 @@ import { AxiosError } from "axios";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useAlertStore, useUserStore } from "@/shared/lib";
|
||||
import {
|
||||
buildElaprPrgrsSttsListFromApln,
|
||||
elaprAplnService,
|
||||
buildElaprPrgrsSttsListFromAplnRows,
|
||||
type ElaprDmndSaveRequestDto,
|
||||
elaprDmndService,
|
||||
hasCurrentUserPendingApprovalRequest,
|
||||
type ElaprTmplt,
|
||||
useElaprDocPrgrsByDocNo,
|
||||
} from "@/entities/elapr";
|
||||
import {
|
||||
@@ -41,7 +39,10 @@ import { type ChecklistFormState } from "../../model/view-types";
|
||||
import type { TechReviewOpinionSectionHandle } from "../sections/tech-review-opinion-section";
|
||||
|
||||
import { ElaprApprovalDialog } from "./elapr-approval-dialog";
|
||||
import { ElaprTmpltPickerDialog } from "./elapr-tmplt-picker-dialog";
|
||||
import {
|
||||
type ElaprTmpltPickerConfirmPayload,
|
||||
ElaprTmpltPickerDialog,
|
||||
} from "./elapr-tmplt-picker-dialog";
|
||||
|
||||
interface FloatingActionButtonsProps {
|
||||
data: InstlSncnMngDetResponse;
|
||||
@@ -146,7 +147,7 @@ function FloatingActionButtons({
|
||||
request,
|
||||
data,
|
||||
onSuccess: () => {
|
||||
alert(successMessage ?? "처리가 완료되었습니다.");
|
||||
if (successMessage) alert(successMessage);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: instlSncnKeys.detail(instlSncnDocNo),
|
||||
});
|
||||
@@ -332,19 +333,12 @@ function FloatingActionButtons({
|
||||
});
|
||||
}
|
||||
|
||||
async function handleElaprTmpltSelect(tmplt: ElaprTmplt) {
|
||||
const aplnPage = await elaprAplnService.findPage({
|
||||
elaprTmpltId: tmplt.elaprTmpltId,
|
||||
page: 0,
|
||||
size: 200,
|
||||
sort: "atrzSeqNo,asc",
|
||||
});
|
||||
|
||||
async function handleElaprTmpltConfirm({ aplnRows }: ElaprTmpltPickerConfirmPayload) {
|
||||
const payload: ElaprDmndSaveRequestDto = {
|
||||
elaprDocNo: data.detDto.instlSncnDocNo,
|
||||
elaprDocNo: docNo,
|
||||
dmndTtl: data.detDto.bizNm ?? "",
|
||||
dmndCn: data.detDto.bizCn ?? "",
|
||||
elaprPrgrsSttsList: buildElaprPrgrsSttsListFromApln(aplnPage.content),
|
||||
elaprPrgrsSttsList: buildElaprPrgrsSttsListFromAplnRows(aplnRows),
|
||||
};
|
||||
|
||||
await executeAction({
|
||||
@@ -393,7 +387,7 @@ function FloatingActionButtons({
|
||||
checklistFormState,
|
||||
"KECO",
|
||||
"TE",
|
||||
"CP",
|
||||
"SV",
|
||||
getReviewOpinionFormData(),
|
||||
opnrNm,
|
||||
);
|
||||
@@ -401,9 +395,9 @@ function FloatingActionButtons({
|
||||
await executeAction({
|
||||
action: INSTL_SNCN_VIEW_BUTTON_ACTION.rvwCmptn,
|
||||
request: payload,
|
||||
successMessage: "검토완료 처리되었습니다.",
|
||||
});
|
||||
techReviewOpinionRef?.current?.resetForm();
|
||||
setActiveDialog("elaprApproval");
|
||||
}
|
||||
|
||||
function handleDmndSplmntClick() {
|
||||
@@ -522,7 +516,7 @@ function FloatingActionButtons({
|
||||
<ElaprTmpltPickerDialog
|
||||
open={activeDialog === "elaprTmplt"}
|
||||
onClose={() => setActiveDialog(null)}
|
||||
onSelect={handleElaprTmpltSelect}
|
||||
onConfirm={handleElaprTmpltConfirm}
|
||||
/>
|
||||
|
||||
<TextInputDialog
|
||||
@@ -540,8 +534,6 @@ function FloatingActionButtons({
|
||||
elaprDocNo={data.detDto.instlSncnDocNo}
|
||||
currentUserUuid={userInfo?.userUnqId ?? ""}
|
||||
onSuccess={() => {
|
||||
setActiveDialog(null);
|
||||
alert("결재가 완료되었습니다.");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: instlSncnKeys.detail(instlSncnDocNo),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { MoveVertical } from "lucide-react";
|
||||
|
||||
import { CodeSelect, findCodeLabel, useCodeList } from "@/entities/code";
|
||||
import type { ElaprAplnRow } from "@/entities/elapr";
|
||||
import { Button, TableCell, TableRow } from "@common/components";
|
||||
|
||||
interface SortableAplnPickerRowProps {
|
||||
row: ElaprAplnRow;
|
||||
displaySeqNo: number;
|
||||
isEditing: boolean;
|
||||
isLocked: boolean;
|
||||
dragDisabled: boolean;
|
||||
onStartEdit: () => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
onDelete: () => void;
|
||||
onChange: (patch: Partial<ElaprAplnRow>) => void;
|
||||
onPickAprvr: () => void;
|
||||
}
|
||||
|
||||
function SortableAplnPickerRow({
|
||||
row,
|
||||
displaySeqNo,
|
||||
isEditing,
|
||||
isLocked,
|
||||
dragDisabled,
|
||||
onStartEdit,
|
||||
onSave,
|
||||
onCancel,
|
||||
onDelete,
|
||||
onChange,
|
||||
onPickAprvr,
|
||||
}: SortableAplnPickerRowProps) {
|
||||
const { data: atrzSeCodes } = useCodeList("ATRZSE");
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: row.rowKey,
|
||||
disabled: dragDisabled,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.6 : 1,
|
||||
};
|
||||
|
||||
const rowDisabled = isLocked && !isEditing;
|
||||
const atrzSeLabel = row.atrzSeComNm || findCodeLabel(atrzSeCodes, row.atrzSeComCd);
|
||||
|
||||
return (
|
||||
<TableRow ref={setNodeRef} style={style} className={isEditing ? "bg-accent/30" : undefined}>
|
||||
<TableCell align="center" className="w-10">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
className="cursor-grab !bg-transparent text-muted-foreground disabled:cursor-not-allowed disabled:opacity-40"
|
||||
disabled={dragDisabled}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label="순서 변경"
|
||||
>
|
||||
<MoveVertical className="!size-6" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell align="center">{row.isNew ? "-" : (row.aplnId ?? "-")}</TableCell>
|
||||
<TableCell align="center">{displaySeqNo}</TableCell>
|
||||
<TableCell align="center">
|
||||
{isEditing ? (
|
||||
<CodeSelect
|
||||
upCd="ATRZSE"
|
||||
className="min-w-[120px]"
|
||||
value={row.atrzSeComCd ?? ""}
|
||||
onValueChange={(v) =>
|
||||
onChange({
|
||||
atrzSeComCd: v,
|
||||
atrzSeComNm: findCodeLabel(atrzSeCodes, v),
|
||||
})
|
||||
}
|
||||
placeholder="결재구분"
|
||||
/>
|
||||
) : (
|
||||
atrzSeLabel || "-"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<span>{row.aprvrNm || row.aprvrUuid || "-"}</span>
|
||||
{isEditing && (
|
||||
<Button type="button" size="sm" onClick={onPickAprvr}>
|
||||
선택
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<div className="flex justify-center gap-2">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button type="button" size="sm" onClick={onSave}>
|
||||
확인
|
||||
</Button>
|
||||
<Button type="button" size="sm" color="white" onClick={onCancel}>
|
||||
취소
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="white"
|
||||
disabled={rowDisabled}
|
||||
onClick={onStartEdit}
|
||||
>
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="gray"
|
||||
disabled={rowDisabled}
|
||||
onClick={onDelete}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export { SortableAplnPickerRow };
|
||||
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { MoveVertical } from "lucide-react";
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import { CodeSelect, findCodeLabel, useCodeList } from "@/entities/code";
|
||||
import { type ElaprPrgrsSttsRow, isPrgrsSttsRowEditable } from "@/entities/elapr";
|
||||
import { Button, cn, TableCell, TableRow } from "@common/components";
|
||||
|
||||
import { getApproverStatus } from "../../lib/approval-utils";
|
||||
|
||||
interface SortablePrgrsSttsRowProps {
|
||||
row: ElaprPrgrsSttsRow;
|
||||
displaySeqNo: number;
|
||||
isEditing: boolean;
|
||||
isLocked: boolean;
|
||||
dragDisabled: boolean;
|
||||
onStartEdit: () => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
onDelete: () => void;
|
||||
onChange: (patch: Partial<ElaprPrgrsSttsRow>) => void;
|
||||
onPickAprvr: () => void;
|
||||
}
|
||||
|
||||
function SortablePrgrsSttsRow({
|
||||
row,
|
||||
displaySeqNo,
|
||||
isEditing,
|
||||
isLocked,
|
||||
dragDisabled,
|
||||
onStartEdit,
|
||||
onSave,
|
||||
onCancel,
|
||||
onDelete,
|
||||
onChange,
|
||||
onPickAprvr,
|
||||
}: SortablePrgrsSttsRowProps) {
|
||||
const { data: atrzSeCodes } = useCodeList("ATRZSE");
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: row.rowKey,
|
||||
disabled: dragDisabled,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.6 : 1,
|
||||
};
|
||||
|
||||
const isEditable = isPrgrsSttsRowEditable(row);
|
||||
const rowDisabled = isLocked && !isEditing;
|
||||
const atrzSeLabel = row.atrzSeComNm || findCodeLabel(atrzSeCodes, row.atrzSeComCd);
|
||||
const status = getApproverStatus(row.atrzPrgrsSttsComCd);
|
||||
const isRejectedRow = row.atrzPrgrsSttsComCd === "ATRZ_PRGRS_004";
|
||||
const rjctRsn = row.rjctRsn?.trim();
|
||||
|
||||
const statusBadge = (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-sm px-3 py-1.5 text-lg font-semibold",
|
||||
status.badgeClass,
|
||||
isRejectedRow && rjctRsn && "cursor-help underline decoration-dotted underline-offset-4",
|
||||
)}
|
||||
>
|
||||
{status.label}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<TableRow ref={setNodeRef} style={style} className={isEditing ? "bg-accent/30" : undefined}>
|
||||
<TableCell align="center" className="w-10">
|
||||
{!dragDisabled && (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
className="cursor-grab !bg-transparent text-muted-foreground disabled:cursor-not-allowed disabled:opacity-40"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label="순서 변경"
|
||||
>
|
||||
<MoveVertical className="!size-6" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">{displaySeqNo}</TableCell>
|
||||
<TableCell align="center">
|
||||
{isEditing ? (
|
||||
<CodeSelect
|
||||
upCd="ATRZSE"
|
||||
className="min-w-[120px]"
|
||||
value={row.atrzSeComCd ?? ""}
|
||||
onValueChange={(v) =>
|
||||
onChange({
|
||||
atrzSeComCd: v,
|
||||
atrzSeComNm: findCodeLabel(atrzSeCodes, v),
|
||||
})
|
||||
}
|
||||
placeholder="결재구분"
|
||||
/>
|
||||
) : (
|
||||
atrzSeLabel || "-"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span>{row.aprvrNm || row.aprvrUuid || "-"}</span>
|
||||
{isEditing && (
|
||||
<Button type="button" size="sm" onClick={onPickAprvr}>
|
||||
선택
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{!isEditing && (row.ognzNm || row.jbpsSeNm || row.jbgdSeNm) && (
|
||||
<div className="text-base text-gray-500">
|
||||
{row.ognzNm ?? ""}
|
||||
{row.jbpsSeNm ? ` · ${row.jbpsSeNm}` : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{isRejectedRow && rjctRsn ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{statusBadge}</TooltipTrigger>
|
||||
<TooltipContent sideOffset={4} className="max-w-md whitespace-pre-wrap">
|
||||
{rjctRsn}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
statusBadge
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<div className="flex justify-center gap-2">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button type="button" size="sm" onClick={onSave}>
|
||||
확인
|
||||
</Button>
|
||||
<Button type="button" size="sm" color="white" onClick={onCancel}>
|
||||
취소
|
||||
</Button>
|
||||
</>
|
||||
) : isEditable ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="white"
|
||||
disabled={rowDisabled}
|
||||
onClick={onStartEdit}
|
||||
>
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="gray"
|
||||
disabled={rowDisabled}
|
||||
onClick={onDelete}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export { SortablePrgrsSttsRow };
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useInstlSncnDetail } from "@/entities/instl-sncn";
|
||||
import { Skeleton } from "@common/components";
|
||||
|
||||
import { InstlFormPage } from "./instl-form-page";
|
||||
|
||||
@@ -9,7 +10,26 @@ interface InstlEditPageProps {
|
||||
}
|
||||
|
||||
function InstlEditPage({ id }: InstlEditPageProps) {
|
||||
const { data, isError } = useInstlSncnDetail(id);
|
||||
const { data, isError, isLoading } = useInstlSncnDetail(id);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="mt-6 space-y-6 px-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-[4rem] w-full" />
|
||||
<Skeleton className="h-[20rem] w-full" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-[4rem] w-full" />
|
||||
<Skeleton className="h-[20rem] w-full" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-[4rem] w-full" />
|
||||
<Skeleton className="h-[20rem] w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return <div className="mt-6 text-xl">수정 대상을 찾을 수 없습니다.</div>;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
BusinessScaleFormSection,
|
||||
ChecklistFormSection,
|
||||
ContactInfoFormSection,
|
||||
isFormChecklistComplete,
|
||||
PrivacyConsentSection,
|
||||
} from "@/widgets/pbadms-sprt";
|
||||
import { Button } from "@common/components";
|
||||
@@ -41,6 +43,7 @@ interface InstlFormPageProps {
|
||||
function InstlFormPage({ id, mode, initialData, taskSeComCd }: InstlFormPageProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const { confirm } = useConfirm();
|
||||
const userInfo = useUserStore((state) => state.userInfo);
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
@@ -55,32 +58,44 @@ function InstlFormPage({ id, mode, initialData, taskSeComCd }: InstlFormPageProp
|
||||
: getCreateDefaultValues(userInfo, taskSeComCd),
|
||||
});
|
||||
|
||||
async function submitForm(
|
||||
data: InstlSncnRegistFormData,
|
||||
prgrsSttsComCd: "SV" | "ST",
|
||||
): Promise<string | undefined> {
|
||||
const { taskSeComCd: formTaskSeComCd, ...instlSncnMngRest } = data.instlSncnMng;
|
||||
|
||||
const payload = {
|
||||
instlSncnMng: {
|
||||
...instlSncnMngRest,
|
||||
instlSncnDocNo: isEditMode ? initialData?.detDto.instlSncnDocNo : undefined,
|
||||
taskSeComCd: formatTaskSeComCdForApi(formTaskSeComCd),
|
||||
prgrsSttsComCd,
|
||||
},
|
||||
bzScaleList: data.bzScaleList,
|
||||
bizCstList: data.bizCstList,
|
||||
cklstResultList: data.cklstResultList.map((item) => ({
|
||||
...item,
|
||||
selected: item.selected,
|
||||
})),
|
||||
esntlAtchFileList: data.esntlAtchFileList,
|
||||
};
|
||||
|
||||
return instlSncnService.saveApply(payload);
|
||||
}
|
||||
|
||||
async function onSave(data: InstlSncnRegistFormData) {
|
||||
if (isSubmitting) return;
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
|
||||
const { taskSeComCd, ...instlSncnMngRest } = data.instlSncnMng;
|
||||
|
||||
const payload = {
|
||||
instlSncnMng: {
|
||||
...instlSncnMngRest,
|
||||
instlSncnDocNo: isEditMode ? initialData?.detDto.instlSncnDocNo : undefined,
|
||||
taskSeComCd: formatTaskSeComCdForApi(taskSeComCd),
|
||||
prgrsSttsComCd: "SV",
|
||||
},
|
||||
bzScaleList: data.bzScaleList,
|
||||
bizCstList: data.bizCstList,
|
||||
cklstResultList: data.cklstResultList,
|
||||
esntlAtchFileList: data.esntlAtchFileList,
|
||||
};
|
||||
|
||||
const instlSncnDocNo = await instlSncnService.saveApply(payload);
|
||||
const instlSncnDocNo = await submitForm(data, "SV");
|
||||
if (!instlSncnDocNo) return;
|
||||
|
||||
alert("저장되었습니다.");
|
||||
router.push(`/instl-sncn/${instlSncnDocNo}`);
|
||||
if (isEditMode && id) queryClient.invalidateQueries({ queryKey: instlSncnKeys.detail(id) });
|
||||
router.push(`/instl-sncn/${instlSncnDocNo}/edit`);
|
||||
if (isEditMode && id) {
|
||||
queryClient.invalidateQueries({ queryKey: instlSncnKeys.detail(id) });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("저장 실패:", error);
|
||||
alert("저장 중 오류가 발생했습니다.");
|
||||
@@ -89,6 +104,61 @@ function InstlFormPage({ id, mode, initialData, taskSeComCd }: InstlFormPageProp
|
||||
}
|
||||
}
|
||||
|
||||
async function onApply(data: InstlSncnRegistFormData) {
|
||||
if (isSubmitting) return;
|
||||
|
||||
if (!isFormChecklistComplete(data.cklstResultList)) {
|
||||
alert("모든 체크리스트 항목을 체크해야 신청이 가능합니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
const instlSncnDocNo = await submitForm(data, "ST");
|
||||
if (!instlSncnDocNo) return;
|
||||
|
||||
alert("신청 완료되었습니다.");
|
||||
router.push(`/instl-sncn/${instlSncnDocNo}`);
|
||||
queryClient.invalidateQueries({ queryKey: instlSncnKeys.list() });
|
||||
if (id) {
|
||||
queryClient.invalidateQueries({ queryKey: instlSncnKeys.detail(id) });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("신청 실패:", error);
|
||||
alert("신청 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleApplyClick() {
|
||||
form.handleSubmit(onApply)();
|
||||
}
|
||||
|
||||
async function handleDelClick() {
|
||||
if (isSubmitting) return;
|
||||
|
||||
const docNo = initialData?.detDto.instlSncnDocNo;
|
||||
if (!docNo) {
|
||||
alert("문서번호가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await confirm("삭제하시겠습니까?"))) return;
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
await instlSncnService.remove(docNo);
|
||||
alert("삭제되었습니다.");
|
||||
router.push("/instl-sncn");
|
||||
} catch (error) {
|
||||
console.error("삭제 실패:", error);
|
||||
alert("삭제 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSave)} className="mt-6 space-y-4">
|
||||
@@ -109,10 +179,8 @@ function InstlFormPage({ id, mode, initialData, taskSeComCd }: InstlFormPageProp
|
||||
<div className="mx-4 flex flex-wrap justify-center gap-3 sm:gap-4">
|
||||
<Button
|
||||
color="white"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
router.back();
|
||||
}}
|
||||
type="button"
|
||||
onClick={() => router.push("/instl-sncn")}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
목록
|
||||
@@ -120,6 +188,21 @@ function InstlFormPage({ id, mode, initialData, taskSeComCd }: InstlFormPageProp
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
저장
|
||||
</Button>
|
||||
{isEditMode && (
|
||||
<>
|
||||
<Button
|
||||
color="green"
|
||||
type="button"
|
||||
onClick={handleApplyClick}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
신청완료
|
||||
</Button>
|
||||
<Button color="red" type="button" onClick={handleDelClick} disabled={isSubmitting}>
|
||||
삭제
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -5,8 +5,12 @@ import { useRouter } from "next/navigation";
|
||||
|
||||
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
|
||||
|
||||
import { useDataTable } from "@/shared/lib";
|
||||
import { type InstlSncnMngSearchParams, useInstlSncnList } from "@/entities/instl-sncn";
|
||||
import { useDataTable, useUserStore } from "@/shared/lib";
|
||||
import {
|
||||
type InstlSncnMngResponseDto,
|
||||
type InstlSncnMngSearchParams,
|
||||
useInstlSncnList,
|
||||
} from "@/entities/instl-sncn";
|
||||
import { TaskSePickerDialog } from "@/widgets/pbadms-sprt";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
@@ -20,6 +24,7 @@ interface InstlListProps {
|
||||
}
|
||||
|
||||
function InstlList({ searchParams, pagination, onPaginationChange }: InstlListProps) {
|
||||
const userInfo = useUserStore((state) => state.userInfo);
|
||||
const router = useRouter();
|
||||
const [isTaskSeDialogOpen, setIsTaskSeDialogOpen] = useState(false);
|
||||
|
||||
@@ -31,7 +36,7 @@ function InstlList({ searchParams, pagination, onPaginationChange }: InstlListPr
|
||||
sggComCd: searchParams.sggComCd,
|
||||
page: pagination.pageIndex,
|
||||
size: pagination.pageSize,
|
||||
sort: searchParams.sort ?? "regDt,desc",
|
||||
sort: searchParams.sort ?? "instlSncnDocNo,desc",
|
||||
}),
|
||||
[searchParams, pagination],
|
||||
);
|
||||
@@ -39,6 +44,16 @@ function InstlList({ searchParams, pagination, onPaginationChange }: InstlListPr
|
||||
const pageQuery = useInstlSncnList(queryParams);
|
||||
const columns = useMemo(() => createInstlSncnColumns(), []);
|
||||
|
||||
const handleRowClick = (row: InstlSncnMngResponseDto) => {
|
||||
const canEdit =
|
||||
(row.prgrsSttsComCd === "SV" || row.prgrsSttsComCd === "SL") &&
|
||||
row.rgtr === userInfo?.userUnqId;
|
||||
|
||||
router.push(
|
||||
canEdit ? `/instl-sncn/${row.instlSncnDocNo}/edit` : `/instl-sncn/${row.instlSncnDocNo}`,
|
||||
);
|
||||
};
|
||||
|
||||
const { tableComponent } = useDataTable({
|
||||
query: pageQuery,
|
||||
tableOptions: {
|
||||
@@ -53,7 +68,7 @@ function InstlList({ searchParams, pagination, onPaginationChange }: InstlListPr
|
||||
},
|
||||
dataTableOptions: {
|
||||
viewPagination: true,
|
||||
onRowClick: (row) => router.push(`/instl-sncn/${row.instlSncnDocNo}`),
|
||||
onRowClick: handleRowClick,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { dayjs } from "@/shared/lib";
|
||||
import type { InstlSncnMngResponseDto } from "@/entities/instl-sncn";
|
||||
import { SectionCollapsible } from "@/widgets/pbadms-sprt";
|
||||
import { Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
|
||||
|
||||
interface HistoryInfoSectionProps {
|
||||
det: InstlSncnMngResponseDto;
|
||||
}
|
||||
|
||||
function HistoryInfoSection({ det }: HistoryInfoSectionProps) {
|
||||
return (
|
||||
<SectionCollapsible title="이력 정보">
|
||||
<Table variant="secondary">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>등록일시</TableHead>
|
||||
<TableCell>
|
||||
{det.regDt ? dayjs(det.regDt).format("YYYY-MM-DD HH:mm:ss") : "-"}
|
||||
</TableCell>
|
||||
<TableHead>수정일시</TableHead>
|
||||
<TableCell>
|
||||
{det.mdfcnDt ? dayjs(det.mdfcnDt).format("YYYY-MM-DD HH:mm:ss") : "-"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>등록자명</TableHead>
|
||||
<TableCell>{det.rgtrNm || "-"}</TableCell>
|
||||
<TableHead>첨부파일그룹ID</TableHead>
|
||||
<TableCell>{det.atchFileGroupId || "-"}</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
</Table>
|
||||
</SectionCollapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export { HistoryInfoSection };
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
import { cn } from "@common/components";
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"origin-(--radix-tooltip-content-transform-origin) animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit text-balance rounded-md border border-border bg-background px-3 py-1.5 text-lg text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[-50%] rotate-45 rounded-[2px] border-b border-r border-border bg-background fill-background" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
|
||||
@@ -8,6 +8,7 @@ export { buildAcntgCostItems, recalculateBizCstTotals } from "./lib/business-cos
|
||||
export { buildCnstpScaleItems, recalculateBzScaleTotals } from "./lib/business-scale-form-utils";
|
||||
export {
|
||||
buildChecklistTabs,
|
||||
isFormChecklistComplete,
|
||||
orderCklstTreeNodes,
|
||||
syncCklstResultListFromTree,
|
||||
} from "./lib/checklist-form-utils";
|
||||
|
||||
@@ -159,3 +159,8 @@ export function syncCklstResultListFromTree(
|
||||
if (isSameCklstResultList(prev, next)) return prev;
|
||||
return next;
|
||||
}
|
||||
|
||||
export function isFormChecklistComplete(cklstResultList: CklstResultListItem[]): boolean {
|
||||
if (cklstResultList.length === 0) return true;
|
||||
return cklstResultList.every((item) => item.selected === "Y" || item.selected === "N");
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ export function validateBizCstSharBalance(
|
||||
);
|
||||
|
||||
if (exstnRemaining !== 0 || chgRqtRemaining !== 0) {
|
||||
return "총사업비를 확인해주세요.";
|
||||
return "재원분담 총사업비를 확인해주세요.";
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -43,6 +43,34 @@ export const sdmInfoFormSchema = z.object({
|
||||
|
||||
export type SdmInfoFormData = z.infer<typeof sdmInfoFormSchema>;
|
||||
|
||||
const formatTelno = (telno?: string | null) => {
|
||||
const digits = (telno ?? '').replace(/\D/g, '');
|
||||
|
||||
if (!digits) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (digits.startsWith('02')) {
|
||||
if (digits.length === 9) {
|
||||
return digits.replace(/(02)(\d{3})(\d{4})/, '$1-$2-$3');
|
||||
}
|
||||
|
||||
if (digits.length === 10) {
|
||||
return digits.replace(/(02)(\d{4})(\d{4})/, '$1-$2-$3');
|
||||
}
|
||||
}
|
||||
|
||||
if (digits.length === 10) {
|
||||
return digits.replace(/(\d{3})(\d{3})(\d{4})/, '$1-$2-$3');
|
||||
}
|
||||
|
||||
if (digits.length === 11) {
|
||||
return digits.replace(/(\d{3})(\d{4})(\d{4})/, '$1-$2-$3');
|
||||
}
|
||||
|
||||
return telno ?? '';
|
||||
};
|
||||
|
||||
|
||||
// 수요처 정보
|
||||
export type DemandCompBizType = {
|
||||
@@ -530,7 +558,7 @@ const getDemandCompList = async (searchText: string, pageCnt: number) => {
|
||||
|
||||
const getSdmInfoStatusList = async (dmdInfoId: string) => {
|
||||
try {
|
||||
const url = '/reuseFc/request/' + dmdInfoId;
|
||||
const url = '/reuseFc/request/' + dmdInfoId + '?infoListYn=Y';
|
||||
const res = await getFetch(fetchType.WR, url);
|
||||
return res?.data || [];
|
||||
} catch (error) {
|
||||
@@ -694,7 +722,7 @@ export const SdmReducer = (set: any): SdmActions => {
|
||||
dmdInfoId: res.dmdInfoId,
|
||||
zip: res.zip || '',
|
||||
daddr: res.daddr,
|
||||
telno: res.telno.replace(/(\d{3})(\d{4})(\d{4})/, '$1-$2-$3'),
|
||||
telno: formatTelno(res.telno),
|
||||
rgtr: res.rgtr,
|
||||
ctpvCd: res.ctpvCd,
|
||||
sggCd: res.sggCd,
|
||||
|
||||
@@ -70,6 +70,16 @@ export default function CoSttn() {
|
||||
const [updateData, setUpdateData] = useState<DemandCompType>();
|
||||
const [isResetSelection, setIsResetSelection] = useState<boolean>(false);
|
||||
|
||||
const sanitizeDemandForPopup = useCallback((data?: DemandCompType) => {
|
||||
if (!data) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const nextData = { ...data };
|
||||
nextData.dmdAplyPswd = "";
|
||||
return nextData;
|
||||
}, []);
|
||||
|
||||
const getSelCity = useCallback(
|
||||
(val: string | number) => {
|
||||
const cityValue = String(val);
|
||||
@@ -330,11 +340,13 @@ export default function CoSttn() {
|
||||
total={coSttnResCount || 0}
|
||||
cellClick={async(index: number | undefined) => {
|
||||
const uuid = await getMyUUID();
|
||||
const updateData = coSttnRes[index as number];
|
||||
if (updateData && typeof uuid === "string") {
|
||||
updateData.mdfr = uuid;
|
||||
const selectedData = sanitizeDemandForPopup(
|
||||
coSttnRes[index as number]
|
||||
);
|
||||
if (selectedData && typeof uuid === "string") {
|
||||
selectedData.mdfr = uuid;
|
||||
}
|
||||
setUpdateData(coSttnRes[index as number]);
|
||||
setUpdateData(selectedData);
|
||||
}}
|
||||
onPageChange={(page) => {
|
||||
const con =
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
} from "@common/business_components/ui";
|
||||
import ListDiv from "@common/business_components/ui/list_div";
|
||||
import { Button, Label, Table, TableBody, TableHeader } from "@common/components/ui";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAlertStore } from "@/src/shared/lib";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { headMakeprfmnc } from "../chkgdnReg/TableHead";
|
||||
@@ -150,6 +150,7 @@ export default function PrfmncPrfmncReg() {
|
||||
const [searchTxt, setSearchTxt] = useState<string>("");
|
||||
const [selectedFacilityId, setSelectedFacilityId] = useState<string>("");
|
||||
const [selectedIndex, setSelectedIndex] = useState<number>(0);
|
||||
const [shouldLoadFirstRow, setShouldLoadFirstRow] = useState(true);
|
||||
const [inputData, setInputData] = useState<any[]>([]);
|
||||
const [isExcelOpen, setIsExcelOPen] = useState<{
|
||||
isModal: boolean;
|
||||
@@ -159,6 +160,7 @@ export default function PrfmncPrfmncReg() {
|
||||
const { confirm } = useConfirm();
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const { t } = useTranslation("base");
|
||||
const prevSearchYearRef = useRef(searchYear);
|
||||
|
||||
const bdstTable = useMemo<PbnstPrfmncRow[]>(
|
||||
() =>
|
||||
@@ -190,7 +192,6 @@ export default function PrfmncPrfmncReg() {
|
||||
|
||||
const detail = await getPbnstPrfRegDtl(searchYear, facilityId);
|
||||
if (detail) {
|
||||
setSelectedFacilityId(facilityId);
|
||||
setIsExcelOPen((prev) => ({
|
||||
...prev,
|
||||
data: detail,
|
||||
@@ -203,6 +204,7 @@ export default function PrfmncPrfmncReg() {
|
||||
const handleSearch = useCallback(() => {
|
||||
if (!laeSeCd) return;
|
||||
|
||||
setShouldLoadFirstRow(true);
|
||||
setIsReset(true);
|
||||
}, [laeSeCd]);
|
||||
|
||||
@@ -233,6 +235,7 @@ export default function PrfmncPrfmncReg() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!laeSeCd) return;
|
||||
setShouldLoadFirstRow(true);
|
||||
void prfmncHasuInit(1, laeSeCd);
|
||||
}, [laeSeCd, prfmncHasuInit]);
|
||||
|
||||
@@ -245,20 +248,21 @@ export default function PrfmncPrfmncReg() {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextIndex = Math.max(
|
||||
prfmncRes.findIndex((item) => item.pbnstRuseFcltId === selectedFacilityId),
|
||||
0
|
||||
);
|
||||
const nextFacility = prfmncRes[nextIndex];
|
||||
if (!nextFacility) {
|
||||
return;
|
||||
}
|
||||
if (!shouldLoadFirstRow) return;
|
||||
|
||||
setSelectedIndex(nextIndex);
|
||||
void loadDetail(nextFacility.pbnstRuseFcltId);
|
||||
}, [loadDetail, prfmncRes]);
|
||||
const firstFacility = prfmncRes[0];
|
||||
if (!firstFacility) return;
|
||||
|
||||
setSelectedIndex(0);
|
||||
setSelectedFacilityId(firstFacility.pbnstRuseFcltId);
|
||||
void loadDetail(firstFacility.pbnstRuseFcltId);
|
||||
setShouldLoadFirstRow(false);
|
||||
}, [loadDetail, prfmncRes, shouldLoadFirstRow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevSearchYearRef.current === searchYear) return;
|
||||
|
||||
prevSearchYearRef.current = searchYear;
|
||||
if (!selectedFacilityId) return;
|
||||
void loadDetail(selectedFacilityId);
|
||||
}, [loadDetail, searchYear, selectedFacilityId]);
|
||||
@@ -342,7 +346,7 @@ export default function PrfmncPrfmncReg() {
|
||||
<div className="grid grid-cols-12 gap-4 h-full">
|
||||
<div className="col-span-4 h-full min-h-0">
|
||||
<ListDiv className="min-h-0">
|
||||
<ScrollArea className="h-[60vh]">
|
||||
<ScrollArea className="h-[75vh]">
|
||||
<UiTable
|
||||
key={"leftTable"}
|
||||
tableData={bdstTable}
|
||||
@@ -356,9 +360,12 @@ export default function PrfmncPrfmncReg() {
|
||||
if (!selected) return;
|
||||
|
||||
setSelectedIndex(index);
|
||||
setSelectedFacilityId(selected.pbnstRuseFcltId);
|
||||
setShouldLoadFirstRow(false);
|
||||
void loadDetail(selected.pbnstRuseFcltId);
|
||||
}}
|
||||
onPageChange={(page: number) => {
|
||||
setShouldLoadFirstRow(true);
|
||||
if (searchTxt.trim().length > 0) {
|
||||
void prfmncHasuNmSearch(page, laeSeCd, searchTxt);
|
||||
} else {
|
||||
@@ -373,7 +380,7 @@ export default function PrfmncPrfmncReg() {
|
||||
</ListDiv>
|
||||
</div>
|
||||
<ListDiv className="col-span-8 pr-6 ">
|
||||
<ScrollArea className="h-[60vh]">
|
||||
<ScrollArea className="h-[75vh]">
|
||||
<div className="card-header">
|
||||
<h3 className="text-2xl font-medium text-label">
|
||||
{"하수처리수재이용 실적(m³) 등록결과"}
|
||||
|
||||
@@ -10,11 +10,11 @@ export const pbnPrfRegExcelZod = z.object({
|
||||
});
|
||||
|
||||
export const pbnstPrfmncHeadListExport: TableUpperProps[] = [
|
||||
{ id: "sido", title: "시도" },
|
||||
{ id: "gugun", title: "시군구" },
|
||||
{ id: "bscFctNm", title: "시설명" },
|
||||
{ id: "ruseUtztnFcltNm", title: "재이용시설명" },
|
||||
{ id: "pbnstRuseFcltId", title: "재이용시설코드" },
|
||||
{ id: "sido", title: "시도", widthPercent: 13 },
|
||||
{ id: "gugun", title: "시군구", widthPercent: 16 },
|
||||
{ id: "bscFctNm", title: "시설명", widthPercent: 24 },
|
||||
{ id: "ruseUtztnFcltNm", title: "재이용시설명", widthPercent: 27 },
|
||||
{ id: "pbnstRuseFcltId", title: "재이용시설코드", widthPercent: 20 },
|
||||
];
|
||||
|
||||
export const monthHeadListExport: TableUpperProps[] = [
|
||||
|
||||
@@ -20,7 +20,7 @@ export type pbnstPrfmncState = {
|
||||
isInit: boolean;
|
||||
pbnstPrfmncHeadList: TableUpperProps[];
|
||||
monthHeadList: TableUpperProps[];
|
||||
prfmncRes: PbnstPrfRegListType[];
|
||||
prfmncRes: PbnstPrfRegListType[]; // 하수재이용시설 목록
|
||||
prfmncResCount: number;
|
||||
excelList: number[];
|
||||
pageKey: number;
|
||||
|
||||
@@ -7,6 +7,8 @@ export default function CoSttnDetailPopup({isDetail} :{isDetail:DemandCompType})
|
||||
code
|
||||
} = useWrStore((state) => state);
|
||||
|
||||
const detailData = { ...isDetail };
|
||||
detailData.dmdAplyPswd = "";
|
||||
|
||||
return(
|
||||
<div>
|
||||
@@ -21,7 +23,7 @@ export default function CoSttnDetailPopup({isDetail} :{isDetail:DemandCompType})
|
||||
console.log(data)
|
||||
}}
|
||||
bodyClassNm={"w-full mx-auto bg-background rounded-lg shadow-md p-6"}
|
||||
updateList={isDetail}
|
||||
updateList={detailData}
|
||||
readOnly={true}
|
||||
openNetwork={false}
|
||||
></SdmInsertForm>
|
||||
|
||||
@@ -49,10 +49,13 @@ function CommonCodeInitializer() {
|
||||
useEffect(() => {
|
||||
if (isLoginPage) return;
|
||||
if (isInit) return;
|
||||
setTimeout(() => {
|
||||
initCommonCodes();
|
||||
}, 3000); // 3초 후에 하는 것으로 왜냐하면 로그인 처리가 먼저 되어야 하기 때문에
|
||||
|
||||
|
||||
// 첫 페인트 이후 바로 공통 코드를 불러와 초기 화면 지연을 줄입니다.
|
||||
const timer = window.setTimeout(() => {
|
||||
void initCommonCodes();
|
||||
}, 2000);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [isInit, initCommonCodes, isLoginPage]);
|
||||
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user