Merge remote-tracking branch 'origin/main' into iipbs/wr/dev

This commit is contained in:
2026-07-15 14:54:42 +09:00
384 changed files with 27658 additions and 10274 deletions
+1
View File
@@ -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} />;
}
@@ -1,3 +1,14 @@
import { InstlRegistPage } from "@/pages/instl-sncn";
export default InstlRegistPage;
interface InstlSncnRegistRouteProps {
searchParams: Promise<{ taskSeComCd?: string }>;
}
export default async function Page({ searchParams }: InstlSncnRegistRouteProps) {
const params = await searchParams;
const taskSeComCd = params.taskSeComCd
? params.taskSeComCd.split(",").filter(Boolean)
: undefined;
return <InstlRegistPage taskSeComCd={taskSeComCd} />;
}
@@ -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` |
+1
View File
@@ -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",
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+2
View File
@@ -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}
>
@@ -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로 설정) */
+14 -5
View File
@@ -1,8 +1,13 @@
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 {
clearRefreshTimer,
removeToken,
setToken,
setupRefreshTimer,
tokenManager,
} from "../lib/token-manager";
async function login(payload: AuthPayload): Promise<void> {
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, {
@@ -58,7 +63,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 +71,10 @@ async function silentRefresh(): Promise<void> {
}
async function logout(): Promise<void> {
clearAuthToken();
tokenManager.clearAuthToken();
removeToken("site-id-key");
removeToken("site-theme-key");
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,
};
@@ -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;
+130
View File
@@ -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);
},
});
}
+104 -48
View File
@@ -1,48 +1,104 @@
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 {
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";
@@ -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,
@@ -1,15 +1,20 @@
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);
export function validateAtrzOrder(rows: AtrzOrderValidateRow[]): string | null {
const visibleRows = rows.filter((row) => !row.isDeleted);
const receiverCount = visibleRows.filter((row) => row.atrzSeComCd === ATRZ_RECEIVER_CD).length;
if (receiverCount === 0) {
@@ -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",
);
}
@@ -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,172 @@
import {
ELAPR_PRGRS_STTS_SAVE_MODE,
type ElaprPrgrsSttsSaveRequestDto,
} from "../model/elapr-prgrs-stts-interface";
export const PRGRS_STTS_WAITING_CD = "ATRZ_PRGRS_001";
/** 서버 행 비교용 최소 필드 */
export interface PrgrsSttsServerRow {
elaprPrgrsId?: string;
aprvrUuid?: string;
atrzSeComCd?: string;
atrzSeqNo?: number;
}
/** 결재선 그리드 로컬 행 — 진행상태 API 응답 + UI 보조 필드 */
export interface ElaprPrgrsSttsRow {
rowKey: string;
elaprPrgrsId?: string;
elaprDmndId?: string;
aprvrUuid?: string;
aprvrNm?: string;
atrzSeComCd?: string;
atrzSeComNm?: string;
atrzSeqNo?: number;
atrzPrgrsSttsComCd?: string;
atrzPrgrsSttsComNm?: string;
rjctRsn?: string | null;
ognzNm?: string;
jbpsSeNm?: string;
jbgdSeNm?: string;
isNew?: boolean;
isDirty?: boolean;
/** 소프트 삭제 — 최종 저장 시 mode:D */
isDeleted?: boolean;
/** 수정 취소용 스냅샷 */
snapshot?: ElaprPrgrsSttsRow;
}
export function isPrgrsSttsRowEditable(row: ElaprPrgrsSttsRow): boolean {
return row.isNew === true || row.atrzPrgrsSttsComCd === PRGRS_STTS_WAITING_CD;
}
function getVisibleRows(rows: ElaprPrgrsSttsRow[]): ElaprPrgrsSttsRow[] {
return rows.filter((row) => !row.isDeleted);
}
/** 그리드 행 순서(1-based)로 결재순서번호 일괄 채번 */
export function assignPrgrsSttsAtrzSeqNumbers(rows: ElaprPrgrsSttsRow[]): ElaprPrgrsSttsRow[] {
return rows.map((row, index) => ({ ...row, atrzSeqNo: index + 1 }));
}
export function validatePrgrsSttsRow(row: ElaprPrgrsSttsRow): string | null {
if (!row.atrzSeComCd?.trim()) {
return "결재구분을 선택해 주세요.";
}
if (!row.aprvrUuid?.trim()) {
return "결재자를 선택해 주세요.";
}
return null;
}
export function validateAllPrgrsSttsRows(
rows: ElaprPrgrsSttsRow[],
isEditingLocked: boolean,
): string | null {
if (isEditingLocked) {
return "편집 중인 행을 먼저 저장하거나 취소해 주세요.";
}
const visibleRows = getVisibleRows(rows);
if (visibleRows.length === 0) {
return "저장할 결재선이 없습니다.";
}
for (const row of visibleRows) {
const fieldError = validatePrgrsSttsRow(row);
if (fieldError) return fieldError;
}
return null;
}
function rowDiffersFromServer(row: ElaprPrgrsSttsRow, serverRow: PrgrsSttsServerRow): boolean {
return (
row.aprvrUuid !== serverRow.aprvrUuid ||
row.atrzSeComCd !== serverRow.atrzSeComCd ||
row.atrzSeqNo !== serverRow.atrzSeqNo
);
}
function shouldIncludeUpdate(row: ElaprPrgrsSttsRow, serverRows: PrgrsSttsServerRow[]): boolean {
if (row.isDirty) return true;
if (!row.elaprPrgrsId) return false;
const serverRow = serverRows.find((s) => s.elaprPrgrsId === row.elaprPrgrsId);
if (!serverRow) return false;
return rowDiffersFromServer(row, serverRow);
}
export function hasUnsavedPrgrsSttsChanges(
rows: ElaprPrgrsSttsRow[],
serverRows: PrgrsSttsServerRow[],
): boolean {
if (rows.some((row) => row.isDeleted && row.elaprPrgrsId)) return true;
if (rows.some((row) => row.isNew && !row.isDeleted)) return true;
const visibleWithSeq = assignPrgrsSttsAtrzSeqNumbers(getVisibleRows(rows));
for (const row of visibleWithSeq) {
if (row.isNew) continue;
if (!row.elaprPrgrsId) continue;
if (row.isDirty) return true;
const serverRow = serverRows.find((s) => s.elaprPrgrsId === row.elaprPrgrsId);
if (!serverRow) continue;
if (rowDiffersFromServer(row, serverRow)) return true;
}
return false;
}
export function buildPrgrsSttsSavePayload(
rows: ElaprPrgrsSttsRow[],
elaprDmndId: string,
serverRows: PrgrsSttsServerRow[],
): ElaprPrgrsSttsSaveRequestDto[] {
const payload: ElaprPrgrsSttsSaveRequestDto[] = [];
for (const row of rows) {
if (row.isDeleted && row.elaprPrgrsId) {
payload.push({
mode: ELAPR_PRGRS_STTS_SAVE_MODE.DELETE,
elaprPrgrsId: row.elaprPrgrsId,
elaprDmndId,
});
}
}
const visibleWithSeq = assignPrgrsSttsAtrzSeqNumbers(getVisibleRows(rows));
for (const row of visibleWithSeq) {
if (row.isNew) {
payload.push({
mode: ELAPR_PRGRS_STTS_SAVE_MODE.CREATE,
elaprDmndId,
aprvrUuid: row.aprvrUuid,
atrzSeComCd: row.atrzSeComCd,
atrzSeqNo: row.atrzSeqNo,
atrzPrgrsSttsComCd: row.atrzPrgrsSttsComCd,
});
continue;
}
if (!row.elaprPrgrsId) continue;
if (shouldIncludeUpdate(row, serverRows)) {
payload.push({
mode: ELAPR_PRGRS_STTS_SAVE_MODE.UPDATE,
elaprPrgrsId: row.elaprPrgrsId,
elaprDmndId,
aprvrUuid: row.aprvrUuid,
atrzSeComCd: row.atrzSeComCd,
atrzSeqNo: row.atrzSeqNo,
atrzPrgrsSttsComCd: row.atrzPrgrsSttsComCd,
});
}
}
return payload;
}
@@ -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;
@@ -1,3 +1,5 @@
"use client";
import { KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
import { sortableKeyboardCoordinates } from "@dnd-kit/sortable";
@@ -0,0 +1,210 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { arrayMove } from "@dnd-kit/sortable";
import type { ElaprDocPrgrsClientResponseDto } from "@/shared/model";
import {
assignPrgrsSttsAtrzSeqNumbers,
type ElaprPrgrsSttsRow,
isPrgrsSttsRowEditable,
PRGRS_STTS_WAITING_CD,
} from "./prgrs-stts-save-utils";
import { createDraftRowKey } from "./use-apln-rows";
function getVisibleRows(rows: ElaprPrgrsSttsRow[]): ElaprPrgrsSttsRow[] {
return rows.filter((row) => !row.isDeleted);
}
function toRow(item: ElaprDocPrgrsClientResponseDto): ElaprPrgrsSttsRow {
return {
rowKey: item.elaprPrgrsId ?? createDraftRowKey(),
elaprPrgrsId: item.elaprPrgrsId,
elaprDmndId: item.elaprDmndId,
aprvrUuid: item.aprvrUuid,
aprvrNm: item.aprvrUuidNm,
atrzSeComCd: item.atrzSeComCd,
atrzSeComNm: item.atrzSeComNm,
atrzSeqNo: item.atrzSeqNo,
atrzPrgrsSttsComCd: item.atrzPrgrsSttsComCd,
atrzPrgrsSttsComNm: item.atrzPrgrsSttsComNm,
rjctRsn: item.rjctRsn,
ognzNm: item.ognzNm,
jbpsSeNm: item.jbpsSeNm,
jbgdSeNm: item.jbgdSeNm,
isNew: false,
isDirty: false,
isDeleted: false,
};
}
interface UsePrgrsSttsRowsOptions {
serverRows?: ElaprDocPrgrsClientResponseDto[];
elaprDmndId?: string | null;
}
export function usePrgrsSttsRows({ serverRows, elaprDmndId }: UsePrgrsSttsRowsOptions) {
const [rows, setRows] = useState<ElaprPrgrsSttsRow[]>([]);
const [editingRowKey, setEditingRowKey] = useState<string | null>(null);
useEffect(() => {
setRows((serverRows ?? []).map(toRow));
setEditingRowKey(null);
}, [serverRows]);
const isEditingLocked = editingRowKey != null;
const visibleRows = getVisibleRows(rows);
const addRow = useCallback(() => {
if (!elaprDmndId || isEditingLocked) return;
const rowKey = createDraftRowKey();
setRows((prev) => [
...prev,
{
rowKey,
elaprDmndId,
atrzSeComCd: "",
aprvrUuid: "",
aprvrNm: "",
atrzPrgrsSttsComCd: PRGRS_STTS_WAITING_CD,
isNew: true,
isDirty: true,
isDeleted: false,
},
]);
setEditingRowKey(rowKey);
}, [elaprDmndId, isEditingLocked]);
const startEdit = useCallback(
(rowKey: string) => {
if (isEditingLocked) return;
const target = rows.find((r) => r.rowKey === rowKey);
if (!target || !isPrgrsSttsRowEditable(target)) return;
setEditingRowKey(rowKey);
setRows((prev) =>
prev.map((row) => (row.rowKey === rowKey ? { ...row, snapshot: { ...row } } : row)),
);
},
[isEditingLocked, rows],
);
const updateRow = useCallback(
(rowKey: string, patch: Partial<ElaprPrgrsSttsRow>) => {
if (editingRowKey !== rowKey) return;
setRows((prev) =>
prev.map((row) => (row.rowKey === rowKey ? { ...row, ...patch, isDirty: true } : row)),
);
},
[editingRowKey],
);
const cancelEdit = useCallback(() => {
if (!editingRowKey) return;
setRows((prev) => {
const target = prev.find((r) => r.rowKey === editingRowKey);
if (target?.isNew) {
return prev.filter((r) => r.rowKey !== editingRowKey);
}
return prev.map((row) => {
if (row.rowKey !== editingRowKey) return row;
if (row.snapshot) {
const {
snapshot: _s,
isNew: _n,
isDirty: _d,
isDeleted: _del,
...restored
} = row.snapshot;
return { ...restored, isNew: false, isDirty: false, isDeleted: false };
}
return { ...row, isDirty: false };
});
});
setEditingRowKey(null);
}, [editingRowKey]);
const commitEdit = useCallback((rowKey: string) => {
setRows((prev) =>
prev.map((row) => {
if (row.rowKey !== rowKey) return row;
const { snapshot: _s, ...committed } = row;
return { ...committed, isDirty: true };
}),
);
setEditingRowKey(null);
}, []);
const markDeleted = useCallback((rowKey: string) => {
setRows((prev) => {
const target = prev.find((r) => r.rowKey === rowKey);
if (!target || !isPrgrsSttsRowEditable(target)) return prev;
if (target.isNew) {
return prev.filter((r) => r.rowKey !== rowKey);
}
return prev.map((row) =>
row.rowKey === rowKey ? { ...row, isDeleted: true, isDirty: true } : row,
);
});
setEditingRowKey((key) => (key === rowKey ? null : key));
}, []);
const reorderRows = useCallback(
(activeKey: string, overKey: string): ElaprPrgrsSttsRow[] => {
if (isEditingLocked) return [];
let payloadRows: ElaprPrgrsSttsRow[] = [];
setRows((prev) => {
const visible = getVisibleRows(prev);
const activeRow = visible.find((r) => r.rowKey === activeKey);
const overRow = visible.find((r) => r.rowKey === overKey);
if (!activeRow || !overRow) return prev;
if (!isPrgrsSttsRowEditable(activeRow) || !isPrgrsSttsRowEditable(overRow)) return prev;
const oldIndex = visible.findIndex((r) => r.rowKey === activeKey);
const newIndex = visible.findIndex((r) => r.rowKey === overKey);
if (oldIndex < 0 || newIndex < 0) return prev;
const reorderedVisible = assignPrgrsSttsAtrzSeqNumbers(
arrayMove(visible, oldIndex, newIndex),
).map((row) => (isPrgrsSttsRowEditable(row) ? { ...row, isDirty: true } : row));
const visibleKeys = new Set(reorderedVisible.map((r) => r.rowKey));
const deletedRows = prev.filter((r) => r.isDeleted);
const merged = [
...reorderedVisible,
...deletedRows.filter((r) => !visibleKeys.has(r.rowKey)),
];
payloadRows = reorderedVisible.filter(isPrgrsSttsRowEditable);
return merged;
});
return payloadRows;
},
[isEditingLocked],
);
return {
rows,
visibleRows,
editingRowKey,
isEditingLocked,
addRow,
startEdit,
updateRow,
cancelEdit,
commitEdit,
markDeleted,
reorderRows,
};
}
@@ -1,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 { 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;
@@ -0,0 +1,90 @@
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;
}
/** 백엔드 ElaprPrgrsSttsSaveRequestDto */
export interface ElaprPrgrsSttsSaveRequestDto {
mode?: ElaprPrgrsSttsSaveMode;
elaprPrgrsId?: string;
elaprDmndId?: string;
aprvrUuid?: string;
atrzSeComCd?: string;
atrzSeqNo?: number;
atrzPrgrsSttsComCd?: 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,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 });
},
});
}
+39
View File
@@ -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[]>;
}
@@ -23,7 +23,7 @@ export const instlSncnKeys = {
export function useInstlSncnList(params: InstlSncnMngSearchParams) {
return useQuery({
queryKey: instlSncnKeys.list(),
queryKey: [...instlSncnKeys.list(), params],
queryFn: () => instlSncnService.getPage(params),
});
}
@@ -1,4 +1,32 @@
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,
SplmntDmndSaveBase,
} from "@/shared/model";
export type {
AtflMngClientResponseDto,
BizCstTreeDto,
BizSclTreeDto,
CklstRvwRsltTreeDto,
DocPrgrsUpdateRequestDto,
ElaprDocPrgrsClientResponseDto,
EsntlAtchFileMngTreeResponseDto,
} from "@/shared/model";
/** 행정구역(시도·시군구) 콤보 응답 DTO */
export interface AdstZoneResponseDto {
@@ -70,16 +98,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 +129,7 @@ export interface InstlSncnMngResponseDto extends InstlSncnMngSaveRequestDto {
}
/** 설치인가 신청 - 사업규모 저장 DTO(배열 원소) */
export interface InstlSncnBzScaleSaveDto {
export interface InstlSncnBzScaleSaveDto extends BzScaleSaveBase {
/** 공종구분코드 */
cnstpSeCd: string;
/** 기본계획수량 */
@@ -125,7 +143,7 @@ export interface InstlSncnBzScaleSaveDto {
}
/** 설치인가 신청 - 총사업비 저장 DTO(배열 원소) */
export interface InstlSncnBizCstSaveRequestDto {
export interface InstlSncnBizCstSaveRequestDto extends BizCstSaveBase {
/** 회계구분코드 */
acntgSeCd: string;
/** 기본계획금액 */
@@ -137,15 +155,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 +169,7 @@ export interface InstlSncnCklstResultSaveDto {
}
/** 설치인가 신청 - 필수첨부파일 저장 DTO(배열 원소) */
export interface InstlSncnEsntlAtchFileSaveDto {
export interface InstlSncnEsntlAtchFileSaveDto extends EsntlAtchFileSaveBase {
/** 필수첨부파일아이디 */
esntlAtchFileId: string;
/** 첨부파일그룹아이디 */
@@ -161,53 +177,16 @@ 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 interface InstlSncnApplySaveRequestDto {
/** 기술검토저장구분(SV.저장, CP.검토완료) */
techReViewSaveCd?: string;
/** 검토단계공통코드(ENV.환경청.EP, KECO.환경공단.TE) */
rvwStpCd?: string;
export interface InstlSncnApplySaveRequestDto extends PbadmsApplySaveHeader {
/** 설치인가관리 정보 */
instlSncnMng: InstlSncnMngSaveRequestDto;
/** 사업규모 목록 */
@@ -228,184 +207,28 @@ 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> {
detDto: InstlSncnMngResponseDto;
bizSclList: BizSclTreeDto[];
bizCstList: BizCstTreeDto[];
cklstResultList?: CklstRvwRsltTreeDto[];
esntlFileList: EsntlAtchFileMngTreeResponseDto[];
etcFileList: AtflMngClientResponseDto[];
rvwList?: InstlSncnRvwOpnnResponseDto[];
elaprDocPrgrsList?: ElaprDocPrgrsClientResponseDto[] | null;
}
@@ -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;
}
@@ -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";
@@ -5,10 +5,9 @@ import { CSS } from "@dnd-kit/utilities";
import { Equal } from "lucide-react";
import { CodeSelect, findCodeLabel, useCodeList } from "@/entities/code";
import type { ElaprAplnRow } from "@/entities/elapr";
import { Button, TableCell, TableRow } from "@common/components";
import type { ElaprAplnRow } from "../model/apln-row-types";
interface SortableAplnRowProps {
row: ElaprAplnRow;
displaySeqNo: number;
@@ -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,
};
}
@@ -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;
}
@@ -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;
}
@@ -0,0 +1,146 @@
"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 { acntgGridConfig } from "../lib/configs/acntg-config";
import { useSeGridRows } from "../lib/use-se-grid-rows";
import type { AcntgSeRow } from "../model/acntg/acntg-se-row-types";
import { AcntgSeRowComponent } from "./acntg-se-row";
function AcntgCodeTabPanel() {
const { confirm } = useConfirm();
const alert = useAlertStore((state) => state.setMessage);
const pageQuery = useAcntgSeMngPage({
page: 0,
size: 500,
sort: "sortSeq,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,
} = useSeGridRows(acntgGridConfig, {
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-4">
<div className="flex flex-wrap items-center justify-end gap-3 px-4">
<Button type="button" color="green" onClick={addRootRow}>
코드 추가
</Button>
<Button type="button" disabled={!canSave || isSaving} onClick={handleSave}>
저장
</Button>
</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 px-4 pb-4 pt-1">
<Table className="w-full">
<colgroup>
<col width="14%" />
<col width="16%" />
<col width="26%" />
<col width="11%" />
<col width="8%" />
<col width="8%" />
<col width="11%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>코드</TableHead>
<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>
)}
</div>
);
}
export { AcntgCodeTabPanel };
@@ -10,12 +10,12 @@ import {
TableRow,
} from "@common/components";
import type { AcntgSeRow } from "../model/acntg-se-row-types";
import type { AcntgSeRow } from "../model/acntg/acntg-se-row-types";
import {
canAddChildRow,
formatCdLvNo,
formatTreeBranchPrefix,
} from "../model/build-acntg-display-rows";
} from "../model/acntg/build-acntg-display-rows";
interface AcntgSeRowProps {
row: AcntgSeRow;
@@ -78,6 +78,17 @@ function AcntgSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: Acn
</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 })
}
className="w-20"
/>
</TableCell>
<TableCell align="center">{levelLabel}</TableCell>
<TableCell align="center">
<div className="flex justify-center gap-2">
@@ -0,0 +1,146 @@
"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 { cnstpGridConfig } from "../lib/configs/cnstp-config";
import { useSeGridRows } from "../lib/use-se-grid-rows";
import type { CnstpSeRow } from "../model/cnstp/cnstp-se-row-types";
import { CnstpSeRowComponent } from "./cnstp-se-row";
function CnstpCodeTabPanel() {
const { confirm } = useConfirm();
const alert = useAlertStore((state) => state.setMessage);
const pageQuery = useCnstpSeMngPage({
page: 0,
size: 500,
sort: "sortSeq,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,
} = useSeGridRows(cnstpGridConfig, {
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-4">
<div className="flex flex-wrap items-center justify-end gap-3 px-4">
<Button type="button" color="green" onClick={addRootRow}>
코드 추가
</Button>
<Button type="button" disabled={!canSave || isSaving} onClick={handleSave}>
저장
</Button>
</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 px-4 pb-4 pt-1">
<Table className="w-full">
<colgroup>
<col width="14%" />
<col width="16%" />
<col width="26%" />
<col width="11%" />
<col width="8%" />
<col width="8%" />
<col width="11%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>코드</TableHead>
<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>
)}
</div>
);
}
export { CnstpCodeTabPanel };
@@ -14,8 +14,8 @@ import {
canAddChildRow,
formatCdLvNo,
formatTreeBranchPrefix,
} from "../model/build-cnstp-display-rows";
import type { CnstpSeRow } from "../model/cnstp-se-row-types";
} from "../model/cnstp/build-cnstp-display-rows";
import type { CnstpSeRow } from "../model/cnstp/cnstp-se-row-types";
interface CnstpSeRowProps {
row: CnstpSeRow;
@@ -78,6 +78,17 @@ function CnstpSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: Cns
</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 })
}
className="w-20"
/>
</TableCell>
<TableCell align="center">{levelLabel}</TableCell>
<TableCell align="center">
<div className="flex justify-center gap-2">
@@ -0,0 +1,55 @@
"use client";
import { useState } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@common/components";
import { AcntgCodeTabPanel } from "./acntg-code-tab-panel";
import { CnstpCodeTabPanel } from "./cnstp-code-tab-panel";
import { FnrsSharCodeTabPanel } from "./fnrs-shar-code-tab-panel";
const TAB = {
ACNTG: "acntg",
CNSTP: "cnstp",
FNRS_SHAR: "fnrs-shar",
} as const;
function CodeManagementPage() {
const [activeTab, setActiveTab] = useState<string>(TAB.ACNTG);
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">
<h3 className="text-2xl font-semibold">코드 관리</h3>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="pt-2">
<TabsList>
<TabsTrigger value={TAB.ACNTG} className="!px-6 !py-3 text-xl">
회계코드
</TabsTrigger>
<TabsTrigger value={TAB.CNSTP} className="!px-6 !py-3 text-xl">
공사코드
</TabsTrigger>
<TabsTrigger value={TAB.FNRS_SHAR} className="!px-6 !py-3 text-xl">
재원분담구분코드
</TabsTrigger>
</TabsList>
<TabsContent value={TAB.ACNTG} className="mt-0 border-0 p-0 shadow-none ring-0">
<AcntgCodeTabPanel />
</TabsContent>
<TabsContent value={TAB.CNSTP} className="mt-0 border-0 p-0 shadow-none ring-0">
<CnstpCodeTabPanel />
</TabsContent>
<TabsContent value={TAB.FNRS_SHAR} className="mt-0 border-0 p-0 shadow-none ring-0">
<FnrsSharCodeTabPanel />
</TabsContent>
</Tabs>
</section>
</div>
);
}
export { CodeManagementPage };
@@ -0,0 +1,146 @@
"use client";
import { useCallback } from "react";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useAlertStore } from "@/shared/lib";
import { useFnrsSharSeMngPage, useSaveMultiFnrsSharSeMng } from "@/entities/fnrs-shar-se";
import {
Button,
Skeleton,
Table,
TableBody,
TableHead,
TableHeader,
TableRow,
} from "@common/components";
import { fnrsSharGridConfig } from "../lib/configs/fnrs-shar-config";
import { useSeGridRows } from "../lib/use-se-grid-rows";
import type { FnrsSharSeRow } from "../model/fnrs-shar/fnrs-shar-se-row-types";
import { FnrsSharSeRowComponent } from "./fnrs-shar-se-row";
function FnrsSharCodeTabPanel() {
const { confirm } = useConfirm();
const alert = useAlertStore((state) => state.setMessage);
const pageQuery = useFnrsSharSeMngPage({
page: 0,
size: 500,
sort: "sortSeq,asc",
});
const saveMultiMutation = useSaveMultiFnrsSharSeMng();
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,
} = useSeGridRows(fnrsSharGridConfig, {
serverContent: pageQuery.data?.content,
onAlert: alert,
onSave: handlePersist,
});
const handleDelete = async (row: FnrsSharSeRow) => {
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-4">
<div className="flex flex-wrap items-center justify-end gap-3 px-4">
<Button type="button" color="green" onClick={addRootRow}>
코드 추가
</Button>
<Button type="button" disabled={!canSave || isSaving} onClick={handleSave}>
저장
</Button>
</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 px-4 pb-4 pt-1">
<Table className="w-full">
<colgroup>
<col width="14%" />
<col width="16%" />
<col width="26%" />
<col width="11%" />
<col width="8%" />
<col width="8%" />
<col width="11%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>코드</TableHead>
<TableHead>코드명</TableHead>
<TableHead>설명</TableHead>
<TableHead>사용 여부</TableHead>
<TableHead>정렬순서</TableHead>
<TableHead>구분</TableHead>
<TableHead>관리</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{displayRows.map(({ row, depth }) => (
<FnrsSharSeRowComponent
key={row.rowKey}
row={row}
depth={depth}
onChange={(patch) => updateRow(row.rowKey, patch)}
onAddChild={() => addChildRow(row)}
onDelete={() => handleDelete(row)}
/>
))}
</TableBody>
</Table>
</div>
)}
</div>
);
}
export { FnrsSharCodeTabPanel };
@@ -0,0 +1,113 @@
"use client";
import {
Button,
Input,
Label,
RadioGroup,
RadioGroupItem,
TableCell,
TableRow,
} from "@common/components";
import {
canAddChildRow,
formatCdLvNo,
formatTreeBranchPrefix,
} from "../model/fnrs-shar/build-fnrs-shar-display-rows";
import type { FnrsSharSeRow } from "../model/fnrs-shar/fnrs-shar-se-row-types";
interface FnrsSharSeRowProps {
row: FnrsSharSeRow;
depth: number;
onChange: (patch: Partial<FnrsSharSeRow>) => void;
onAddChild: () => void;
onDelete: () => void;
}
function FnrsSharSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: FnrsSharSeRowProps) {
const levelLabel = formatCdLvNo(row.cdLvNo);
const branchPrefix = formatTreeBranchPrefix(depth);
return (
<TableRow>
<TableCell>
<div className="flex min-w-[10rem] items-center gap-1">
{branchPrefix ? (
<span className="shrink-0 font-mono text-muted">{branchPrefix}</span>
) : null}
<Input
value={row.fnrsSharSeCd}
onChange={(e) => onChange({ fnrsSharSeCd: e.target.value })}
placeholder="코드"
maxLength={10}
disabled={!row.isNew}
className="flex-1"
/>
</div>
</TableCell>
<TableCell>
<Input
value={row.fnrsSharSeCdNm ?? ""}
onChange={(e) => onChange({ fnrsSharSeCdNm: e.target.value })}
placeholder="코드명"
maxLength={100}
/>
</TableCell>
<TableCell>
<Input
value={row.expln ?? ""}
onChange={(e) => onChange({ expln: e.target.value })}
placeholder="설명"
maxLength={1000}
/>
</TableCell>
<TableCell align="center">
<RadioGroup
value={row.useYn === "N" ? "N" : "Y"}
onValueChange={(v) => onChange({ useYn: v })}
className="flex justify-center gap-4"
>
<div className="flex items-center gap-2">
<RadioGroupItem value="Y" id={`${row.rowKey}-use-y`} />
<Label htmlFor={`${row.rowKey}-use-y`}>예</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem value="N" id={`${row.rowKey}-use-n`} />
<Label htmlFor={`${row.rowKey}-use-n`}>아니오</Label>
</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 })
}
className="w-20"
/>
</TableCell>
<TableCell align="center">{levelLabel}</TableCell>
<TableCell align="center">
<div className="flex justify-center gap-2">
<Button
type="button"
size="sm"
color="white"
disabled={!canAddChildRow()}
onClick={onAddChild}
>
하위추가
</Button>
<Button type="button" size="sm" color="gray" onClick={onDelete}>
삭제
</Button>
</div>
</TableCell>
</TableRow>
);
}
export { FnrsSharSeRowComponent };
+5
View File
@@ -0,0 +1,5 @@
export { FnrsEditPage } from "./ui/fnrs-edit-page";
export { FnrsFormPage } from "./ui/fnrs-form-page";
export { FnrsListPage } from "./ui/fnrs-list-page";
export { FnrsRegistPage } from "./ui/fnrs-regist-page";
export { FnrsViewPage } from "./ui/fnrs-view-page";
@@ -0,0 +1,28 @@
import type { ElaprDocPrgrsClientResponseDto } from "@/entities/fnrs-cnsltn";
export interface ApproverStatusConfig {
label: string;
badgeClass: string;
}
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" },
};
export function sortByAtrzSeqNo(
list: ElaprDocPrgrsClientResponseDto[],
): ElaprDocPrgrsClientResponseDto[] {
return list.slice().sort((a, b) => Number(a.atrzSeqNo ?? 0) - Number(b.atrzSeqNo ?? 0));
}
export function getApproverStatus(status: string | undefined): ApproverStatusConfig {
return (
APPROVER_STATUS_MAP[status ?? ""] ?? {
label: status || "-",
badgeClass: "bg-gray-100 text-gray-700",
}
);
}
@@ -0,0 +1,35 @@
import type { BizCstTreeDto } from "@/entities/fnrs-cnsltn";
const ACNTG_TREE_PATH_SEPARATOR = "|";
/** treePath를 회계구분 코드 배열로 파싱 */
export function parseAcntgTreePath(treePath: string): string[] {
return treePath.split(ACNTG_TREE_PATH_SEPARATOR).filter(Boolean);
}
/** treePath에서 직계 부모 acntgSeCd 반환 (루트면 null) */
export function getParentAcntgSeCd(node: Pick<BizCstTreeDto, "treePath">): string | null {
const segments = parseAcntgTreePath(node.treePath);
if (segments.length <= 1) return null;
return segments[segments.length - 2];
}
/** treePath 기준으로 bizCstList 정렬 (원본 불변) */
export function sortBizCstListByTreePath(list: BizCstTreeDto[]): BizCstTreeDto[] {
return [...list].sort((a, b) => {
const byPath = a.treePath.localeCompare(b.treePath);
if (byPath !== 0) return byPath;
return a.acntgSeCd.localeCompare(b.acntgSeCd);
});
}
/** 자식 노드가 없는 leaf 여부 (treePath | 구분자 기준) */
export function isBizCstLeafNode(
node: Pick<BizCstTreeDto, "acntgSeCd" | "treePath">,
allNodes: Pick<BizCstTreeDto, "acntgSeCd" | "treePath">[],
): boolean {
const childPrefix = `${node.treePath}${ACNTG_TREE_PATH_SEPARATOR}`;
return !allNodes.some(
(other) => other.acntgSeCd !== node.acntgSeCd && other.treePath.startsWith(childPrefix),
);
}
@@ -0,0 +1,143 @@
export const FNRS_CNSLTN_VIEW_BUTTON_ACTION = {
strg: "strg",
aply: "aply",
del: "del",
splmntCmptn: "splmntCmptn",
fnrsCnsltnAncmnt: "fnrsCnsltnAncmnt",
envoSbmsn: "envoSbmsn",
rjct: "rjct",
sncnAprv: "sncnAprv",
rvwRply: "rvwRply",
rqstTechRvw: "rqstTechRvw",
dsgntRvwr: "dsgntRvwr",
rvwStrg: "rvwStrg",
rvwCmptn: "rvwCmptn",
dmndSplmnt: "dmndSplmnt",
atrzAprv: "atrzAprv",
atrzRply: "atrzRply",
grndsChck: "grndsChck",
} as const;
export type FnrsCnsltnViewButtonAction =
(typeof FNRS_CNSLTN_VIEW_BUTTON_ACTION)[keyof typeof FNRS_CNSLTN_VIEW_BUTTON_ACTION];
const ROLE_STATUS_BUTTON_ACTIONS: Record<string, Record<string, FnrsCnsltnViewButtonAction[]>> = {
ROLE_USER05: {
"": [
FNRS_CNSLTN_VIEW_BUTTON_ACTION.strg,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.aply,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.del,
],
SV: [
FNRS_CNSLTN_VIEW_BUTTON_ACTION.strg,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.aply,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.del,
],
SL: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.splmntCmptn],
TA: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.fnrsCnsltnAncmnt],
},
ROLE_USER04: {
ST: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.envoSbmsn, FNRS_CNSLTN_VIEW_BUTTON_ACTION.rjct],
EP: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.sncnAprv],
},
ROLE_USER03: {
LE: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRply, FNRS_CNSLTN_VIEW_BUTTON_ACTION.rqstTechRvw],
TP: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRply],
},
ROLE_USER01: {
TE: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.grndsChck],
SL: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.splmntCmptn],
},
};
const ROLE_USER01_ATRZ_STATUS_CODES = ["ER", "TE"] as const;
const ROLE_USER01_ATRZ_STATUS_ACTIONS: Record<
string,
Record<string, FnrsCnsltnViewButtonAction[]>
> = {
ER: {
ATRZ01: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.dsgntRvwr],
},
TE: {
ATRZ02: [
FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwStrg,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwCmptn,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.dmndSplmnt,
],
ATRZ03: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzAprv],
ATRZ99: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzRply],
},
};
function resolveButtonConfigs(actions: FnrsCnsltnViewButtonAction[]): FnrsCnsltnViewButtonAction[] {
return actions.map((action) => FNRS_CNSLTN_VIEW_BUTTON_ACTION[action]);
}
interface GetButtonConfigsOptions {
hasPendingApprovalRequest?: boolean;
}
function resolveRoleUser01AtrzButtonActions(
prgrsSttsComCd: string,
atrzSeComCdList: string[],
options?: GetButtonConfigsOptions,
): FnrsCnsltnViewButtonAction[] {
if (prgrsSttsComCd === "TE" && options?.hasPendingApprovalRequest === false) {
return [];
}
const atrzStatusMap = ROLE_USER01_ATRZ_STATUS_ACTIONS[prgrsSttsComCd];
if (!atrzStatusMap || atrzSeComCdList.length === 0) return [];
const seen = new Set<FnrsCnsltnViewButtonAction>();
const actions: FnrsCnsltnViewButtonAction[] = [];
for (const atrzSeComCd of atrzSeComCdList) {
const mappedActions = atrzStatusMap[atrzSeComCd];
if (!mappedActions) continue;
for (const action of mappedActions) {
if (seen.has(action)) continue;
seen.add(action);
actions.push(action);
}
}
return actions;
}
export function needsRoleUser01AtrzResolve(
dtlInstCd: string | undefined,
prgrsSttsComCd: string,
): boolean {
return (
dtlInstCd === "ROLE_USER01" &&
ROLE_USER01_ATRZ_STATUS_CODES.includes(
prgrsSttsComCd as (typeof ROLE_USER01_ATRZ_STATUS_CODES)[number],
)
);
}
export function getButtonConfigs(
dtlInstCd: string | undefined,
prgrsSttsComCd: string,
atrzSeComCdList?: string[],
options?: GetButtonConfigsOptions,
): FnrsCnsltnViewButtonAction[] {
if (!dtlInstCd) return [];
if (needsRoleUser01AtrzResolve(dtlInstCd, prgrsSttsComCd)) {
return resolveButtonConfigs(
resolveRoleUser01AtrzButtonActions(prgrsSttsComCd, atrzSeComCdList ?? [], options),
);
}
const statusMap = ROLE_STATUS_BUTTON_ACTIONS[dtlInstCd];
if (!statusMap) return [];
const actions = statusMap[prgrsSttsComCd];
if (!actions) return [];
return resolveButtonConfigs(actions);
}
@@ -0,0 +1,178 @@
import type { CklstResultItem, CklstRvwRsltTreeDto } from "@/entities/fnrs-cnsltn";
import {
type ChecklistFormState,
type ChecklistProgress,
type ChecklistRowKey,
type ChecklistStepMap,
REVIEW_STEP_CODES,
type ReviewStepCode,
} from "../model/view-types";
export function buildChecklistRowKey(
row: CklstRvwRsltTreeDto,
reviewStep?: ReviewStepCode,
): ChecklistRowKey {
const step = reviewStep ?? (row.rvwStpComCd as ReviewStepCode) ?? "LOCAL";
return `${row.taskSeComCd}_${row.cklstSn}_${row.cklstArtclSn}_${row.cklstRvwSn}_${step}`;
}
export function groupByReviewStep(rows: CklstRvwRsltTreeDto[]): ChecklistStepMap {
const base: ChecklistStepMap = { LOCAL: [], ENV: [], KECO: [] };
rows.forEach((row) => {
const step = (row.rvwStpComCd ?? "LOCAL") as ReviewStepCode;
if (REVIEW_STEP_CODES.includes(step)) base[step].push(row);
});
return base;
}
export function extractTaskCodes(rows: CklstRvwRsltTreeDto[]): string[] {
return Array.from(new Set(rows.map((row) => row.taskSeComCd).filter(Boolean)));
}
export function filterChecklistRows(
rows: CklstRvwRsltTreeDto[],
taskCode: string | null,
): CklstRvwRsltTreeDto[] {
return rows.filter((row) => (taskCode ? row.taskSeComCd === taskCode : true)).slice();
}
export function getChecklistDisplayRows(
rows: CklstRvwRsltTreeDto[],
taskCode?: string | null,
): CklstRvwRsltTreeDto[] {
const filtered = filterChecklistRows(rows, taskCode ?? null);
const seen = new Set<string>();
const deduped: CklstRvwRsltTreeDto[] = [];
for (const row of filtered) {
const dedupeKey = `${row.taskSeComCd}_${row.cklstRvwSn}`;
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
deduped.push(row);
}
return deduped;
}
export function computeChecklistProgress(
rows: CklstRvwRsltTreeDto[],
results: ChecklistFormState["results"],
): ChecklistProgress {
const activeRows = rows.filter((row) => row.bchckUseYn === "Y");
const total = activeRows.length;
const completed = activeRows.reduce((acc, row) => {
const key = buildChecklistRowKey(row);
const val = results[key] ?? row.rvwRsltAnmlyYn ?? "";
return acc + (val ? 1 : 0);
}, 0);
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
return { total, completed, percent };
}
export function filterChecklistRowsByReviewStep(
rows: CklstRvwRsltTreeDto[],
reviewStep: ReviewStepCode,
): CklstRvwRsltTreeDto[] {
return rows.filter((row) => row.rvwStpComCd === reviewStep);
}
export function computeReviewStepChecklistProgress(
rows: CklstRvwRsltTreeDto[],
results: ChecklistFormState["results"],
reviewStep: ReviewStepCode,
): ChecklistProgress {
const activeRows = getChecklistDisplayRows(rows).filter((row) => row.bchckUseYn === "Y");
const total = activeRows.length;
const completed = activeRows.reduce((acc, row) => {
const key = buildChecklistRowKey(row, reviewStep);
const val = results[key];
return acc + (val === "Y" || val === "N" ? 1 : 0);
}, 0);
const percent = total > 0 ? Math.round((completed / total) * 100) : 100;
return { total, completed, percent };
}
export function buildChecklistFormState(rows: CklstRvwRsltTreeDto[]): ChecklistFormState {
const results: ChecklistFormState["results"] = {};
const comments: ChecklistFormState["comments"] = {};
rows.forEach((row) => {
const key = buildChecklistRowKey(row);
if (row.rvwRsltAnmlyYn === "Y" || row.rvwRsltAnmlyYn === "N") {
results[key] = row.rvwRsltAnmlyYn;
}
if (row.picRvwCn) comments[key] = row.picRvwCn;
});
return {
results,
comments,
progress: computeChecklistProgress(rows, results),
};
}
export function mergeChecklistFormStateFromServer(
prev: ChecklistFormState,
allRows: CklstRvwRsltTreeDto[],
): ChecklistFormState {
const results = { ...prev.results };
const comments = { ...prev.comments };
for (const row of allRows) {
const step = row.rvwStpComCd as ReviewStepCode | undefined;
if (!step || !REVIEW_STEP_CODES.includes(step)) continue;
const key = buildChecklistRowKey(row);
if ((row.rvwRsltAnmlyYn === "Y" || row.rvwRsltAnmlyYn === "N") && results[key] === undefined) {
results[key] = row.rvwRsltAnmlyYn;
}
if (row.picRvwCn && !comments[key]) {
comments[key] = row.picRvwCn;
}
}
return {
results,
comments,
progress: computeChecklistProgress(getChecklistDisplayRows(allRows), results),
};
}
export function patchChecklistFormState(
prev: ChecklistFormState,
patch: Partial<Pick<ChecklistFormState, "results" | "comments">>,
allRows: CklstRvwRsltTreeDto[],
reviewStep?: ReviewStepCode,
): ChecklistFormState {
const results = patch.results ?? prev.results;
const comments = patch.comments ?? prev.comments;
const progress = reviewStep
? computeReviewStepChecklistProgress(allRows, results, reviewStep)
: computeChecklistProgress(getChecklistDisplayRows(allRows), results);
return {
results,
comments,
progress,
};
}
export function toCklstSaveDto(
rows: CklstRvwRsltTreeDto[],
formState: ChecklistFormState,
reviewStep: ReviewStepCode,
): CklstResultItem[] {
return getChecklistDisplayRows(rows)
.filter((row) => row.bchckUseYn === "Y")
.map((row) => {
const key = buildChecklistRowKey(row, reviewStep);
const selected = formState.results[key] ?? null;
return {
cklstSn: row.cklstSn,
cklstArtclSn: row.cklstArtclSn,
cklstRvwSn: row.cklstRvwSn,
rvwStpComCd: reviewStep,
selected,
picRvwCn: formState.comments[key] ?? null,
};
});
}
@@ -0,0 +1,97 @@
import { z } from "zod";
export const fnrsCnsltnRegistSchema = z.object({
fnrsCnsltnMng: z.object({
bizNm: z.string().min(1, "사업명은 필수입니다"),
bizBgngYr: z.string().min(1, "사업시작연도는 필수입니다"),
bizEndYr: z.string().min(1, "사업종료연도는 필수입니다"),
bizCn: z.string().min(1, "사업내용은 필수입니다"),
taskSeComCd: z.array(z.string()).min(1, "구분은 필수입니다"),
cfmtnBgtBizNo: z.string().optional(),
bgtAplyYr: z.string().optional(),
dsgnrNm: z.string().min(1, "설계자 이름은 필수입니다"),
dsgnrTelno: z.string().min(1, "설계자 전화번호는 필수입니다"),
dsgnrOgdpNm: z.string().min(1, "설계자 소속은 필수입니다"),
dsgnrEmlAddr: z
.string()
.min(1, "설계자 이메일은 필수입니다")
.email("올바른 이메일 형식이 아닙니다"),
aplcntNm: z.string().optional(),
aplcntMblTelno: z.string().optional(),
aplcntOgdpNm: z.string().optional(),
aplcntEmlAddr: z.string().optional(),
fnrsCnsltnDocNo: z.string().optional(),
admdstCd: z.string().optional(),
envoSeComCd: z.string().optional(),
rtlztnFcltSeComCd: z.string().optional(),
rtlztnUsgSeComCd: z.string().optional(),
prgrsSttsComCd: z.string().optional(),
atchFileGroupId: z.string().optional(),
sharAtchFileGroupId: z.string().optional(),
envoSeCdNm: z.string().optional(),
taskSeCdNm: z.string().optional(),
prgrsSttsCdNm: z.string().optional(),
sido: z.string().optional(),
gugun: z.string().optional(),
regDt: z.string().optional(),
rgtr: z.string().optional(),
rgtrNm: z.string().optional(),
mdfcnDt: z.string().optional(),
}),
bzScaleList: z.array(
z.object({
cnstpSeCd: z.string(),
bscPl: z.number(),
budgetReflct: z.number(),
thtiDsgn: z.number(),
diff: z.number().optional(),
}),
),
bizCstList: z.array(
z.object({
acntgSeCd: z.string(),
bscPlanAmt: z.number(),
bgtRfltAmt: z.number(),
tstmDesignAmt: z.number(),
}),
),
fnrsSharList: z.array(
z.object({
fnrsSharSeCd: z.string(),
exstnAmt: z.number(),
chgRqtAmt: z.number(),
ajmtAmt: z.number().optional(),
rmrkCn: z.string().optional(),
}),
),
cklstResultList: z.array(
z.object({
cklstSn: z.number(),
cklstArtclSn: z.number(),
cklstRvwSn: z.number(),
rvwStpComCd: z.string(),
selected: z.enum(["Y", "N"]).nullable(),
picRvwCn: z.string().nullable().optional(),
}),
),
esntlAtchFileList: z.array(
z.object({
esntlAtchFileId: z.string(),
atchFileGroupId: z.string(),
}),
),
privacyConsent: z.enum(["Y", "N"], {
required_error: "개인정보 수집/이용 동의는 필수입니다",
}),
});
export type FnrsCnsltnRegistFormData = z.infer<typeof fnrsCnsltnRegistSchema>;
export function formatTaskSeComCdForApi(taskSeComCd: string[]): string {
return taskSeComCd.join(",");
}
export function parseTaskSeComCdFromApi(taskSeComCd?: string): string[] {
if (!taskSeComCd) return [];
return taskSeComCd.split(",").filter(Boolean);
}
@@ -0,0 +1,135 @@
import type { IUserInfo } from "@/shared/store";
import type { FnrsCnsltnMngDetResponseDto } from "@/entities/fnrs-cnsltn";
import { mapFnrsSharDetToFormList } from "@/widgets/pbadms-sprt";
import type { FnrsCnsltnRegistFormData } from "./fnrs-cnsltn-regist-schema";
import { parseTaskSeComCdFromApi } from "./fnrs-cnsltn-regist-schema";
function mapSelectedValue(value?: string | null): "Y" | "N" | null {
if (value === "Y" || value === "N") return value;
return null;
}
function mapEsntlAtchFileList(esntlFileList: FnrsCnsltnMngDetResponseDto["esntlFileList"]) {
const esntlMap = new Map<string, string>();
for (const item of esntlFileList ?? []) {
if (!item.esntlAtchFileId || !item.atflCd) continue;
esntlMap.set(item.esntlAtchFileId, item.atflCd);
}
return Array.from(esntlMap.entries()).map(([esntlAtchFileId, atchFileGroupId]) => ({
esntlAtchFileId,
atchFileGroupId,
}));
}
export function mapDetailToFormData(data: FnrsCnsltnMngDetResponseDto): FnrsCnsltnRegistFormData {
const detDto = data.detDto;
if (!detDto) {
throw new Error("상세 정보(detDto)가 없습니다.");
}
const bizSclList = data.bizSclList ?? [];
const bizCstList = data.bizCstList ?? [];
const cklstResultList = data.cklstResultList;
const esntlFileList = data.esntlFileList;
return {
fnrsCnsltnMng: {
fnrsCnsltnDocNo: detDto.fnrsCnsltnDocNo,
bizNm: detDto.bizNm ?? "",
bizBgngYr: detDto.bizBgngYr ?? "",
bizEndYr: detDto.bizEndYr ?? "",
bizCn: detDto.bizCn ?? "",
taskSeComCd: parseTaskSeComCdFromApi(detDto.taskSeComCd),
cfmtnBgtBizNo: detDto.cfmtnBgtBizNo,
bgtAplyYr: detDto.bgtAplyYr,
dsgnrNm: detDto.dsgnrNm ?? "",
dsgnrTelno: detDto.dsgnrTelno ?? "",
dsgnrOgdpNm: detDto.dsgnrOgdpNm ?? "",
dsgnrEmlAddr: detDto.dsgnrEmlAddr ?? "",
aplcntNm: detDto.aplcntNm ?? "",
aplcntMblTelno: detDto.aplcntMblTelno ?? "",
aplcntOgdpNm: detDto.aplcntOgdpNm ?? "",
aplcntEmlAddr: detDto.aplcntEmlAddr ?? "",
admdstCd: detDto.admdstCd,
envoSeComCd: detDto.envoSeComCd,
rtlztnFcltSeComCd: detDto.rtlztnFcltSeComCd ?? "",
rtlztnUsgSeComCd: detDto.rtlztnUsgSeComCd ?? "",
prgrsSttsComCd: detDto.prgrsSttsComCd,
atchFileGroupId: detDto.atchFileGroupId ?? "",
sharAtchFileGroupId: detDto.sharAtchFileGroupId ?? "",
envoSeCdNm: detDto.envoSeCdNm,
taskSeCdNm: detDto.taskSeCdNm,
prgrsSttsCdNm: detDto.prgrsSttsCdNm,
sido: detDto.sido,
gugun: detDto.gugun,
regDt: detDto.regDt,
rgtr: detDto.rgtr,
rgtrNm: detDto.rgtrNm,
mdfcnDt: detDto.mdfcnDt,
},
bzScaleList: bizSclList.map((item) => ({
cnstpSeCd: item.cnstpSeCd,
bscPl: item.bscPlanQty,
budgetReflct: item.bgtRfltQty,
thtiDsgn: item.tstmDesignQty,
diff: item.tstmDesignQty - item.bgtRfltQty,
})),
bizCstList: bizCstList.map((item) => ({
acntgSeCd: item.acntgSeCd,
bscPlanAmt: item.bscPlanAmt,
bgtRfltAmt: item.bgtRfltAmt,
tstmDesignAmt: item.tstmDesignAmt,
})),
fnrsSharList: data.fnrsSharList?.length ? mapFnrsSharDetToFormList(data.fnrsSharList) : [],
cklstResultList:
cklstResultList?.map((item) => ({
cklstSn: item.cklstSn,
cklstArtclSn: item.cklstArtclSn,
cklstRvwSn: item.cklstRvwSn,
rvwStpComCd: item.rvwStpComCd,
selected: mapSelectedValue(item.rvwRsltAnmlyYn),
picRvwCn: item.picRvwCn,
})) ?? [],
esntlAtchFileList: mapEsntlAtchFileList(esntlFileList),
privacyConsent: "Y",
};
}
export function getCreateDefaultValues(
userInfo: IUserInfo | null,
taskSeComCd?: string[],
): FnrsCnsltnRegistFormData {
return {
fnrsCnsltnMng: {
bizNm: "",
bizBgngYr: "",
bizEndYr: "",
bizCn: "",
taskSeComCd: taskSeComCd ?? [],
cfmtnBgtBizNo: "",
bgtAplyYr: "",
dsgnrNm: "",
dsgnrTelno: "",
dsgnrOgdpNm: "",
dsgnrEmlAddr: "",
aplcntNm: userInfo?.userName ?? "",
aplcntMblTelno: userInfo?.mphn ?? "",
aplcntOgdpNm: userInfo?.ogdpSeNm ?? "",
aplcntEmlAddr: userInfo?.email ?? "",
prgrsSttsComCd: "SV",
atchFileGroupId: "",
sharAtchFileGroupId: "",
rtlztnFcltSeComCd: "",
rtlztnUsgSeComCd: "",
},
bzScaleList: [],
bizCstList: [],
fnrsSharList: [],
cklstResultList: [],
esntlAtchFileList: [],
privacyConsent: "N",
};
}
@@ -0,0 +1,94 @@
import type {
FnrsCnsltnMngDetResponseDto,
FnrsCnsltnRvwOpnnResponseDto,
FnrsCnsltnRvwOpnnSaveRequestDto,
} from "@/entities/fnrs-cnsltn";
export interface ReviewOpinionFormData {
rvwRsltCn: string;
rvwRsltAnmlyYn: "Y" | "N" | "";
atchFileGroupId: string;
opnrSeComCd: string;
rvwOpnnSn?: number;
}
export interface ReviewOpinionGetFormDataResult {
formData: ReviewOpinionFormData;
rvwList: FnrsCnsltnRvwOpnnResponseDto[];
}
function hasReviewOpinionContent(formData: ReviewOpinionFormData) {
return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
}
function mapServerItemToSaveDto(
item: FnrsCnsltnRvwOpnnResponseDto,
fnrsCnsltnDocNo: string,
): FnrsCnsltnRvwOpnnSaveRequestDto | null {
if (item.rvwOpnnSn == null) return null;
return {
fnrsCnsltnDocNo,
rvwOpnnSn: item.rvwOpnnSn,
opnrSeComCd: item.opnrSeComCd,
opnrNm: item.opnrNm,
rvwRsltCn: item.rvwRsltCn,
rvwRsltAnmlyYn: item.rvwRsltAnmlyYn || undefined,
atchFileGroupId: item.atchFileGroupId || undefined,
};
}
function mapFormDataToSaveDto(
formData: ReviewOpinionFormData,
fnrsCnsltnDocNo: string,
opnrNm?: string,
): FnrsCnsltnRvwOpnnSaveRequestDto {
const base = {
fnrsCnsltnDocNo,
opnrSeComCd: formData.opnrSeComCd,
opnrNm: opnrNm ?? "",
rvwRsltCn: formData.rvwRsltCn,
rvwRsltAnmlyYn: formData.rvwRsltAnmlyYn || undefined,
atchFileGroupId: formData.atchFileGroupId || undefined,
};
if (formData.rvwOpnnSn != null) {
return { ...base, rvwOpnnSn: formData.rvwOpnnSn };
}
return base as FnrsCnsltnRvwOpnnSaveRequestDto;
}
export function toRvwOpnnSaveDto(
data: FnrsCnsltnMngDetResponseDto,
result: ReviewOpinionGetFormDataResult | null | undefined,
opnrNm?: string,
): FnrsCnsltnRvwOpnnSaveRequestDto[] | undefined {
if (!result || !data.detDto) return undefined;
const { formData, rvwList } = result;
const fnrsCnsltnDocNo = data.detDto.fnrsCnsltnDocNo;
const list: FnrsCnsltnRvwOpnnSaveRequestDto[] = rvwList
.map((item) => mapServerItemToSaveDto(item, fnrsCnsltnDocNo))
.filter((item): item is FnrsCnsltnRvwOpnnSaveRequestDto => item != null);
if (!hasReviewOpinionContent(formData)) {
return list.length > 0 ? list : undefined;
}
const formDto = mapFormDataToSaveDto(formData, fnrsCnsltnDocNo, opnrNm);
if (formData.rvwOpnnSn != null) {
const existingIndex = list.findIndex((item) => item.rvwOpnnSn === formData.rvwOpnnSn);
if (existingIndex >= 0) {
list[existingIndex] = formDto;
} else {
list.push(formDto);
}
} else {
list.push(formDto);
}
return list.length > 0 ? list : undefined;
}
@@ -0,0 +1,44 @@
import {
PBADMS_PROGRESS_ORDER,
REVIEW_STEP_CODES,
REVIEW_STEP_ROLE_RANK,
type ReviewStepCode,
ROLE_TO_REVIEW_STEP,
} from "../model/view-types";
export function getProgressStepCount(prgrsSttsComCd?: string): number {
const idx = PBADMS_PROGRESS_ORDER.indexOf(
prgrsSttsComCd as (typeof PBADMS_PROGRESS_ORDER)[number],
);
if (idx <= PBADMS_PROGRESS_ORDER.indexOf("SV")) return 1;
if (idx <= PBADMS_PROGRESS_ORDER.indexOf("LE")) return 2;
return 3;
}
export function getRoleStepCount(dtlInstCd?: string): number {
const mapped = dtlInstCd ? ROLE_TO_REVIEW_STEP[dtlInstCd] : undefined;
return mapped ? REVIEW_STEP_ROLE_RANK[mapped] : 0;
}
export function getVisibleReviewSteps(
prgrsSttsComCd: string,
userRoleCode?: string,
): ReviewStepCode[] {
const count = Math.min(getProgressStepCount(prgrsSttsComCd), getRoleStepCount(userRoleCode));
return REVIEW_STEP_CODES.slice(0, count);
}
export function getEditableReviewStep(
dtlInstCd?: string,
prgrsSttsComCd?: string,
): ReviewStepCode | undefined {
if (dtlInstCd === "ROLE_USER03" && (prgrsSttsComCd === "LE" || prgrsSttsComCd === "TP")) {
return "ENV";
}
if (dtlInstCd === "ROLE_USER01" && prgrsSttsComCd === "TE") {
return "KECO";
}
return undefined;
}
@@ -0,0 +1,150 @@
import type {
BizCstItem,
BizCstTreeDto,
BizSclTreeDto,
BzScaleItem,
CklstResultItem,
EsntlAtchFileItem,
EsntlAtchFileMngTreeResponseDto,
FnrsCnsltnApplySaveRequestDto,
FnrsCnsltnMngDetResponseDto,
FnrsCnsltnMngResponseDto,
FnrsCnsltnSplmntDmndItem,
} from "@/entities/fnrs-cnsltn";
import { mapFnrsSharDetToFormList, toFnrsSharSaveList } from "@/widgets/pbadms-sprt";
import type { ChecklistFormState, ReviewStepCode } from "../model/view-types";
import { toCklstSaveDto } from "./checklist-utils";
import { type ReviewOpinionGetFormDataResult, toRvwOpnnSaveDto } from "./review-opinion-utils";
export function toBzScaleSaveDto(bizSclList: BizSclTreeDto[]): BzScaleItem[] {
return bizSclList
.filter((node) => node.useYn === "Y")
.map((node) => ({
cnstpSeCd: node.cnstpSeCd,
bscPl: node.bscPlanQty,
budgetReflct: node.bgtRfltQty,
thtiDsgn: node.tstmDesignQty,
}));
}
export function toBizCstSaveDto(bizCstList: BizCstTreeDto[]): BizCstItem[] {
return bizCstList
.filter((node) => node.useYn === "Y")
.map((node) => ({
acntgSeCd: node.acntgSeCd,
bscPlanAmt: node.bscPlanAmt,
bgtRfltAmt: node.bgtRfltAmt,
tstmDesignAmt: node.tstmDesignAmt,
}));
}
export function toEsntlAtchFileSaveDto(
esntlFileList: EsntlAtchFileMngTreeResponseDto[],
): EsntlAtchFileItem[] {
const esntlMap = new Map<string, string>();
for (const item of esntlFileList) {
if (!item.esntlAtchFileId || !item.atflCd) continue;
esntlMap.set(item.esntlAtchFileId, item.atflCd);
}
return Array.from(esntlMap.entries()).map(([esntlAtchFileId, atchFileGroupId]) => ({
esntlAtchFileId,
atchFileGroupId,
}));
}
export function buildSavePayload(
data: FnrsCnsltnMngDetResponseDto,
checklistFormState: ChecklistFormState,
reviewStep: ReviewStepCode,
prgrsSttsComCd: string,
): FnrsCnsltnApplySaveRequestDto {
const detDto = data.detDto;
if (!detDto) {
throw new Error("상세 정보(detDto)가 없습니다.");
}
const bizSclList = data.bizSclList ?? [];
const bizCstList = data.bizCstList ?? [];
const cklstResultList = data.cklstResultList ?? [];
const esntlFileList = data.esntlFileList ?? [];
return {
fnrsCnsltnMng: { ...detDto, prgrsSttsComCd },
bzScaleList: toBzScaleSaveDto(bizSclList),
bizCstList: toBizCstSaveDto(bizCstList),
cklstResultList: toCklstSaveDto(cklstResultList, checklistFormState, reviewStep),
esntlAtchFileList: toEsntlAtchFileSaveDto(esntlFileList),
fnrsSharList: toFnrsSharSaveList(mapFnrsSharDetToFormList(data.fnrsSharList ?? [])),
};
}
export function buildTechReviewReplyPayload(
data: FnrsCnsltnMngDetResponseDto,
checklistFormState: ChecklistFormState,
reviewStep: ReviewStepCode,
prgrsSttsComCd: string,
techReViewSaveCd: string,
reviewOpinionFormData?: ReviewOpinionGetFormDataResult | null,
opnrNm?: string,
): FnrsCnsltnApplySaveRequestDto {
return {
...buildSavePayload(data, checklistFormState, reviewStep, prgrsSttsComCd),
techReViewSaveCd,
rvwStpCd: reviewStep,
rvwOpnnList: toRvwOpnnSaveDto(data, reviewOpinionFormData, opnrNm),
};
}
export function buildSupplementReqPayload(
data: FnrsCnsltnMngDetResponseDto,
splmntDmndCn: string,
): FnrsCnsltnApplySaveRequestDto {
const detDto = data.detDto;
if (!detDto) {
throw new Error("상세 정보(detDto)가 없습니다.");
}
const nextSn =
(data.splmntDmndList?.reduce((max, item) => Math.max(max, item.splmntDmndSn ?? 0), 0) ?? 0) + 1;
const splmntDmndList: FnrsCnsltnSplmntDmndItem[] = [
...(data.splmntDmndList ?? []),
{
splmntDmndSn: nextSn,
splmntDmndCn,
cmptnYn: "N",
},
];
return {
fnrsCnsltnMng: detDto,
splmntDmndList,
};
}
export function buildSupplementSubmitPayload(
data: FnrsCnsltnMngDetResponseDto,
checklistFormState: ChecklistFormState,
): FnrsCnsltnApplySaveRequestDto {
return {
...buildSavePayload(data, checklistFormState, "LOCAL", "SL"),
splmntDmndList: data.splmntDmndList,
};
}
export function buildTechReviewPayload(
det: FnrsCnsltnMngResponseDto,
cklstResultList: CklstResultItem[],
): FnrsCnsltnApplySaveRequestDto {
return {
fnrsCnsltnMng: { ...det, prgrsSttsComCd: "ER" },
bzScaleList: [],
bizCstList: [],
cklstResultList,
esntlAtchFileList: [],
};
}
@@ -0,0 +1,61 @@
import { useCallback, useEffect, useState } from "react";
import type { CklstRvwRsltTreeDto } from "@/entities/fnrs-cnsltn";
import {
type ChecklistFormState,
DEFAULT_CHECKLIST_PROGRESS,
type ReviewStepCode,
} from "../model/view-types";
import { mergeChecklistFormStateFromServer, patchChecklistFormState } from "./checklist-utils";
const EMPTY_CKLST_RESULT_LIST: CklstRvwRsltTreeDto[] = [];
function isSameChecklistFormState(a: ChecklistFormState, b: ChecklistFormState): boolean {
if (
a.progress.total !== b.progress.total ||
a.progress.completed !== b.progress.completed ||
a.progress.percent !== b.progress.percent
) {
return false;
}
const aResultKeys = Object.keys(a.results);
if (aResultKeys.length !== Object.keys(b.results).length) return false;
if (!aResultKeys.every((key) => a.results[key] === b.results[key])) return false;
const aCommentKeys = Object.keys(a.comments);
if (aCommentKeys.length !== Object.keys(b.comments).length) return false;
return aCommentKeys.every((key) => a.comments[key] === b.comments[key]);
}
function useChecklistFormState(cklstResultList: CklstRvwRsltTreeDto[] = EMPTY_CKLST_RESULT_LIST) {
const [formState, setFormState] = useState<ChecklistFormState>({
results: {},
comments: {},
progress: DEFAULT_CHECKLIST_PROGRESS,
});
useEffect(() => {
setFormState((prev) => {
const next = mergeChecklistFormStateFromServer(prev, cklstResultList);
return isSameChecklistFormState(prev, next) ? prev : next;
});
}, [cklstResultList]);
const patchFormState = useCallback(
(
patch: Partial<Pick<ChecklistFormState, "results" | "comments">>,
reviewStep?: ReviewStepCode,
) => {
setFormState((prev) => patchChecklistFormState(prev, patch, cklstResultList, reviewStep));
},
[cklstResultList],
);
return { formState, setFormState, patchFormState };
}
export { EMPTY_CKLST_RESULT_LIST, useChecklistFormState };
@@ -2,12 +2,13 @@
import { Fragment } from "react";
import { createColumnHelper } from "@tanstack/react-table";
import { dayjs } from "@/shared/lib";
import type { FnrsCnsltnMngResponseDto } from "@/entities/fnrs-cnsltn";
import { Badge } from "@common/components";
interface ProgressStepIndicatorProps {
prgrsSttsComCd: string;
prgrsSttsCdNm: string;
}
const columnHelper = createColumnHelper<FnrsCnsltnMngResponseDto>();
const MAIN_STEP_LABELS = {
save: "저장",
@@ -90,22 +91,80 @@ function getBadgeColor(state: BadgeState) {
}
}
function ProgressStepIndicator({ prgrsSttsComCd, prgrsSttsCdNm }: ProgressStepIndicatorProps) {
function formatOptional(value: string | undefined) {
return value?.trim() ? value : "-";
}
interface ProgressStepBadgeProps {
prgrsSttsComCd: string;
prgrsSttsCdNm: string;
}
function ProgressStepBadge({ prgrsSttsComCd, prgrsSttsCdNm }: ProgressStepBadgeProps) {
const badges = buildProgressBadges(prgrsSttsComCd, prgrsSttsCdNm);
return (
<div className="cnfm-card">
<h3 className="text-[1.5rem] font-semibold">진행 단계</h3>
<div className="cnfm-step-wrap mt-3">
{badges.map((badge, index) => (
<Fragment key={badge.label}>
{index > 0 && <span className="cnfm-step-separator">&gt;</span>}
<Badge color={getBadgeColor(badge.state)}>{badge.label}</Badge>
</Fragment>
))}
</div>
<div className="flex flex-wrap items-center justify-start">
{badges.map((badge, index) => (
<Fragment key={badge.label}>
{index > 0 && <span className="mx-2">&gt;</span>}
<Badge color={getBadgeColor(badge.state)}>{badge.label}</Badge>
</Fragment>
))}
</div>
);
}
export { ProgressStepIndicator };
export function createFnrsCnsltnColumns() {
return [
columnHelper.accessor("sido", {
header: "시도",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("gugun", {
header: "시군구",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("bizNm", {
header: "사업명",
meta: { align: "left" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("taskSeCdNm", {
header: "업무구분",
meta: { align: "center" },
cell: ({ row }) => formatOptional(row.original.taskSeCdNm || row.original.taskSeComCd),
}),
columnHelper.accessor("aplcntNm", {
header: "담당자",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("envoSeCdNm", {
header: "해당기관",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.display({
id: "progress",
header: "진행상황",
meta: { align: "center" },
cell: ({ row }) => (
<ProgressStepBadge
prgrsSttsComCd={row.original.prgrsSttsComCd || ""}
prgrsSttsCdNm={row.original.prgrsSttsCdNm || ""}
/>
),
}),
columnHelper.accessor("regDt", {
header: "신청일",
meta: { align: "center" },
cell: ({ getValue }) => {
const value = getValue();
return value ? dayjs(value).format("YYYY-MM-DD") : "-";
},
}),
];
}
@@ -0,0 +1,5 @@
export interface ReviewerOption {
uuid: string;
name: string;
deptNm: string;
}

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