[as] feat: 설치인가 전자결재 로직 수정

This commit is contained in:
2026-07-09 17:39:03 +09:00
parent 0141ed56c2
commit 343a3f81bf
15 changed files with 1696 additions and 287 deletions
@@ -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` |
@@ -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">
@@ -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>
+19 -4
View File
@@ -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 };