Merge branch 'iipbs/wr/dev' into iipbs/sn/dev
This commit is contained in:
@@ -29,4 +29,5 @@ settings.json
|
||||
.agent
|
||||
|
||||
apps/**/tsconfig.tsbuildinfo
|
||||
packages/**/tsconfig.tsbuildinfo
|
||||
logs/
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { AcntgCodeManagementPage as default } from "@/pages/acntg-code-management";
|
||||
@@ -1 +0,0 @@
|
||||
export { CnstpCodeManagementPage as default } from "@/pages/cnstp-code-management";
|
||||
@@ -0,0 +1 @@
|
||||
export { CodeManagementPage as default } from "@/pages/code-management";
|
||||
@@ -0,0 +1,10 @@
|
||||
import { FnrsEditPage } from "@/pages/fnrs-cnsltn";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function Page({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
return <FnrsEditPage id={id} />;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { FnrsViewPage } from "@/pages/fnrs-cnsltn";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function Page({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
return <FnrsViewPage id={id} />;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { FnrsListPage } from "@/pages/fnrs-cnsltn";
|
||||
|
||||
export default FnrsListPage;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { FnrsRegistPage } from "@/pages/fnrs-cnsltn";
|
||||
import { parseTaskSeComCdFromApi } from "@/pages/fnrs-cnsltn/lib/fnrs-cnsltn-regist-schema";
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ taskSeComCd?: string }>;
|
||||
}
|
||||
|
||||
export default async function Page({ searchParams }: PageProps) {
|
||||
const params = await searchParams;
|
||||
const taskSeComCd = parseTaskSeComCdFromApi(params.taskSeComCd);
|
||||
return <FnrsRegistPage taskSeComCd={taskSeComCd.length ? taskSeComCd : undefined} />;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { CnfmModifyPage } from "@/pages/instl-sncn-old";
|
||||
|
||||
export default async function InstlSncnModifyPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return <CnfmModifyPage id={id} />;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { CnfmViewPage } from "@/pages/instl-sncn-old";
|
||||
|
||||
export default async function InstlSncnViewPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return <CnfmViewPage id={id} />;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import { CnfmListPage } from "@/pages/instl-sncn-old";
|
||||
|
||||
export default CnfmListPage;
|
||||
@@ -1,5 +0,0 @@
|
||||
import { CnfmRegistPage } from "@/pages/instl-sncn-old";
|
||||
|
||||
export default function Page(props: any) {
|
||||
return <CnfmRegistPage {...props} />;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ACCESS_TOKEN,
|
||||
AUTH_USER_ID,
|
||||
CLAIM_NAME,
|
||||
COOKIE_DOMAIN,
|
||||
REFRESH_TOKEN,
|
||||
SERVER_API_URL,
|
||||
SITE_ID,
|
||||
@@ -20,6 +21,10 @@ async function handler(
|
||||
"Content-Type": "application/json",
|
||||
"X-Site-Id": SITE_ID,
|
||||
};
|
||||
const deviceId = request.headers.get("X-Device-Id");
|
||||
if (deviceId) {
|
||||
headers["X-Device-Id"] = deviceId;
|
||||
}
|
||||
let body = {};
|
||||
const isRefreshHandler = params.url.includes("refresh");
|
||||
|
||||
@@ -39,27 +44,13 @@ async function handler(
|
||||
}
|
||||
|
||||
const targetUrl = `${SERVER_API_URL}/${params.url.join("/")}`;
|
||||
console.log(`[Proxy API] ${method} ${params.url.join("/")} -> ${targetUrl}`);
|
||||
console.log(`[Proxy API] Headers:`, headers);
|
||||
if (!isRefreshHandler) {
|
||||
console.log(`[Proxy API] Body:`, body);
|
||||
}
|
||||
|
||||
const result = await fetch(targetUrl, {
|
||||
method,
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
console.log(`[Proxy API] Response Status: ${result.status}`);
|
||||
console.log(`[Proxy API] Response Headers:`, {
|
||||
[ACCESS_TOKEN]: result.headers.get(ACCESS_TOKEN),
|
||||
[REFRESH_TOKEN]: result.headers.get(REFRESH_TOKEN) ? '***hidden***' : undefined,
|
||||
[AUTH_USER_ID]: result.headers.get(AUTH_USER_ID),
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
console.error(`[Proxy API] Request failed with status ${result.status}`);
|
||||
return NextResponse.json({ message: "Invalid Credentials" }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -68,24 +59,16 @@ async function handler(
|
||||
const userId = result.headers.get(AUTH_USER_ID);
|
||||
|
||||
if (accessToken && userId) {
|
||||
console.log(`[Proxy API] Login successful for user: ${userId}`);
|
||||
const response = NextResponse.json({
|
||||
[ACCESS_TOKEN]: accessToken,
|
||||
[AUTH_USER_ID]: userId,
|
||||
});
|
||||
|
||||
const forwardedHost = request.headers.get("x-forwarded-host");
|
||||
const host = request.headers.get("host");
|
||||
const actualHost = forwardedHost || host || new URL(request.url).hostname.toString();
|
||||
|
||||
// 운영 환경에서 domain 설정
|
||||
const domain = actualHost.includes("hasuon.or.kr") ? ".hasuon.or.kr" : actualHost;
|
||||
|
||||
if (refreshToken) {
|
||||
cookieStore.set(REFRESH_TOKEN, refreshToken, {
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
domain: domain,
|
||||
domain: getCookieDomain(),
|
||||
path: "/",
|
||||
});
|
||||
}
|
||||
@@ -109,3 +92,14 @@ export async function PUT(request: Request, props: { params: Promise<{ url: stri
|
||||
const params = await props.params;
|
||||
return handler(request, params, "PUT");
|
||||
}
|
||||
|
||||
function getCookieDomain(): string | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
|
||||
if (COOKIE_DOMAIN) return COOKIE_DOMAIN;
|
||||
|
||||
const { hostname } = window.location;
|
||||
if (hostname === "localhost" || hostname === "127.0.0.1") return undefined;
|
||||
|
||||
return hostname;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
"@tanstack/react-query": "^5.75.5",
|
||||
"@tanstack/react-query-devtools": "^5.75.5",
|
||||
"@tanstack/react-table": "^8.21.2",
|
||||
@@ -31,6 +32,7 @@
|
||||
"next": "15.5.18",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"tus-js-client": "^4.3.1",
|
||||
"uuid": "^14.0.1",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -7,6 +7,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useMenuStore } from "@/shared/lib";
|
||||
import { PageLayout } from "@/shared/ui/page-layout";
|
||||
import { authService } from "@/entities/auth";
|
||||
import type { MenuItem } from "@/entities/menu";
|
||||
import { BaseLayout } from "@common/components";
|
||||
|
||||
@@ -32,6 +33,7 @@ export function BaseLayoutWrapper({ children }: BaseLayoutWrapperProps) {
|
||||
return (
|
||||
<BaseLayout
|
||||
queryClient={queryClient}
|
||||
authService={authService}
|
||||
currentPath={pathname ?? ""}
|
||||
handleMenuClick={handleMenuClick}
|
||||
>
|
||||
|
||||
@@ -27,7 +27,9 @@ export function Providers({ children }: ProvidersProps) {
|
||||
</AuthWrapper>
|
||||
</ConfirmProvider>
|
||||
</StoreProvider>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
<div style={{ fontSize: "1.4rem" }}>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface AcntgSeMngResponseDto {
|
||||
cdLvNo: number;
|
||||
useYn: string;
|
||||
expln?: string;
|
||||
sortSeq?: number;
|
||||
rgtr?: string;
|
||||
rgtrNm?: string;
|
||||
mdfr?: string;
|
||||
@@ -39,6 +40,7 @@ export interface AcntgSeMngSaveRequestDto {
|
||||
cdLvNo: number;
|
||||
useYn?: string;
|
||||
expln?: string;
|
||||
sortSeq?: number;
|
||||
}
|
||||
|
||||
/** PUT /{acntgSeCd} 요청 (mode는 서비스에서 U로 설정) */
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { ACCESS_TOKEN, AUTH_USER_ID, CLAIM_NAME, ENDPOINT } from "@/shared/config";
|
||||
import { getRemainingMs } from "@common/auth";
|
||||
import { type AuthPayload, type AuthService, getRemainingMs } from "@common/auth";
|
||||
|
||||
import { clearAuthToken, setToken, setupRefreshTimer } from "../lib/token-manager";
|
||||
import type { AuthPayload, AuthService } from "../model/auth-interface";
|
||||
import { clearDeviceId, getOrCreateDeviceId } from "../lib/device-id";
|
||||
import { clearRefreshTimer, setToken, setupRefreshTimer, tokenManager } from "../lib/token-manager";
|
||||
|
||||
async function login(payload: AuthPayload): Promise<void> {
|
||||
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Device-Id": getOrCreateDeviceId(),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
@@ -34,6 +37,9 @@ async function silentRefresh(): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"X-Device-Id": getOrCreateDeviceId(),
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
@@ -58,7 +64,7 @@ async function silentRefresh(): Promise<void> {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[silentRefresh] Failed at ${new Date().toISOString()}`, error);
|
||||
clearAuthToken();
|
||||
tokenManager.clearAuthToken();
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("auth:logout"));
|
||||
}
|
||||
@@ -66,7 +72,9 @@ async function silentRefresh(): Promise<void> {
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
clearAuthToken();
|
||||
tokenManager.clearAuthToken();
|
||||
clearDeviceId();
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("auth:logout"));
|
||||
}
|
||||
@@ -75,5 +83,6 @@ async function logout(): Promise<void> {
|
||||
export const authService: AuthService = {
|
||||
login,
|
||||
silentRefresh,
|
||||
declineSessionExtend: clearRefreshTimer,
|
||||
logout,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
const DEVICE_ID_KEY = "x-device-id";
|
||||
|
||||
export function getOrCreateDeviceId(): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
const existing = localStorage.getItem(DEVICE_ID_KEY);
|
||||
if (existing) return existing;
|
||||
const id = uuidv4();
|
||||
localStorage.setItem(DEVICE_ID_KEY, id);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function clearDeviceId(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.removeItem(DEVICE_ID_KEY);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ export const {
|
||||
setToken,
|
||||
removeToken,
|
||||
setupRefreshTimer,
|
||||
clearAuthToken,
|
||||
clearRefreshTimer,
|
||||
getAccessToken,
|
||||
getRefreshToken,
|
||||
getUserId,
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
export interface AuthPayload {
|
||||
userId: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface AuthService {
|
||||
login(payload: AuthPayload): Promise<void>;
|
||||
silentRefresh(): Promise<void>;
|
||||
logout(): Promise<void>;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export interface CnstpSeMngResponseDto {
|
||||
cdLvNo: number;
|
||||
useYn: string;
|
||||
expln?: string;
|
||||
sortSeq?: number;
|
||||
rgtr?: string;
|
||||
rgtrNm?: string;
|
||||
mdfr?: string;
|
||||
@@ -39,6 +40,7 @@ export interface CnstpSeMngSaveRequestDto {
|
||||
cdLvNo?: number;
|
||||
useYn?: string;
|
||||
expln?: string;
|
||||
sortSeq?: number;
|
||||
}
|
||||
|
||||
/** PUT /{cnstpSeCd} 요청 (mode는 서비스에서 U로 설정) */
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { client } from "@/shared/api";
|
||||
|
||||
export async function processElaprPrgrs(elaprPrgrsId: string, atrzPrgrsSttsComCd = "ATRZ_STTS_003"): Promise<void> {
|
||||
await client.put(`/as-elapr-service/api/v1/elapr-prgrs-stts/${elaprPrgrsId}/process`, {
|
||||
atrzPrgrsSttsComCd,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type { PageResponse } from "@/shared/model";
|
||||
|
||||
import type {
|
||||
ElaprApproverChangeRequestDto,
|
||||
ElaprApproverInsertRequestDto,
|
||||
ElaprPrgrsSttsMyDocSearchParams,
|
||||
ElaprPrgrsSttsResponseDto,
|
||||
ElaprPrgrsSttsSaveRequestDto,
|
||||
ElaprPrgrsSttsService,
|
||||
ElaprProcessRequestDto,
|
||||
} from "../model/elapr-prgrs-stts-interface";
|
||||
|
||||
const BASE = ENDPOINT.AS_ELAPR_SERVICE.ELAPR_PRGRS_STTS;
|
||||
|
||||
async function findAllByDmndId(elaprDmndId?: string): Promise<ElaprPrgrsSttsResponseDto[]> {
|
||||
const response = await client.get<ElaprPrgrsSttsResponseDto[]>(BASE, {
|
||||
params: elaprDmndId ? { elaprDmndId } : undefined,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findById(elaprPrgrsId: string): Promise<ElaprPrgrsSttsResponseDto> {
|
||||
const response = await client.get<ElaprPrgrsSttsResponseDto>(`${BASE}/${elaprPrgrsId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function save(
|
||||
requestDtos: ElaprPrgrsSttsSaveRequestDto[],
|
||||
): Promise<ElaprPrgrsSttsResponseDto[]> {
|
||||
const response = await client.post<ElaprPrgrsSttsResponseDto[]>(BASE, requestDtos);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function process(
|
||||
elaprPrgrsId: string,
|
||||
body: ElaprProcessRequestDto,
|
||||
): Promise<ElaprPrgrsSttsResponseDto> {
|
||||
const response = await client.put<ElaprPrgrsSttsResponseDto>(
|
||||
`${BASE}/${elaprPrgrsId}/process`,
|
||||
body,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function processByDocNoAndAprvrUuid(
|
||||
elaprDocNo: string,
|
||||
aprvrUuid: string,
|
||||
body: ElaprProcessRequestDto,
|
||||
): Promise<ElaprPrgrsSttsResponseDto> {
|
||||
const response = await client.put<ElaprPrgrsSttsResponseDto>(
|
||||
`${BASE}/doc/${elaprDocNo}/aprvr/${aprvrUuid}/process`,
|
||||
body,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function deletePrgrsStts(elaprPrgrsId: string): Promise<void> {
|
||||
await client.delete(`${BASE}/${elaprPrgrsId}`);
|
||||
}
|
||||
|
||||
async function updateApprover(
|
||||
elaprPrgrsId: string,
|
||||
body: ElaprApproverChangeRequestDto,
|
||||
): Promise<ElaprPrgrsSttsResponseDto> {
|
||||
const response = await client.put<ElaprPrgrsSttsResponseDto>(
|
||||
`${BASE}/${elaprPrgrsId}/approver`,
|
||||
body,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function insertApprover(
|
||||
elaprDmndId: string,
|
||||
body: ElaprApproverInsertRequestDto,
|
||||
): Promise<ElaprPrgrsSttsResponseDto> {
|
||||
const response = await client.post<ElaprPrgrsSttsResponseDto>(
|
||||
`${BASE}/${elaprDmndId}/approver/insert`,
|
||||
body,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findMyElaperDocList(
|
||||
params: ElaprPrgrsSttsMyDocSearchParams,
|
||||
): Promise<PageResponse<ElaprPrgrsSttsResponseDto>> {
|
||||
const response = await client.get<PageResponse<ElaprPrgrsSttsResponseDto>>(`${BASE}/my/doc`, {
|
||||
params,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const elaprPrgrsSttsService = {
|
||||
findAllByDmndId,
|
||||
findById,
|
||||
save,
|
||||
process,
|
||||
processByDocNoAndAprvrUuid,
|
||||
delete: deletePrgrsStts,
|
||||
updateApprover,
|
||||
insertApprover,
|
||||
findMyElaperDocList,
|
||||
} satisfies ElaprPrgrsSttsService;
|
||||
@@ -9,6 +9,13 @@ import type {
|
||||
ElaprAplnUpdateRequest,
|
||||
} from "../model/elapr-apln-interface";
|
||||
import type { ElaprDmndSaveRequestDto, ElaprDmndSearchDto } from "../model/elapr-dmnd-interface";
|
||||
import type {
|
||||
ElaprApproverChangeRequestDto,
|
||||
ElaprApproverInsertRequestDto,
|
||||
ElaprPrgrsSttsMyDocSearchParams,
|
||||
ElaprPrgrsSttsSaveRequestDto,
|
||||
ElaprProcessRequestDto,
|
||||
} from "../model/elapr-prgrs-stts-interface";
|
||||
import type {
|
||||
ElaprTmpltSaveRequest,
|
||||
ElaprTmpltSearchParams,
|
||||
@@ -17,11 +24,18 @@ import type {
|
||||
|
||||
import { elaprAplnService } from "./elapr-apln-service";
|
||||
import { elaprDmndService } from "./elapr-dmnd-service";
|
||||
import { elaprPrgrsSttsService } from "./elapr-prgrs-stts-service";
|
||||
import { elaprTmpltService } from "./elapr-tmplt-service";
|
||||
|
||||
const elaprTmpltBaseKey = [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_TMPLT] as const;
|
||||
const elaprAplnBaseKey = [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN] as const;
|
||||
const elaprDmndBaseKey = [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND] as const;
|
||||
const elaprPrgrsSttsBaseKey = [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_PRGRS_STTS] as const;
|
||||
|
||||
function invalidateElaprPrgrsSttsQueries(queryClient: ReturnType<typeof useQueryClient>) {
|
||||
queryClient.invalidateQueries({ queryKey: elaprPrgrsSttsBaseKey });
|
||||
queryClient.invalidateQueries({ queryKey: elaprDmndBaseKey });
|
||||
}
|
||||
|
||||
export function useElaprTmpltPage(params: ElaprTmpltSearchParams) {
|
||||
return useQuery({
|
||||
@@ -230,3 +244,119 @@ export function useRemoveElaprDmnd() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useElaprPrgrsSttsByDmndId(elaprDmndId: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [...elaprPrgrsSttsBaseKey, "findAllByDmndId", elaprDmndId],
|
||||
queryFn: () => elaprPrgrsSttsService.findAllByDmndId(elaprDmndId!),
|
||||
enabled: Boolean(elaprDmndId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useElaprPrgrsStts(elaprPrgrsId: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [...elaprPrgrsSttsBaseKey, elaprPrgrsId],
|
||||
queryFn: () => elaprPrgrsSttsService.findById(elaprPrgrsId!),
|
||||
enabled: Boolean(elaprPrgrsId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useElaprPrgrsSttsMyDocPage(params: ElaprPrgrsSttsMyDocSearchParams) {
|
||||
return useQuery({
|
||||
queryKey: [...elaprPrgrsSttsBaseKey, "findMyElaperDocList", params],
|
||||
queryFn: () => elaprPrgrsSttsService.findMyElaperDocList(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveElaprPrgrsStts() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (requestDtos: ElaprPrgrsSttsSaveRequestDto[]) =>
|
||||
elaprPrgrsSttsService.save(requestDtos),
|
||||
onSuccess: () => {
|
||||
invalidateElaprPrgrsSttsQueries(queryClient);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useProcessElaprPrgrsStts() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
elaprPrgrsId,
|
||||
body,
|
||||
}: {
|
||||
elaprPrgrsId: string;
|
||||
body: ElaprProcessRequestDto;
|
||||
}) => elaprPrgrsSttsService.process(elaprPrgrsId, body),
|
||||
onSuccess: () => {
|
||||
invalidateElaprPrgrsSttsQueries(queryClient);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useProcessElaprPrgrsSttsByDoc() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
elaprDocNo,
|
||||
aprvrUuid,
|
||||
body,
|
||||
}: {
|
||||
elaprDocNo: string;
|
||||
aprvrUuid: string;
|
||||
body: ElaprProcessRequestDto;
|
||||
}) => elaprPrgrsSttsService.processByDocNoAndAprvrUuid(elaprDocNo, aprvrUuid, body),
|
||||
onSuccess: () => {
|
||||
invalidateElaprPrgrsSttsQueries(queryClient);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteElaprPrgrsStts() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (elaprPrgrsId: string) => elaprPrgrsSttsService.delete(elaprPrgrsId),
|
||||
onSuccess: () => {
|
||||
invalidateElaprPrgrsSttsQueries(queryClient);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateElaprPrgrsSttsApprover() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
elaprPrgrsId,
|
||||
body,
|
||||
}: {
|
||||
elaprPrgrsId: string;
|
||||
body: ElaprApproverChangeRequestDto;
|
||||
}) => elaprPrgrsSttsService.updateApprover(elaprPrgrsId, body),
|
||||
onSuccess: () => {
|
||||
invalidateElaprPrgrsSttsQueries(queryClient);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useInsertElaprPrgrsSttsApprover() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
elaprDmndId,
|
||||
body,
|
||||
}: {
|
||||
elaprDmndId: string;
|
||||
body: ElaprApproverInsertRequestDto;
|
||||
}) => elaprPrgrsSttsService.insertApprover(elaprDmndId, body),
|
||||
onSuccess: () => {
|
||||
invalidateElaprPrgrsSttsQueries(queryClient);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,48 +1,122 @@
|
||||
export { elaprAplnService } from "./api/elapr-apln-service";
|
||||
export { elaprDmndService } from "./api/elapr-dmnd-service";
|
||||
export { elaprTmpltService } from "./api/elapr-tmplt-service";
|
||||
export {
|
||||
useCreateElaprTmplt,
|
||||
useDeleteElaprApln,
|
||||
useDeleteElaprTmplt,
|
||||
useElaprApln,
|
||||
useElaprAplnPage,
|
||||
useElaprDmnd,
|
||||
useElaprDmndPage,
|
||||
useElaprDocPrgrsByDocNo,
|
||||
useElaprTmplt,
|
||||
useElaprTmpltPage,
|
||||
useRemoveElaprDmnd,
|
||||
useReorderElaprApln,
|
||||
useSaveElaprDmnd,
|
||||
useSaveMultiElaprApln,
|
||||
useUpdateElaprApln,
|
||||
useUpdateElaprDmnd,
|
||||
useUpdateElaprDmndStatus,
|
||||
useUpdateElaprTmplt,
|
||||
} from "./api/queries";
|
||||
export {
|
||||
ELAPR_APLN_SAVE_MODE,
|
||||
type ElaprApln,
|
||||
type ElaprAplnListParams,
|
||||
type ElaprAplnReorderItem,
|
||||
type ElaprAplnReorderRequest,
|
||||
type ElaprAplnSaveMode,
|
||||
type ElaprAplnSaveRequest,
|
||||
type ElaprAplnService,
|
||||
type ElaprAplnUpdateRequest,
|
||||
} from "./model/elapr-apln-interface";
|
||||
export type {
|
||||
ElaprDmndResponseDto,
|
||||
ElaprDmndSaveRequestDto,
|
||||
ElaprDmndSearchDto,
|
||||
ElaprDmndService,
|
||||
ElaprDocPrgrsResponseDto,
|
||||
} from "./model/elapr-dmnd-interface";
|
||||
export type {
|
||||
ElaprTmplt,
|
||||
ElaprTmpltSaveRequest,
|
||||
ElaprTmpltSearchParams,
|
||||
ElaprTmpltService,
|
||||
ElaprTmpltUpdateRequest,
|
||||
} from "./model/elapr-tmplt-interface";
|
||||
export { elaprAplnService } from "./api/elapr-apln-service";
|
||||
export { elaprDmndService } from "./api/elapr-dmnd-service";
|
||||
export { elaprPrgrsSttsService } from "./api/elapr-prgrs-stts-service";
|
||||
export { elaprTmpltService } from "./api/elapr-tmplt-service";
|
||||
export {
|
||||
useCreateElaprTmplt,
|
||||
useDeleteElaprApln,
|
||||
useDeleteElaprPrgrsStts,
|
||||
useDeleteElaprTmplt,
|
||||
useElaprApln,
|
||||
useElaprAplnPage,
|
||||
useElaprDmnd,
|
||||
useElaprDmndPage,
|
||||
useElaprDocPrgrsByDocNo,
|
||||
useElaprPrgrsStts,
|
||||
useElaprPrgrsSttsByDmndId,
|
||||
useElaprPrgrsSttsMyDocPage,
|
||||
useElaprTmplt,
|
||||
useElaprTmpltPage,
|
||||
useInsertElaprPrgrsSttsApprover,
|
||||
useProcessElaprPrgrsStts,
|
||||
useProcessElaprPrgrsSttsByDoc,
|
||||
useRemoveElaprDmnd,
|
||||
useReorderElaprApln,
|
||||
useSaveElaprDmnd,
|
||||
useSaveElaprPrgrsStts,
|
||||
useSaveMultiElaprApln,
|
||||
useUpdateElaprApln,
|
||||
useUpdateElaprDmnd,
|
||||
useUpdateElaprDmndStatus,
|
||||
useUpdateElaprPrgrsSttsApprover,
|
||||
useUpdateElaprTmplt,
|
||||
} from "./api/queries";
|
||||
export {
|
||||
assignAtrzSeqNumbers,
|
||||
buildSaveMultiPayload,
|
||||
hasUnsavedChanges,
|
||||
} from "./lib/apln-save-utils";
|
||||
export {
|
||||
getVisibleRows,
|
||||
validateAllRowsForSave,
|
||||
validateAplnRow,
|
||||
validateAtrzOrder,
|
||||
validateUniqueSeqNo,
|
||||
} from "./lib/apln-validators";
|
||||
export {
|
||||
findCurrentUserPendingApprovalRequest,
|
||||
hasCurrentUserPendingApprovalRequest,
|
||||
} from "./lib/approval-request-utils";
|
||||
export {
|
||||
APPROVER_STATUS_MAP,
|
||||
type ApproverStatusConfig,
|
||||
getApproverStatus,
|
||||
sortByAtrzSeqNo,
|
||||
} from "./lib/approval-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,
|
||||
type ElaprAplnListParams,
|
||||
type ElaprAplnReorderItem,
|
||||
type ElaprAplnReorderRequest,
|
||||
type ElaprAplnSaveMode,
|
||||
type ElaprAplnSaveRequest,
|
||||
type ElaprAplnService,
|
||||
type ElaprAplnUpdateRequest,
|
||||
} from "./model/elapr-apln-interface";
|
||||
export type { ElaprAplnRow } from "./model/elapr-apln-row-types";
|
||||
export type {
|
||||
ElaprDmndResponseDto,
|
||||
ElaprDmndSaveRequestDto,
|
||||
ElaprDmndSearchDto,
|
||||
ElaprDmndService,
|
||||
ElaprDocPrgrsResponseDto,
|
||||
} from "./model/elapr-dmnd-interface";
|
||||
export type {
|
||||
ElaprApproverChangeRequestDto,
|
||||
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,
|
||||
ElaprTmpltSearchParams,
|
||||
ElaprTmpltService,
|
||||
ElaprTmpltUpdateRequest,
|
||||
} from "./model/elapr-tmplt-interface";
|
||||
export type { ReviewerOption } from "./model/reviewer-option";
|
||||
export { AprvrPickerDialog } from "./ui/aprvr-picker-dialog";
|
||||
export { ElaprAplnPickerGrid } from "./ui/elapr-apln-picker-grid";
|
||||
export { ElaprApprovalDialog } from "./ui/elapr-approval-dialog";
|
||||
export { ElaprDocPrgrsStatus } from "./ui/elapr-doc-prgrs-status";
|
||||
export {
|
||||
type ElaprTmpltPickerConfirmPayload,
|
||||
ElaprTmpltPickerDialog,
|
||||
} from "./ui/elapr-tmplt-picker-dialog";
|
||||
export { ElaprTmpltSelectTable } from "./ui/elapr-tmplt-select-table";
|
||||
export { SortableAplnPickerRow } from "./ui/sortable-apln-picker-row";
|
||||
export { SortablePrgrsSttsRow } from "./ui/sortable-prgrs-stts-row";
|
||||
|
||||
+31
-4
@@ -1,8 +1,16 @@
|
||||
import { ELAPR_APLN_SAVE_MODE, type ElaprApln, type ElaprAplnSaveRequest } from "@/entities/elapr";
|
||||
import {
|
||||
ELAPR_APLN_SAVE_MODE,
|
||||
type ElaprApln,
|
||||
type ElaprAplnSaveRequest,
|
||||
} from "../model/elapr-apln-interface";
|
||||
import type { ElaprAplnRow } from "../model/elapr-apln-row-types";
|
||||
|
||||
import { assignAtrzSeqNumbers } from "./assign-atrz-seq";
|
||||
import { getVisibleRows } from "./validators";
|
||||
import type { ElaprAplnRow } from "../model/apln-row-types";
|
||||
import { getVisibleRows } from "./apln-validators";
|
||||
|
||||
/** 그리드 행 순서(1-based)로 결재순서번호 일괄 채번 */
|
||||
export function assignAtrzSeqNumbers(rows: ElaprAplnRow[]): ElaprAplnRow[] {
|
||||
return rows.map((row, index) => ({ ...row, atrzSeqNo: index + 1 }));
|
||||
}
|
||||
|
||||
function rowDiffersFromServer(row: ElaprAplnRow, serverRow: ElaprApln): boolean {
|
||||
return (
|
||||
@@ -22,6 +30,25 @@ function shouldIncludeUpdate(row: ElaprAplnRow, serverRows: ElaprApln[]): boolea
|
||||
return rowDiffersFromServer(row, serverRow);
|
||||
}
|
||||
|
||||
export function hasUnsavedChanges(rows: ElaprAplnRow[], serverRows: ElaprApln[]): boolean {
|
||||
if (rows.some((row) => row.isDeleted && row.aplnId)) return true;
|
||||
if (rows.some((row) => row.isNew && !row.isDeleted)) return true;
|
||||
|
||||
const visibleWithSeq = assignAtrzSeqNumbers(getVisibleRows(rows));
|
||||
|
||||
for (const row of visibleWithSeq) {
|
||||
if (row.isNew) continue;
|
||||
if (!row.aplnId) continue;
|
||||
if (row.isDirty) return true;
|
||||
|
||||
const serverRow = serverRows.find((s) => s.aplnId === row.aplnId);
|
||||
if (!serverRow) continue;
|
||||
if (rowDiffersFromServer(row, serverRow)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function buildSaveMultiPayload(
|
||||
rows: ElaprAplnRow[],
|
||||
elaprTmpltId: string,
|
||||
+8
-16
@@ -1,23 +1,19 @@
|
||||
import type { ElaprAplnRow } from "../model/apln-row-types";
|
||||
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);
|
||||
|
||||
const receiverCount = visibleRows.filter((row) => row.atrzSeComCd === ATRZ_RECEIVER_CD).length;
|
||||
if (receiverCount === 0) {
|
||||
return "접수자는 반드시 등록해야 합니다.";
|
||||
}
|
||||
if (receiverCount > 1) {
|
||||
return "접수자는 한 명만 등록할 수 있습니다.";
|
||||
}
|
||||
export function validateAtrzOrder(rows: AtrzOrderValidateRow[]): string | null {
|
||||
const visibleRows = rows.filter((row) => !row.isDeleted);
|
||||
|
||||
const finalCount = visibleRows.filter((row) => row.atrzSeComCd === ATRZ_FINAL_CD).length;
|
||||
if (finalCount === 0) {
|
||||
@@ -27,10 +23,6 @@ export function validateAtrzOrder(rows: ElaprAplnRow[]): string | null {
|
||||
return "최종승인자는 한 명만 등록할 수 있습니다.";
|
||||
}
|
||||
|
||||
if (receiverCount === 1 && visibleRows[0]?.atrzSeComCd !== ATRZ_RECEIVER_CD) {
|
||||
return "접수자는 결재선 첫 번째에 배치해야 합니다.";
|
||||
}
|
||||
|
||||
if (finalCount === 1) {
|
||||
const lastRow = visibleRows[visibleRows.length - 1];
|
||||
if (lastRow?.atrzSeComCd !== ATRZ_FINAL_CD) {
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ElaprDocPrgrsResponseDto } from "../model/elapr-dmnd-interface";
|
||||
|
||||
export function hasCurrentUserPendingApprovalRequest(
|
||||
list: ElaprDocPrgrsResponseDto[],
|
||||
currentUserUuid: string | undefined,
|
||||
): boolean {
|
||||
if (!currentUserUuid) return false;
|
||||
|
||||
return list.some(
|
||||
(row) => row.aprvrUuid === currentUserUuid && row.atrzPrgrsSttsComCd === "ATRZ_PRGRS_002",
|
||||
);
|
||||
}
|
||||
|
||||
export function findCurrentUserPendingApprovalRequest(
|
||||
list?: ElaprDocPrgrsResponseDto[],
|
||||
currentUserUuid?: string | undefined,
|
||||
): ElaprDocPrgrsResponseDto | undefined {
|
||||
if (!currentUserUuid) return undefined;
|
||||
|
||||
return list?.find(
|
||||
(row) => row.aprvrUuid === currentUserUuid && row.atrzPrgrsSttsComCd === "ATRZ_PRGRS_002",
|
||||
);
|
||||
}
|
||||
+5
-4
@@ -1,4 +1,4 @@
|
||||
import type { ElaprDocPrgrsClientResponseDto } from "@/entities/instl-sncn";
|
||||
import type { ElaprDocPrgrsClientResponseDto } from "@/shared/model";
|
||||
|
||||
export interface ApproverStatusConfig {
|
||||
label: string;
|
||||
@@ -7,9 +7,10 @@ export interface ApproverStatusConfig {
|
||||
|
||||
export const APPROVER_STATUS_MAP: Record<string, ApproverStatusConfig> = {
|
||||
ATRZ_PRGRS_001: { label: "대기", badgeClass: "bg-gray-100 text-gray-700" },
|
||||
ATRZ_PRGRS_002: { label: "요청", badgeClass: "bg-yellow-100 text-yellow-800" },
|
||||
ATRZ_PRGRS_003: { label: "승인", badgeClass: "bg-green-100 text-green-800" },
|
||||
ATRZ_PRGRS_004: { label: "반려", badgeClass: "bg-red-100 text-red-800" },
|
||||
ATRZ_PRGRS_002: { label: "요청", badgeClass: "bg-yellow-100 text-yellow-700" },
|
||||
ATRZ_PRGRS_003: { label: "승인", badgeClass: "bg-green-100 text-green-700" },
|
||||
ATRZ_PRGRS_004: { label: "반려", badgeClass: "bg-red-100 text-red-700" },
|
||||
ATRZ_PRGRS_999: { label: "결재안함", badgeClass: "bg-blue-100 text-blue-700" },
|
||||
};
|
||||
|
||||
export function sortByAtrzSeqNo(
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ElaprApln } from "../model/elapr-apln-interface";
|
||||
import type { ElaprAplnRow } from "../model/elapr-apln-row-types";
|
||||
import type { ElaprPrgrsSttsSaveRequestDto } from "../model/elapr-prgrs-stts-interface";
|
||||
|
||||
import { getVisibleRows } from "./apln-validators";
|
||||
|
||||
function toPrgrsSttsItem(
|
||||
aprvrUuid: string | undefined,
|
||||
atrzSeComCd: string | undefined,
|
||||
atrzSeqNo: number | undefined,
|
||||
): ElaprPrgrsSttsSaveRequestDto {
|
||||
if (!aprvrUuid?.trim()) {
|
||||
throw new Error("결재자가 지정되지 않은 행이 있습니다.");
|
||||
}
|
||||
if (!atrzSeComCd?.trim()) {
|
||||
throw new Error("결재구분이 지정되지 않은 행이 있습니다.");
|
||||
}
|
||||
if (atrzSeqNo == null) {
|
||||
throw new Error("결재순서번호가 지정되지 않은 행이 있습니다.");
|
||||
}
|
||||
|
||||
return {
|
||||
aprvrUuid,
|
||||
atrzSeComCd,
|
||||
atrzSeqNo,
|
||||
};
|
||||
}
|
||||
|
||||
/** 서식 결재선 API 응답 → 기안 요청용 진행상태 목록 */
|
||||
export function buildElaprPrgrsSttsListFromApln(
|
||||
items: ElaprApln[],
|
||||
): ElaprPrgrsSttsSaveRequestDto[] {
|
||||
return items.map((item) => toPrgrsSttsItem(item.aprvrUuid, item.atrzSeComCd, item.atrzSeqNo));
|
||||
}
|
||||
|
||||
/** 결재선 그리드 행 → 기안 요청용 진행상태 목록 (서버가 seq 기준 상태 자동 결정) */
|
||||
export function buildElaprPrgrsSttsListFromAplnRows(
|
||||
rows: ElaprAplnRow[],
|
||||
): ElaprPrgrsSttsSaveRequestDto[] {
|
||||
return getVisibleRows(rows).map((row) =>
|
||||
toPrgrsSttsItem(row.aprvrUuid, row.atrzSeComCd, row.atrzSeqNo),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
ELAPR_PRGRS_STTS_SAVE_MODE,
|
||||
type ElaprPrgrsSttsSaveRequestDto,
|
||||
} from "../model/elapr-prgrs-stts-interface";
|
||||
|
||||
export const PRGRS_STTS_WAITING_CD = "ATRZ_PRGRS_001";
|
||||
export const PRGRS_STTS_APPROVED_CD = "ATRZ_PRGRS_003";
|
||||
|
||||
/** 서버 행 비교용 최소 필드 */
|
||||
export interface PrgrsSttsServerRow {
|
||||
elaprPrgrsId?: string;
|
||||
aprvrUuid?: string;
|
||||
atrzSeComCd?: string;
|
||||
atrzSeqNo?: number;
|
||||
dcarbYn?: string;
|
||||
}
|
||||
|
||||
/** 결재선 그리드 로컬 행 — 진행상태 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;
|
||||
dcarbYn?: 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;
|
||||
}
|
||||
|
||||
export function isPrgrsSttsRowDcarbable(row: ElaprPrgrsSttsRow): boolean {
|
||||
return row.atrzPrgrsSttsComCd !== PRGRS_STTS_APPROVED_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 ||
|
||||
(row.dcarbYn ?? "N") !== (serverRow.dcarbYn ?? "N")
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
dcarbYn: row.dcarbYn ?? "N",
|
||||
});
|
||||
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,
|
||||
dcarbYn: row.dcarbYn ?? "N",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
+25
-20
@@ -4,12 +4,11 @@ import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { arrayMove } from "@dnd-kit/sortable";
|
||||
|
||||
import type { ElaprApln } from "@/entities/elapr";
|
||||
import type { ElaprApln } from "../model/elapr-apln-interface";
|
||||
import type { ElaprAplnRow } from "../model/elapr-apln-row-types";
|
||||
|
||||
import type { ElaprAplnRow } from "../model/apln-row-types";
|
||||
|
||||
import { assignAtrzSeqNumbers } from "./assign-atrz-seq";
|
||||
import { getVisibleRows } from "./validators";
|
||||
import { assignAtrzSeqNumbers } from "./apln-save-utils";
|
||||
import { getVisibleRows } from "./apln-validators";
|
||||
|
||||
function toRow(item: ElaprApln): ElaprAplnRow {
|
||||
return {
|
||||
@@ -101,7 +100,13 @@ export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) {
|
||||
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;
|
||||
const {
|
||||
snapshot: _s,
|
||||
isNew: _n,
|
||||
isDirty: _d,
|
||||
isDeleted: _del,
|
||||
...restored
|
||||
} = row.snapshot;
|
||||
return { ...restored, isNew: false, isDirty: false, isDeleted: false };
|
||||
}
|
||||
return { ...row, isDirty: false };
|
||||
@@ -110,19 +115,16 @@ export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) {
|
||||
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 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) => {
|
||||
@@ -158,7 +160,10 @@ export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) {
|
||||
|
||||
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))];
|
||||
const merged = [
|
||||
...reorderedVisible,
|
||||
...deletedRows.filter((r) => !visibleKeys.has(r.rowKey)),
|
||||
];
|
||||
|
||||
payloadRows = reorderedVisible;
|
||||
return merged;
|
||||
+2
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
|
||||
import { sortableKeyboardCoordinates } from "@dnd-kit/sortable";
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
"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.aprvrNm,
|
||||
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,
|
||||
dcarbYn: item.dcarbYn ?? "N",
|
||||
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,
|
||||
dcarbYn: "N",
|
||||
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 setDcarbYn = useCallback(
|
||||
(rowKey: string, checked: boolean) => {
|
||||
if (editingRowKey != null && editingRowKey !== rowKey) return;
|
||||
|
||||
setRows((prev) => {
|
||||
const target = prev.find((r) => r.rowKey === rowKey);
|
||||
if (!target || target.isDeleted) return prev;
|
||||
|
||||
const nextYn = checked ? "Y" : "N";
|
||||
|
||||
return prev.map((row) => {
|
||||
if (row.isDeleted) return row;
|
||||
|
||||
if (row.rowKey === rowKey) {
|
||||
if ((row.dcarbYn ?? "N") === nextYn) return row;
|
||||
return { ...row, dcarbYn: nextYn, isDirty: true };
|
||||
}
|
||||
|
||||
if (!checked) return row;
|
||||
if ((row.dcarbYn ?? "N") === "N") return row;
|
||||
return { ...row, dcarbYn: "N", isDirty: true };
|
||||
});
|
||||
});
|
||||
},
|
||||
[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,
|
||||
setDcarbYn,
|
||||
cancelEdit,
|
||||
commitEdit,
|
||||
markDeleted,
|
||||
reorderRows,
|
||||
};
|
||||
}
|
||||
@@ -6,14 +6,14 @@ export const ELAPR_APLN_SAVE_MODE = {
|
||||
DELETE: "D",
|
||||
} as const;
|
||||
|
||||
export type ElaprAplnSaveMode =
|
||||
(typeof ELAPR_APLN_SAVE_MODE)[keyof typeof ELAPR_APLN_SAVE_MODE];
|
||||
export type ElaprAplnSaveMode = (typeof ELAPR_APLN_SAVE_MODE)[keyof typeof ELAPR_APLN_SAVE_MODE];
|
||||
|
||||
export interface ElaprApln {
|
||||
aplnId: string;
|
||||
elaprTmpltId: string;
|
||||
aprvrUuid?: string;
|
||||
aprvrNm?: string;
|
||||
aprvrUserId?: string;
|
||||
atrzSeComCd?: string;
|
||||
atrzSeComNm?: string;
|
||||
atrzSeqNo?: number;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import type { ElaprApln } from "@/entities/elapr";
|
||||
import type { ElaprApln } from "./elapr-apln-interface";
|
||||
|
||||
/** 그리드 로컬 행 — API 응답 + UI 보조 필드 */
|
||||
export interface ElaprAplnRow extends Omit<ElaprApln, "aplnId"> {
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { PageableParams, PageResponse } from "@/shared/model";
|
||||
import type { ElaprDocPrgrsClientResponseDto, PageableParams, PageResponse } from "@/shared/model";
|
||||
|
||||
import type { ElaprPrgrsSttsSaveRequestDto } from "./elapr-prgrs-stts-interface";
|
||||
|
||||
/** 백엔드 ElaprDmndSaveRequestDto */
|
||||
export interface ElaprDmndSaveRequestDto {
|
||||
elaprDocNo: string;
|
||||
elaprTmpltId: string;
|
||||
dmndTtl: string;
|
||||
dmndCn: string;
|
||||
elaprPrgrsSttsList?: ElaprPrgrsSttsSaveRequestDto[];
|
||||
}
|
||||
|
||||
/** 백엔드 ElaprDmndResponseDto */
|
||||
@@ -13,7 +15,6 @@ export interface ElaprDmndResponseDto {
|
||||
elaprDmndId: string;
|
||||
rqstrUuid?: string;
|
||||
elaprDocNo: string;
|
||||
elaprTmpltId: string;
|
||||
dmndTtl: string;
|
||||
dmndCn: string;
|
||||
atrzSttsComCd: string;
|
||||
@@ -32,23 +33,7 @@ export interface ElaprDmndSearchDto extends PageableParams {
|
||||
}
|
||||
|
||||
/** 백엔드 ElaprDocPrgrsResponseDto */
|
||||
export interface ElaprDocPrgrsResponseDto {
|
||||
elaprDocNo: string;
|
||||
elaprDmndId: string;
|
||||
elaprPrgrsId: string;
|
||||
atrzSeqNo?: number;
|
||||
atrzSeComCd: string;
|
||||
atrzSeComNm?: string;
|
||||
aprvrUuid?: string;
|
||||
aprvrUuidNm?: string;
|
||||
atrzPrgrsSttsComCd: string;
|
||||
atrzPrgrsSttsComNm?: string;
|
||||
rjctRsn?: string;
|
||||
atrzDt?: string;
|
||||
jbpsSeNm?: string;
|
||||
jbgdSeNm?: string;
|
||||
ognzNm?: string;
|
||||
}
|
||||
export type ElaprDocPrgrsResponseDto = ElaprDocPrgrsClientResponseDto;
|
||||
|
||||
/** Controller public 메서드명·시그니처와 동일 */
|
||||
export interface ElaprDmndService {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
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;
|
||||
elaprDmndId?: string;
|
||||
aprvrUuid?: string;
|
||||
atrzSeComCd?: string;
|
||||
atrzSeComNm?: string;
|
||||
atrzSeqNo?: number;
|
||||
atrzPrgrsSttsComCd?: string;
|
||||
atrzPrgrsSttsComNm?: string;
|
||||
rjctRsn?: string;
|
||||
atrzDt?: string;
|
||||
dcarbYn?: string;
|
||||
}
|
||||
|
||||
/** 백엔드 ElaprPrgrsSttsSaveRequestDto */
|
||||
export interface ElaprPrgrsSttsSaveRequestDto {
|
||||
mode?: ElaprPrgrsSttsSaveMode;
|
||||
elaprPrgrsId?: string;
|
||||
elaprDmndId?: string;
|
||||
aprvrUuid?: string;
|
||||
atrzSeComCd?: string;
|
||||
atrzSeqNo?: number;
|
||||
atrzPrgrsSttsComCd?: string;
|
||||
dcarbYn?: string;
|
||||
}
|
||||
|
||||
/** 백엔드 ElaprPrgrsSttsSearchDto */
|
||||
export interface ElaprPrgrsSttsSearchDto {
|
||||
atrzSeComCd: string;
|
||||
atrzSeqNo: number;
|
||||
atrzPrgrsSttsComCd?: string;
|
||||
}
|
||||
|
||||
/** 백엔드 ElaprPrgrsSttsSearchDto + Pageable */
|
||||
export interface ElaprPrgrsSttsMyDocSearchParams extends ElaprPrgrsSttsSearchDto, PageableParams {}
|
||||
|
||||
/** 백엔드 ElaprProcessRequestDto */
|
||||
export interface ElaprProcessRequestDto {
|
||||
atrzPrgrsSttsComCd: string;
|
||||
rjctRsn?: string;
|
||||
finalApprovalYn?: boolean;
|
||||
}
|
||||
|
||||
/** 백엔드 ElaprApproverChangeRequestDto */
|
||||
export interface ElaprApproverChangeRequestDto {
|
||||
aprvrUuid: string;
|
||||
}
|
||||
|
||||
/** 백엔드 ElaprApproverInsertRequestDto */
|
||||
export interface ElaprApproverInsertRequestDto {
|
||||
aprvrUuid: string;
|
||||
atrzSeComCd: string;
|
||||
atrzSeqNo: number;
|
||||
}
|
||||
|
||||
/** Controller public 메서드명·시그니처와 동일 */
|
||||
export interface ElaprPrgrsSttsService {
|
||||
findAllByDmndId(elaprDmndId?: string): Promise<ElaprPrgrsSttsResponseDto[]>;
|
||||
findById(elaprPrgrsId: string): Promise<ElaprPrgrsSttsResponseDto>;
|
||||
save(requestDtos: ElaprPrgrsSttsSaveRequestDto[]): Promise<ElaprPrgrsSttsResponseDto[]>;
|
||||
process(elaprPrgrsId: string, body: ElaprProcessRequestDto): Promise<ElaprPrgrsSttsResponseDto>;
|
||||
processByDocNoAndAprvrUuid(
|
||||
elaprDocNo: string,
|
||||
aprvrUuid: string,
|
||||
body: ElaprProcessRequestDto,
|
||||
): Promise<ElaprPrgrsSttsResponseDto>;
|
||||
delete(elaprPrgrsId: string): Promise<void>;
|
||||
updateApprover(
|
||||
elaprPrgrsId: string,
|
||||
body: ElaprApproverChangeRequestDto,
|
||||
): Promise<ElaprPrgrsSttsResponseDto>;
|
||||
insertApprover(
|
||||
elaprDmndId: string,
|
||||
body: ElaprApproverInsertRequestDto,
|
||||
): Promise<ElaprPrgrsSttsResponseDto>;
|
||||
findMyElaperDocList(
|
||||
params: ElaprPrgrsSttsMyDocSearchParams,
|
||||
): Promise<PageResponse<ElaprPrgrsSttsResponseDto>>;
|
||||
}
|
||||
@@ -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,202 @@
|
||||
"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 {
|
||||
Button,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
import { useElaprAplnPage } from "../api/queries";
|
||||
import { validateAplnRow } from "../lib/apln-validators";
|
||||
import { useAplnRows } from "../lib/use-apln-rows";
|
||||
import { useDndSensors } from "../lib/use-dnd-sensors";
|
||||
import type { ElaprAplnRow } from "../model/elapr-apln-row-types";
|
||||
import type { ReviewerOption } from "../model/reviewer-option";
|
||||
|
||||
import { AprvrPickerDialog } from "./aprvr-picker-dialog";
|
||||
import { SortableAplnPickerRow } from "./sortable-apln-picker-row";
|
||||
|
||||
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="10%" />
|
||||
<col width="10%" />
|
||||
<col width="50%" />
|
||||
<col width="20%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10" />
|
||||
<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 };
|
||||
@@ -0,0 +1,408 @@
|
||||
"use client";
|
||||
|
||||
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 {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Textarea,
|
||||
} from "@common/components";
|
||||
|
||||
import { elaprPrgrsSttsService } from "../api/elapr-prgrs-stts-service";
|
||||
import { useElaprDocPrgrsByDocNo, useSaveElaprPrgrsStts } from "../api/queries";
|
||||
import { validateAtrzOrder } from "../lib/apln-validators";
|
||||
import { findCurrentUserPendingApprovalRequest } from "../lib/approval-request-utils";
|
||||
import {
|
||||
buildPrgrsSttsSavePayload,
|
||||
hasUnsavedPrgrsSttsChanges,
|
||||
isPrgrsSttsRowEditable,
|
||||
validateAllPrgrsSttsRows,
|
||||
validatePrgrsSttsRow,
|
||||
} from "../lib/prgrs-stts-save-utils";
|
||||
import { useDndSensors } from "../lib/use-dnd-sensors";
|
||||
import { usePrgrsSttsRows } from "../lib/use-prgrs-stts-rows";
|
||||
import type { ReviewerOption } from "../model/reviewer-option";
|
||||
|
||||
import { AprvrPickerDialog } from "./aprvr-picker-dialog";
|
||||
import { SortablePrgrsSttsRow } from "./sortable-prgrs-stts-row";
|
||||
|
||||
interface ElaprApprovalDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
elaprDocNo: string;
|
||||
currentUserUuid: string;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const UNSAVED_PRGRS_STTS_MESSAGE = "결재선 변경 이력이 있습니다.\n결재선 저장을 해 주세요.";
|
||||
|
||||
function ElaprApprovalDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
elaprDocNo,
|
||||
currentUserUuid,
|
||||
onSuccess,
|
||||
}: ElaprApprovalDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
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 { data: elaprDocPrgrsList, isLoading } = useElaprDocPrgrsByDocNo(open ? elaprDocNo : null);
|
||||
|
||||
const elaprDmndId = elaprDocPrgrsList?.[0]?.elaprDmndId;
|
||||
|
||||
const {
|
||||
rows,
|
||||
visibleRows,
|
||||
editingRowKey,
|
||||
isEditingLocked,
|
||||
addRow,
|
||||
startEdit,
|
||||
updateRow,
|
||||
setDcarbYn,
|
||||
cancelEdit,
|
||||
commitEdit,
|
||||
markDeleted,
|
||||
reorderRows,
|
||||
} = usePrgrsSttsRows({
|
||||
serverRows: elaprDocPrgrsList,
|
||||
elaprDmndId,
|
||||
});
|
||||
|
||||
const savePrgrsSttsMutation = useSaveElaprPrgrsStts();
|
||||
const sensors = useDndSensors();
|
||||
|
||||
const currentUserPrgrs = findCurrentUserPendingApprovalRequest(
|
||||
elaprDocPrgrsList,
|
||||
currentUserUuid,
|
||||
);
|
||||
const canApprove = Boolean(currentUserPrgrs?.elaprPrgrsId);
|
||||
|
||||
const pickingRow = pickingRowKey ? rows.find((r) => r.rowKey === pickingRowKey) : undefined;
|
||||
const isBusy = isProcessing || savePrgrsSttsMutation.isPending;
|
||||
const isPrgrsSttsUnsaved = hasUnsavedPrgrsSttsChanges(rows, elaprDocPrgrsList ?? []);
|
||||
|
||||
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",
|
||||
});
|
||||
alert("결재가 완료되었습니다.");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, "findDocPrgrsByDocNo", elaprDocNo],
|
||||
});
|
||||
onSuccess();
|
||||
onOpenChange(false);
|
||||
} catch (error: unknown) {
|
||||
let errorMessage = "";
|
||||
if (error instanceof AxiosError) {
|
||||
errorMessage = error.response?.data?.message ?? "";
|
||||
} else {
|
||||
errorMessage = "결재 처리 중 오류가 발생했습니다.";
|
||||
}
|
||||
alert(errorMessage);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject() {
|
||||
if (!currentUserPrgrs?.elaprPrgrsId) return;
|
||||
if (!rjctRsn.trim()) {
|
||||
alert("반려 사유를 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
if (!assertPrgrsSttsSaved()) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
await elaprPrgrsSttsService.process(currentUserPrgrs.elaprPrgrsId, {
|
||||
atrzPrgrsSttsComCd: "ATRZ_PRGRS_004",
|
||||
rjctRsn: rjctRsn.trim(),
|
||||
});
|
||||
alert("반려 처리가 완료되었습니다.");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, "findDocPrgrsByDocNo", elaprDocNo],
|
||||
});
|
||||
onSuccess();
|
||||
onOpenChange(false);
|
||||
} catch (error: unknown) {
|
||||
let errorMessage = "";
|
||||
if (error instanceof AxiosError) {
|
||||
errorMessage = error.response?.data?.message ?? "";
|
||||
} else {
|
||||
errorMessage = "반려 처리 중 오류가 발생했습니다.";
|
||||
}
|
||||
alert(errorMessage);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
setRjctRsn("");
|
||||
setPickingRowKey(null);
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-[72rem]" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>전자결재</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<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>
|
||||
) : visibleRows.length === 0 ? (
|
||||
<div className="py-8 text-center text-gray-500">
|
||||
{elaprDmndId
|
||||
? "등록된 결재선이 없습니다. [결재선 추가]로 결재자를 등록하세요."
|
||||
: "결재 정보가 없습니다."}
|
||||
</div>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<Table className="w-full">
|
||||
<colgroup>
|
||||
<col width="8%" />
|
||||
<col width="8%" />
|
||||
<col width="10%" />
|
||||
<col width="30%" />
|
||||
<col width="10%" />
|
||||
<col width="10%" />
|
||||
<col width="24%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10" />
|
||||
<TableHead>결재순서</TableHead>
|
||||
<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) => (
|
||||
<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);
|
||||
}}
|
||||
onToggleDcarb={(checked) => setDcarbYn(row.rowKey, checked)}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canApprove && (
|
||||
<div>
|
||||
<h4 className="mb-3 text-xl font-semibold">반려 의견</h4>
|
||||
<Textarea
|
||||
value={rjctRsn}
|
||||
onChange={(event) => setRjctRsn(event.target.value)}
|
||||
placeholder="반려 시 사유를 입력하세요"
|
||||
rows={4}
|
||||
className="!text-xl"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<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">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export { ElaprApprovalDialog };
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
import type { ElaprDocPrgrsClientResponseDto } from "@/shared/model";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import { cn, Skeleton } from "@common/components";
|
||||
|
||||
import { useElaprDocPrgrsByDocNo } from "../api/queries";
|
||||
import { getApproverStatus, sortByAtrzSeqNo } from "../lib/approval-utils";
|
||||
|
||||
const REJECT_STATUS_CD = "ATRZ_PRGRS_004";
|
||||
|
||||
interface ElaprDocPrgrsStatusProps {
|
||||
elaprDocNo: string | null | undefined;
|
||||
}
|
||||
|
||||
function ElaprDocPrgrsStep({ item }: { item: ElaprDocPrgrsClientResponseDto }) {
|
||||
const status = getApproverStatus(item.atrzPrgrsSttsComCd);
|
||||
const isRejected = item.atrzPrgrsSttsComCd === REJECT_STATUS_CD;
|
||||
const rjctRsn = item.rjctRsn?.trim();
|
||||
const metaParts = [item.ognzNm, item.jbpsSeNm].filter(Boolean);
|
||||
|
||||
const statusBadge = (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-sm px-2 py-1 text-lg font-medium",
|
||||
status.badgeClass,
|
||||
isRejected && rjctRsn && "cursor-help underline decoration-dotted underline-offset-4",
|
||||
)}
|
||||
>
|
||||
{status.label}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-w-[12rem] flex-1 flex-col items-center gap-3 p-4 text-center">
|
||||
{isRejected && rjctRsn ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{statusBadge}</TooltipTrigger>
|
||||
<TooltipContent sideOffset={4} className="max-w-md whitespace-pre-wrap">
|
||||
{rjctRsn}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
statusBadge
|
||||
)}
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl text-muted-foreground">[{item.atrzSeComNm || "-"}]</span>
|
||||
<span className="text-xl font-semibold">{item.aprvrNm || item.aprvrUuid || "-"}</span>
|
||||
</div>
|
||||
{metaParts.length > 0 && (
|
||||
<span className="text-base text-gray-500">{metaParts.join(" · ")}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ElaprDocPrgrsStatus({ elaprDocNo }: ElaprDocPrgrsStatusProps) {
|
||||
const { data, isLoading } = useElaprDocPrgrsByDocNo(elaprDocNo);
|
||||
const steps = sortByAtrzSeqNo(data ?? []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-6">
|
||||
<Skeleton className="h-24 flex-1 rounded-sm" />
|
||||
<Skeleton className="h-24 flex-1 rounded-sm" />
|
||||
<Skeleton className="h-24 flex-1 rounded-sm" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (steps.length === 0) {
|
||||
return <div className="py-8 text-center text-xl text-gray-500">결재 정보가 없습니다.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-stretch justify-start gap-4 overflow-x-auto">
|
||||
{steps.map((item, index) => (
|
||||
<Fragment key={item.elaprPrgrsId ?? `${item.atrzSeqNo}-${item.aprvrUuid ?? index}`}>
|
||||
<div className="flex items-center">
|
||||
{index > 0 && <ChevronRight className="size-8 text-muted-foreground" />}
|
||||
</div>
|
||||
<ElaprDocPrgrsStep item={item} />
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { ElaprDocPrgrsStatus };
|
||||
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@common/components";
|
||||
|
||||
import { useElaprTmpltPage } from "../api/queries";
|
||||
import {
|
||||
getVisibleRows,
|
||||
validateAllRowsForSave,
|
||||
validateAtrzOrder,
|
||||
validateUniqueSeqNo,
|
||||
} from "../lib/apln-validators";
|
||||
import type { ElaprAplnRow } from "../model/elapr-apln-row-types";
|
||||
import type { ElaprTmplt } from "../model/elapr-tmplt-interface";
|
||||
|
||||
import { ElaprAplnPickerGrid } from "./elapr-apln-picker-grid";
|
||||
import { ElaprTmpltSelectTable } from "./elapr-tmplt-select-table";
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
interface ElaprTmpltPickerConfirmPayload {
|
||||
tmplt: ElaprTmplt;
|
||||
aplnRows: ElaprAplnRow[];
|
||||
}
|
||||
|
||||
interface ElaprTmpltPickerDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
docSeComCd: string;
|
||||
onSelect?: (tmplt: ElaprTmplt) => void;
|
||||
onConfirm?: (payload: ElaprTmpltPickerConfirmPayload) => void;
|
||||
}
|
||||
|
||||
function ElaprTmpltPickerDialog({
|
||||
open,
|
||||
onClose,
|
||||
docSeComCd,
|
||||
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,
|
||||
});
|
||||
|
||||
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,97 @@
|
||||
"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 { Skeleton } from "@common/components";
|
||||
|
||||
import type { ElaprTmplt } from "../model/elapr-tmplt-interface";
|
||||
|
||||
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("elaprTmpltNm", {
|
||||
header: "결재서식명",
|
||||
meta: { align: "left" },
|
||||
}),
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
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 };
|
||||
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { MoveVertical } from "lucide-react";
|
||||
|
||||
import { findCodeLabel, useCodeList } from "@/entities/code";
|
||||
import {
|
||||
Button,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
TableCell,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
import type { ElaprAplnRow } from "../model/elapr-apln-row-types";
|
||||
|
||||
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 selectableAtrzSeCodes = atrzSeCodes?.filter((code) => code.lwpoCd !== "ATRZ01");
|
||||
|
||||
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">{displaySeqNo}</TableCell>
|
||||
<TableCell align="center">
|
||||
{isEditing ? (
|
||||
<Select
|
||||
value={row.atrzSeComCd ?? ""}
|
||||
onValueChange={(v) =>
|
||||
onChange({ atrzSeComCd: v, atrzSeComNm: findCodeLabel(selectableAtrzSeCodes, v) })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="min-w-[120px]">
|
||||
<SelectValue placeholder="결재구분" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{selectableAtrzSeCodes?.map((code) => (
|
||||
<SelectItem key={code.lwpoCd} value={code.lwpoCd}>
|
||||
{code.lwpoCdNm}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
atrzSeLabel || "-"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span>{row.aprvrNm || row.aprvrUuid || ""}</span>
|
||||
{isEditing && (
|
||||
<Button type="button" size="sm" onClick={onPickAprvr} disabled={!row.atrzSeComCd}>
|
||||
선택
|
||||
</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,212 @@
|
||||
"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 { findCodeLabel, useCodeList } from "@/entities/code";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
cn,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
TableCell,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
import { getApproverStatus } from "../lib/approval-utils";
|
||||
import {
|
||||
type ElaprPrgrsSttsRow,
|
||||
isPrgrsSttsRowDcarbable,
|
||||
isPrgrsSttsRowEditable,
|
||||
} from "../lib/prgrs-stts-save-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;
|
||||
onToggleDcarb: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
function SortablePrgrsSttsRow({
|
||||
row,
|
||||
displaySeqNo,
|
||||
isEditing,
|
||||
isLocked,
|
||||
dragDisabled,
|
||||
onStartEdit,
|
||||
onSave,
|
||||
onCancel,
|
||||
onDelete,
|
||||
onChange,
|
||||
onPickAprvr,
|
||||
onToggleDcarb,
|
||||
}: SortablePrgrsSttsRowProps) {
|
||||
const { data: atrzSeCodes } = useCodeList("ATRZSE");
|
||||
const selectableAtrzSeCodes = atrzSeCodes?.filter((code) => code.lwpoCd !== "ATRZ01");
|
||||
|
||||
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 isDcarbable = isPrgrsSttsRowDcarbable(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 ? (
|
||||
<Select
|
||||
value={row.atrzSeComCd ?? ""}
|
||||
onValueChange={(v) =>
|
||||
onChange({ atrzSeComCd: v, atrzSeComNm: findCodeLabel(selectableAtrzSeCodes, v) })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="min-w-[120px]">
|
||||
<SelectValue placeholder="결재구분" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{selectableAtrzSeCodes?.map((code) => (
|
||||
<SelectItem key={code.lwpoCd} value={code.lwpoCd}>
|
||||
{code.lwpoCdNm}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
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">
|
||||
{isDcarbable && !rowDisabled && (
|
||||
<Checkbox
|
||||
checked={row.dcarbYn === "Y"}
|
||||
onCheckedChange={(checked) => onToggleDcarb(checked === true)}
|
||||
aria-label="전결자 지정"
|
||||
/>
|
||||
)}
|
||||
</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 };
|
||||
@@ -0,0 +1,86 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type { PageResponse } from "@/shared/model";
|
||||
|
||||
import type {
|
||||
DocPrgrsUpdateRequestDto,
|
||||
FnrsCnsltnApplySaveRequestDto,
|
||||
FnrsCnsltnMngDetResponseDto,
|
||||
FnrsCnsltnMngResponseDto,
|
||||
FnrsCnsltnMngSaveRequestDto,
|
||||
FnrsCnsltnMngSearchDto,
|
||||
FnrsCnsltnMngService,
|
||||
FnrsCnsltnMngUpdateRequestDto,
|
||||
} from "../model/fnrs-cnsltn-interface";
|
||||
|
||||
const BASE = ENDPOINT.AS_SERVICE.FNRS_CNSLTN_MNG;
|
||||
|
||||
async function findPage(
|
||||
searchDto: FnrsCnsltnMngSearchDto,
|
||||
): Promise<PageResponse<FnrsCnsltnMngResponseDto>> {
|
||||
const response = await client.get<PageResponse<FnrsCnsltnMngResponseDto>>(BASE, {
|
||||
params: searchDto,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findById(fnrsCnsltnDocNo: string): Promise<FnrsCnsltnMngDetResponseDto> {
|
||||
const response = await client.get<FnrsCnsltnMngDetResponseDto>(`${BASE}/${fnrsCnsltnDocNo}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function save(body: FnrsCnsltnMngSaveRequestDto): Promise<string> {
|
||||
const response = await client.post<string>(BASE, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function saveApply(body: FnrsCnsltnApplySaveRequestDto): Promise<string> {
|
||||
const response = await client.post<string>(`${BASE}/save-apply`, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function update(
|
||||
fnrsCnsltnDocNo: string,
|
||||
body: FnrsCnsltnMngUpdateRequestDto,
|
||||
): Promise<string> {
|
||||
const response = await client.put<string>(`${BASE}/${fnrsCnsltnDocNo}`, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function techReview(id: string, body: FnrsCnsltnApplySaveRequestDto): Promise<void> {
|
||||
await client.post<void>(`${BASE}/${id}/tech-review`, body);
|
||||
}
|
||||
|
||||
async function submitSupplement(id: string, body: FnrsCnsltnApplySaveRequestDto): Promise<string> {
|
||||
const response = await client.post<string>(`${BASE}/${id}/submitsupplement`, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function submitReqSupplement(
|
||||
id: string,
|
||||
body: FnrsCnsltnApplySaveRequestDto,
|
||||
): Promise<string> {
|
||||
const response = await client.post<string>(`${BASE}/${id}/reqsupplement`, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function remove(fnrsCnsltnDocNo: string): Promise<void> {
|
||||
await client.delete<void>(`${BASE}/${fnrsCnsltnDocNo}`);
|
||||
}
|
||||
|
||||
async function changePrgrsSttsComCd(body: DocPrgrsUpdateRequestDto): Promise<void> {
|
||||
await client.post<void>(`${BASE}/doc/updatePrgrsStts`, body);
|
||||
}
|
||||
|
||||
export const fnrsCnsltnMngService = {
|
||||
findPage,
|
||||
findById,
|
||||
save,
|
||||
saveApply,
|
||||
update,
|
||||
techReview,
|
||||
submitSupplement,
|
||||
submitReqSupplement,
|
||||
remove,
|
||||
changePrgrsSttsComCd,
|
||||
} satisfies FnrsCnsltnMngService;
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type {
|
||||
DocPrgrsUpdateRequestDto,
|
||||
FnrsCnsltnApplySaveRequestDto,
|
||||
FnrsCnsltnMngSaveRequestDto,
|
||||
FnrsCnsltnMngSearchDto,
|
||||
FnrsCnsltnMngUpdateRequestDto,
|
||||
} from "../model/fnrs-cnsltn-interface";
|
||||
|
||||
import { fnrsCnsltnMngService } from "./fnrs-cnsltn-service";
|
||||
|
||||
export const fnrsCnsltnMngBaseKey = [ENDPOINT.AS_SERVICE.FNRS_CNSLTN_MNG] as const;
|
||||
|
||||
export function useFnrsCnsltnMngPage(params: FnrsCnsltnMngSearchDto) {
|
||||
return useQuery({
|
||||
queryKey: [...fnrsCnsltnMngBaseKey, "findPage", params],
|
||||
queryFn: () => fnrsCnsltnMngService.findPage(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useFnrsCnsltnMng(fnrsCnsltnDocNo: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [...fnrsCnsltnMngBaseKey, "findById", fnrsCnsltnDocNo],
|
||||
queryFn: () => fnrsCnsltnMngService.findById(fnrsCnsltnDocNo!),
|
||||
enabled: Boolean(fnrsCnsltnDocNo),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveFnrsCnsltnMng() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: FnrsCnsltnMngSaveRequestDto) => fnrsCnsltnMngService.save(body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveApplyFnrsCnsltnMng() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: FnrsCnsltnApplySaveRequestDto) => fnrsCnsltnMngService.saveApply(body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateFnrsCnsltnMng() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
fnrsCnsltnDocNo,
|
||||
body,
|
||||
}: {
|
||||
fnrsCnsltnDocNo: string;
|
||||
body: FnrsCnsltnMngUpdateRequestDto;
|
||||
}) => fnrsCnsltnMngService.update(fnrsCnsltnDocNo, body),
|
||||
onSuccess: (_data, { fnrsCnsltnDocNo }) => {
|
||||
queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...fnrsCnsltnMngBaseKey, "findById", fnrsCnsltnDocNo],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTechReviewFnrsCnsltnMng() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, body }: { id: string; body: FnrsCnsltnApplySaveRequestDto }) =>
|
||||
fnrsCnsltnMngService.techReview(id, body),
|
||||
onSuccess: (_data, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...fnrsCnsltnMngBaseKey, "findById", id],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSubmitSupplementFnrsCnsltnMng() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, body }: { id: string; body: FnrsCnsltnApplySaveRequestDto }) =>
|
||||
fnrsCnsltnMngService.submitSupplement(id, body),
|
||||
onSuccess: (_data, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...fnrsCnsltnMngBaseKey, "findById", id],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSubmitReqSupplementFnrsCnsltnMng() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, body }: { id: string; body: FnrsCnsltnApplySaveRequestDto }) =>
|
||||
fnrsCnsltnMngService.submitReqSupplement(id, body),
|
||||
onSuccess: (_data, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...fnrsCnsltnMngBaseKey, "findById", id],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteFnrsCnsltnMng() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (fnrsCnsltnDocNo: string) => fnrsCnsltnMngService.remove(fnrsCnsltnDocNo),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useChangePrgrsSttsComCd() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: DocPrgrsUpdateRequestDto) => fnrsCnsltnMngService.changePrgrsSttsComCd(body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export { fnrsCnsltnMngService } from "./api/fnrs-cnsltn-service";
|
||||
export { fnrsCnsltnMngBaseKey } from "./api/queries";
|
||||
export {
|
||||
useChangePrgrsSttsComCd,
|
||||
useDeleteFnrsCnsltnMng,
|
||||
useFnrsCnsltnMng,
|
||||
useFnrsCnsltnMngPage,
|
||||
useSaveApplyFnrsCnsltnMng,
|
||||
useSaveFnrsCnsltnMng,
|
||||
useSubmitReqSupplementFnrsCnsltnMng,
|
||||
useSubmitSupplementFnrsCnsltnMng,
|
||||
useTechReviewFnrsCnsltnMng,
|
||||
useUpdateFnrsCnsltnMng,
|
||||
} from "./api/queries";
|
||||
export type {
|
||||
AtflMngClientResponseDto,
|
||||
BizCstItem,
|
||||
BizCstTreeDto,
|
||||
BizSclTreeDto,
|
||||
BzScaleItem,
|
||||
CklstResultItem,
|
||||
CklstRvwRsltTreeDto,
|
||||
DocPrgrsUpdateRequestDto,
|
||||
ElaprDocPrgrsClientResponseDto,
|
||||
EsntlAtchFileItem,
|
||||
EsntlAtchFileMngTreeResponseDto,
|
||||
FnrsCnsltnApplySaveRequestDto,
|
||||
FnrsCnsltnMngDetResponseDto,
|
||||
FnrsCnsltnMngResponseDto,
|
||||
FnrsCnsltnMngSaveRequestDto,
|
||||
FnrsCnsltnMngSearchDto,
|
||||
FnrsCnsltnMngService,
|
||||
FnrsCnsltnMngUpdateRequestDto,
|
||||
FnrsCnsltnRvwOpnnResponseDto,
|
||||
FnrsCnsltnRvwOpnnSaveRequestDto,
|
||||
FnrsCnsltnSplmntDmndItem,
|
||||
FnrsCnsltnSplmntDmndResponseDto,
|
||||
FnrsSharItem,
|
||||
} from "./model/fnrs-cnsltn-interface";
|
||||
@@ -0,0 +1,177 @@
|
||||
import type {
|
||||
AtflMngClientResponseDto,
|
||||
BizCstSaveBase,
|
||||
BzScaleSaveBase,
|
||||
CklstResultSaveBase,
|
||||
DocPrgrsUpdateRequestDto,
|
||||
EsntlAtchFileSaveBase,
|
||||
PageableParams,
|
||||
PageResponse,
|
||||
PbadmsApplySaveHeader,
|
||||
PbadmsMngDetResponseBase,
|
||||
RvwOpnnResponseExtension,
|
||||
RvwOpnnSaveBase,
|
||||
SplmntDmndResponseExtension,
|
||||
SplmntDmndSaveBase,
|
||||
} from "@/shared/model";
|
||||
import type { FnrsSharBizCstTreeDto } from "@/entities/fnrs-shar-se";
|
||||
|
||||
export type {
|
||||
AtflMngClientResponseDto,
|
||||
BizCstTreeDto,
|
||||
BizSclTreeDto,
|
||||
CklstRvwRsltTreeDto,
|
||||
DocPrgrsUpdateRequestDto,
|
||||
ElaprDocPrgrsClientResponseDto,
|
||||
EsntlAtchFileMngTreeResponseDto,
|
||||
} from "@/shared/model";
|
||||
|
||||
/** 백엔드 FnrsCnsltnMngSaveRequestDto */
|
||||
export interface FnrsCnsltnMngSaveRequestDto {
|
||||
fnrsCnsltnDocNo?: string;
|
||||
admdstCd?: string;
|
||||
envoSeComCd?: string;
|
||||
taskSeComCd?: string;
|
||||
cfmtnBgtBizNo?: string;
|
||||
bgtAplyYr?: string;
|
||||
bizNm?: string;
|
||||
bizBgngYr?: string;
|
||||
bizEndYr?: string;
|
||||
bizCn?: string;
|
||||
aplcntNm?: string;
|
||||
aplcntMblTelno?: string;
|
||||
aplcntOgdpNm?: string;
|
||||
aplcntEmlAddr?: string;
|
||||
dsgnrNm?: string;
|
||||
dsgnrTelno?: string;
|
||||
dsgnrOgdpNm?: string;
|
||||
dsgnrEmlAddr?: string;
|
||||
atchFileGroupId?: string;
|
||||
sharAtchFileGroupId?: string;
|
||||
prgrsSttsComCd?: string;
|
||||
rtlztnFcltSeComCd?: string;
|
||||
rtlztnUsgSeComCd?: string;
|
||||
}
|
||||
|
||||
/** 백엔드 FnrsCnsltnMngUpdateRequestDto */
|
||||
export type FnrsCnsltnMngUpdateRequestDto = Omit<FnrsCnsltnMngSaveRequestDto, "fnrsCnsltnDocNo">;
|
||||
|
||||
/** 백엔드 FnrsCnsltnMngSearchDto + Pageable */
|
||||
export interface FnrsCnsltnMngSearchDto extends PageableParams {
|
||||
fnrsCnsltnDocNo?: string;
|
||||
admdstCd?: string;
|
||||
envoSeComCd?: string;
|
||||
taskSeComCd?: string;
|
||||
cfmtnBgtBizNo?: string;
|
||||
bgtAplyYr?: string;
|
||||
bizNm?: string;
|
||||
bizBgngYr?: string;
|
||||
bizEndYr?: string;
|
||||
bizCn?: string;
|
||||
aplcntNm?: string;
|
||||
aplcntMblTelno?: string;
|
||||
aplcntOgdpNm?: string;
|
||||
aplcntEmlAddr?: string;
|
||||
dsgnrNm?: string;
|
||||
dsgnrTelno?: string;
|
||||
dsgnrOgdpNm?: string;
|
||||
dsgnrEmlAddr?: string;
|
||||
atchFileGroupId?: string;
|
||||
sharAtchFileGroupId?: string;
|
||||
prgrsSttsComCd?: string;
|
||||
}
|
||||
|
||||
interface FnrsCnsltnMngResponseExtension {
|
||||
fnrsCnsltnDocNo: string;
|
||||
envoSeCdNm?: string;
|
||||
taskSeCdNm?: string;
|
||||
prgrsSttsCdNm?: string;
|
||||
regDt?: string;
|
||||
rgtr?: string;
|
||||
rgtrNm?: string;
|
||||
mdfcnDt?: string;
|
||||
sido?: string;
|
||||
gugun?: string;
|
||||
}
|
||||
|
||||
/** 백엔드 FnrsCnsltnMngResponseDto */
|
||||
export type FnrsCnsltnMngResponseDto = Omit<FnrsCnsltnMngSaveRequestDto, "fnrsCnsltnDocNo"> &
|
||||
FnrsCnsltnMngResponseExtension;
|
||||
|
||||
export type BzScaleItem = BzScaleSaveBase;
|
||||
|
||||
export type BizCstItem = BizCstSaveBase;
|
||||
|
||||
export interface CklstResultItem extends CklstResultSaveBase {
|
||||
selected?: string;
|
||||
}
|
||||
|
||||
export type EsntlAtchFileItem = EsntlAtchFileSaveBase;
|
||||
|
||||
export type FnrsCnsltnSplmntDmndItem = SplmntDmndSaveBase;
|
||||
|
||||
/** 백엔드 FnrsCnsltnApplySaveRequestDto.FnrsSharItem */
|
||||
export interface FnrsSharItem {
|
||||
fnrsSharSeCd?: string;
|
||||
exstnAmt?: number;
|
||||
chgRqtAmt?: number;
|
||||
ajmtAmt?: number;
|
||||
rmrkCn?: string;
|
||||
envoRmrkCn?: string;
|
||||
}
|
||||
|
||||
/** 백엔드 FnrsCnsltnRvwOpnnSaveRequestDto */
|
||||
export interface FnrsCnsltnRvwOpnnSaveRequestDto extends RvwOpnnSaveBase {
|
||||
fnrsCnsltnDocNo: string;
|
||||
rvwOpnnSn: number;
|
||||
}
|
||||
|
||||
/** 백엔드 FnrsCnsltnRvwOpnnResponseDto */
|
||||
export type FnrsCnsltnRvwOpnnResponseDto = Partial<
|
||||
Pick<FnrsCnsltnRvwOpnnSaveRequestDto, "fnrsCnsltnDocNo" | "rvwOpnnSn">
|
||||
> &
|
||||
Omit<FnrsCnsltnRvwOpnnSaveRequestDto, "fnrsCnsltnDocNo" | "rvwOpnnSn"> &
|
||||
RvwOpnnResponseExtension;
|
||||
|
||||
/** 백엔드 FnrsCnsltnSplmntDmndResponseDto */
|
||||
export type FnrsCnsltnSplmntDmndResponseDto = FnrsCnsltnSplmntDmndItem &
|
||||
SplmntDmndResponseExtension & {
|
||||
fnrsCnsltnDocNo?: string;
|
||||
};
|
||||
|
||||
/** 백엔드 FnrsCnsltnMngDetResponseDto */
|
||||
export type FnrsCnsltnMngDetResponseDto = PbadmsMngDetResponseBase<
|
||||
FnrsCnsltnMngResponseDto,
|
||||
FnrsCnsltnRvwOpnnResponseDto,
|
||||
FnrsCnsltnSplmntDmndResponseDto
|
||||
> & {
|
||||
fnrsSharList?: FnrsSharBizCstTreeDto[];
|
||||
sharAtchFileList?: AtflMngClientResponseDto[];
|
||||
};
|
||||
|
||||
/** 백엔드 FnrsCnsltnApplySaveRequestDto */
|
||||
export interface FnrsCnsltnApplySaveRequestDto extends PbadmsApplySaveHeader {
|
||||
fnrsCnsltnMng: FnrsCnsltnMngSaveRequestDto;
|
||||
bzScaleList?: BzScaleItem[];
|
||||
bizCstList?: BizCstItem[];
|
||||
cklstResultList?: CklstResultItem[];
|
||||
cklstResultMap?: Record<string, CklstResultItem[]>;
|
||||
esntlAtchFileList?: EsntlAtchFileItem[];
|
||||
rvwOpnnList?: FnrsCnsltnRvwOpnnSaveRequestDto[];
|
||||
splmntDmndList?: FnrsCnsltnSplmntDmndItem[];
|
||||
fnrsSharList?: FnrsSharItem[];
|
||||
}
|
||||
|
||||
/** Controller public 메서드명·시그니처와 동일 */
|
||||
export interface FnrsCnsltnMngService {
|
||||
findPage(searchDto: FnrsCnsltnMngSearchDto): Promise<PageResponse<FnrsCnsltnMngResponseDto>>;
|
||||
findById(fnrsCnsltnDocNo: string): Promise<FnrsCnsltnMngDetResponseDto>;
|
||||
save(body: FnrsCnsltnMngSaveRequestDto): Promise<string>;
|
||||
saveApply(body: FnrsCnsltnApplySaveRequestDto): Promise<string>;
|
||||
update(fnrsCnsltnDocNo: string, body: FnrsCnsltnMngUpdateRequestDto): Promise<string>;
|
||||
techReview(id: string, body: FnrsCnsltnApplySaveRequestDto): Promise<void>;
|
||||
submitSupplement(id: string, body: FnrsCnsltnApplySaveRequestDto): Promise<string>;
|
||||
submitReqSupplement(id: string, body: FnrsCnsltnApplySaveRequestDto): Promise<string>;
|
||||
remove(fnrsCnsltnDocNo: string): Promise<void>;
|
||||
changePrgrsSttsComCd(body: DocPrgrsUpdateRequestDto): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type { PageResponse } from "@/shared/model";
|
||||
|
||||
import {
|
||||
FNRS_SHAR_SE_SAVE_MODE,
|
||||
type FnrsSharBizCstTreeDto,
|
||||
type FnrsSharSeMngResponseDto,
|
||||
type FnrsSharSeMngSaveRequestDto,
|
||||
type FnrsSharSeMngSearchDto,
|
||||
type FnrsSharSeMngService,
|
||||
type FnrsSharSeMngTreeDto,
|
||||
} from "../model/fnrs-shar-se-interface";
|
||||
|
||||
const BASE = ENDPOINT.AS_SERVICE.FNRS_SHAR_SE;
|
||||
|
||||
async function findAll(
|
||||
params: FnrsSharSeMngSearchDto,
|
||||
): Promise<PageResponse<FnrsSharSeMngResponseDto>> {
|
||||
const response = await client.get<PageResponse<FnrsSharSeMngResponseDto>>(BASE, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findTree(): Promise<FnrsSharSeMngTreeDto[]> {
|
||||
const response = await client.get<FnrsSharSeMngTreeDto[]>(`${BASE}/list/tree`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findById(fnrsSharSeCd: string): Promise<FnrsSharSeMngResponseDto> {
|
||||
const response = await client.get<FnrsSharSeMngResponseDto>(`${BASE}/${fnrsSharSeCd}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function saveMulti(
|
||||
body: FnrsSharSeMngSaveRequestDto[],
|
||||
): Promise<FnrsSharSeMngResponseDto[]> {
|
||||
const response = await client.post<FnrsSharSeMngResponseDto[]>(BASE, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function update(fnrsSharSeCd: string, body: FnrsSharSeMngSaveRequestDto): Promise<string> {
|
||||
const response = await client.put<string>(`${BASE}/${fnrsSharSeCd}`, {
|
||||
...body,
|
||||
fnrsSharSeCd,
|
||||
mode: FNRS_SHAR_SE_SAVE_MODE.UPDATE,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function deleteFnrsSharSeCd(fnrsSharSeCd: string): Promise<string> {
|
||||
const response = await client.delete<string>(`${BASE}/${fnrsSharSeCd}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findTreeFnrsShar(fnrsCnsltnDocNo: string): Promise<FnrsSharBizCstTreeDto[]> {
|
||||
const response = await client.get<FnrsSharBizCstTreeDto[]>(
|
||||
`${BASE}/list/tree/fnrs-shar/${fnrsCnsltnDocNo}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const fnrsSharSeMngService = {
|
||||
findAll,
|
||||
findTree,
|
||||
findById,
|
||||
saveMulti,
|
||||
update,
|
||||
delete: deleteFnrsSharSeCd,
|
||||
findTreeFnrsShar,
|
||||
} satisfies FnrsSharSeMngService;
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type {
|
||||
FnrsSharSeMngSaveRequestDto,
|
||||
FnrsSharSeMngSearchDto,
|
||||
} from "../model/fnrs-shar-se-interface";
|
||||
|
||||
import { fnrsSharSeMngService } from "./fnrs-shar-se-service";
|
||||
|
||||
const fnrsSharSeMngBaseKey = [ENDPOINT.AS_SERVICE.FNRS_SHAR_SE] as const;
|
||||
|
||||
export function useFnrsSharSeMngPage(params: FnrsSharSeMngSearchDto) {
|
||||
return useQuery({
|
||||
queryKey: [...fnrsSharSeMngBaseKey, "findAll", params],
|
||||
queryFn: () => fnrsSharSeMngService.findAll(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useFnrsSharSeMngTree() {
|
||||
return useQuery({
|
||||
queryKey: [...fnrsSharSeMngBaseKey, "findTree"],
|
||||
queryFn: () => fnrsSharSeMngService.findTree(),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
export function useFnrsSharSeMng(fnrsSharSeCd: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [...fnrsSharSeMngBaseKey, "findById", fnrsSharSeCd],
|
||||
queryFn: () => fnrsSharSeMngService.findById(fnrsSharSeCd!),
|
||||
enabled: Boolean(fnrsSharSeCd),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveMultiFnrsSharSeMng() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (body: FnrsSharSeMngSaveRequestDto[]) => fnrsSharSeMngService.saveMulti(body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: fnrsSharSeMngBaseKey });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateFnrsSharSeMng() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
fnrsSharSeCd,
|
||||
body,
|
||||
}: {
|
||||
fnrsSharSeCd: string;
|
||||
body: FnrsSharSeMngSaveRequestDto;
|
||||
}) => fnrsSharSeMngService.update(fnrsSharSeCd, body),
|
||||
onSuccess: (_data, { fnrsSharSeCd }) => {
|
||||
queryClient.invalidateQueries({ queryKey: fnrsSharSeMngBaseKey });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...fnrsSharSeMngBaseKey, "findById", fnrsSharSeCd],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteFnrsSharSeMng() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (fnrsSharSeCd: string) => fnrsSharSeMngService.delete(fnrsSharSeCd),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: fnrsSharSeMngBaseKey });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useFnrsSharBizCstTree(fnrsCnsltnDocNo: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [...fnrsSharSeMngBaseKey, "findTreeFnrsShar", fnrsCnsltnDocNo],
|
||||
queryFn: () => fnrsSharSeMngService.findTreeFnrsShar(fnrsCnsltnDocNo!),
|
||||
enabled: Boolean(fnrsCnsltnDocNo),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export { fnrsSharSeMngService } from "./api/fnrs-shar-se-service";
|
||||
export {
|
||||
useDeleteFnrsSharSeMng,
|
||||
useFnrsSharBizCstTree,
|
||||
useFnrsSharSeMng,
|
||||
useFnrsSharSeMngPage,
|
||||
useFnrsSharSeMngTree,
|
||||
useSaveMultiFnrsSharSeMng,
|
||||
useUpdateFnrsSharSeMng,
|
||||
} from "./api/queries";
|
||||
export type {
|
||||
FnrsSharBizCstTreeDto,
|
||||
FnrsSharSeMngResponseDto,
|
||||
FnrsSharSeMngSaveRequestDto,
|
||||
FnrsSharSeMngSearchDto,
|
||||
FnrsSharSeMngService,
|
||||
FnrsSharSeMngTreeDto,
|
||||
FnrsSharSeSaveMode,
|
||||
} from "./model/fnrs-shar-se-interface";
|
||||
export { FNRS_SHAR_SE_SAVE_MODE } from "./model/fnrs-shar-se-interface";
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { PageableParams, PageResponse } from "@/shared/model";
|
||||
|
||||
export const FNRS_SHAR_SE_SAVE_MODE = {
|
||||
CREATE: "C",
|
||||
UPDATE: "U",
|
||||
DELETE: "D",
|
||||
} as const;
|
||||
|
||||
export type FnrsSharSeSaveMode = (typeof FNRS_SHAR_SE_SAVE_MODE)[keyof typeof FNRS_SHAR_SE_SAVE_MODE];
|
||||
|
||||
/** 백엔드 FnrsSharSeMngResponseDto */
|
||||
export interface FnrsSharSeMngResponseDto {
|
||||
fnrsSharSeCd: string;
|
||||
fnrsSharSeCdNm: string;
|
||||
upFnrsSharSeCd?: string;
|
||||
cdLvNo: number;
|
||||
useYn: string;
|
||||
expln?: string;
|
||||
sortSeq?: number;
|
||||
rgtr?: string;
|
||||
rgtrNm?: string;
|
||||
mdfr?: string;
|
||||
regDt?: string;
|
||||
mdfcnDt?: string;
|
||||
}
|
||||
|
||||
/** 백엔드 FnrsSharSeMngSearchDto + Pageable */
|
||||
export interface FnrsSharSeMngSearchDto extends PageableParams {
|
||||
fnrsSharSeCd?: string;
|
||||
fnrsSharSeCdNm?: string;
|
||||
useYn?: string;
|
||||
}
|
||||
|
||||
/** 백엔드 FnrsSharSeMngSaveRequestDto */
|
||||
export interface FnrsSharSeMngSaveRequestDto {
|
||||
fnrsSharSeCd: string;
|
||||
mode: FnrsSharSeSaveMode;
|
||||
fnrsSharSeCdNm: string;
|
||||
upFnrsSharSeCd?: string;
|
||||
cdLvNo: number;
|
||||
useYn?: string;
|
||||
expln?: string;
|
||||
sortSeq?: number;
|
||||
}
|
||||
|
||||
/** 백엔드 FnrsSharSeMngTreeDto */
|
||||
export interface FnrsSharSeMngTreeDto {
|
||||
indFnrsSharSeCdNm: string;
|
||||
fnrsSharSeCd: string;
|
||||
fnrsSharSeCdNm: string;
|
||||
upFnrsSharSeCd?: string;
|
||||
useYn: string;
|
||||
treeLevel: number;
|
||||
treePath: string;
|
||||
sortPath: string;
|
||||
}
|
||||
|
||||
/** 백엔드 FnrsSharBizCstTreeDto */
|
||||
export interface FnrsSharBizCstTreeDto {
|
||||
indFnrsSharSeCdNm: string;
|
||||
fnrsSharSeCd: string;
|
||||
fnrsSharSeCdNm: string;
|
||||
useYn: string;
|
||||
treeLevel: number;
|
||||
treePath: string;
|
||||
sortPath: string;
|
||||
exstnAmt?: number;
|
||||
chgRqtAmt?: number;
|
||||
ajmtAmt?: number;
|
||||
rmrkCn?: string;
|
||||
envoRmrkCn?: string;
|
||||
}
|
||||
|
||||
/** Controller public 메서드명·시그니처와 동일 */
|
||||
export interface FnrsSharSeMngService {
|
||||
findAll(params: FnrsSharSeMngSearchDto): Promise<PageResponse<FnrsSharSeMngResponseDto>>;
|
||||
findTree(): Promise<FnrsSharSeMngTreeDto[]>;
|
||||
findById(fnrsSharSeCd: string): Promise<FnrsSharSeMngResponseDto>;
|
||||
saveMulti(body: FnrsSharSeMngSaveRequestDto[]): Promise<FnrsSharSeMngResponseDto[]>;
|
||||
update(fnrsSharSeCd: string, body: FnrsSharSeMngSaveRequestDto): Promise<string>;
|
||||
delete(fnrsSharSeCd: string): Promise<string>;
|
||||
findTreeFnrsShar(fnrsCnsltnDocNo: string): Promise<FnrsSharBizCstTreeDto[]>;
|
||||
}
|
||||
@@ -65,6 +65,22 @@ async function techReview(id: string, payload: InstlSncnApplySaveRequestDto): Pr
|
||||
await client.post(`${BASE}/${id}/tech-review`, payload);
|
||||
}
|
||||
|
||||
async function submitSupplement(
|
||||
id: string,
|
||||
payload: InstlSncnApplySaveRequestDto,
|
||||
): Promise<string> {
|
||||
const response = await client.post<string>(`${BASE}/${id}/submitsupplement`, payload);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function submitReqSupplement(
|
||||
id: string,
|
||||
payload: InstlSncnApplySaveRequestDto,
|
||||
): Promise<string> {
|
||||
const response = await client.post<string>(`${BASE}/${id}/reqsupplement`, payload);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function remove(id: string): Promise<void> {
|
||||
await client.delete(`${BASE}/${id}`);
|
||||
}
|
||||
@@ -80,6 +96,8 @@ export const instlSncnService = {
|
||||
saveApply,
|
||||
update,
|
||||
techReview,
|
||||
submitSupplement,
|
||||
submitReqSupplement,
|
||||
remove,
|
||||
changePrgrsSttsComCd,
|
||||
} satisfies InstlSncnMngService;
|
||||
|
||||
@@ -99,6 +99,32 @@ export function useInstlSncnTechReview() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useInstlSncnSubmitSupplement() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, payload }: { id: string; payload: InstlSncnApplySaveRequestDto }) =>
|
||||
instlSncnService.submitSupplement(id, payload),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: instlSncnKeys.detail(variables.id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useInstlSncnSubmitReqSupplement() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, payload }: { id: string; payload: InstlSncnApplySaveRequestDto }) =>
|
||||
instlSncnService.submitReqSupplement(id, payload),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: instlSncnKeys.detail(variables.id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useInstlSncnRemove() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ export {
|
||||
useInstlSncnList,
|
||||
useInstlSncnSidoCombo,
|
||||
useInstlSncnSigunguCombo,
|
||||
useInstlSncnSubmitReqSupplement,
|
||||
useInstlSncnSubmitSupplement,
|
||||
useInstlSncnTechReview,
|
||||
} from "./api/queries";
|
||||
export {
|
||||
@@ -28,6 +30,7 @@ export {
|
||||
type InstlSncnMngSearchParams,
|
||||
type InstlSncnRvwOpnnResponseDto,
|
||||
type InstlSncnRvwOpnnSaveRequestDto,
|
||||
type InstlSncnSplmntDmndResponseDto,
|
||||
type InstlSncnSplmntDmndSaveDto,
|
||||
type ReviewStepCode,
|
||||
} from "./model/instl-sncn-interface";
|
||||
|
||||
@@ -1,4 +1,33 @@
|
||||
import type { PageableParams, PageResponse } from "@/shared/model";
|
||||
import type {
|
||||
AtflMngClientResponseDto,
|
||||
BizCstSaveBase,
|
||||
BizCstTreeDto,
|
||||
BizSclTreeDto,
|
||||
BzScaleSaveBase,
|
||||
CklstResultSaveBase,
|
||||
DocPrgrsUpdateRequestDto,
|
||||
ElaprDocPrgrsClientResponseDto,
|
||||
EsntlAtchFileMngTreeResponseDto,
|
||||
EsntlAtchFileSaveBase,
|
||||
PageableParams,
|
||||
PageResponse,
|
||||
PbadmsApplySaveHeader,
|
||||
PbadmsMngDetResponseBase,
|
||||
RvwOpnnResponseExtension,
|
||||
RvwOpnnSaveBase,
|
||||
SplmntDmndResponseExtension,
|
||||
SplmntDmndSaveBase,
|
||||
} from "@/shared/model";
|
||||
|
||||
export type {
|
||||
AtflMngClientResponseDto,
|
||||
BizCstTreeDto,
|
||||
BizSclTreeDto,
|
||||
CklstRvwRsltTreeDto,
|
||||
DocPrgrsUpdateRequestDto,
|
||||
ElaprDocPrgrsClientResponseDto,
|
||||
EsntlAtchFileMngTreeResponseDto,
|
||||
} from "@/shared/model";
|
||||
|
||||
/** 행정구역(시도·시군구) 콤보 응답 DTO */
|
||||
export interface AdstZoneResponseDto {
|
||||
@@ -70,16 +99,6 @@ export interface InstlSncnMngSaveRequestDto {
|
||||
/** 설치인가관리 수정 요청 DTO */
|
||||
export type InstlSncnMngUpdateRequestDto = InstlSncnMngSaveRequestDto;
|
||||
|
||||
/** 문서 진행상태 수정 요청 DTO */
|
||||
export interface DocPrgrsUpdateRequestDto {
|
||||
/** 행정지원문서번호 */
|
||||
docNo: string;
|
||||
/** 문서구분코드 */
|
||||
docSeComCd: string;
|
||||
/** 진행상태공통코드 */
|
||||
prgrsSttsComCd: string;
|
||||
}
|
||||
|
||||
/** 설치인가관리 응답 DTO */
|
||||
export interface InstlSncnMngResponseDto extends InstlSncnMngSaveRequestDto {
|
||||
/** 설치인가문서번호 (필수로 재정의) */
|
||||
@@ -111,7 +130,7 @@ export interface InstlSncnMngResponseDto extends InstlSncnMngSaveRequestDto {
|
||||
}
|
||||
|
||||
/** 설치인가 신청 - 사업규모 저장 DTO(배열 원소) */
|
||||
export interface InstlSncnBzScaleSaveDto {
|
||||
export interface InstlSncnBzScaleSaveDto extends BzScaleSaveBase {
|
||||
/** 공종구분코드 */
|
||||
cnstpSeCd: string;
|
||||
/** 기본계획수량 */
|
||||
@@ -125,7 +144,7 @@ export interface InstlSncnBzScaleSaveDto {
|
||||
}
|
||||
|
||||
/** 설치인가 신청 - 총사업비 저장 DTO(배열 원소) */
|
||||
export interface InstlSncnBizCstSaveRequestDto {
|
||||
export interface InstlSncnBizCstSaveRequestDto extends BizCstSaveBase {
|
||||
/** 회계구분코드 */
|
||||
acntgSeCd: string;
|
||||
/** 기본계획금액 */
|
||||
@@ -137,15 +156,13 @@ export interface InstlSncnBizCstSaveRequestDto {
|
||||
}
|
||||
|
||||
/** 설치인가 신청 - 체크리스트 결과 저장 DTO(배열 원소) */
|
||||
export interface InstlSncnCklstResultSaveDto {
|
||||
export interface InstlSncnCklstResultSaveDto extends CklstResultSaveBase {
|
||||
/** 점검표일련번호 */
|
||||
cklstSn: number;
|
||||
/** 점검표항목일련번호 */
|
||||
cklstArtclSn: number;
|
||||
/** 점검표검토일련번호 */
|
||||
cklstRvwSn: number;
|
||||
/** 검토단계공통코드 */
|
||||
rvwStpComCd: string;
|
||||
/** 선택값(Y/N/null) */
|
||||
selected: "Y" | "N" | null;
|
||||
/** 담당자검토내용(기술검토의견) */
|
||||
@@ -153,7 +170,7 @@ export interface InstlSncnCklstResultSaveDto {
|
||||
}
|
||||
|
||||
/** 설치인가 신청 - 필수첨부파일 저장 DTO(배열 원소) */
|
||||
export interface InstlSncnEsntlAtchFileSaveDto {
|
||||
export interface InstlSncnEsntlAtchFileSaveDto extends EsntlAtchFileSaveBase {
|
||||
/** 필수첨부파일아이디 */
|
||||
esntlAtchFileId: string;
|
||||
/** 첨부파일그룹아이디 */
|
||||
@@ -161,65 +178,34 @@ export interface InstlSncnEsntlAtchFileSaveDto {
|
||||
}
|
||||
|
||||
/** 설치인가검토의견 저장 요청 DTO */
|
||||
export interface InstlSncnRvwOpnnSaveRequestDto {
|
||||
export interface InstlSncnRvwOpnnSaveRequestDto extends RvwOpnnSaveBase {
|
||||
/** 설치인가문서번호 */
|
||||
instlSncnDocNo: string;
|
||||
/** 검토의견일련번호 */
|
||||
rvwOpnnSn?: number;
|
||||
/** 의견자구분공통코드 */
|
||||
opnrSeComCd?: string;
|
||||
/** 의견자명 */
|
||||
opnrNm?: string;
|
||||
/** 검토결과내용 */
|
||||
rvwRsltCn?: string;
|
||||
/** 검토결과이상여부 */
|
||||
rvwRsltAnmlyYn?: string;
|
||||
/** 첨부파일그룹아이디 */
|
||||
atchFileGroupId?: string;
|
||||
}
|
||||
|
||||
/** 설치인가 보완요구 저장 DTO(배열 원소) */
|
||||
export interface InstlSncnSplmntDmndSaveDto {
|
||||
/** 보완요구 일련번호 */
|
||||
splmntDmndSn?: number;
|
||||
/** 보완항목 공통코드 */
|
||||
splmntArtclComCd?: string;
|
||||
/** 보완요구 내용 */
|
||||
splmntDmndCn?: string;
|
||||
/** 보완요구 일시 */
|
||||
splmntDmndDt?: string;
|
||||
/** 보완요구 첨부파일 그룹 ID */
|
||||
splmntDmndAtchFileGroupId?: string;
|
||||
/** 보완 내용 */
|
||||
splmntCn?: string;
|
||||
/** 보완 담당자 명 */
|
||||
splmntPicNm?: string;
|
||||
/** 보완완료 일시 */
|
||||
splmntCmptnDt?: string;
|
||||
/** 보완 첨부파일 그룹 ID */
|
||||
splmntAtchFileGroupId?: string;
|
||||
/** 완료 여부 */
|
||||
cmptnYn?: string;
|
||||
}
|
||||
export type InstlSncnSplmntDmndSaveDto = SplmntDmndSaveBase;
|
||||
|
||||
/** 설치인가 보완요구 응답 DTO */
|
||||
export type InstlSncnSplmntDmndResponseDto = InstlSncnSplmntDmndSaveDto &
|
||||
SplmntDmndResponseExtension & {
|
||||
instlSncnDocNo?: string;
|
||||
};
|
||||
|
||||
/** 설치인가 신청 - 통합 저장 요청 DTO */
|
||||
export interface InstlSncnApplySaveRequestDto {
|
||||
/** 기술검토저장구분(SV.저장, CP.검토완료) */
|
||||
techReViewSaveCd?: string;
|
||||
/** 검토단계공통코드(ENV.환경청.EP, KECO.환경공단.TE) */
|
||||
rvwStpCd?: string;
|
||||
export interface InstlSncnApplySaveRequestDto extends PbadmsApplySaveHeader {
|
||||
/** 설치인가관리 정보 */
|
||||
instlSncnMng: InstlSncnMngSaveRequestDto;
|
||||
/** 사업규모 목록 */
|
||||
bzScaleList: InstlSncnBzScaleSaveDto[];
|
||||
bzScaleList?: InstlSncnBzScaleSaveDto[];
|
||||
/** 총사업비 목록 */
|
||||
bizCstList: InstlSncnBizCstSaveRequestDto[];
|
||||
bizCstList?: InstlSncnBizCstSaveRequestDto[];
|
||||
/** 체크리스트 검토결과 목록 */
|
||||
cklstResultList: InstlSncnCklstResultSaveDto[];
|
||||
cklstResultList?: InstlSncnCklstResultSaveDto[];
|
||||
/** 체크리스트 검토결과 맵 (rvwStpComCd -> 목록) */
|
||||
cklstResultMap?: Record<string, InstlSncnCklstResultSaveDto[]>;
|
||||
/** 필수첨부파일 목록 */
|
||||
esntlAtchFileList: InstlSncnEsntlAtchFileSaveDto[];
|
||||
esntlAtchFileList?: InstlSncnEsntlAtchFileSaveDto[];
|
||||
/** 검토의견 목록 */
|
||||
rvwOpnnList?: InstlSncnRvwOpnnSaveRequestDto[];
|
||||
/** 보완요구 목록 */
|
||||
@@ -228,184 +214,31 @@ export interface InstlSncnApplySaveRequestDto {
|
||||
|
||||
export type ReviewStepCode = "LOCAL" | "ENV" | "KECO";
|
||||
|
||||
/** 공종 트리와 사업규모(수량) 조합 DTO */
|
||||
export interface BizSclTreeDto {
|
||||
/** 들여쓰기 적용된 공종명(시각적) */
|
||||
indCnstpSeCdNm: string;
|
||||
/** 공종 코드 */
|
||||
cnstpSeCd: string;
|
||||
/** 공종 코드명 */
|
||||
cnstpSeCdNm: string;
|
||||
/** 사용 여부(Y/N) */
|
||||
useYn: string;
|
||||
/** 트리 레벨(깊이) */
|
||||
treeLevel: number;
|
||||
/** 트리 경로 표시용 문자열 */
|
||||
treePath: string;
|
||||
/** 기본계획수량 */
|
||||
bscPlanQty: number;
|
||||
/** 예산반영수량 */
|
||||
bgtRfltQty: number;
|
||||
/** 금회설계수량 */
|
||||
tstmDesignQty: number;
|
||||
}
|
||||
|
||||
/** 회계구분 트리와 사업비용 조합 DTO */
|
||||
export interface BizCstTreeDto {
|
||||
/** 들여쓰기 적용된 회계구분명(시각적) */
|
||||
indAcntgSeCdNm: string;
|
||||
/** 회계구분 코드 */
|
||||
acntgSeCd: string;
|
||||
/** 회계구분 코드명 */
|
||||
acntgSeCdNm: string;
|
||||
/** 사용 여부(Y/N) */
|
||||
useYn: string;
|
||||
/** 트리 레벨(깊이) */
|
||||
treeLevel: number;
|
||||
/** 트리 경로 표시용 문자열 */
|
||||
treePath: string;
|
||||
/** 기본계획금액 */
|
||||
bscPlanAmt: number;
|
||||
/** 예산반영금액 */
|
||||
bgtRfltAmt: number;
|
||||
/** 금회설계금액 */
|
||||
tstmDesignAmt: number;
|
||||
}
|
||||
|
||||
/** 점검표 검토결과 트리 DTO */
|
||||
export interface CklstRvwRsltTreeDto {
|
||||
/** 점검표일련번호 */
|
||||
cklstSn: number;
|
||||
/** 점검표명 */
|
||||
cklstNm: string;
|
||||
/** 점검표항목일련번호 */
|
||||
cklstArtclSn: number;
|
||||
/** 점검표항목명 */
|
||||
cklstArtclNm: string;
|
||||
/** 검토일련번호 */
|
||||
cklstRvwSn: number;
|
||||
/** 상위검토일련번호 */
|
||||
upCklstRvwSn: number | null;
|
||||
/** 트리 레벨 */
|
||||
treeLevel: number;
|
||||
/** 검토내용 */
|
||||
rvwCn: string;
|
||||
/** 점검 사용 여부 */
|
||||
bchckUseYn: string;
|
||||
/** 정렬순서 */
|
||||
sortSeq: number;
|
||||
/** 업무구분코드 */
|
||||
taskSeComCd: string;
|
||||
/** 검토단계공통코드 */
|
||||
rvwStpComCd: string;
|
||||
/** 검토결과이상여부 */
|
||||
rvwRsltAnmlyYn: string;
|
||||
/** 검토담당자 UUID */
|
||||
rvwPicUuid: string | null;
|
||||
/** 담당자검토내용 */
|
||||
picRvwCn: string | null;
|
||||
/** 검토일시 */
|
||||
rvwDt: string | null;
|
||||
}
|
||||
|
||||
/** 필수첨부파일관리 트리 조회 응답 DTO */
|
||||
export interface EsntlAtchFileMngTreeResponseDto {
|
||||
/** 필수첨부파일아이디 */
|
||||
esntlAtchFileId: string;
|
||||
/** 문서구분공통코드 */
|
||||
docSeComCd: string;
|
||||
/** 필수첨부파일명 */
|
||||
esntlAtchFileNm: string;
|
||||
/** 필수첨부여부 */
|
||||
esntlAtchYn: string;
|
||||
/** 필수첨부파일비고 */
|
||||
esntlAtchFileRmrk: string | null;
|
||||
/** 첨부파일 코드 */
|
||||
atflCd?: string;
|
||||
/** 첨부파일 일련번호 */
|
||||
atflSn?: number;
|
||||
/** 첨부파일 ID */
|
||||
atflId?: string;
|
||||
/** 원본 파일명 */
|
||||
orgnlFileNm?: string;
|
||||
/** 첨부파일 크기 */
|
||||
atflFlieSz?: number;
|
||||
/** 첨부파일 타입 */
|
||||
atflType?: string;
|
||||
/** 다운로드 횟수 */
|
||||
dwnldNocs?: number;
|
||||
/** 등록일시 */
|
||||
regDt?: string;
|
||||
}
|
||||
|
||||
/** 첨부파일 응답 DTO (attach-service client) */
|
||||
export interface AtflMngClientResponseDto {
|
||||
atflCd: string;
|
||||
atflSn: number;
|
||||
atflId: string;
|
||||
physFileNm: string;
|
||||
orgnlFileNm: string;
|
||||
atflFlieSz: number;
|
||||
atflType: string;
|
||||
dwnldNocs: number;
|
||||
delYn: string;
|
||||
lnkgDmnId: string | null;
|
||||
lnkgDmnNm: string | null;
|
||||
regDt: string;
|
||||
}
|
||||
|
||||
/** 설치인가검토의견 응답 DTO */
|
||||
export interface InstlSncnRvwOpnnResponseDto {
|
||||
/** 설치인가문서번호 */
|
||||
instlSncnDocNo: string;
|
||||
/** 검토의견일련번호 */
|
||||
rvwOpnnSn: number;
|
||||
/** 의견자구분공통코드 */
|
||||
opnrSeComCd: string;
|
||||
/** 의견자구분공통코드명 */
|
||||
opnrSeCdNm: string | null;
|
||||
/** 의견자명 */
|
||||
opnrNm: string;
|
||||
/** 검토결과내용 */
|
||||
rvwRsltCn: string;
|
||||
/** 검토결과이상여부 */
|
||||
rvwRsltAnmlyYn: string;
|
||||
/** 첨부파일그룹아이디 */
|
||||
atchFileGroupId: string | null;
|
||||
/** 첨부파일 상세 정보 */
|
||||
atchFileInfo?: AtflMngClientResponseDto;
|
||||
/** 등록일시 */
|
||||
regDt: string;
|
||||
}
|
||||
|
||||
/** 전자결재 진행상태 (클라이언트 응답용) */
|
||||
export interface ElaprDocPrgrsClientResponseDto {
|
||||
elaprDocNo?: string;
|
||||
elaprDmndId?: string;
|
||||
elaprPrgrsId?: string;
|
||||
atrzSeqNo?: number;
|
||||
atrzSeComCd?: string;
|
||||
atrzSeComNm?: string;
|
||||
aprvrUuid?: string;
|
||||
aprvrUuidNm?: string;
|
||||
atrzPrgrsSttsComCd?: string;
|
||||
atrzPrgrsSttsComNm?: string;
|
||||
rjctRsn?: string | null;
|
||||
atrzDt?: string | null;
|
||||
jbpsSeNm?: string;
|
||||
jbgdSeNm?: string;
|
||||
ognzNm?: string;
|
||||
}
|
||||
export type InstlSncnRvwOpnnResponseDto = InstlSncnRvwOpnnSaveRequestDto &
|
||||
RvwOpnnResponseExtension & {
|
||||
instlSncnDocNo: string;
|
||||
rvwOpnnSn: number;
|
||||
opnrSeComCd: string;
|
||||
opnrNm: string;
|
||||
rvwRsltCn: string;
|
||||
rvwRsltAnmlyYn: string;
|
||||
atchFileGroupId: string | null;
|
||||
opnrSeCdNm: string | null;
|
||||
regDt: string;
|
||||
};
|
||||
|
||||
/** 설치인가관리 상세 응답 DTO */
|
||||
export interface InstlSncnMngDetResponse {
|
||||
export interface InstlSncnMngDetResponse extends PbadmsMngDetResponseBase<
|
||||
InstlSncnMngResponseDto,
|
||||
InstlSncnRvwOpnnResponseDto,
|
||||
InstlSncnSplmntDmndResponseDto
|
||||
> {
|
||||
detDto: InstlSncnMngResponseDto;
|
||||
bizSclList: BizSclTreeDto[];
|
||||
bizCstList: BizCstTreeDto[];
|
||||
cklstResultList?: CklstRvwRsltTreeDto[];
|
||||
esntlFileList: EsntlAtchFileMngTreeResponseDto[];
|
||||
etcFileList: AtflMngClientResponseDto[];
|
||||
rvwList?: InstlSncnRvwOpnnResponseDto[];
|
||||
elaprDocPrgrsList?: ElaprDocPrgrsClientResponseDto[] | null;
|
||||
}
|
||||
|
||||
@@ -416,6 +249,8 @@ export interface InstlSncnMngService {
|
||||
saveApply: (payload: InstlSncnApplySaveRequestDto) => Promise<string>;
|
||||
update: (id: string, payload: InstlSncnMngUpdateRequestDto) => Promise<void>;
|
||||
techReview: (id: string, payload: InstlSncnApplySaveRequestDto) => Promise<void>;
|
||||
submitSupplement: (id: string, payload: InstlSncnApplySaveRequestDto) => Promise<string>;
|
||||
submitReqSupplement: (id: string, payload: InstlSncnApplySaveRequestDto) => Promise<string>;
|
||||
remove: (id: string) => Promise<void>;
|
||||
changePrgrsSttsComCd: (payload: DocPrgrsUpdateRequestDto) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { AcntgCodeManagementPage } from "./ui/acntg-code-management-page";
|
||||
@@ -1,245 +0,0 @@
|
||||
import type { AcntgSeMngResponseDto } from "@/entities/acntg";
|
||||
|
||||
import type {
|
||||
AcntgDisplaySlot,
|
||||
AcntgSeBaseEntry,
|
||||
AcntgSeGridState,
|
||||
} from "../model/acntg-se-grid-types";
|
||||
import type { AcntgSeRow } from "../model/acntg-se-row-types";
|
||||
import { buildAcntgDisplayRows } from "../model/build-acntg-display-rows";
|
||||
|
||||
export function createLoadedRowKey(index: number, acntgSeCd: string): string {
|
||||
return `loaded-${index}-${acntgSeCd}`;
|
||||
}
|
||||
|
||||
export function createDraftRowKey(): string {
|
||||
return `draft-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
export function createEmptyGridState(): AcntgSeGridState {
|
||||
return {
|
||||
baseByKey: {},
|
||||
structure: [],
|
||||
drafts: {},
|
||||
patches: {},
|
||||
deletedKeys: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
function toInitialRow(entry: AcntgSeBaseEntry): AcntgSeRow {
|
||||
return {
|
||||
...entry,
|
||||
isNew: false,
|
||||
isDirty: false,
|
||||
isDeleted: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildStructureFromBaseRows(
|
||||
baseByKey: Record<string, AcntgSeBaseEntry>,
|
||||
): AcntgDisplaySlot[] {
|
||||
const rows = Object.values(baseByKey).map(toInitialRow);
|
||||
const display = buildAcntgDisplayRows(rows);
|
||||
const cdToRowKey = new Map(rows.map((row) => [row.acntgSeCd, row.rowKey]));
|
||||
|
||||
return display.map(({ row, depth }) => ({
|
||||
rowKey: row.rowKey,
|
||||
depth,
|
||||
parentRowKey: row.upAcntgSeCd?.trim() ? (cdToRowKey.get(row.upAcntgSeCd.trim()) ?? null) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
export function hydrateGridFromServer(content: AcntgSeMngResponseDto[]): AcntgSeGridState {
|
||||
const baseByKey: Record<string, AcntgSeBaseEntry> = {};
|
||||
|
||||
content.forEach((item, index) => {
|
||||
const rowKey = createLoadedRowKey(index, item.acntgSeCd);
|
||||
baseByKey[rowKey] = { ...item, rowKey };
|
||||
});
|
||||
|
||||
return {
|
||||
baseByKey,
|
||||
structure: buildStructureFromBaseRows(baseByKey),
|
||||
drafts: {},
|
||||
patches: {},
|
||||
deletedKeys: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveParentAcntgSeCd(
|
||||
state: AcntgSeGridState,
|
||||
parentRowKey: string | null,
|
||||
): string | undefined {
|
||||
if (!parentRowKey) return undefined;
|
||||
const parent = mergeAcntgRow(state, parentRowKey);
|
||||
const cd = parent?.acntgSeCd?.trim();
|
||||
return cd || undefined;
|
||||
}
|
||||
|
||||
export function mergeAcntgRow(state: AcntgSeGridState, rowKey: string): AcntgSeRow | null {
|
||||
const draft = state.drafts[rowKey];
|
||||
if (draft) {
|
||||
return {
|
||||
rowKey,
|
||||
acntgSeCd: draft.acntgSeCd,
|
||||
acntgSeCdNm: draft.acntgSeCdNm,
|
||||
expln: draft.expln,
|
||||
useYn: draft.useYn,
|
||||
cdLvNo: draft.cdLvNo,
|
||||
upAcntgSeCd: resolveParentAcntgSeCd(state, draft.parentRowKey),
|
||||
isNew: true,
|
||||
isDirty: true,
|
||||
isDeleted: false,
|
||||
};
|
||||
}
|
||||
|
||||
const base = state.baseByKey[rowKey];
|
||||
if (!base) return null;
|
||||
|
||||
const patch = state.patches[rowKey] ?? {};
|
||||
const hasPatch = Object.keys(patch).length > 0;
|
||||
|
||||
return {
|
||||
...base,
|
||||
...patch,
|
||||
rowKey,
|
||||
isNew: false,
|
||||
isDirty: hasPatch,
|
||||
isDeleted: state.deletedKeys.has(rowKey),
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeAllAcntgRows(state: AcntgSeGridState): AcntgSeRow[] {
|
||||
const keys = new Set([...Object.keys(state.baseByKey), ...Object.keys(state.drafts)]);
|
||||
|
||||
return [...keys]
|
||||
.map((rowKey) => mergeAcntgRow(state, rowKey))
|
||||
.filter((row): row is AcntgSeRow => row != null);
|
||||
}
|
||||
|
||||
export function hasAcntgGridPendingChanges(state: AcntgSeGridState): boolean {
|
||||
return (
|
||||
state.deletedKeys.size > 0 ||
|
||||
Object.keys(state.drafts).length > 0 ||
|
||||
Object.keys(state.patches).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function collectDescendantRowKeys(
|
||||
structure: AcntgDisplaySlot[],
|
||||
rootRowKey: string,
|
||||
): Set<string> {
|
||||
const result = new Set<string>([rootRowKey]);
|
||||
const queue = [rootRowKey];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const parentKey = queue.shift()!;
|
||||
for (const slot of structure) {
|
||||
if (slot.parentRowKey === parentKey && !result.has(slot.rowKey)) {
|
||||
result.add(slot.rowKey);
|
||||
queue.push(slot.rowKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function insertRootSlot(
|
||||
structure: AcntgDisplaySlot[],
|
||||
slot: AcntgDisplaySlot,
|
||||
): AcntgDisplaySlot[] {
|
||||
const firstRootIndex = structure.findIndex((item) => item.parentRowKey === null);
|
||||
if (firstRootIndex === -1) return [slot, ...structure];
|
||||
return [...structure.slice(0, firstRootIndex), slot, ...structure.slice(firstRootIndex)];
|
||||
}
|
||||
|
||||
export function insertChildSlot(
|
||||
structure: AcntgDisplaySlot[],
|
||||
parentRowKey: string,
|
||||
slot: AcntgDisplaySlot,
|
||||
): AcntgDisplaySlot[] {
|
||||
const parentIndex = structure.findIndex((item) => item.rowKey === parentRowKey);
|
||||
if (parentIndex === -1) return [...structure, slot];
|
||||
|
||||
const insertAt = parentIndex + 1;
|
||||
return [...structure.slice(0, insertAt), slot, ...structure.slice(insertAt)];
|
||||
}
|
||||
|
||||
export function removeSlotsFromStructure(
|
||||
structure: AcntgDisplaySlot[],
|
||||
rowKeysToRemove: Set<string>,
|
||||
): AcntgDisplaySlot[] {
|
||||
return structure.filter((slot) => !rowKeysToRemove.has(slot.rowKey));
|
||||
}
|
||||
|
||||
export function patchAcntgGridRow(
|
||||
state: AcntgSeGridState,
|
||||
rowKey: string,
|
||||
patch: Partial<AcntgSeRow>,
|
||||
): AcntgSeGridState {
|
||||
const draft = state.drafts[rowKey];
|
||||
if (draft) {
|
||||
return {
|
||||
...state,
|
||||
drafts: {
|
||||
...state.drafts,
|
||||
[rowKey]: {
|
||||
...draft,
|
||||
...(patch.acntgSeCd !== undefined ? { acntgSeCd: patch.acntgSeCd } : {}),
|
||||
...(patch.acntgSeCdNm !== undefined ? { acntgSeCdNm: patch.acntgSeCdNm ?? "" } : {}),
|
||||
...(patch.expln !== undefined ? { expln: patch.expln ?? "" } : {}),
|
||||
...(patch.useYn !== undefined ? { useYn: patch.useYn } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { rowKey: _rk, isNew: _n, isDirty: _d, isDeleted: _del, ...dtoPatch } = patch;
|
||||
|
||||
return {
|
||||
...state,
|
||||
patches: {
|
||||
...state.patches,
|
||||
[rowKey]: { ...state.patches[rowKey], ...dtoPatch },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function markAcntgRowsDeleted(
|
||||
state: AcntgSeGridState,
|
||||
rootRowKey: string,
|
||||
): AcntgSeGridState {
|
||||
const keysToDelete = collectDescendantRowKeys(state.structure, rootRowKey);
|
||||
const deletedKeys = new Set(state.deletedKeys);
|
||||
for (const key of keysToDelete) {
|
||||
deletedKeys.add(key);
|
||||
}
|
||||
return { ...state, deletedKeys };
|
||||
}
|
||||
|
||||
export function validateDraftParentAcntgSeCd(
|
||||
state: AcntgSeGridState,
|
||||
rowKey: string,
|
||||
): string | null {
|
||||
const draft = state.drafts[rowKey];
|
||||
if (!draft?.parentRowKey) return null;
|
||||
|
||||
const parent = mergeAcntgRow(state, draft.parentRowKey);
|
||||
if (!parent?.acntgSeCd?.trim()) {
|
||||
return "하위 항목을 추가하려면 상위 코드를 먼저 입력하세요.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function removeDraftFromGrid(state: AcntgSeGridState, rowKey: string): AcntgSeGridState {
|
||||
const { [rowKey]: _removed, ...restDrafts } = state.drafts;
|
||||
const keysToRemove = collectDescendantRowKeys(state.structure, rowKey);
|
||||
|
||||
return {
|
||||
...state,
|
||||
drafts: restDrafts,
|
||||
structure: removeSlotsFromStructure(state.structure, keysToRemove),
|
||||
};
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { ACNTG_SE_SAVE_MODE, type AcntgSeMngSaveRequestDto } from "@/entities/acntg";
|
||||
|
||||
import type { AcntgSeRow } from "../model/acntg-se-row-types";
|
||||
|
||||
import { getVisibleRows } from "./row-state";
|
||||
|
||||
function compareCdLvNoDesc(a: AcntgSeRow, b: AcntgSeRow): number {
|
||||
return (b.cdLvNo ?? 1) - (a.cdLvNo ?? 1);
|
||||
}
|
||||
|
||||
function compareCdLvNoAsc(a: AcntgSeRow, b: AcntgSeRow): number {
|
||||
return (a.cdLvNo ?? 1) - (b.cdLvNo ?? 1);
|
||||
}
|
||||
|
||||
export function buildSaveMultiPayload(rows: AcntgSeRow[]): AcntgSeMngSaveRequestDto[] {
|
||||
const payload: AcntgSeMngSaveRequestDto[] = [];
|
||||
|
||||
const deletedRows = rows.filter((row) => row.isDeleted && !row.isNew).sort(compareCdLvNoDesc);
|
||||
|
||||
for (const row of deletedRows) {
|
||||
payload.push({
|
||||
mode: ACNTG_SE_SAVE_MODE.DELETE,
|
||||
acntgSeCd: row.acntgSeCd,
|
||||
acntgSeCdNm: row.acntgSeCdNm?.trim() || "",
|
||||
upAcntgSeCd: row.upAcntgSeCd,
|
||||
cdLvNo: row.cdLvNo,
|
||||
useYn: row.useYn,
|
||||
expln: row.expln?.trim() || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const visibleRows = getVisibleRows(rows).sort(compareCdLvNoAsc);
|
||||
|
||||
for (const row of visibleRows) {
|
||||
if (row.isNew) {
|
||||
payload.push({
|
||||
mode: ACNTG_SE_SAVE_MODE.CREATE,
|
||||
acntgSeCd: row.acntgSeCd.trim(),
|
||||
acntgSeCdNm: row.acntgSeCdNm.trim(),
|
||||
upAcntgSeCd: row.upAcntgSeCd,
|
||||
cdLvNo: row.cdLvNo,
|
||||
useYn: row.useYn,
|
||||
expln: row.expln?.trim() || undefined,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (row.isDirty) {
|
||||
payload.push({
|
||||
mode: ACNTG_SE_SAVE_MODE.UPDATE,
|
||||
acntgSeCd: row.acntgSeCd.trim(),
|
||||
acntgSeCdNm: row.acntgSeCdNm.trim(),
|
||||
upAcntgSeCd: row.upAcntgSeCd,
|
||||
cdLvNo: row.cdLvNo,
|
||||
useYn: row.useYn,
|
||||
expln: row.expln?.trim() || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { AcntgSeRow } from "../model/acntg-se-row-types";
|
||||
|
||||
export function getVisibleRows(rows: AcntgSeRow[]): AcntgSeRow[] {
|
||||
return rows.filter((row) => !row.isDeleted);
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { AcntgSeMngResponseDto } from "@/entities/acntg";
|
||||
|
||||
import type { AcntgSeGridState } from "../model/acntg-se-grid-types";
|
||||
import type { AcntgSeRow } from "../model/acntg-se-row-types";
|
||||
|
||||
import {
|
||||
createDraftRowKey,
|
||||
createEmptyGridState,
|
||||
hasAcntgGridPendingChanges,
|
||||
hydrateGridFromServer,
|
||||
insertChildSlot,
|
||||
insertRootSlot,
|
||||
markAcntgRowsDeleted,
|
||||
mergeAcntgRow,
|
||||
mergeAllAcntgRows,
|
||||
patchAcntgGridRow,
|
||||
removeDraftFromGrid,
|
||||
validateDraftParentAcntgSeCd,
|
||||
} from "./acntg-se-grid";
|
||||
import { buildSaveMultiPayload } from "./build-save-multi-payload";
|
||||
|
||||
function validateAcntgSeRow(row: AcntgSeRow): string | null {
|
||||
if (!row.acntgSeCd.trim()) return "코드를 입력하세요.";
|
||||
if (!row.acntgSeCdNm?.trim()) return "코드명을 입력하세요.";
|
||||
const parentCd = row.upAcntgSeCd?.trim();
|
||||
if (parentCd && row.acntgSeCd.trim() === parentCd) {
|
||||
return "상위 코드와 동일한 코드는 사용할 수 없습니다.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface UseAcntgSeRowsOptions {
|
||||
serverContent: AcntgSeMngResponseDto[] | undefined;
|
||||
onAlert: (message: string) => void;
|
||||
onSave: (payload: ReturnType<typeof buildSaveMultiPayload>) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useAcntgSeRows({ serverContent, onAlert, onSave }: UseAcntgSeRowsOptions) {
|
||||
const [grid, setGrid] = useState<AcntgSeGridState>(createEmptyGridState);
|
||||
const hasPendingRef = useRef(false);
|
||||
|
||||
hasPendingRef.current = hasAcntgGridPendingChanges(grid);
|
||||
|
||||
useEffect(() => {
|
||||
if (serverContent == null) return;
|
||||
if (hasPendingRef.current) return;
|
||||
setGrid(hydrateGridFromServer(serverContent));
|
||||
}, [serverContent]);
|
||||
|
||||
const displayRows = useMemo(() => {
|
||||
return grid.structure
|
||||
.filter((slot) => !grid.deletedKeys.has(slot.rowKey))
|
||||
.map((slot) => {
|
||||
const row = mergeAcntgRow(grid, slot.rowKey);
|
||||
return row ? { row, depth: slot.depth } : null;
|
||||
})
|
||||
.filter((item): item is { row: AcntgSeRow; depth: number } => item != null);
|
||||
}, [grid]);
|
||||
|
||||
const canSave = hasAcntgGridPendingChanges(grid);
|
||||
|
||||
const updateRow = useCallback((rowKey: string, patch: Partial<AcntgSeRow>) => {
|
||||
setGrid((prev) => patchAcntgGridRow(prev, rowKey, patch));
|
||||
}, []);
|
||||
|
||||
const addRootRow = useCallback(() => {
|
||||
const rowKey = createDraftRowKey();
|
||||
setGrid((prev) => ({
|
||||
...prev,
|
||||
drafts: {
|
||||
...prev.drafts,
|
||||
[rowKey]: {
|
||||
rowKey,
|
||||
parentRowKey: null,
|
||||
cdLvNo: 1,
|
||||
acntgSeCd: "",
|
||||
acntgSeCdNm: "",
|
||||
expln: "",
|
||||
useYn: "Y",
|
||||
},
|
||||
},
|
||||
structure: insertRootSlot(prev.structure, { rowKey, depth: 0, parentRowKey: null }),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const addChildRow = useCallback((parent: AcntgSeRow) => {
|
||||
const rowKey = createDraftRowKey();
|
||||
const slot = {
|
||||
rowKey,
|
||||
depth: 0,
|
||||
parentRowKey: parent.rowKey,
|
||||
};
|
||||
|
||||
setGrid((prev) => {
|
||||
const parentSlot = prev.structure.find((item) => item.rowKey === parent.rowKey);
|
||||
const depth = (parentSlot?.depth ?? 0) + 1;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
drafts: {
|
||||
...prev.drafts,
|
||||
[rowKey]: {
|
||||
rowKey,
|
||||
parentRowKey: parent.rowKey,
|
||||
cdLvNo: (parent.cdLvNo ?? 1) + 1,
|
||||
acntgSeCd: "",
|
||||
acntgSeCdNm: "",
|
||||
expln: "",
|
||||
useYn: "Y",
|
||||
},
|
||||
},
|
||||
structure: insertChildSlot(prev.structure, parent.rowKey, {
|
||||
...slot,
|
||||
depth,
|
||||
}),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeDraft = useCallback((rowKey: string) => {
|
||||
setGrid((prev) => removeDraftFromGrid(prev, rowKey));
|
||||
}, []);
|
||||
|
||||
const markDeleted = useCallback((rowKey: string) => {
|
||||
setGrid((prev) => markAcntgRowsDeleted(prev, rowKey));
|
||||
}, []);
|
||||
|
||||
const resetFromServer = useCallback((content: AcntgSeMngResponseDto[]) => {
|
||||
setGrid(hydrateGridFromServer(content));
|
||||
}, []);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
const mergedRows = mergeAllAcntgRows(grid);
|
||||
const payload = buildSaveMultiPayload(mergedRows);
|
||||
if (payload.length === 0) return false;
|
||||
|
||||
for (const row of mergedRows) {
|
||||
if (!row.isDeleted && (row.isNew || row.isDirty)) {
|
||||
const parentCodeError = validateDraftParentAcntgSeCd(grid, row.rowKey);
|
||||
if (parentCodeError) {
|
||||
onAlert(parentCodeError);
|
||||
return false;
|
||||
}
|
||||
|
||||
const fieldError = validateAcntgSeRow(row);
|
||||
if (fieldError) {
|
||||
onAlert(fieldError);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await onSave(payload);
|
||||
return true;
|
||||
}, [grid, onAlert, onSave]);
|
||||
|
||||
return {
|
||||
displayRows,
|
||||
canSave,
|
||||
updateRow,
|
||||
addRootRow,
|
||||
addChildRow,
|
||||
removeDraft,
|
||||
markDeleted,
|
||||
resetFromServer,
|
||||
save,
|
||||
};
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { useAcntgSeMngPage, useSaveMultiAcntgSeMng } from "@/entities/acntg";
|
||||
import {
|
||||
Button,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
import { useAcntgSeRows } from "../lib/use-acntg-se-rows";
|
||||
import type { AcntgSeRow } from "../model/acntg-se-row-types";
|
||||
|
||||
import { AcntgSeRowComponent } from "./acntg-se-row";
|
||||
|
||||
function AcntgCodeManagementPage() {
|
||||
const { confirm } = useConfirm();
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
|
||||
const pageQuery = useAcntgSeMngPage({
|
||||
page: 0,
|
||||
size: 500,
|
||||
sort: "cdLvNo,asc",
|
||||
});
|
||||
|
||||
const saveMultiMutation = useSaveMultiAcntgSeMng();
|
||||
|
||||
const handlePersist = useCallback(
|
||||
async (payload: Parameters<typeof saveMultiMutation.mutateAsync>[0]) => {
|
||||
await saveMultiMutation.mutateAsync(payload);
|
||||
},
|
||||
[saveMultiMutation],
|
||||
);
|
||||
|
||||
const {
|
||||
displayRows,
|
||||
canSave,
|
||||
updateRow,
|
||||
addRootRow,
|
||||
addChildRow,
|
||||
removeDraft,
|
||||
markDeleted,
|
||||
resetFromServer,
|
||||
save,
|
||||
} = useAcntgSeRows({
|
||||
serverContent: pageQuery.data?.content,
|
||||
onAlert: alert,
|
||||
onSave: handlePersist,
|
||||
});
|
||||
|
||||
const handleDelete = async (row: AcntgSeRow) => {
|
||||
if (!(await confirm("삭제하시겠습니까?"))) return;
|
||||
|
||||
if (row.isNew) {
|
||||
removeDraft(row.rowKey);
|
||||
return;
|
||||
}
|
||||
|
||||
markDeleted(row.rowKey);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!(await confirm("저장하시겠습니까?"))) return;
|
||||
|
||||
const saved = await save();
|
||||
if (!saved) return;
|
||||
|
||||
alert("저장되었습니다.");
|
||||
const result = await pageQuery.refetch();
|
||||
resetFromServer(result.data?.content ?? []);
|
||||
};
|
||||
|
||||
const isSaving = saveMultiMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-[1.8rem] pb-6">
|
||||
<section className="card card-border mx-0 bg-background pb-6 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 type="button" color="green" onClick={addRootRow}>
|
||||
코드 추가
|
||||
</Button>
|
||||
<Button type="button" disabled={!canSave || isSaving} onClick={handleSave}>
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pageQuery.isPending ? (
|
||||
<div className="flex flex-col gap-2 px-4 pb-4">
|
||||
<Skeleton className="h-[4rem]" />
|
||||
<Skeleton className="h-[16rem]" />
|
||||
</div>
|
||||
) : displayRows.length === 0 ? (
|
||||
<p className="py-8 text-center text-lg text-muted-foreground">
|
||||
등록된 코드가 없습니다. [코드 추가]로 1단계 코드를 등록하세요.
|
||||
</p>
|
||||
) : (
|
||||
<div className="max-h-[60rem] overflow-y-auto pt-3">
|
||||
<Table className="w-full">
|
||||
<colgroup>
|
||||
<col width="15%" />
|
||||
<col width="20%" />
|
||||
<col width="35%" />
|
||||
<col width="12%" />
|
||||
<col width="8%" />
|
||||
<col width="10%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>코드</TableHead>
|
||||
<TableHead>코드명</TableHead>
|
||||
<TableHead>설명</TableHead>
|
||||
<TableHead>사용 여부</TableHead>
|
||||
<TableHead>구분</TableHead>
|
||||
<TableHead>관리</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{displayRows.map(({ row, depth }) => (
|
||||
<AcntgSeRowComponent
|
||||
key={row.rowKey}
|
||||
row={row}
|
||||
depth={depth}
|
||||
onChange={(patch) => updateRow(row.rowKey, patch)}
|
||||
onAddChild={() => addChildRow(row)}
|
||||
onDelete={() => handleDelete(row)}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { AcntgCodeManagementPage };
|
||||
@@ -1,6 +0,0 @@
|
||||
import type { ElaprAplnRow } from "../model/apln-row-types";
|
||||
|
||||
/** 그리드 행 순서(1-based)로 결재순서번호 일괄 채번 */
|
||||
export function assignAtrzSeqNumbers(rows: ElaprAplnRow[]): ElaprAplnRow[] {
|
||||
return rows.map((row, index) => ({ ...row, atrzSeqNo: index + 1 }));
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { ElaprApln } from "@/entities/elapr";
|
||||
|
||||
import { assignAtrzSeqNumbers } from "./assign-atrz-seq";
|
||||
import { getVisibleRows } from "./validators";
|
||||
import type { ElaprAplnRow } from "../model/apln-row-types";
|
||||
|
||||
function rowDiffersFromServer(row: ElaprAplnRow, serverRow: ElaprApln): boolean {
|
||||
return (
|
||||
row.aprvrUuid !== serverRow.aprvrUuid ||
|
||||
row.atrzSeComCd !== serverRow.atrzSeComCd ||
|
||||
row.atrzSeqNo !== serverRow.atrzSeqNo
|
||||
);
|
||||
}
|
||||
|
||||
export function hasUnsavedChanges(rows: ElaprAplnRow[], serverRows: ElaprApln[]): boolean {
|
||||
if (rows.some((row) => row.isDeleted && row.aplnId)) return true;
|
||||
if (rows.some((row) => row.isNew && !row.isDeleted)) return true;
|
||||
|
||||
const visibleWithSeq = assignAtrzSeqNumbers(getVisibleRows(rows));
|
||||
|
||||
for (const row of visibleWithSeq) {
|
||||
if (row.isNew) continue;
|
||||
if (!row.aplnId) continue;
|
||||
if (row.isDirty) return true;
|
||||
|
||||
const serverRow = serverRows.find((s) => s.aplnId === row.aplnId);
|
||||
if (!serverRow) continue;
|
||||
if (rowDiffersFromServer(row, serverRow)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -14,10 +14,6 @@ interface CreateTmpltColumnsOptions {
|
||||
|
||||
export function createTmpltColumns({ onEdit, onDelete }: CreateTmpltColumnsOptions) {
|
||||
return [
|
||||
columnHelper.accessor("elaprTmpltId", {
|
||||
header: "전자결재서식아이디",
|
||||
meta: { align: "left" },
|
||||
}),
|
||||
columnHelper.accessor("elaprTmpltNm", {
|
||||
header: "결재서식명",
|
||||
meta: { align: "left" },
|
||||
|
||||
@@ -11,7 +11,7 @@ function ApprovalLineManagementPage() {
|
||||
const [selectedTmplt, setSelectedTmplt] = useState<ElaprTmplt | null>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-[1.8rem] pb-6">
|
||||
<div className="grid grid-cols-1 gap-6 px-[1.8rem] xl:grid-cols-2">
|
||||
<ElaprTmpltList
|
||||
selectedTmpltId={selectedTmplt?.elaprTmpltId ?? null}
|
||||
onSelectTmplt={setSelectedTmplt}
|
||||
|
||||
@@ -7,7 +7,19 @@ import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { useElaprAplnPage, useSaveMultiElaprApln } from "@/entities/elapr";
|
||||
import {
|
||||
buildSaveMultiPayload,
|
||||
type ElaprAplnRow,
|
||||
hasUnsavedChanges,
|
||||
useAplnRows,
|
||||
useDndSensors,
|
||||
useElaprAplnPage,
|
||||
useSaveMultiElaprApln,
|
||||
validateAllRowsForSave,
|
||||
validateAplnRow,
|
||||
validateAtrzOrder,
|
||||
validateUniqueSeqNo,
|
||||
} from "@/entities/elapr";
|
||||
import {
|
||||
Button,
|
||||
Skeleton,
|
||||
@@ -18,17 +30,6 @@ import {
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
import { buildSaveMultiPayload } from "../lib/build-save-multi-payload";
|
||||
import { hasUnsavedChanges } from "../lib/has-unsaved-changes";
|
||||
import { useAplnRows } from "../lib/use-apln-rows";
|
||||
import { useDndSensors } from "../lib/use-dnd-sensors";
|
||||
import {
|
||||
validateAllRowsForSave,
|
||||
validateAplnRow,
|
||||
validateAtrzOrder,
|
||||
validateUniqueSeqNo,
|
||||
} from "../lib/validators";
|
||||
import type { ElaprAplnRow } from "../model/apln-row-types";
|
||||
import type { ReviewerOption } from "../model/reviewer-option";
|
||||
|
||||
import { AprvrPickerDialog } from "./aprvr-picker-dialog";
|
||||
@@ -185,20 +186,20 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<Table className="w-full">
|
||||
<colgroup>
|
||||
<col width="10%" />
|
||||
<col width="30%" />
|
||||
<col width="5%" />
|
||||
<col width="10%" />
|
||||
<col width="10%" />
|
||||
<col width="15%" />
|
||||
<col width="15%" />
|
||||
<col width="25%" />
|
||||
<col width="20%" />
|
||||
<col width="10%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10" />
|
||||
<TableHead>결재선아이디</TableHead>
|
||||
<TableHead>결재순서</TableHead>
|
||||
<TableHead>결재구분</TableHead>
|
||||
<TableHead>결재자 ID</TableHead>
|
||||
<TableHead>결재자</TableHead>
|
||||
<TableHead>등록일시</TableHead>
|
||||
<TableHead>관리</TableHead>
|
||||
|
||||
@@ -4,10 +4,18 @@ import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Equal } from "lucide-react";
|
||||
|
||||
import { CodeSelect, findCodeLabel, useCodeList } from "@/entities/code";
|
||||
import { Button, TableCell, TableRow } from "@common/components";
|
||||
|
||||
import type { ElaprAplnRow } from "../model/apln-row-types";
|
||||
import { findCodeLabel, useCodeList } from "@/entities/code";
|
||||
import type { ElaprAplnRow } from "@/entities/elapr";
|
||||
import {
|
||||
Button,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
TableCell,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
interface SortableAplnRowProps {
|
||||
row: ElaprAplnRow;
|
||||
@@ -37,6 +45,7 @@ function SortableAplnRow({
|
||||
onPickAprvr,
|
||||
}: SortableAplnRowProps) {
|
||||
const { data: atrzSeCodes } = useCodeList("ATRZSE");
|
||||
const selectableAtrzSeCodes = atrzSeCodes?.filter((code) => code.lwpoCd !== "ATRZ01");
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: row.rowKey,
|
||||
@@ -50,7 +59,7 @@ function SortableAplnRow({
|
||||
};
|
||||
|
||||
const rowDisabled = isLocked && !isEditing;
|
||||
const atrzSeLabel = row.atrzSeComNm || findCodeLabel(atrzSeCodes, row.atrzSeComCd);
|
||||
const atrzSeLabel = row.atrzSeComNm || findCodeLabel(selectableAtrzSeCodes, row.atrzSeComCd);
|
||||
|
||||
return (
|
||||
<TableRow ref={setNodeRef} style={style} className={isEditing ? "bg-accent/30" : undefined}>
|
||||
@@ -67,31 +76,36 @@ function SortableAplnRow({
|
||||
<Equal 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]"
|
||||
<Select
|
||||
value={row.atrzSeComCd ?? ""}
|
||||
onValueChange={(v) =>
|
||||
onChange({
|
||||
atrzSeComCd: v,
|
||||
atrzSeComNm: findCodeLabel(atrzSeCodes, v),
|
||||
})
|
||||
onChange({ atrzSeComCd: v, atrzSeComNm: findCodeLabel(selectableAtrzSeCodes, v) })
|
||||
}
|
||||
placeholder="결재구분"
|
||||
/>
|
||||
>
|
||||
<SelectTrigger className="min-w-[120px]">
|
||||
<SelectValue placeholder="결재구분" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{selectableAtrzSeCodes?.map((code) => (
|
||||
<SelectItem key={code.lwpoCd} value={code.lwpoCd}>
|
||||
{code.lwpoCdNm}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
atrzSeLabel || "-"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">{row.aprvrUserId ?? "-"}</TableCell>
|
||||
<TableCell align="center">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<span>{row.aprvrNm || row.aprvrUuid || "-"}</span>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span>{row.aprvrNm || row.aprvrUuid || ""}</span>
|
||||
{isEditing && (
|
||||
<Button type="button" size="sm" onClick={onPickAprvr}>
|
||||
<Button type="button" size="sm" onClick={onPickAprvr} disabled={!row.atrzSeComCd}>
|
||||
선택
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -49,21 +49,15 @@ export function formatTreeBranchPrefix(depth: number): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
function compareSortSeq(a: CklstRvwRow, b: CklstRvwRow): number {
|
||||
return (a.sortSeq ?? 0) - (b.sortSeq ?? 0);
|
||||
}
|
||||
|
||||
export function buildRvwDisplayRows(rows: CklstRvwRow[]): CklstRvwDisplayRow[] {
|
||||
const result: CklstRvwDisplayRow[] = [];
|
||||
|
||||
const roots = rows
|
||||
.filter((row) => (row.rvwCnLvNo ?? 1) === 1 && row.upCklstRvwSn == null)
|
||||
.sort(compareSortSeq);
|
||||
const roots = rows.filter((row) => (row.rvwCnLvNo ?? 1) === 1 && row.upCklstRvwSn == null);
|
||||
|
||||
function appendSubtree(parent: CklstRvwRow, depth: number) {
|
||||
const children = rows
|
||||
.filter((row) => row.upCklstRvwSn != null && row.upCklstRvwSn === parent.cklstRvwSn)
|
||||
.sort(compareSortSeq);
|
||||
const children = rows.filter(
|
||||
(row) => row.upCklstRvwSn != null && row.upCklstRvwSn === parent.cklstRvwSn,
|
||||
);
|
||||
|
||||
for (const child of children) {
|
||||
result.push({ row: child, depth });
|
||||
@@ -82,7 +76,7 @@ export function buildRvwDisplayRows(rows: CklstRvwRow[]): CklstRvwDisplayRow[] {
|
||||
(row) => !result.some((display) => display.row.rowKey === row.rowKey),
|
||||
);
|
||||
|
||||
for (const orphan of orphans.sort(compareSortSeq)) {
|
||||
for (const orphan of orphans) {
|
||||
result.push({ row: orphan, depth: Math.max(0, (orphan.rvwCnLvNo ?? 1) - 1) });
|
||||
}
|
||||
|
||||
|
||||
@@ -279,12 +279,13 @@ function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) {
|
||||
) : (
|
||||
<Table className="w-full">
|
||||
<colgroup>
|
||||
<col width="18%" />
|
||||
<col width="42%" />
|
||||
<col width="12%" />
|
||||
<col width="12%" />
|
||||
<col width="16%" />
|
||||
<col width="36%" />
|
||||
<col width="10%" />
|
||||
<col width="10%" />
|
||||
<col width="8%" />
|
||||
<col width="5%" />
|
||||
<col width="11%" />
|
||||
<col width="15%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -292,6 +293,7 @@ function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) {
|
||||
<TableHead>주요 검토내용</TableHead>
|
||||
<TableHead>사용 여부</TableHead>
|
||||
<TableHead>체크 사용</TableHead>
|
||||
<TableHead>정렬 순서</TableHead>
|
||||
<TableHead>구분</TableHead>
|
||||
<TableHead>관리</TableHead>
|
||||
</TableRow>
|
||||
|
||||
@@ -107,6 +107,20 @@ function CklstRvwRowComponent({
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={row.sortSeq ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
sortSeq: e.target.value ? Number(e.target.value) : undefined,
|
||||
isDirty: true,
|
||||
})
|
||||
}
|
||||
className="w-20"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="center">{levelLabel}</TableCell>
|
||||
<TableCell align="center">
|
||||
<div className="flex justify-center gap-2">
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { CnstpCodeManagementPage } from "./ui/cnstp-code-management-page";
|
||||
@@ -1,62 +0,0 @@
|
||||
import { CNSTP_SE_SAVE_MODE, type CnstpSeMngSaveRequestDto } from "@/entities/cnstp";
|
||||
|
||||
import type { CnstpSeRow } from "../model/cnstp-se-row-types";
|
||||
|
||||
import { getVisibleRows } from "./row-state";
|
||||
|
||||
function compareCdLvNoDesc(a: CnstpSeRow, b: CnstpSeRow): number {
|
||||
return (b.cdLvNo ?? 1) - (a.cdLvNo ?? 1);
|
||||
}
|
||||
|
||||
function compareCdLvNoAsc(a: CnstpSeRow, b: CnstpSeRow): number {
|
||||
return (a.cdLvNo ?? 1) - (b.cdLvNo ?? 1);
|
||||
}
|
||||
|
||||
export function buildSaveMultiPayload(rows: CnstpSeRow[]): CnstpSeMngSaveRequestDto[] {
|
||||
const payload: CnstpSeMngSaveRequestDto[] = [];
|
||||
|
||||
const deletedRows = rows.filter((row) => row.isDeleted && !row.isNew).sort(compareCdLvNoDesc);
|
||||
|
||||
for (const row of deletedRows) {
|
||||
payload.push({
|
||||
mode: CNSTP_SE_SAVE_MODE.DELETE,
|
||||
cnstpSeCd: row.cnstpSeCd,
|
||||
cnstpSeCdNm: row.cnstpSeCdNm?.trim() || undefined,
|
||||
upCnstpCd: row.upCnstpCd,
|
||||
cdLvNo: row.cdLvNo,
|
||||
useYn: row.useYn,
|
||||
expln: row.expln?.trim() || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const visibleRows = getVisibleRows(rows).sort(compareCdLvNoAsc);
|
||||
|
||||
for (const row of visibleRows) {
|
||||
if (row.isNew) {
|
||||
payload.push({
|
||||
mode: CNSTP_SE_SAVE_MODE.CREATE,
|
||||
cnstpSeCd: row.cnstpSeCd.trim(),
|
||||
cnstpSeCdNm: row.cnstpSeCdNm?.trim() || undefined,
|
||||
upCnstpCd: row.upCnstpCd,
|
||||
cdLvNo: row.cdLvNo,
|
||||
useYn: row.useYn,
|
||||
expln: row.expln?.trim() || undefined,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (row.isDirty) {
|
||||
payload.push({
|
||||
mode: CNSTP_SE_SAVE_MODE.UPDATE,
|
||||
cnstpSeCd: row.cnstpSeCd.trim(),
|
||||
cnstpSeCdNm: row.cnstpSeCdNm?.trim() || undefined,
|
||||
upCnstpCd: row.upCnstpCd,
|
||||
cdLvNo: row.cdLvNo,
|
||||
useYn: row.useYn,
|
||||
expln: row.expln?.trim() || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
import type { CnstpSeMngResponseDto } from "@/entities/cnstp";
|
||||
|
||||
import { buildCnstpDisplayRows } from "../model/build-cnstp-display-rows";
|
||||
import type {
|
||||
CnstpDisplaySlot,
|
||||
CnstpSeBaseEntry,
|
||||
CnstpSeGridState,
|
||||
} from "../model/cnstp-se-grid-types";
|
||||
import type { CnstpSeRow } from "../model/cnstp-se-row-types";
|
||||
|
||||
export function createLoadedRowKey(index: number, cnstpSeCd: string): string {
|
||||
return `loaded-${index}-${cnstpSeCd}`;
|
||||
}
|
||||
|
||||
export function createDraftRowKey(): string {
|
||||
return `draft-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
export function createEmptyGridState(): CnstpSeGridState {
|
||||
return {
|
||||
baseByKey: {},
|
||||
structure: [],
|
||||
drafts: {},
|
||||
patches: {},
|
||||
deletedKeys: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
function toInitialRow(entry: CnstpSeBaseEntry): CnstpSeRow {
|
||||
return {
|
||||
...entry,
|
||||
isNew: false,
|
||||
isDirty: false,
|
||||
isDeleted: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildStructureFromBaseRows(
|
||||
baseByKey: Record<string, CnstpSeBaseEntry>,
|
||||
): CnstpDisplaySlot[] {
|
||||
const rows = Object.values(baseByKey).map(toInitialRow);
|
||||
const display = buildCnstpDisplayRows(rows);
|
||||
const cdToRowKey = new Map(rows.map((row) => [row.cnstpSeCd, row.rowKey]));
|
||||
|
||||
return display.map(({ row, depth }) => ({
|
||||
rowKey: row.rowKey,
|
||||
depth,
|
||||
parentRowKey: row.upCnstpCd?.trim() ? (cdToRowKey.get(row.upCnstpCd.trim()) ?? null) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
export function hydrateGridFromServer(content: CnstpSeMngResponseDto[]): CnstpSeGridState {
|
||||
const baseByKey: Record<string, CnstpSeBaseEntry> = {};
|
||||
|
||||
content.forEach((item, index) => {
|
||||
const rowKey = createLoadedRowKey(index, item.cnstpSeCd);
|
||||
baseByKey[rowKey] = { ...item, rowKey };
|
||||
});
|
||||
|
||||
return {
|
||||
baseByKey,
|
||||
structure: buildStructureFromBaseRows(baseByKey),
|
||||
drafts: {},
|
||||
patches: {},
|
||||
deletedKeys: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveParentCnstpSeCd(
|
||||
state: CnstpSeGridState,
|
||||
parentRowKey: string | null,
|
||||
): string | undefined {
|
||||
if (!parentRowKey) return undefined;
|
||||
const parent = mergeCnstpRow(state, parentRowKey);
|
||||
const cd = parent?.cnstpSeCd?.trim();
|
||||
return cd || undefined;
|
||||
}
|
||||
|
||||
export function mergeCnstpRow(state: CnstpSeGridState, rowKey: string): CnstpSeRow | null {
|
||||
const draft = state.drafts[rowKey];
|
||||
if (draft) {
|
||||
return {
|
||||
rowKey,
|
||||
cnstpSeCd: draft.cnstpSeCd,
|
||||
cnstpSeCdNm: draft.cnstpSeCdNm,
|
||||
expln: draft.expln,
|
||||
useYn: draft.useYn,
|
||||
cdLvNo: draft.cdLvNo,
|
||||
upCnstpCd: resolveParentCnstpSeCd(state, draft.parentRowKey),
|
||||
isNew: true,
|
||||
isDirty: true,
|
||||
isDeleted: false,
|
||||
};
|
||||
}
|
||||
|
||||
const base = state.baseByKey[rowKey];
|
||||
if (!base) return null;
|
||||
|
||||
const patch = state.patches[rowKey] ?? {};
|
||||
const hasPatch = Object.keys(patch).length > 0;
|
||||
|
||||
return {
|
||||
...base,
|
||||
...patch,
|
||||
rowKey,
|
||||
isNew: false,
|
||||
isDirty: hasPatch,
|
||||
isDeleted: state.deletedKeys.has(rowKey),
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeAllCnstpRows(state: CnstpSeGridState): CnstpSeRow[] {
|
||||
const keys = new Set([...Object.keys(state.baseByKey), ...Object.keys(state.drafts)]);
|
||||
|
||||
return [...keys]
|
||||
.map((rowKey) => mergeCnstpRow(state, rowKey))
|
||||
.filter((row): row is CnstpSeRow => row != null);
|
||||
}
|
||||
|
||||
export function hasCnstpGridPendingChanges(state: CnstpSeGridState): boolean {
|
||||
return (
|
||||
state.deletedKeys.size > 0 ||
|
||||
Object.keys(state.drafts).length > 0 ||
|
||||
Object.keys(state.patches).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function collectDescendantRowKeys(
|
||||
structure: CnstpDisplaySlot[],
|
||||
rootRowKey: string,
|
||||
): Set<string> {
|
||||
const result = new Set<string>([rootRowKey]);
|
||||
const queue = [rootRowKey];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const parentKey = queue.shift()!;
|
||||
for (const slot of structure) {
|
||||
if (slot.parentRowKey === parentKey && !result.has(slot.rowKey)) {
|
||||
result.add(slot.rowKey);
|
||||
queue.push(slot.rowKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function insertRootSlot(
|
||||
structure: CnstpDisplaySlot[],
|
||||
slot: CnstpDisplaySlot,
|
||||
): CnstpDisplaySlot[] {
|
||||
const firstRootIndex = structure.findIndex((item) => item.parentRowKey === null);
|
||||
if (firstRootIndex === -1) return [slot, ...structure];
|
||||
return [...structure.slice(0, firstRootIndex), slot, ...structure.slice(firstRootIndex)];
|
||||
}
|
||||
|
||||
export function insertChildSlot(
|
||||
structure: CnstpDisplaySlot[],
|
||||
parentRowKey: string,
|
||||
slot: CnstpDisplaySlot,
|
||||
): CnstpDisplaySlot[] {
|
||||
const parentIndex = structure.findIndex((item) => item.rowKey === parentRowKey);
|
||||
if (parentIndex === -1) return [...structure, slot];
|
||||
|
||||
const insertAt = parentIndex + 1;
|
||||
return [...structure.slice(0, insertAt), slot, ...structure.slice(insertAt)];
|
||||
}
|
||||
|
||||
export function removeSlotsFromStructure(
|
||||
structure: CnstpDisplaySlot[],
|
||||
rowKeysToRemove: Set<string>,
|
||||
): CnstpDisplaySlot[] {
|
||||
return structure.filter((slot) => !rowKeysToRemove.has(slot.rowKey));
|
||||
}
|
||||
|
||||
export function patchCnstpGridRow(
|
||||
state: CnstpSeGridState,
|
||||
rowKey: string,
|
||||
patch: Partial<CnstpSeRow>,
|
||||
): CnstpSeGridState {
|
||||
const draft = state.drafts[rowKey];
|
||||
if (draft) {
|
||||
return {
|
||||
...state,
|
||||
drafts: {
|
||||
...state.drafts,
|
||||
[rowKey]: {
|
||||
...draft,
|
||||
...(patch.cnstpSeCd !== undefined ? { cnstpSeCd: patch.cnstpSeCd } : {}),
|
||||
...(patch.cnstpSeCdNm !== undefined ? { cnstpSeCdNm: patch.cnstpSeCdNm ?? "" } : {}),
|
||||
...(patch.expln !== undefined ? { expln: patch.expln ?? "" } : {}),
|
||||
...(patch.useYn !== undefined ? { useYn: patch.useYn } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { rowKey: _rk, isNew: _n, isDirty: _d, isDeleted: _del, ...dtoPatch } = patch;
|
||||
|
||||
return {
|
||||
...state,
|
||||
patches: {
|
||||
...state.patches,
|
||||
[rowKey]: { ...state.patches[rowKey], ...dtoPatch },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function markCnstpRowsDeleted(
|
||||
state: CnstpSeGridState,
|
||||
rootRowKey: string,
|
||||
): CnstpSeGridState {
|
||||
const keysToDelete = collectDescendantRowKeys(state.structure, rootRowKey);
|
||||
const deletedKeys = new Set(state.deletedKeys);
|
||||
for (const key of keysToDelete) {
|
||||
deletedKeys.add(key);
|
||||
}
|
||||
return { ...state, deletedKeys };
|
||||
}
|
||||
|
||||
export function validateDraftParentCnstpSeCd(
|
||||
state: CnstpSeGridState,
|
||||
rowKey: string,
|
||||
): string | null {
|
||||
const draft = state.drafts[rowKey];
|
||||
if (!draft?.parentRowKey) return null;
|
||||
|
||||
const parent = mergeCnstpRow(state, draft.parentRowKey);
|
||||
if (!parent?.cnstpSeCd?.trim()) {
|
||||
return "하위 항목을 추가하려면 상위 코드를 먼저 입력하세요.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function removeDraftFromGrid(state: CnstpSeGridState, rowKey: string): CnstpSeGridState {
|
||||
const { [rowKey]: _removed, ...restDrafts } = state.drafts;
|
||||
const keysToRemove = collectDescendantRowKeys(state.structure, rowKey);
|
||||
|
||||
return {
|
||||
...state,
|
||||
drafts: restDrafts,
|
||||
structure: removeSlotsFromStructure(state.structure, keysToRemove),
|
||||
};
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { CnstpSeRow } from "../model/cnstp-se-row-types";
|
||||
|
||||
export function getVisibleRows(rows: CnstpSeRow[]): CnstpSeRow[] {
|
||||
return rows.filter((row) => !row.isDeleted);
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { CnstpSeMngResponseDto } from "@/entities/cnstp";
|
||||
|
||||
import type { CnstpSeGridState } from "../model/cnstp-se-grid-types";
|
||||
import type { CnstpSeRow } from "../model/cnstp-se-row-types";
|
||||
|
||||
import { buildSaveMultiPayload } from "./build-save-multi-payload";
|
||||
import {
|
||||
createDraftRowKey,
|
||||
createEmptyGridState,
|
||||
hasCnstpGridPendingChanges,
|
||||
hydrateGridFromServer,
|
||||
insertChildSlot,
|
||||
insertRootSlot,
|
||||
markCnstpRowsDeleted,
|
||||
mergeAllCnstpRows,
|
||||
mergeCnstpRow,
|
||||
patchCnstpGridRow,
|
||||
removeDraftFromGrid,
|
||||
validateDraftParentCnstpSeCd,
|
||||
} from "./cnstp-se-grid";
|
||||
|
||||
function validateCnstpSeRow(row: CnstpSeRow): string | null {
|
||||
if (!row.cnstpSeCd.trim()) return "코드를 입력하세요.";
|
||||
const parentCd = row.upCnstpCd?.trim();
|
||||
if (parentCd && row.cnstpSeCd.trim() === parentCd) {
|
||||
return "상위 코드와 동일한 코드는 사용할 수 없습니다.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface UseCnstpSeRowsOptions {
|
||||
serverContent: CnstpSeMngResponseDto[] | undefined;
|
||||
onAlert: (message: string) => void;
|
||||
onSave: (payload: ReturnType<typeof buildSaveMultiPayload>) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useCnstpSeRows({ serverContent, onAlert, onSave }: UseCnstpSeRowsOptions) {
|
||||
const [grid, setGrid] = useState<CnstpSeGridState>(createEmptyGridState);
|
||||
const hasPendingRef = useRef(false);
|
||||
|
||||
hasPendingRef.current = hasCnstpGridPendingChanges(grid);
|
||||
|
||||
useEffect(() => {
|
||||
if (serverContent == null) return;
|
||||
if (hasPendingRef.current) return;
|
||||
setGrid(hydrateGridFromServer(serverContent));
|
||||
}, [serverContent]);
|
||||
|
||||
const displayRows = useMemo(() => {
|
||||
return grid.structure
|
||||
.filter((slot) => !grid.deletedKeys.has(slot.rowKey))
|
||||
.map((slot) => {
|
||||
const row = mergeCnstpRow(grid, slot.rowKey);
|
||||
return row ? { row, depth: slot.depth } : null;
|
||||
})
|
||||
.filter((item): item is { row: CnstpSeRow; depth: number } => item != null);
|
||||
}, [grid]);
|
||||
|
||||
const canSave = hasCnstpGridPendingChanges(grid);
|
||||
|
||||
const updateRow = useCallback((rowKey: string, patch: Partial<CnstpSeRow>) => {
|
||||
setGrid((prev) => patchCnstpGridRow(prev, rowKey, patch));
|
||||
}, []);
|
||||
|
||||
const addRootRow = useCallback(() => {
|
||||
const rowKey = createDraftRowKey();
|
||||
setGrid((prev) => ({
|
||||
...prev,
|
||||
drafts: {
|
||||
...prev.drafts,
|
||||
[rowKey]: {
|
||||
rowKey,
|
||||
parentRowKey: null,
|
||||
cdLvNo: 1,
|
||||
cnstpSeCd: "",
|
||||
cnstpSeCdNm: "",
|
||||
expln: "",
|
||||
useYn: "Y",
|
||||
},
|
||||
},
|
||||
structure: insertRootSlot(prev.structure, { rowKey, depth: 0, parentRowKey: null }),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const addChildRow = useCallback((parent: CnstpSeRow) => {
|
||||
const rowKey = createDraftRowKey();
|
||||
|
||||
setGrid((prev) => {
|
||||
const parentSlot = prev.structure.find((item) => item.rowKey === parent.rowKey);
|
||||
const depth = (parentSlot?.depth ?? 0) + 1;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
drafts: {
|
||||
...prev.drafts,
|
||||
[rowKey]: {
|
||||
rowKey,
|
||||
parentRowKey: parent.rowKey,
|
||||
cdLvNo: (parent.cdLvNo ?? 1) + 1,
|
||||
cnstpSeCd: "",
|
||||
cnstpSeCdNm: "",
|
||||
expln: "",
|
||||
useYn: "Y",
|
||||
},
|
||||
},
|
||||
structure: insertChildSlot(prev.structure, parent.rowKey, {
|
||||
rowKey,
|
||||
depth,
|
||||
parentRowKey: parent.rowKey,
|
||||
}),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeDraft = useCallback((rowKey: string) => {
|
||||
setGrid((prev) => removeDraftFromGrid(prev, rowKey));
|
||||
}, []);
|
||||
|
||||
const markDeleted = useCallback((rowKey: string) => {
|
||||
setGrid((prev) => markCnstpRowsDeleted(prev, rowKey));
|
||||
}, []);
|
||||
|
||||
const resetFromServer = useCallback((content: CnstpSeMngResponseDto[]) => {
|
||||
setGrid(hydrateGridFromServer(content));
|
||||
}, []);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
const mergedRows = mergeAllCnstpRows(grid);
|
||||
const payload = buildSaveMultiPayload(mergedRows);
|
||||
if (payload.length === 0) return false;
|
||||
|
||||
for (const row of mergedRows) {
|
||||
if (!row.isDeleted && (row.isNew || row.isDirty)) {
|
||||
const parentCodeError = validateDraftParentCnstpSeCd(grid, row.rowKey);
|
||||
if (parentCodeError) {
|
||||
onAlert(parentCodeError);
|
||||
return false;
|
||||
}
|
||||
|
||||
const fieldError = validateCnstpSeRow(row);
|
||||
if (fieldError) {
|
||||
onAlert(fieldError);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await onSave(payload);
|
||||
return true;
|
||||
}, [grid, onAlert, onSave]);
|
||||
|
||||
return {
|
||||
displayRows,
|
||||
canSave,
|
||||
updateRow,
|
||||
addRootRow,
|
||||
addChildRow,
|
||||
removeDraft,
|
||||
markDeleted,
|
||||
resetFromServer,
|
||||
save,
|
||||
};
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { useCnstpSeMngPage, useSaveMultiCnstpSeMng } from "@/entities/cnstp";
|
||||
import {
|
||||
Button,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
import { useCnstpSeRows } from "../lib/use-cnstp-se-rows";
|
||||
import type { CnstpSeRow } from "../model/cnstp-se-row-types";
|
||||
|
||||
import { CnstpSeRowComponent } from "./cnstp-se-row";
|
||||
|
||||
function CnstpCodeManagementPage() {
|
||||
const { confirm } = useConfirm();
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
|
||||
const pageQuery = useCnstpSeMngPage({
|
||||
page: 0,
|
||||
size: 500,
|
||||
sort: "cdLvNo,asc",
|
||||
});
|
||||
|
||||
const saveMultiMutation = useSaveMultiCnstpSeMng();
|
||||
|
||||
const handlePersist = useCallback(
|
||||
async (payload: Parameters<typeof saveMultiMutation.mutateAsync>[0]) => {
|
||||
await saveMultiMutation.mutateAsync(payload);
|
||||
},
|
||||
[saveMultiMutation],
|
||||
);
|
||||
|
||||
const {
|
||||
displayRows,
|
||||
canSave,
|
||||
updateRow,
|
||||
addRootRow,
|
||||
addChildRow,
|
||||
removeDraft,
|
||||
markDeleted,
|
||||
resetFromServer,
|
||||
save,
|
||||
} = useCnstpSeRows({
|
||||
serverContent: pageQuery.data?.content,
|
||||
onAlert: alert,
|
||||
onSave: handlePersist,
|
||||
});
|
||||
|
||||
const handleDelete = async (row: CnstpSeRow) => {
|
||||
if (!(await confirm("삭제하시겠습니까?"))) return;
|
||||
|
||||
if (row.isNew) {
|
||||
removeDraft(row.rowKey);
|
||||
return;
|
||||
}
|
||||
|
||||
markDeleted(row.rowKey);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!(await confirm("저장하시겠습니까?"))) return;
|
||||
|
||||
const saved = await save();
|
||||
if (!saved) return;
|
||||
|
||||
alert("저장되었습니다.");
|
||||
const result = await pageQuery.refetch();
|
||||
resetFromServer(result.data?.content ?? []);
|
||||
};
|
||||
|
||||
const isSaving = saveMultiMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-[1.8rem] pb-6">
|
||||
<section className="card card-border mx-0 bg-background pb-6 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 type="button" color="green" onClick={addRootRow}>
|
||||
코드 추가
|
||||
</Button>
|
||||
<Button type="button" disabled={!canSave || isSaving} onClick={handleSave}>
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pageQuery.isPending ? (
|
||||
<div className="flex flex-col gap-2 px-4 pb-4">
|
||||
<Skeleton className="h-[4rem]" />
|
||||
<Skeleton className="h-[16rem]" />
|
||||
</div>
|
||||
) : displayRows.length === 0 ? (
|
||||
<p className="py-8 text-center text-lg text-muted-foreground">
|
||||
등록된 코드가 없습니다. [코드 추가]로 1단계 코드를 등록하세요.
|
||||
</p>
|
||||
) : (
|
||||
<div className="max-h-[60rem] overflow-y-auto pt-3">
|
||||
<Table className="w-full">
|
||||
<colgroup>
|
||||
<col width="15%" />
|
||||
<col width="20%" />
|
||||
<col width="35%" />
|
||||
<col width="12%" />
|
||||
<col width="8%" />
|
||||
<col width="10%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>코드</TableHead>
|
||||
<TableHead>코드명</TableHead>
|
||||
<TableHead>설명</TableHead>
|
||||
<TableHead>사용 여부</TableHead>
|
||||
<TableHead>구분</TableHead>
|
||||
<TableHead>관리</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{displayRows.map(({ row, depth }) => (
|
||||
<CnstpSeRowComponent
|
||||
key={row.rowKey}
|
||||
row={row}
|
||||
depth={depth}
|
||||
onChange={(patch) => updateRow(row.rowKey, patch)}
|
||||
onAddChild={() => addChildRow(row)}
|
||||
onDelete={() => handleDelete(row)}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { CnstpCodeManagementPage };
|
||||
@@ -0,0 +1 @@
|
||||
export { CodeManagementPage } from "./ui/code-management-page";
|
||||
@@ -0,0 +1,60 @@
|
||||
export interface SaveModeMap {
|
||||
CREATE: string;
|
||||
UPDATE: string;
|
||||
DELETE: string;
|
||||
}
|
||||
|
||||
export interface SavePayloadBuilderConfig<TRow, TSaveDto> {
|
||||
saveModes: SaveModeMap;
|
||||
toDeleteDto: (row: TRow) => TSaveDto;
|
||||
toCreateDto: (row: TRow) => TSaveDto;
|
||||
toUpdateDto: (row: TRow) => TSaveDto;
|
||||
}
|
||||
|
||||
interface RowWithState {
|
||||
cdLvNo?: number;
|
||||
isNew: boolean;
|
||||
isDirty: boolean;
|
||||
isDeleted: boolean;
|
||||
}
|
||||
|
||||
function getVisibleRows<T extends { isDeleted: boolean }>(rows: T[]): T[] {
|
||||
return rows.filter((row) => !row.isDeleted);
|
||||
}
|
||||
|
||||
function compareCdLvNoDesc<T extends RowWithState>(a: T, b: T): number {
|
||||
return (b.cdLvNo ?? 1) - (a.cdLvNo ?? 1);
|
||||
}
|
||||
|
||||
function compareCdLvNoAsc<T extends RowWithState>(a: T, b: T): number {
|
||||
return (a.cdLvNo ?? 1) - (b.cdLvNo ?? 1);
|
||||
}
|
||||
|
||||
export function createSavePayloadBuilder<TRow extends RowWithState, TSaveDto>(
|
||||
config: SavePayloadBuilderConfig<TRow, TSaveDto>,
|
||||
) {
|
||||
return function buildSavePayload(rows: TRow[]): TSaveDto[] {
|
||||
const payload: TSaveDto[] = [];
|
||||
|
||||
const deletedRows = rows.filter((row) => row.isDeleted && !row.isNew).sort(compareCdLvNoDesc);
|
||||
|
||||
for (const row of deletedRows) {
|
||||
payload.push(config.toDeleteDto(row));
|
||||
}
|
||||
|
||||
const visibleRows = getVisibleRows(rows).sort(compareCdLvNoAsc);
|
||||
|
||||
for (const row of visibleRows) {
|
||||
if (row.isNew) {
|
||||
payload.push(config.toCreateDto(row));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (row.isDirty) {
|
||||
payload.push(config.toUpdateDto(row));
|
||||
}
|
||||
}
|
||||
|
||||
return payload;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
ACNTG_SE_SAVE_MODE,
|
||||
type AcntgSeMngResponseDto,
|
||||
type AcntgSeMngSaveRequestDto,
|
||||
} from "@/entities/acntg";
|
||||
|
||||
import type { AcntgSeDraft, AcntgSeGridState } from "../../model/acntg/acntg-se-grid-types";
|
||||
import type { AcntgSeRow } from "../../model/acntg/acntg-se-row-types";
|
||||
import { buildAcntgDisplayRows } from "../../model/acntg/build-acntg-display-rows";
|
||||
import { createSavePayloadBuilder } from "../build-save-payload";
|
||||
import type { SeGridConfig } from "../grid-types";
|
||||
|
||||
const buildSavePayload = createSavePayloadBuilder<AcntgSeRow, AcntgSeMngSaveRequestDto>({
|
||||
saveModes: ACNTG_SE_SAVE_MODE,
|
||||
toDeleteDto: (row) => ({
|
||||
mode: ACNTG_SE_SAVE_MODE.DELETE,
|
||||
acntgSeCd: row.acntgSeCd,
|
||||
acntgSeCdNm: row.acntgSeCdNm?.trim() || "",
|
||||
upAcntgSeCd: row.upAcntgSeCd,
|
||||
cdLvNo: row.cdLvNo,
|
||||
useYn: row.useYn,
|
||||
expln: row.expln?.trim() || undefined,
|
||||
sortSeq: row.sortSeq,
|
||||
}),
|
||||
toCreateDto: (row) => ({
|
||||
mode: ACNTG_SE_SAVE_MODE.CREATE,
|
||||
acntgSeCd: row.acntgSeCd.trim(),
|
||||
acntgSeCdNm: row.acntgSeCdNm.trim(),
|
||||
upAcntgSeCd: row.upAcntgSeCd,
|
||||
cdLvNo: row.cdLvNo,
|
||||
useYn: row.useYn,
|
||||
expln: row.expln?.trim() || undefined,
|
||||
sortSeq: row.sortSeq,
|
||||
}),
|
||||
toUpdateDto: (row) => ({
|
||||
mode: ACNTG_SE_SAVE_MODE.UPDATE,
|
||||
acntgSeCd: row.acntgSeCd.trim(),
|
||||
acntgSeCdNm: row.acntgSeCdNm.trim(),
|
||||
upAcntgSeCd: row.upAcntgSeCd,
|
||||
cdLvNo: row.cdLvNo,
|
||||
useYn: row.useYn,
|
||||
expln: row.expln?.trim() || undefined,
|
||||
sortSeq: row.sortSeq,
|
||||
}),
|
||||
});
|
||||
|
||||
export const acntgGridConfig: SeGridConfig<
|
||||
AcntgSeMngResponseDto,
|
||||
AcntgSeGridState["baseByKey"][string],
|
||||
AcntgSeRow,
|
||||
AcntgSeDraft,
|
||||
Partial<AcntgSeMngResponseDto>,
|
||||
AcntgSeMngSaveRequestDto
|
||||
> = {
|
||||
getCode: (item) => item.acntgSeCd,
|
||||
getName: (item) => item.acntgSeCdNm,
|
||||
getParentCode: (item) => item.upAcntgSeCd,
|
||||
createEmptyDraft: ({ rowKey, parentRowKey, cdLvNo }) => ({
|
||||
rowKey,
|
||||
parentRowKey,
|
||||
cdLvNo,
|
||||
acntgSeCd: "",
|
||||
acntgSeCdNm: "",
|
||||
expln: "",
|
||||
useYn: "Y",
|
||||
}),
|
||||
applyDraftPatch: (draft, patch) => ({
|
||||
...draft,
|
||||
...(patch.acntgSeCd !== undefined ? { acntgSeCd: patch.acntgSeCd } : {}),
|
||||
...(patch.acntgSeCdNm !== undefined ? { acntgSeCdNm: patch.acntgSeCdNm ?? "" } : {}),
|
||||
...(patch.expln !== undefined ? { expln: patch.expln ?? "" } : {}),
|
||||
...(patch.useYn !== undefined ? { useYn: patch.useYn } : {}),
|
||||
...(patch.sortSeq !== undefined ? { sortSeq: patch.sortSeq } : {}),
|
||||
}),
|
||||
assembleDraftRow: (draft, parentCode) => ({
|
||||
rowKey: draft.rowKey,
|
||||
acntgSeCd: draft.acntgSeCd,
|
||||
acntgSeCdNm: draft.acntgSeCdNm,
|
||||
expln: draft.expln,
|
||||
useYn: draft.useYn,
|
||||
cdLvNo: draft.cdLvNo,
|
||||
upAcntgSeCd: parentCode,
|
||||
sortSeq: draft.sortSeq,
|
||||
isNew: true,
|
||||
isDirty: true,
|
||||
isDeleted: false,
|
||||
}),
|
||||
buildDisplayRows: buildAcntgDisplayRows,
|
||||
validateRow: (row) => {
|
||||
if (!row.acntgSeCd.trim()) return "코드를 입력하세요.";
|
||||
if (!row.acntgSeCdNm?.trim()) return "코드명을 입력하세요.";
|
||||
const parentCd = row.upAcntgSeCd?.trim();
|
||||
if (parentCd && row.acntgSeCd.trim() === parentCd) {
|
||||
return "상위 코드와 동일한 코드는 사용할 수 없습니다.";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
buildSavePayload,
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
CNSTP_SE_SAVE_MODE,
|
||||
type CnstpSeMngResponseDto,
|
||||
type CnstpSeMngSaveRequestDto,
|
||||
} from "@/entities/cnstp";
|
||||
|
||||
import { buildCnstpDisplayRows } from "../../model/cnstp/build-cnstp-display-rows";
|
||||
import type { CnstpSeDraft, CnstpSeGridState } from "../../model/cnstp/cnstp-se-grid-types";
|
||||
import type { CnstpSeRow } from "../../model/cnstp/cnstp-se-row-types";
|
||||
import { createSavePayloadBuilder } from "../build-save-payload";
|
||||
import type { SeGridConfig } from "../grid-types";
|
||||
|
||||
const buildSavePayload = createSavePayloadBuilder<CnstpSeRow, CnstpSeMngSaveRequestDto>({
|
||||
saveModes: CNSTP_SE_SAVE_MODE,
|
||||
toDeleteDto: (row) => ({
|
||||
mode: CNSTP_SE_SAVE_MODE.DELETE,
|
||||
cnstpSeCd: row.cnstpSeCd,
|
||||
cnstpSeCdNm: row.cnstpSeCdNm?.trim() || undefined,
|
||||
upCnstpCd: row.upCnstpCd,
|
||||
cdLvNo: row.cdLvNo,
|
||||
useYn: row.useYn,
|
||||
expln: row.expln?.trim() || undefined,
|
||||
sortSeq: row.sortSeq,
|
||||
}),
|
||||
toCreateDto: (row) => ({
|
||||
mode: CNSTP_SE_SAVE_MODE.CREATE,
|
||||
cnstpSeCd: row.cnstpSeCd.trim(),
|
||||
cnstpSeCdNm: row.cnstpSeCdNm?.trim() || undefined,
|
||||
upCnstpCd: row.upCnstpCd,
|
||||
cdLvNo: row.cdLvNo,
|
||||
useYn: row.useYn,
|
||||
expln: row.expln?.trim() || undefined,
|
||||
sortSeq: row.sortSeq,
|
||||
}),
|
||||
toUpdateDto: (row) => ({
|
||||
mode: CNSTP_SE_SAVE_MODE.UPDATE,
|
||||
cnstpSeCd: row.cnstpSeCd.trim(),
|
||||
cnstpSeCdNm: row.cnstpSeCdNm?.trim() || undefined,
|
||||
upCnstpCd: row.upCnstpCd,
|
||||
cdLvNo: row.cdLvNo,
|
||||
useYn: row.useYn,
|
||||
expln: row.expln?.trim() || undefined,
|
||||
sortSeq: row.sortSeq,
|
||||
}),
|
||||
});
|
||||
|
||||
export const cnstpGridConfig: SeGridConfig<
|
||||
CnstpSeMngResponseDto,
|
||||
CnstpSeGridState["baseByKey"][string],
|
||||
CnstpSeRow,
|
||||
CnstpSeDraft,
|
||||
Partial<CnstpSeMngResponseDto>,
|
||||
CnstpSeMngSaveRequestDto
|
||||
> = {
|
||||
getCode: (item) => item.cnstpSeCd,
|
||||
getName: (item) => item.cnstpSeCdNm,
|
||||
getParentCode: (item) => item.upCnstpCd,
|
||||
createEmptyDraft: ({ rowKey, parentRowKey, cdLvNo }) => ({
|
||||
rowKey,
|
||||
parentRowKey,
|
||||
cdLvNo,
|
||||
cnstpSeCd: "",
|
||||
cnstpSeCdNm: "",
|
||||
expln: "",
|
||||
useYn: "Y",
|
||||
}),
|
||||
applyDraftPatch: (draft, patch) => ({
|
||||
...draft,
|
||||
...(patch.cnstpSeCd !== undefined ? { cnstpSeCd: patch.cnstpSeCd } : {}),
|
||||
...(patch.cnstpSeCdNm !== undefined ? { cnstpSeCdNm: patch.cnstpSeCdNm ?? "" } : {}),
|
||||
...(patch.expln !== undefined ? { expln: patch.expln ?? "" } : {}),
|
||||
...(patch.useYn !== undefined ? { useYn: patch.useYn } : {}),
|
||||
...(patch.sortSeq !== undefined ? { sortSeq: patch.sortSeq } : {}),
|
||||
}),
|
||||
assembleDraftRow: (draft, parentCode) => ({
|
||||
rowKey: draft.rowKey,
|
||||
cnstpSeCd: draft.cnstpSeCd,
|
||||
cnstpSeCdNm: draft.cnstpSeCdNm,
|
||||
expln: draft.expln,
|
||||
useYn: draft.useYn,
|
||||
cdLvNo: draft.cdLvNo,
|
||||
upCnstpCd: parentCode,
|
||||
sortSeq: draft.sortSeq,
|
||||
isNew: true,
|
||||
isDirty: true,
|
||||
isDeleted: false,
|
||||
}),
|
||||
buildDisplayRows: buildCnstpDisplayRows,
|
||||
validateRow: (row) => {
|
||||
if (!row.cnstpSeCd.trim()) return "코드를 입력하세요.";
|
||||
const parentCd = row.upCnstpCd?.trim();
|
||||
if (parentCd && row.cnstpSeCd.trim() === parentCd) {
|
||||
return "상위 코드와 동일한 코드는 사용할 수 없습니다.";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
buildSavePayload,
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
FNRS_SHAR_SE_SAVE_MODE,
|
||||
type FnrsSharSeMngResponseDto,
|
||||
type FnrsSharSeMngSaveRequestDto,
|
||||
} from "@/entities/fnrs-shar-se";
|
||||
|
||||
import { buildFnrsSharDisplayRows } from "../../model/fnrs-shar/build-fnrs-shar-display-rows";
|
||||
import type {
|
||||
FnrsSharSeDraft,
|
||||
FnrsSharSeGridState,
|
||||
} from "../../model/fnrs-shar/fnrs-shar-se-grid-types";
|
||||
import type { FnrsSharSeRow } from "../../model/fnrs-shar/fnrs-shar-se-row-types";
|
||||
import { createSavePayloadBuilder } from "../build-save-payload";
|
||||
import type { SeGridConfig } from "../grid-types";
|
||||
|
||||
const buildSavePayload = createSavePayloadBuilder<FnrsSharSeRow, FnrsSharSeMngSaveRequestDto>({
|
||||
saveModes: FNRS_SHAR_SE_SAVE_MODE,
|
||||
toDeleteDto: (row) => ({
|
||||
mode: FNRS_SHAR_SE_SAVE_MODE.DELETE,
|
||||
fnrsSharSeCd: row.fnrsSharSeCd,
|
||||
fnrsSharSeCdNm: row.fnrsSharSeCdNm?.trim() || "",
|
||||
upFnrsSharSeCd: row.upFnrsSharSeCd,
|
||||
cdLvNo: row.cdLvNo,
|
||||
useYn: row.useYn,
|
||||
expln: row.expln?.trim() || undefined,
|
||||
sortSeq: row.sortSeq,
|
||||
}),
|
||||
toCreateDto: (row) => ({
|
||||
mode: FNRS_SHAR_SE_SAVE_MODE.CREATE,
|
||||
fnrsSharSeCd: row.fnrsSharSeCd.trim(),
|
||||
fnrsSharSeCdNm: row.fnrsSharSeCdNm.trim(),
|
||||
upFnrsSharSeCd: row.upFnrsSharSeCd,
|
||||
cdLvNo: row.cdLvNo,
|
||||
useYn: row.useYn,
|
||||
expln: row.expln?.trim() || undefined,
|
||||
sortSeq: row.sortSeq,
|
||||
}),
|
||||
toUpdateDto: (row) => ({
|
||||
mode: FNRS_SHAR_SE_SAVE_MODE.UPDATE,
|
||||
fnrsSharSeCd: row.fnrsSharSeCd.trim(),
|
||||
fnrsSharSeCdNm: row.fnrsSharSeCdNm.trim(),
|
||||
upFnrsSharSeCd: row.upFnrsSharSeCd,
|
||||
cdLvNo: row.cdLvNo,
|
||||
useYn: row.useYn,
|
||||
expln: row.expln?.trim() || undefined,
|
||||
sortSeq: row.sortSeq,
|
||||
}),
|
||||
});
|
||||
|
||||
export const fnrsSharGridConfig: SeGridConfig<
|
||||
FnrsSharSeMngResponseDto,
|
||||
FnrsSharSeGridState["baseByKey"][string],
|
||||
FnrsSharSeRow,
|
||||
FnrsSharSeDraft,
|
||||
Partial<FnrsSharSeMngResponseDto>,
|
||||
FnrsSharSeMngSaveRequestDto
|
||||
> = {
|
||||
getCode: (item) => item.fnrsSharSeCd,
|
||||
getName: (item) => item.fnrsSharSeCdNm,
|
||||
getParentCode: (item) => item.upFnrsSharSeCd,
|
||||
createEmptyDraft: ({ rowKey, parentRowKey, cdLvNo }) => ({
|
||||
rowKey,
|
||||
parentRowKey,
|
||||
cdLvNo,
|
||||
fnrsSharSeCd: "",
|
||||
fnrsSharSeCdNm: "",
|
||||
expln: "",
|
||||
useYn: "Y",
|
||||
}),
|
||||
applyDraftPatch: (draft, patch) => ({
|
||||
...draft,
|
||||
...(patch.fnrsSharSeCd !== undefined ? { fnrsSharSeCd: patch.fnrsSharSeCd } : {}),
|
||||
...(patch.fnrsSharSeCdNm !== undefined ? { fnrsSharSeCdNm: patch.fnrsSharSeCdNm ?? "" } : {}),
|
||||
...(patch.expln !== undefined ? { expln: patch.expln ?? "" } : {}),
|
||||
...(patch.useYn !== undefined ? { useYn: patch.useYn } : {}),
|
||||
...(patch.sortSeq !== undefined ? { sortSeq: patch.sortSeq } : {}),
|
||||
}),
|
||||
assembleDraftRow: (draft, parentCode) => ({
|
||||
rowKey: draft.rowKey,
|
||||
fnrsSharSeCd: draft.fnrsSharSeCd,
|
||||
fnrsSharSeCdNm: draft.fnrsSharSeCdNm,
|
||||
expln: draft.expln,
|
||||
useYn: draft.useYn,
|
||||
cdLvNo: draft.cdLvNo,
|
||||
upFnrsSharSeCd: parentCode,
|
||||
sortSeq: draft.sortSeq,
|
||||
isNew: true,
|
||||
isDirty: true,
|
||||
isDeleted: false,
|
||||
}),
|
||||
buildDisplayRows: buildFnrsSharDisplayRows,
|
||||
validateRow: (row) => {
|
||||
if (!row.fnrsSharSeCd.trim()) return "코드를 입력하세요.";
|
||||
if (!row.fnrsSharSeCdNm?.trim()) return "코드명을 입력하세요.";
|
||||
const parentCd = row.upFnrsSharSeCd?.trim();
|
||||
if (parentCd && row.fnrsSharSeCd.trim() === parentCd) {
|
||||
return "상위 코드와 동일한 코드는 사용할 수 없습니다.";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
buildSavePayload,
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
export interface BaseSeDto {
|
||||
cdLvNo: number;
|
||||
useYn: string;
|
||||
expln?: string;
|
||||
sortSeq?: number;
|
||||
}
|
||||
|
||||
export interface SeRowState {
|
||||
rowKey: string;
|
||||
isNew: boolean;
|
||||
isDirty: boolean;
|
||||
isDeleted: boolean;
|
||||
}
|
||||
|
||||
export interface DisplaySlot {
|
||||
rowKey: string;
|
||||
depth: number;
|
||||
parentRowKey: string | null;
|
||||
}
|
||||
|
||||
export interface SeDraftBase {
|
||||
rowKey: string;
|
||||
parentRowKey: string | null;
|
||||
cdLvNo: number;
|
||||
expln: string;
|
||||
useYn: string;
|
||||
sortSeq?: number;
|
||||
}
|
||||
|
||||
export interface SeGridState<
|
||||
TDto extends BaseSeDto,
|
||||
TBaseEntry extends TDto & { rowKey: string },
|
||||
TDraft extends SeDraftBase,
|
||||
TPatch extends Partial<TDto>,
|
||||
> {
|
||||
baseByKey: Record<string, TBaseEntry>;
|
||||
structure: DisplaySlot[];
|
||||
drafts: Record<string, TDraft>;
|
||||
patches: Record<string, TPatch>;
|
||||
deletedKeys: Set<string>;
|
||||
}
|
||||
|
||||
export interface SeDisplayRow<TRow> {
|
||||
row: TRow;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
export interface SeGridConfig<
|
||||
TDto extends BaseSeDto,
|
||||
TBaseEntry extends TDto & { rowKey: string },
|
||||
TRow extends TBaseEntry & SeRowState,
|
||||
TDraft extends SeDraftBase,
|
||||
_TPatch extends Partial<TDto>,
|
||||
TSaveDto,
|
||||
> {
|
||||
getCode: (item: TDto | TRow | TDraft) => string;
|
||||
getName: (item: TDto | TRow | TDraft) => string | undefined;
|
||||
getParentCode: (item: TDto | TRow) => string | undefined;
|
||||
createEmptyDraft: (params: {
|
||||
rowKey: string;
|
||||
parentRowKey: string | null;
|
||||
cdLvNo: number;
|
||||
}) => TDraft;
|
||||
applyDraftPatch: (draft: TDraft, patch: Partial<TRow>) => TDraft;
|
||||
assembleDraftRow: (draft: TDraft, parentCode: string | undefined) => TRow;
|
||||
buildDisplayRows: (rows: TRow[]) => SeDisplayRow<TRow>[];
|
||||
validateRow: (row: TRow) => string | null;
|
||||
buildSavePayload: (rows: TRow[]) => TSaveDto[];
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import type {
|
||||
BaseSeDto,
|
||||
DisplaySlot,
|
||||
SeDraftBase,
|
||||
SeGridConfig,
|
||||
SeGridState,
|
||||
SeRowState,
|
||||
} from "./grid-types";
|
||||
|
||||
export function createLoadedRowKey(index: number, code: string): string {
|
||||
return `loaded-${index}-${code}`;
|
||||
}
|
||||
|
||||
export function createDraftRowKey(): string {
|
||||
return `draft-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
export function createSeGridUtils<
|
||||
TDto extends BaseSeDto,
|
||||
TBaseEntry extends TDto & { rowKey: string },
|
||||
TRow extends TBaseEntry & SeRowState,
|
||||
TDraft extends SeDraftBase,
|
||||
TPatch extends Partial<TDto>,
|
||||
TSaveDto,
|
||||
>(config: SeGridConfig<TDto, TBaseEntry, TRow, TDraft, TPatch, TSaveDto>) {
|
||||
function createEmptyGridState(): SeGridState<TDto, TBaseEntry, TDraft, TPatch> {
|
||||
return {
|
||||
baseByKey: {},
|
||||
structure: [],
|
||||
drafts: {},
|
||||
patches: {},
|
||||
deletedKeys: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
function toInitialRow(entry: TBaseEntry): TRow {
|
||||
return {
|
||||
...entry,
|
||||
isNew: false,
|
||||
isDirty: false,
|
||||
isDeleted: false,
|
||||
} as unknown as TRow;
|
||||
}
|
||||
|
||||
function buildStructureFromBaseRows(baseByKey: Record<string, TBaseEntry>): DisplaySlot[] {
|
||||
const rows = Object.values(baseByKey).map(toInitialRow);
|
||||
const display = config.buildDisplayRows(rows);
|
||||
const cdToRowKey = new Map(rows.map((row) => [config.getCode(row), row.rowKey]));
|
||||
|
||||
return display.map(({ row, depth }) => ({
|
||||
rowKey: row.rowKey,
|
||||
depth,
|
||||
parentRowKey: config.getParentCode(row)?.trim()
|
||||
? (cdToRowKey.get(config.getParentCode(row)!.trim()) ?? null)
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
|
||||
function hydrateGridFromServer(content: TDto[]): SeGridState<TDto, TBaseEntry, TDraft, TPatch> {
|
||||
const baseByKey: Record<string, TBaseEntry> = {};
|
||||
|
||||
content.forEach((item, index) => {
|
||||
const rowKey = createLoadedRowKey(index, config.getCode(item));
|
||||
baseByKey[rowKey] = { ...item, rowKey } as TBaseEntry;
|
||||
});
|
||||
|
||||
return {
|
||||
baseByKey,
|
||||
structure: buildStructureFromBaseRows(baseByKey),
|
||||
drafts: {},
|
||||
patches: {},
|
||||
deletedKeys: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveParentCode(
|
||||
state: SeGridState<TDto, TBaseEntry, TDraft, TPatch>,
|
||||
parentRowKey: string | null,
|
||||
): string | undefined {
|
||||
if (!parentRowKey) return undefined;
|
||||
const parent = mergeRow(state, parentRowKey);
|
||||
const code = parent ? config.getCode(parent).trim() : "";
|
||||
return code || undefined;
|
||||
}
|
||||
|
||||
function mergeRow(
|
||||
state: SeGridState<TDto, TBaseEntry, TDraft, TPatch>,
|
||||
rowKey: string,
|
||||
): TRow | null {
|
||||
const draft = state.drafts[rowKey];
|
||||
if (draft) {
|
||||
const parentCode = resolveParentCode(state, draft.parentRowKey);
|
||||
return config.assembleDraftRow(draft, parentCode);
|
||||
}
|
||||
|
||||
const base = state.baseByKey[rowKey];
|
||||
if (!base) return null;
|
||||
|
||||
const patch = state.patches[rowKey] ?? ({} as TPatch);
|
||||
const hasPatch = Object.keys(patch).length > 0;
|
||||
|
||||
return {
|
||||
...base,
|
||||
...patch,
|
||||
rowKey,
|
||||
isNew: false,
|
||||
isDirty: hasPatch,
|
||||
isDeleted: state.deletedKeys.has(rowKey),
|
||||
} as unknown as TRow;
|
||||
}
|
||||
|
||||
function mergeAllRows(state: SeGridState<TDto, TBaseEntry, TDraft, TPatch>): TRow[] {
|
||||
const keys = new Set([...Object.keys(state.baseByKey), ...Object.keys(state.drafts)]);
|
||||
|
||||
return [...keys]
|
||||
.map((rowKey) => mergeRow(state, rowKey))
|
||||
.filter((row): row is TRow => row != null);
|
||||
}
|
||||
|
||||
function hasGridPendingChanges(state: SeGridState<TDto, TBaseEntry, TDraft, TPatch>): boolean {
|
||||
return (
|
||||
state.deletedKeys.size > 0 ||
|
||||
Object.keys(state.drafts).length > 0 ||
|
||||
Object.keys(state.patches).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
function collectDescendantRowKeys(structure: DisplaySlot[], rootRowKey: string): Set<string> {
|
||||
const result = new Set<string>([rootRowKey]);
|
||||
const queue = [rootRowKey];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const parentKey = queue.shift()!;
|
||||
for (const slot of structure) {
|
||||
if (slot.parentRowKey === parentKey && !result.has(slot.rowKey)) {
|
||||
result.add(slot.rowKey);
|
||||
queue.push(slot.rowKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function insertRootSlot(structure: DisplaySlot[], slot: DisplaySlot): DisplaySlot[] {
|
||||
const firstRootIndex = structure.findIndex((item) => item.parentRowKey === null);
|
||||
if (firstRootIndex === -1) return [slot, ...structure];
|
||||
return [...structure.slice(0, firstRootIndex), slot, ...structure.slice(firstRootIndex)];
|
||||
}
|
||||
|
||||
function insertChildSlot(
|
||||
structure: DisplaySlot[],
|
||||
parentRowKey: string,
|
||||
slot: DisplaySlot,
|
||||
): DisplaySlot[] {
|
||||
const parentIndex = structure.findIndex((item) => item.rowKey === parentRowKey);
|
||||
if (parentIndex === -1) return [...structure, slot];
|
||||
|
||||
const insertAt = parentIndex + 1;
|
||||
return [...structure.slice(0, insertAt), slot, ...structure.slice(insertAt)];
|
||||
}
|
||||
|
||||
function removeSlotsFromStructure(
|
||||
structure: DisplaySlot[],
|
||||
rowKeysToRemove: Set<string>,
|
||||
): DisplaySlot[] {
|
||||
return structure.filter((slot) => !rowKeysToRemove.has(slot.rowKey));
|
||||
}
|
||||
|
||||
function patchGridRow(
|
||||
state: SeGridState<TDto, TBaseEntry, TDraft, TPatch>,
|
||||
rowKey: string,
|
||||
patch: Partial<TRow>,
|
||||
): SeGridState<TDto, TBaseEntry, TDraft, TPatch> {
|
||||
const draft = state.drafts[rowKey];
|
||||
if (draft) {
|
||||
return {
|
||||
...state,
|
||||
drafts: {
|
||||
...state.drafts,
|
||||
[rowKey]: config.applyDraftPatch(draft, patch),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { rowKey: _rk, isNew: _n, isDirty: _d, isDeleted: _del, ...dtoPatch } = patch;
|
||||
|
||||
return {
|
||||
...state,
|
||||
patches: {
|
||||
...state.patches,
|
||||
[rowKey]: { ...state.patches[rowKey], ...dtoPatch } as TPatch,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function markRowsDeleted(
|
||||
state: SeGridState<TDto, TBaseEntry, TDraft, TPatch>,
|
||||
rootRowKey: string,
|
||||
): SeGridState<TDto, TBaseEntry, TDraft, TPatch> {
|
||||
const keysToDelete = collectDescendantRowKeys(state.structure, rootRowKey);
|
||||
const deletedKeys = new Set(state.deletedKeys);
|
||||
for (const key of keysToDelete) {
|
||||
deletedKeys.add(key);
|
||||
}
|
||||
return { ...state, deletedKeys };
|
||||
}
|
||||
|
||||
function validateDraftParentCode(
|
||||
state: SeGridState<TDto, TBaseEntry, TDraft, TPatch>,
|
||||
rowKey: string,
|
||||
): string | null {
|
||||
const draft = state.drafts[rowKey];
|
||||
if (!draft?.parentRowKey) return null;
|
||||
|
||||
const parent = mergeRow(state, draft.parentRowKey);
|
||||
if (!parent || !config.getCode(parent).trim()) {
|
||||
return "하위 항목을 추가하려면 상위 코드를 먼저 입력하세요.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function removeDraftFromGrid(
|
||||
state: SeGridState<TDto, TBaseEntry, TDraft, TPatch>,
|
||||
rowKey: string,
|
||||
): SeGridState<TDto, TBaseEntry, TDraft, TPatch> {
|
||||
const { [rowKey]: _removed, ...restDrafts } = state.drafts;
|
||||
const keysToRemove = collectDescendantRowKeys(state.structure, rowKey);
|
||||
|
||||
return {
|
||||
...state,
|
||||
drafts: restDrafts,
|
||||
structure: removeSlotsFromStructure(state.structure, keysToRemove),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
createEmptyGridState,
|
||||
hydrateGridFromServer,
|
||||
mergeRow,
|
||||
mergeAllRows,
|
||||
hasGridPendingChanges,
|
||||
collectDescendantRowKeys,
|
||||
insertRootSlot,
|
||||
insertChildSlot,
|
||||
removeSlotsFromStructure,
|
||||
patchGridRow,
|
||||
markRowsDeleted,
|
||||
validateDraftParentCode,
|
||||
removeDraftFromGrid,
|
||||
resolveParentCode,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { BaseSeDto, SeDraftBase, SeGridConfig, SeGridState, SeRowState } from "./grid-types";
|
||||
import { createDraftRowKey, createSeGridUtils } from "./grid-utils";
|
||||
|
||||
interface UseSeGridRowsOptions<
|
||||
TDto extends BaseSeDto,
|
||||
TBaseEntry extends TDto & { rowKey: string },
|
||||
_TRow extends TBaseEntry & SeRowState,
|
||||
_TDraft extends SeDraftBase,
|
||||
_TPatch extends Partial<TDto>,
|
||||
TSaveDto,
|
||||
> {
|
||||
serverContent: TDto[] | undefined;
|
||||
onAlert: (message: string) => void;
|
||||
onSave: (payload: TSaveDto[]) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useSeGridRows<
|
||||
TDto extends BaseSeDto,
|
||||
TBaseEntry extends TDto & { rowKey: string },
|
||||
TRow extends TBaseEntry & SeRowState,
|
||||
TDraft extends SeDraftBase,
|
||||
TPatch extends Partial<TDto>,
|
||||
TSaveDto,
|
||||
>(
|
||||
config: SeGridConfig<TDto, TBaseEntry, TRow, TDraft, TPatch, TSaveDto>,
|
||||
{
|
||||
serverContent,
|
||||
onAlert,
|
||||
onSave,
|
||||
}: UseSeGridRowsOptions<TDto, TBaseEntry, TRow, TDraft, TPatch, TSaveDto>,
|
||||
) {
|
||||
const utils = useMemo(() => createSeGridUtils(config), [config]);
|
||||
|
||||
const [grid, setGrid] = useState<SeGridState<TDto, TBaseEntry, TDraft, TPatch>>(
|
||||
utils.createEmptyGridState,
|
||||
);
|
||||
const hasPendingRef = useRef(false);
|
||||
|
||||
hasPendingRef.current = utils.hasGridPendingChanges(grid);
|
||||
|
||||
useEffect(() => {
|
||||
if (serverContent == null) return;
|
||||
if (hasPendingRef.current) return;
|
||||
setGrid(utils.hydrateGridFromServer(serverContent));
|
||||
}, [serverContent, utils]);
|
||||
|
||||
const displayRows = useMemo(() => {
|
||||
return grid.structure
|
||||
.filter((slot) => !grid.deletedKeys.has(slot.rowKey))
|
||||
.map((slot) => {
|
||||
const row = utils.mergeRow(grid, slot.rowKey);
|
||||
return row ? { row, depth: slot.depth } : null;
|
||||
})
|
||||
.filter((item): item is { row: TRow; depth: number } => item != null);
|
||||
}, [grid, utils]);
|
||||
|
||||
const canSave = utils.hasGridPendingChanges(grid);
|
||||
|
||||
const updateRow = useCallback(
|
||||
(rowKey: string, patch: Partial<TRow>) => {
|
||||
setGrid((prev) => utils.patchGridRow(prev, rowKey, patch));
|
||||
},
|
||||
[utils],
|
||||
);
|
||||
|
||||
const addRootRow = useCallback(() => {
|
||||
const rowKey = createDraftRowKey();
|
||||
setGrid((prev) => ({
|
||||
...prev,
|
||||
drafts: {
|
||||
...prev.drafts,
|
||||
[rowKey]: config.createEmptyDraft({
|
||||
rowKey,
|
||||
parentRowKey: null,
|
||||
cdLvNo: 1,
|
||||
}),
|
||||
},
|
||||
structure: utils.insertRootSlot(prev.structure, {
|
||||
rowKey,
|
||||
depth: 0,
|
||||
parentRowKey: null,
|
||||
}),
|
||||
}));
|
||||
}, [config, utils]);
|
||||
|
||||
const addChildRow = useCallback(
|
||||
(parent: TRow) => {
|
||||
const rowKey = createDraftRowKey();
|
||||
|
||||
setGrid((prev) => {
|
||||
const parentSlot = prev.structure.find((item) => item.rowKey === parent.rowKey);
|
||||
const depth = (parentSlot?.depth ?? 0) + 1;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
drafts: {
|
||||
...prev.drafts,
|
||||
[rowKey]: config.createEmptyDraft({
|
||||
rowKey,
|
||||
parentRowKey: parent.rowKey,
|
||||
cdLvNo: (parent.cdLvNo ?? 1) + 1,
|
||||
}),
|
||||
},
|
||||
structure: utils.insertChildSlot(prev.structure, parent.rowKey, {
|
||||
rowKey,
|
||||
depth,
|
||||
parentRowKey: parent.rowKey,
|
||||
}),
|
||||
};
|
||||
});
|
||||
},
|
||||
[config, utils],
|
||||
);
|
||||
|
||||
const removeDraft = useCallback(
|
||||
(rowKey: string) => {
|
||||
setGrid((prev) => utils.removeDraftFromGrid(prev, rowKey));
|
||||
},
|
||||
[utils],
|
||||
);
|
||||
|
||||
const markDeleted = useCallback(
|
||||
(rowKey: string) => {
|
||||
setGrid((prev) => utils.markRowsDeleted(prev, rowKey));
|
||||
},
|
||||
[utils],
|
||||
);
|
||||
|
||||
const resetFromServer = useCallback(
|
||||
(content: TDto[]) => {
|
||||
setGrid(utils.hydrateGridFromServer(content));
|
||||
},
|
||||
[utils],
|
||||
);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
const mergedRows = utils.mergeAllRows(grid);
|
||||
const payload = config.buildSavePayload(mergedRows);
|
||||
if (payload.length === 0) return false;
|
||||
|
||||
for (const row of mergedRows) {
|
||||
if (!row.isDeleted && (row.isNew || row.isDirty)) {
|
||||
const parentCodeError = utils.validateDraftParentCode(grid, row.rowKey);
|
||||
if (parentCodeError) {
|
||||
onAlert(parentCodeError);
|
||||
return false;
|
||||
}
|
||||
|
||||
const fieldError = config.validateRow(row);
|
||||
if (fieldError) {
|
||||
onAlert(fieldError);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await onSave(payload);
|
||||
return true;
|
||||
}, [config, grid, onAlert, onSave, utils]);
|
||||
|
||||
return {
|
||||
displayRows,
|
||||
canSave,
|
||||
updateRow,
|
||||
addRootRow,
|
||||
addChildRow,
|
||||
removeDraft,
|
||||
markDeleted,
|
||||
resetFromServer,
|
||||
save,
|
||||
};
|
||||
}
|
||||
+35
-34
@@ -1,34 +1,35 @@
|
||||
import type { AcntgSeMngResponseDto } from "@/entities/acntg";
|
||||
|
||||
export interface AcntgDisplaySlot {
|
||||
rowKey: string;
|
||||
depth: number;
|
||||
parentRowKey: string | null;
|
||||
}
|
||||
|
||||
export interface AcntgSeDraft {
|
||||
rowKey: string;
|
||||
parentRowKey: string | null;
|
||||
cdLvNo: number;
|
||||
acntgSeCd: string;
|
||||
acntgSeCdNm: string;
|
||||
expln: string;
|
||||
useYn: string;
|
||||
}
|
||||
|
||||
export interface AcntgSeBaseEntry extends AcntgSeMngResponseDto {
|
||||
rowKey: string;
|
||||
}
|
||||
|
||||
export interface AcntgSeGridState {
|
||||
baseByKey: Record<string, AcntgSeBaseEntry>;
|
||||
structure: AcntgDisplaySlot[];
|
||||
drafts: Record<string, AcntgSeDraft>;
|
||||
patches: Record<string, Partial<AcntgSeMngResponseDto>>;
|
||||
deletedKeys: Set<string>;
|
||||
}
|
||||
|
||||
export interface AcntgSeDisplayItem {
|
||||
rowKey: string;
|
||||
depth: number;
|
||||
}
|
||||
import type { AcntgSeMngResponseDto } from "@/entities/acntg";
|
||||
|
||||
export interface AcntgDisplaySlot {
|
||||
rowKey: string;
|
||||
depth: number;
|
||||
parentRowKey: string | null;
|
||||
}
|
||||
|
||||
export interface AcntgSeDraft {
|
||||
rowKey: string;
|
||||
parentRowKey: string | null;
|
||||
cdLvNo: number;
|
||||
acntgSeCd: string;
|
||||
acntgSeCdNm: string;
|
||||
expln: string;
|
||||
useYn: string;
|
||||
sortSeq?: number;
|
||||
}
|
||||
|
||||
export interface AcntgSeBaseEntry extends AcntgSeMngResponseDto {
|
||||
rowKey: string;
|
||||
}
|
||||
|
||||
export interface AcntgSeGridState {
|
||||
baseByKey: Record<string, AcntgSeBaseEntry>;
|
||||
structure: AcntgDisplaySlot[];
|
||||
drafts: Record<string, AcntgSeDraft>;
|
||||
patches: Record<string, Partial<AcntgSeMngResponseDto>>;
|
||||
deletedKeys: Set<string>;
|
||||
}
|
||||
|
||||
export interface AcntgSeDisplayItem {
|
||||
rowKey: string;
|
||||
depth: number;
|
||||
}
|
||||
+30
-29
@@ -1,29 +1,30 @@
|
||||
import type { CnstpSeMngResponseDto } from "@/entities/cnstp";
|
||||
|
||||
export interface CnstpDisplaySlot {
|
||||
rowKey: string;
|
||||
depth: number;
|
||||
parentRowKey: string | null;
|
||||
}
|
||||
|
||||
export interface CnstpSeDraft {
|
||||
rowKey: string;
|
||||
parentRowKey: string | null;
|
||||
cdLvNo: number;
|
||||
cnstpSeCd: string;
|
||||
cnstpSeCdNm: string;
|
||||
expln: string;
|
||||
useYn: string;
|
||||
}
|
||||
|
||||
export interface CnstpSeBaseEntry extends CnstpSeMngResponseDto {
|
||||
rowKey: string;
|
||||
}
|
||||
|
||||
export interface CnstpSeGridState {
|
||||
baseByKey: Record<string, CnstpSeBaseEntry>;
|
||||
structure: CnstpDisplaySlot[];
|
||||
drafts: Record<string, CnstpSeDraft>;
|
||||
patches: Record<string, Partial<CnstpSeMngResponseDto>>;
|
||||
deletedKeys: Set<string>;
|
||||
}
|
||||
import type { CnstpSeMngResponseDto } from "@/entities/cnstp";
|
||||
|
||||
export interface CnstpDisplaySlot {
|
||||
rowKey: string;
|
||||
depth: number;
|
||||
parentRowKey: string | null;
|
||||
}
|
||||
|
||||
export interface CnstpSeDraft {
|
||||
rowKey: string;
|
||||
parentRowKey: string | null;
|
||||
cdLvNo: number;
|
||||
cnstpSeCd: string;
|
||||
cnstpSeCdNm: string;
|
||||
expln: string;
|
||||
useYn: string;
|
||||
sortSeq?: number;
|
||||
}
|
||||
|
||||
export interface CnstpSeBaseEntry extends CnstpSeMngResponseDto {
|
||||
rowKey: string;
|
||||
}
|
||||
|
||||
export interface CnstpSeGridState {
|
||||
baseByKey: Record<string, CnstpSeBaseEntry>;
|
||||
structure: CnstpDisplaySlot[];
|
||||
drafts: Record<string, CnstpSeDraft>;
|
||||
patches: Record<string, Partial<CnstpSeMngResponseDto>>;
|
||||
deletedKeys: Set<string>;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { FnrsSharSeRow } from "./fnrs-shar-se-row-types";
|
||||
|
||||
export interface FnrsSharSeDisplayRow {
|
||||
row: FnrsSharSeRow;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
export function formatCdLvNo(lvNo?: number): string {
|
||||
return `${lvNo ?? 1}단계`;
|
||||
}
|
||||
|
||||
export function canAddChildRow(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function formatTreeBranchPrefix(depth: number): string {
|
||||
if (depth <= 0) return "";
|
||||
const vertical = "\u2502 ";
|
||||
const branch = "\u251C\u2500";
|
||||
return vertical.repeat(depth - 1) + branch;
|
||||
}
|
||||
|
||||
function isRootRow(row: FnrsSharSeRow): boolean {
|
||||
return (row.cdLvNo ?? 1) === 1 && !row.upFnrsSharSeCd?.trim();
|
||||
}
|
||||
|
||||
function rowOrderIndex(rows: FnrsSharSeRow[], row: FnrsSharSeRow): number {
|
||||
return rows.findIndex((candidate) => candidate.rowKey === row.rowKey);
|
||||
}
|
||||
|
||||
function pushDisplayRow(
|
||||
result: FnrsSharSeDisplayRow[],
|
||||
displayedRowKeys: Set<string>,
|
||||
row: FnrsSharSeRow,
|
||||
depth: number,
|
||||
) {
|
||||
if (displayedRowKeys.has(row.rowKey)) return;
|
||||
displayedRowKeys.add(row.rowKey);
|
||||
result.push({ row, depth });
|
||||
}
|
||||
|
||||
export function buildFnrsSharDisplayRows(rows: FnrsSharSeRow[]): FnrsSharSeDisplayRow[] {
|
||||
const result: FnrsSharSeDisplayRow[] = [];
|
||||
const displayedRowKeys = new Set<string>();
|
||||
|
||||
const roots = rows.filter(isRootRow);
|
||||
|
||||
function appendSubtree(parent: FnrsSharSeRow, depth: number) {
|
||||
const children = rows
|
||||
.filter(
|
||||
(row) =>
|
||||
row.rowKey !== parent.rowKey &&
|
||||
row.upFnrsSharSeCd != null &&
|
||||
row.upFnrsSharSeCd === parent.fnrsSharSeCd &&
|
||||
row.fnrsSharSeCd !== parent.fnrsSharSeCd,
|
||||
)
|
||||
.sort((a, b) => rowOrderIndex(rows, a) - rowOrderIndex(rows, b));
|
||||
|
||||
for (const child of children) {
|
||||
pushDisplayRow(result, displayedRowKeys, child, depth);
|
||||
appendSubtree(child, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const root of roots) {
|
||||
pushDisplayRow(result, displayedRowKeys, root, 0);
|
||||
appendSubtree(root, 1);
|
||||
}
|
||||
|
||||
const orphans = rows.filter((row) => !displayedRowKeys.has(row.rowKey));
|
||||
|
||||
for (const orphan of orphans) {
|
||||
pushDisplayRow(result, displayedRowKeys, orphan, Math.max(0, (orphan.cdLvNo ?? 1) - 1));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { FnrsSharSeMngResponseDto } from "@/entities/fnrs-shar-se";
|
||||
|
||||
export interface FnrsSharDisplaySlot {
|
||||
rowKey: string;
|
||||
depth: number;
|
||||
parentRowKey: string | null;
|
||||
}
|
||||
|
||||
export interface FnrsSharSeDraft {
|
||||
rowKey: string;
|
||||
parentRowKey: string | null;
|
||||
cdLvNo: number;
|
||||
fnrsSharSeCd: string;
|
||||
fnrsSharSeCdNm: string;
|
||||
expln: string;
|
||||
useYn: string;
|
||||
sortSeq?: number;
|
||||
}
|
||||
|
||||
export interface FnrsSharSeBaseEntry extends FnrsSharSeMngResponseDto {
|
||||
rowKey: string;
|
||||
}
|
||||
|
||||
export interface FnrsSharSeGridState {
|
||||
baseByKey: Record<string, FnrsSharSeBaseEntry>;
|
||||
structure: FnrsSharDisplaySlot[];
|
||||
drafts: Record<string, FnrsSharSeDraft>;
|
||||
patches: Record<string, Partial<FnrsSharSeMngResponseDto>>;
|
||||
deletedKeys: Set<string>;
|
||||
}
|
||||
|
||||
export interface FnrsSharSeDisplayItem {
|
||||
rowKey: string;
|
||||
depth: number;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { FnrsSharSeMngResponseDto } from "@/entities/fnrs-shar-se";
|
||||
|
||||
export interface FnrsSharSeRow extends FnrsSharSeMngResponseDto {
|
||||
rowKey: string;
|
||||
isNew: boolean;
|
||||
isDirty: boolean;
|
||||
isDeleted: boolean;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user