diff --git a/.gitignore b/.gitignore index ee2cfd052..871b209f0 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,5 @@ settings.json .agent apps/**/tsconfig.tsbuildinfo +packages/**/tsconfig.tsbuildinfo logs/ diff --git a/apps/as/app/(with-base-layout)/acntg-code-management/page.tsx b/apps/as/app/(with-base-layout)/acntg-code-management/page.tsx deleted file mode 100644 index c22d0f0b4..000000000 --- a/apps/as/app/(with-base-layout)/acntg-code-management/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { AcntgCodeManagementPage as default } from "@/pages/acntg-code-management"; diff --git a/apps/as/app/(with-base-layout)/cnstp-code-management/page.tsx b/apps/as/app/(with-base-layout)/cnstp-code-management/page.tsx deleted file mode 100644 index d20a49e4f..000000000 --- a/apps/as/app/(with-base-layout)/cnstp-code-management/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { CnstpCodeManagementPage as default } from "@/pages/cnstp-code-management"; diff --git a/apps/as/app/(with-base-layout)/code-management/page.tsx b/apps/as/app/(with-base-layout)/code-management/page.tsx new file mode 100644 index 000000000..7a4daf43f --- /dev/null +++ b/apps/as/app/(with-base-layout)/code-management/page.tsx @@ -0,0 +1 @@ +export { CodeManagementPage as default } from "@/pages/code-management"; diff --git a/apps/as/app/(with-base-layout)/fnrs-cnsltn/[id]/edit/page.tsx b/apps/as/app/(with-base-layout)/fnrs-cnsltn/[id]/edit/page.tsx new file mode 100644 index 000000000..f1371598e --- /dev/null +++ b/apps/as/app/(with-base-layout)/fnrs-cnsltn/[id]/edit/page.tsx @@ -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 ; +} diff --git a/apps/as/app/(with-base-layout)/fnrs-cnsltn/[id]/page.tsx b/apps/as/app/(with-base-layout)/fnrs-cnsltn/[id]/page.tsx new file mode 100644 index 000000000..ec9a3911e --- /dev/null +++ b/apps/as/app/(with-base-layout)/fnrs-cnsltn/[id]/page.tsx @@ -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 ; +} diff --git a/apps/as/app/(with-base-layout)/fnrs-cnsltn/page.tsx b/apps/as/app/(with-base-layout)/fnrs-cnsltn/page.tsx new file mode 100644 index 000000000..734a0afd9 --- /dev/null +++ b/apps/as/app/(with-base-layout)/fnrs-cnsltn/page.tsx @@ -0,0 +1,3 @@ +import { FnrsListPage } from "@/pages/fnrs-cnsltn"; + +export default FnrsListPage; diff --git a/apps/as/app/(with-base-layout)/fnrs-cnsltn/regist/page.tsx b/apps/as/app/(with-base-layout)/fnrs-cnsltn/regist/page.tsx new file mode 100644 index 000000000..4360a6979 --- /dev/null +++ b/apps/as/app/(with-base-layout)/fnrs-cnsltn/regist/page.tsx @@ -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 ; +} diff --git a/apps/as/app/(with-base-layout)/instl-sncn-old/[id]/modify/page.tsx b/apps/as/app/(with-base-layout)/instl-sncn-old/[id]/modify/page.tsx deleted file mode 100644 index baf4de7af..000000000 --- a/apps/as/app/(with-base-layout)/instl-sncn-old/[id]/modify/page.tsx +++ /dev/null @@ -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 ; -} diff --git a/apps/as/app/(with-base-layout)/instl-sncn-old/[id]/page.tsx b/apps/as/app/(with-base-layout)/instl-sncn-old/[id]/page.tsx deleted file mode 100644 index 92769d3ef..000000000 --- a/apps/as/app/(with-base-layout)/instl-sncn-old/[id]/page.tsx +++ /dev/null @@ -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 ; -} diff --git a/apps/as/app/(with-base-layout)/instl-sncn-old/page.tsx b/apps/as/app/(with-base-layout)/instl-sncn-old/page.tsx deleted file mode 100644 index 6c1e37f16..000000000 --- a/apps/as/app/(with-base-layout)/instl-sncn-old/page.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { CnfmListPage } from "@/pages/instl-sncn-old"; - -export default CnfmListPage; diff --git a/apps/as/app/(with-base-layout)/instl-sncn-old/register/page.tsx b/apps/as/app/(with-base-layout)/instl-sncn-old/register/page.tsx deleted file mode 100644 index 82a838f8a..000000000 --- a/apps/as/app/(with-base-layout)/instl-sncn-old/register/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { CnfmRegistPage } from "@/pages/instl-sncn-old"; - -export default function Page(props: any) { - return ; -} diff --git a/apps/as/app/(with-base-layout)/instl-sncn/regist/page.tsx b/apps/as/app/(with-base-layout)/instl-sncn/regist/page.tsx index b19f7abb6..476d0b7c2 100644 --- a/apps/as/app/(with-base-layout)/instl-sncn/regist/page.tsx +++ b/apps/as/app/(with-base-layout)/instl-sncn/regist/page.tsx @@ -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 ; +} diff --git a/apps/as/docs/fnrs-cnsltn-vs-instl-sncn-diff.md b/apps/as/docs/fnrs-cnsltn-vs-instl-sncn-diff.md new file mode 100644 index 000000000..c90dec22f --- /dev/null +++ b/apps/as/docs/fnrs-cnsltn-vs-instl-sncn-diff.md @@ -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 { + // TODO: 보완완료 API 호출 + context.onSuccess?.(); +} +// ... +async function handleDmndSplmnt(context: ActionHandlerContext): Promise { + // 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` | diff --git a/apps/as/package.json b/apps/as/package.json index ea9e0545a..ed4016352 100644 --- a/apps/as/package.json +++ b/apps/as/package.json @@ -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", diff --git a/apps/as/public/favicon.ico b/apps/as/public/favicon.ico new file mode 100644 index 000000000..d304b6981 Binary files /dev/null and b/apps/as/public/favicon.ico differ diff --git a/apps/as/src/app/layouts/base-layout.tsx b/apps/as/src/app/layouts/base-layout.tsx index fc84a5e3e..ebe24b41c 100644 --- a/apps/as/src/app/layouts/base-layout.tsx +++ b/apps/as/src/app/layouts/base-layout.tsx @@ -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 ( diff --git a/apps/as/src/entities/acntg/model/acntg-interface.ts b/apps/as/src/entities/acntg/model/acntg-interface.ts index c1457208d..ea64fc218 100644 --- a/apps/as/src/entities/acntg/model/acntg-interface.ts +++ b/apps/as/src/entities/acntg/model/acntg-interface.ts @@ -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로 설정) */ diff --git a/apps/as/src/entities/auth/api/auth-service.ts b/apps/as/src/entities/auth/api/auth-service.ts index 0c1730054..97b664d6f 100644 --- a/apps/as/src/entities/auth/api/auth-service.ts +++ b/apps/as/src/entities/auth/api/auth-service.ts @@ -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 { const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, { @@ -58,7 +63,7 @@ async function silentRefresh(): Promise { } } 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 { } async function logout(): Promise { - 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 { export const authService: AuthService = { login, silentRefresh, + declineSessionExtend: clearRefreshTimer, logout, }; diff --git a/apps/as/src/entities/auth/lib/token-manager.ts b/apps/as/src/entities/auth/lib/token-manager.ts index d449e711b..78d24bd5e 100644 --- a/apps/as/src/entities/auth/lib/token-manager.ts +++ b/apps/as/src/entities/auth/lib/token-manager.ts @@ -8,7 +8,7 @@ export const { setToken, removeToken, setupRefreshTimer, - clearAuthToken, + clearRefreshTimer, getAccessToken, getRefreshToken, getUserId, diff --git a/apps/as/src/entities/auth/model/auth-interface.ts b/apps/as/src/entities/auth/model/auth-interface.ts deleted file mode 100644 index fb6e10630..000000000 --- a/apps/as/src/entities/auth/model/auth-interface.ts +++ /dev/null @@ -1,10 +0,0 @@ -export interface AuthPayload { - userId: string; - password: string; -} - -export interface AuthService { - login(payload: AuthPayload): Promise; - silentRefresh(): Promise; - logout(): Promise; -} diff --git a/apps/as/src/entities/cnstp/model/cnstp-interface.ts b/apps/as/src/entities/cnstp/model/cnstp-interface.ts index def16901a..eabd12f46 100644 --- a/apps/as/src/entities/cnstp/model/cnstp-interface.ts +++ b/apps/as/src/entities/cnstp/model/cnstp-interface.ts @@ -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로 설정) */ diff --git a/apps/as/src/entities/elapr/api/elapr-prgrs-service.ts b/apps/as/src/entities/elapr/api/elapr-prgrs-service.ts deleted file mode 100644 index 97f925113..000000000 --- a/apps/as/src/entities/elapr/api/elapr-prgrs-service.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { client } from "@/shared/api"; - -export async function processElaprPrgrs(elaprPrgrsId: string, atrzPrgrsSttsComCd = "ATRZ_STTS_003"): Promise { - await client.put(`/as-elapr-service/api/v1/elapr-prgrs-stts/${elaprPrgrsId}/process`, { - atrzPrgrsSttsComCd, - }); -} diff --git a/apps/as/src/entities/elapr/api/elapr-prgrs-stts-service.ts b/apps/as/src/entities/elapr/api/elapr-prgrs-stts-service.ts new file mode 100644 index 000000000..f1931d556 --- /dev/null +++ b/apps/as/src/entities/elapr/api/elapr-prgrs-stts-service.ts @@ -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 { + const response = await client.get(BASE, { + params: elaprDmndId ? { elaprDmndId } : undefined, + }); + return response.data; +} + +async function findById(elaprPrgrsId: string): Promise { + const response = await client.get(`${BASE}/${elaprPrgrsId}`); + return response.data; +} + +async function save( + requestDtos: ElaprPrgrsSttsSaveRequestDto[], +): Promise { + const response = await client.post(BASE, requestDtos); + return response.data; +} + +async function process( + elaprPrgrsId: string, + body: ElaprProcessRequestDto, +): Promise { + const response = await client.put( + `${BASE}/${elaprPrgrsId}/process`, + body, + ); + return response.data; +} + +async function processByDocNoAndAprvrUuid( + elaprDocNo: string, + aprvrUuid: string, + body: ElaprProcessRequestDto, +): Promise { + const response = await client.put( + `${BASE}/doc/${elaprDocNo}/aprvr/${aprvrUuid}/process`, + body, + ); + return response.data; +} + +async function deletePrgrsStts(elaprPrgrsId: string): Promise { + await client.delete(`${BASE}/${elaprPrgrsId}`); +} + +async function updateApprover( + elaprPrgrsId: string, + body: ElaprApproverChangeRequestDto, +): Promise { + const response = await client.put( + `${BASE}/${elaprPrgrsId}/approver`, + body, + ); + return response.data; +} + +async function insertApprover( + elaprDmndId: string, + body: ElaprApproverInsertRequestDto, +): Promise { + const response = await client.post( + `${BASE}/${elaprDmndId}/approver/insert`, + body, + ); + return response.data; +} + +async function findMyElaperDocList( + params: ElaprPrgrsSttsMyDocSearchParams, +): Promise> { + const response = await client.get>(`${BASE}/my/doc`, { + params, + }); + return response.data; +} + +export const elaprPrgrsSttsService = { + findAllByDmndId, + findById, + save, + process, + processByDocNoAndAprvrUuid, + delete: deletePrgrsStts, + updateApprover, + insertApprover, + findMyElaperDocList, +} satisfies ElaprPrgrsSttsService; diff --git a/apps/as/src/entities/elapr/api/queries.ts b/apps/as/src/entities/elapr/api/queries.ts index fae6a0d03..e93c1540e 100644 --- a/apps/as/src/entities/elapr/api/queries.ts +++ b/apps/as/src/entities/elapr/api/queries.ts @@ -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) { + 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); + }, + }); +} diff --git a/apps/as/src/entities/elapr/index.ts b/apps/as/src/entities/elapr/index.ts index 4b14ea0d9..577742c77 100644 --- a/apps/as/src/entities/elapr/index.ts +++ b/apps/as/src/entities/elapr/index.ts @@ -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"; diff --git a/apps/as/src/pages/approval-line-management/lib/build-save-multi-payload.ts b/apps/as/src/entities/elapr/lib/apln-save-utils.ts similarity index 57% rename from apps/as/src/pages/approval-line-management/lib/build-save-multi-payload.ts rename to apps/as/src/entities/elapr/lib/apln-save-utils.ts index 0c66b7623..17974a166 100644 --- a/apps/as/src/pages/approval-line-management/lib/build-save-multi-payload.ts +++ b/apps/as/src/entities/elapr/lib/apln-save-utils.ts @@ -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, diff --git a/apps/as/src/pages/approval-line-management/lib/validators.ts b/apps/as/src/entities/elapr/lib/apln-validators.ts similarity index 86% rename from apps/as/src/pages/approval-line-management/lib/validators.ts rename to apps/as/src/entities/elapr/lib/apln-validators.ts index 4e301afa9..3ab1bcd30 100644 --- a/apps/as/src/pages/approval-line-management/lib/validators.ts +++ b/apps/as/src/entities/elapr/lib/apln-validators.ts @@ -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) { diff --git a/apps/as/src/entities/elapr/lib/approval-request-utils.ts b/apps/as/src/entities/elapr/lib/approval-request-utils.ts new file mode 100644 index 000000000..7886e0ad2 --- /dev/null +++ b/apps/as/src/entities/elapr/lib/approval-request-utils.ts @@ -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", + ); +} diff --git a/apps/as/src/entities/elapr/lib/dmnd-save-utils.ts b/apps/as/src/entities/elapr/lib/dmnd-save-utils.ts new file mode 100644 index 000000000..7eee2894d --- /dev/null +++ b/apps/as/src/entities/elapr/lib/dmnd-save-utils.ts @@ -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), + ); +} diff --git a/apps/as/src/entities/elapr/lib/prgrs-stts-save-utils.ts b/apps/as/src/entities/elapr/lib/prgrs-stts-save-utils.ts new file mode 100644 index 000000000..db6ee3e5c --- /dev/null +++ b/apps/as/src/entities/elapr/lib/prgrs-stts-save-utils.ts @@ -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; +} diff --git a/apps/as/src/pages/approval-line-management/lib/use-apln-rows.ts b/apps/as/src/entities/elapr/lib/use-apln-rows.ts similarity index 80% rename from apps/as/src/pages/approval-line-management/lib/use-apln-rows.ts rename to apps/as/src/entities/elapr/lib/use-apln-rows.ts index 01c71ce3b..384c8ffc1 100644 --- a/apps/as/src/pages/approval-line-management/lib/use-apln-rows.ts +++ b/apps/as/src/entities/elapr/lib/use-apln-rows.ts @@ -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; diff --git a/apps/as/src/pages/approval-line-management/lib/use-dnd-sensors.ts b/apps/as/src/entities/elapr/lib/use-dnd-sensors.ts similarity index 93% rename from apps/as/src/pages/approval-line-management/lib/use-dnd-sensors.ts rename to apps/as/src/entities/elapr/lib/use-dnd-sensors.ts index 7c4e82ba9..039414303 100644 --- a/apps/as/src/pages/approval-line-management/lib/use-dnd-sensors.ts +++ b/apps/as/src/entities/elapr/lib/use-dnd-sensors.ts @@ -1,3 +1,5 @@ +"use client"; + import { KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; import { sortableKeyboardCoordinates } from "@dnd-kit/sortable"; diff --git a/apps/as/src/entities/elapr/lib/use-prgrs-stts-rows.ts b/apps/as/src/entities/elapr/lib/use-prgrs-stts-rows.ts new file mode 100644 index 000000000..297e574a4 --- /dev/null +++ b/apps/as/src/entities/elapr/lib/use-prgrs-stts-rows.ts @@ -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([]); + const [editingRowKey, setEditingRowKey] = useState(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) => { + 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, + }; +} diff --git a/apps/as/src/pages/approval-line-management/model/apln-row-types.ts b/apps/as/src/entities/elapr/model/elapr-apln-row-types.ts similarity index 87% rename from apps/as/src/pages/approval-line-management/model/apln-row-types.ts rename to apps/as/src/entities/elapr/model/elapr-apln-row-types.ts index cabc7bb08..b3532df8a 100644 --- a/apps/as/src/pages/approval-line-management/model/apln-row-types.ts +++ b/apps/as/src/entities/elapr/model/elapr-apln-row-types.ts @@ -1,4 +1,4 @@ -import type { ElaprApln } from "@/entities/elapr"; +import type { ElaprApln } from "./elapr-apln-interface"; /** 그리드 로컬 행 — API 응답 + UI 보조 필드 */ export interface ElaprAplnRow extends Omit { diff --git a/apps/as/src/entities/elapr/model/elapr-dmnd-interface.ts b/apps/as/src/entities/elapr/model/elapr-dmnd-interface.ts index f653249f2..5a3fbc689 100644 --- a/apps/as/src/entities/elapr/model/elapr-dmnd-interface.ts +++ b/apps/as/src/entities/elapr/model/elapr-dmnd-interface.ts @@ -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; diff --git a/apps/as/src/entities/elapr/model/elapr-prgrs-stts-interface.ts b/apps/as/src/entities/elapr/model/elapr-prgrs-stts-interface.ts new file mode 100644 index 000000000..46dad56ec --- /dev/null +++ b/apps/as/src/entities/elapr/model/elapr-prgrs-stts-interface.ts @@ -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; + findById(elaprPrgrsId: string): Promise; + save(requestDtos: ElaprPrgrsSttsSaveRequestDto[]): Promise; + process(elaprPrgrsId: string, body: ElaprProcessRequestDto): Promise; + processByDocNoAndAprvrUuid( + elaprDocNo: string, + aprvrUuid: string, + body: ElaprProcessRequestDto, + ): Promise; + delete(elaprPrgrsId: string): Promise; + updateApprover( + elaprPrgrsId: string, + body: ElaprApproverChangeRequestDto, + ): Promise; + insertApprover( + elaprDmndId: string, + body: ElaprApproverInsertRequestDto, + ): Promise; + findMyElaperDocList( + params: ElaprPrgrsSttsMyDocSearchParams, + ): Promise>; +} diff --git a/apps/as/src/entities/fnrs-cnsltn/api/fnrs-cnsltn-service.ts b/apps/as/src/entities/fnrs-cnsltn/api/fnrs-cnsltn-service.ts new file mode 100644 index 000000000..e67a9a6c7 --- /dev/null +++ b/apps/as/src/entities/fnrs-cnsltn/api/fnrs-cnsltn-service.ts @@ -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> { + const response = await client.get>(BASE, { + params: searchDto, + }); + return response.data; +} + +async function findById(fnrsCnsltnDocNo: string): Promise { + const response = await client.get(`${BASE}/${fnrsCnsltnDocNo}`); + return response.data; +} + +async function save(body: FnrsCnsltnMngSaveRequestDto): Promise { + const response = await client.post(BASE, body); + return response.data; +} + +async function saveApply(body: FnrsCnsltnApplySaveRequestDto): Promise { + const response = await client.post(`${BASE}/save-apply`, body); + return response.data; +} + +async function update( + fnrsCnsltnDocNo: string, + body: FnrsCnsltnMngUpdateRequestDto, +): Promise { + const response = await client.put(`${BASE}/${fnrsCnsltnDocNo}`, body); + return response.data; +} + +async function techReview(id: string, body: FnrsCnsltnApplySaveRequestDto): Promise { + await client.post(`${BASE}/${id}/tech-review`, body); +} + +async function submitSupplement(id: string, body: FnrsCnsltnApplySaveRequestDto): Promise { + const response = await client.post(`${BASE}/${id}/submitsupplement`, body); + return response.data; +} + +async function submitReqSupplement( + id: string, + body: FnrsCnsltnApplySaveRequestDto, +): Promise { + const response = await client.post(`${BASE}/${id}/reqsupplement`, body); + return response.data; +} + +async function remove(fnrsCnsltnDocNo: string): Promise { + await client.delete(`${BASE}/${fnrsCnsltnDocNo}`); +} + +async function changePrgrsSttsComCd(body: DocPrgrsUpdateRequestDto): Promise { + await client.post(`${BASE}/doc/updatePrgrsStts`, body); +} + +export const fnrsCnsltnMngService = { + findPage, + findById, + save, + saveApply, + update, + techReview, + submitSupplement, + submitReqSupplement, + remove, + changePrgrsSttsComCd, +} satisfies FnrsCnsltnMngService; diff --git a/apps/as/src/entities/fnrs-cnsltn/api/queries.ts b/apps/as/src/entities/fnrs-cnsltn/api/queries.ts new file mode 100644 index 000000000..127e87e08 --- /dev/null +++ b/apps/as/src/entities/fnrs-cnsltn/api/queries.ts @@ -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 }); + }, + }); +} diff --git a/apps/as/src/entities/fnrs-cnsltn/index.ts b/apps/as/src/entities/fnrs-cnsltn/index.ts new file mode 100644 index 000000000..645065ffe --- /dev/null +++ b/apps/as/src/entities/fnrs-cnsltn/index.ts @@ -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"; diff --git a/apps/as/src/entities/fnrs-cnsltn/model/fnrs-cnsltn-interface.ts b/apps/as/src/entities/fnrs-cnsltn/model/fnrs-cnsltn-interface.ts new file mode 100644 index 000000000..d0a43a71b --- /dev/null +++ b/apps/as/src/entities/fnrs-cnsltn/model/fnrs-cnsltn-interface.ts @@ -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; + +/** 백엔드 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 & + 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 +> & + Omit & + 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; + esntlAtchFileList?: EsntlAtchFileItem[]; + rvwOpnnList?: FnrsCnsltnRvwOpnnSaveRequestDto[]; + splmntDmndList?: FnrsCnsltnSplmntDmndItem[]; + fnrsSharList?: FnrsSharItem[]; +} + +/** Controller public 메서드명·시그니처와 동일 */ +export interface FnrsCnsltnMngService { + findPage(searchDto: FnrsCnsltnMngSearchDto): Promise>; + findById(fnrsCnsltnDocNo: string): Promise; + save(body: FnrsCnsltnMngSaveRequestDto): Promise; + saveApply(body: FnrsCnsltnApplySaveRequestDto): Promise; + update(fnrsCnsltnDocNo: string, body: FnrsCnsltnMngUpdateRequestDto): Promise; + techReview(id: string, body: FnrsCnsltnApplySaveRequestDto): Promise; + submitSupplement(id: string, body: FnrsCnsltnApplySaveRequestDto): Promise; + submitReqSupplement(id: string, body: FnrsCnsltnApplySaveRequestDto): Promise; + remove(fnrsCnsltnDocNo: string): Promise; + changePrgrsSttsComCd(body: DocPrgrsUpdateRequestDto): Promise; +} diff --git a/apps/as/src/entities/fnrs-shar-se/api/fnrs-shar-se-service.ts b/apps/as/src/entities/fnrs-shar-se/api/fnrs-shar-se-service.ts new file mode 100644 index 000000000..57bfa67ec --- /dev/null +++ b/apps/as/src/entities/fnrs-shar-se/api/fnrs-shar-se-service.ts @@ -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> { + const response = await client.get>(BASE, { params }); + return response.data; +} + +async function findTree(): Promise { + const response = await client.get(`${BASE}/list/tree`); + return response.data; +} + +async function findById(fnrsSharSeCd: string): Promise { + const response = await client.get(`${BASE}/${fnrsSharSeCd}`); + return response.data; +} + +async function saveMulti( + body: FnrsSharSeMngSaveRequestDto[], +): Promise { + const response = await client.post(BASE, body); + return response.data; +} + +async function update(fnrsSharSeCd: string, body: FnrsSharSeMngSaveRequestDto): Promise { + const response = await client.put(`${BASE}/${fnrsSharSeCd}`, { + ...body, + fnrsSharSeCd, + mode: FNRS_SHAR_SE_SAVE_MODE.UPDATE, + }); + return response.data; +} + +async function deleteFnrsSharSeCd(fnrsSharSeCd: string): Promise { + const response = await client.delete(`${BASE}/${fnrsSharSeCd}`); + return response.data; +} + +async function findTreeFnrsShar(fnrsCnsltnDocNo: string): Promise { + const response = await client.get( + `${BASE}/list/tree/fnrs-shar/${fnrsCnsltnDocNo}`, + ); + return response.data; +} + +export const fnrsSharSeMngService = { + findAll, + findTree, + findById, + saveMulti, + update, + delete: deleteFnrsSharSeCd, + findTreeFnrsShar, +} satisfies FnrsSharSeMngService; diff --git a/apps/as/src/entities/fnrs-shar-se/api/queries.ts b/apps/as/src/entities/fnrs-shar-se/api/queries.ts new file mode 100644 index 000000000..0dd73596e --- /dev/null +++ b/apps/as/src/entities/fnrs-shar-se/api/queries.ts @@ -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), + }); +} diff --git a/apps/as/src/entities/fnrs-shar-se/index.ts b/apps/as/src/entities/fnrs-shar-se/index.ts new file mode 100644 index 000000000..2527aead1 --- /dev/null +++ b/apps/as/src/entities/fnrs-shar-se/index.ts @@ -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"; diff --git a/apps/as/src/entities/fnrs-shar-se/model/fnrs-shar-se-interface.ts b/apps/as/src/entities/fnrs-shar-se/model/fnrs-shar-se-interface.ts new file mode 100644 index 000000000..ab60a868f --- /dev/null +++ b/apps/as/src/entities/fnrs-shar-se/model/fnrs-shar-se-interface.ts @@ -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>; + findTree(): Promise; + findById(fnrsSharSeCd: string): Promise; + saveMulti(body: FnrsSharSeMngSaveRequestDto[]): Promise; + update(fnrsSharSeCd: string, body: FnrsSharSeMngSaveRequestDto): Promise; + delete(fnrsSharSeCd: string): Promise; + findTreeFnrsShar(fnrsCnsltnDocNo: string): Promise; +} diff --git a/apps/as/src/entities/instl-sncn/api/queries.ts b/apps/as/src/entities/instl-sncn/api/queries.ts index 4b5f4dd10..ac651be47 100644 --- a/apps/as/src/entities/instl-sncn/api/queries.ts +++ b/apps/as/src/entities/instl-sncn/api/queries.ts @@ -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), }); } diff --git a/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts b/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts index 378d1e427..500cfb842 100644 --- a/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts +++ b/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts @@ -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 { detDto: InstlSncnMngResponseDto; bizSclList: BizSclTreeDto[]; bizCstList: BizCstTreeDto[]; - cklstResultList?: CklstRvwRsltTreeDto[]; esntlFileList: EsntlAtchFileMngTreeResponseDto[]; etcFileList: AtflMngClientResponseDto[]; - rvwList?: InstlSncnRvwOpnnResponseDto[]; elaprDocPrgrsList?: ElaprDocPrgrsClientResponseDto[] | null; } diff --git a/apps/as/src/pages/acntg-code-management/index.ts b/apps/as/src/pages/acntg-code-management/index.ts deleted file mode 100644 index ee4bfa445..000000000 --- a/apps/as/src/pages/acntg-code-management/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { AcntgCodeManagementPage } from "./ui/acntg-code-management-page"; diff --git a/apps/as/src/pages/acntg-code-management/lib/acntg-se-grid.ts b/apps/as/src/pages/acntg-code-management/lib/acntg-se-grid.ts deleted file mode 100644 index c0ca7abc7..000000000 --- a/apps/as/src/pages/acntg-code-management/lib/acntg-se-grid.ts +++ /dev/null @@ -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, -): 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 = {}; - - 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 { - const result = new Set([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, -): AcntgDisplaySlot[] { - return structure.filter((slot) => !rowKeysToRemove.has(slot.rowKey)); -} - -export function patchAcntgGridRow( - state: AcntgSeGridState, - rowKey: string, - patch: Partial, -): 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), - }; -} diff --git a/apps/as/src/pages/acntg-code-management/lib/build-save-multi-payload.ts b/apps/as/src/pages/acntg-code-management/lib/build-save-multi-payload.ts deleted file mode 100644 index f18634b51..000000000 --- a/apps/as/src/pages/acntg-code-management/lib/build-save-multi-payload.ts +++ /dev/null @@ -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; -} diff --git a/apps/as/src/pages/acntg-code-management/lib/row-state.ts b/apps/as/src/pages/acntg-code-management/lib/row-state.ts deleted file mode 100644 index 908df1f70..000000000 --- a/apps/as/src/pages/acntg-code-management/lib/row-state.ts +++ /dev/null @@ -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); -} diff --git a/apps/as/src/pages/acntg-code-management/lib/use-acntg-se-rows.ts b/apps/as/src/pages/acntg-code-management/lib/use-acntg-se-rows.ts deleted file mode 100644 index 5fd9f85c5..000000000 --- a/apps/as/src/pages/acntg-code-management/lib/use-acntg-se-rows.ts +++ /dev/null @@ -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) => Promise; -} - -export function useAcntgSeRows({ serverContent, onAlert, onSave }: UseAcntgSeRowsOptions) { - const [grid, setGrid] = useState(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) => { - 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, - }; -} diff --git a/apps/as/src/pages/acntg-code-management/ui/acntg-code-management-page.tsx b/apps/as/src/pages/acntg-code-management/ui/acntg-code-management-page.tsx deleted file mode 100644 index d480ced59..000000000 --- a/apps/as/src/pages/acntg-code-management/ui/acntg-code-management-page.tsx +++ /dev/null @@ -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[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 ( -
-
-
-

회계코드 관리

-
- - -
-
- - {pageQuery.isPending ? ( -
- - -
- ) : displayRows.length === 0 ? ( -

- 등록된 코드가 없습니다. [코드 추가]로 1단계 코드를 등록하세요. -

- ) : ( -
- - - - - - - - - - - - 코드 - 코드명 - 설명 - 사용 여부 - 구분 - 관리 - - - - {displayRows.map(({ row, depth }) => ( - updateRow(row.rowKey, patch)} - onAddChild={() => addChildRow(row)} - onDelete={() => handleDelete(row)} - /> - ))} - -
-
- )} -
-
- ); -} - -export { AcntgCodeManagementPage }; diff --git a/apps/as/src/pages/approval-line-management/lib/assign-atrz-seq.ts b/apps/as/src/pages/approval-line-management/lib/assign-atrz-seq.ts deleted file mode 100644 index 6b5b63bd4..000000000 --- a/apps/as/src/pages/approval-line-management/lib/assign-atrz-seq.ts +++ /dev/null @@ -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 })); -} diff --git a/apps/as/src/pages/approval-line-management/lib/has-unsaved-changes.ts b/apps/as/src/pages/approval-line-management/lib/has-unsaved-changes.ts deleted file mode 100644 index 46efa59f3..000000000 --- a/apps/as/src/pages/approval-line-management/lib/has-unsaved-changes.ts +++ /dev/null @@ -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; -} diff --git a/apps/as/src/pages/approval-line-management/ui/elapr-apln-grid.tsx b/apps/as/src/pages/approval-line-management/ui/elapr-apln-grid.tsx index c2393715b..83c01c7ab 100644 --- a/apps/as/src/pages/approval-line-management/ui/elapr-apln-grid.tsx +++ b/apps/as/src/pages/approval-line-management/ui/elapr-apln-grid.tsx @@ -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"; diff --git a/apps/as/src/pages/approval-line-management/ui/sortable-apln-row.tsx b/apps/as/src/pages/approval-line-management/ui/sortable-apln-row.tsx index fcb315bdb..94d28be22 100644 --- a/apps/as/src/pages/approval-line-management/ui/sortable-apln-row.tsx +++ b/apps/as/src/pages/approval-line-management/ui/sortable-apln-row.tsx @@ -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; diff --git a/apps/as/src/pages/cnstp-code-management/index.ts b/apps/as/src/pages/cnstp-code-management/index.ts deleted file mode 100644 index 2ed1cb4ba..000000000 --- a/apps/as/src/pages/cnstp-code-management/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { CnstpCodeManagementPage } from "./ui/cnstp-code-management-page"; diff --git a/apps/as/src/pages/cnstp-code-management/lib/build-save-multi-payload.ts b/apps/as/src/pages/cnstp-code-management/lib/build-save-multi-payload.ts deleted file mode 100644 index 20ec66853..000000000 --- a/apps/as/src/pages/cnstp-code-management/lib/build-save-multi-payload.ts +++ /dev/null @@ -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; -} diff --git a/apps/as/src/pages/cnstp-code-management/lib/cnstp-se-grid.ts b/apps/as/src/pages/cnstp-code-management/lib/cnstp-se-grid.ts deleted file mode 100644 index 6b1bd99f9..000000000 --- a/apps/as/src/pages/cnstp-code-management/lib/cnstp-se-grid.ts +++ /dev/null @@ -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, -): 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 = {}; - - 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 { - const result = new Set([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, -): CnstpDisplaySlot[] { - return structure.filter((slot) => !rowKeysToRemove.has(slot.rowKey)); -} - -export function patchCnstpGridRow( - state: CnstpSeGridState, - rowKey: string, - patch: Partial, -): 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), - }; -} diff --git a/apps/as/src/pages/cnstp-code-management/lib/row-state.ts b/apps/as/src/pages/cnstp-code-management/lib/row-state.ts deleted file mode 100644 index b7a98aca7..000000000 --- a/apps/as/src/pages/cnstp-code-management/lib/row-state.ts +++ /dev/null @@ -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); -} diff --git a/apps/as/src/pages/cnstp-code-management/lib/use-cnstp-se-rows.ts b/apps/as/src/pages/cnstp-code-management/lib/use-cnstp-se-rows.ts deleted file mode 100644 index 7e4320c8f..000000000 --- a/apps/as/src/pages/cnstp-code-management/lib/use-cnstp-se-rows.ts +++ /dev/null @@ -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) => Promise; -} - -export function useCnstpSeRows({ serverContent, onAlert, onSave }: UseCnstpSeRowsOptions) { - const [grid, setGrid] = useState(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) => { - 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, - }; -} diff --git a/apps/as/src/pages/cnstp-code-management/ui/cnstp-code-management-page.tsx b/apps/as/src/pages/cnstp-code-management/ui/cnstp-code-management-page.tsx deleted file mode 100644 index 25ad711cb..000000000 --- a/apps/as/src/pages/cnstp-code-management/ui/cnstp-code-management-page.tsx +++ /dev/null @@ -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[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 ( -
-
-
-

공종코드 관리

-
- - -
-
- - {pageQuery.isPending ? ( -
- - -
- ) : displayRows.length === 0 ? ( -

- 등록된 코드가 없습니다. [코드 추가]로 1단계 코드를 등록하세요. -

- ) : ( -
- - - - - - - - - - - - 코드 - 코드명 - 설명 - 사용 여부 - 구분 - 관리 - - - - {displayRows.map(({ row, depth }) => ( - updateRow(row.rowKey, patch)} - onAddChild={() => addChildRow(row)} - onDelete={() => handleDelete(row)} - /> - ))} - -
-
- )} -
-
- ); -} - -export { CnstpCodeManagementPage }; diff --git a/apps/as/src/pages/code-management/index.ts b/apps/as/src/pages/code-management/index.ts new file mode 100644 index 000000000..3b00522e0 --- /dev/null +++ b/apps/as/src/pages/code-management/index.ts @@ -0,0 +1 @@ +export { CodeManagementPage } from "./ui/code-management-page"; diff --git a/apps/as/src/pages/code-management/lib/build-save-payload.ts b/apps/as/src/pages/code-management/lib/build-save-payload.ts new file mode 100644 index 000000000..80eac476c --- /dev/null +++ b/apps/as/src/pages/code-management/lib/build-save-payload.ts @@ -0,0 +1,60 @@ +export interface SaveModeMap { + CREATE: string; + UPDATE: string; + DELETE: string; +} + +export interface SavePayloadBuilderConfig { + 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(rows: T[]): T[] { + return rows.filter((row) => !row.isDeleted); +} + +function compareCdLvNoDesc(a: T, b: T): number { + return (b.cdLvNo ?? 1) - (a.cdLvNo ?? 1); +} + +function compareCdLvNoAsc(a: T, b: T): number { + return (a.cdLvNo ?? 1) - (b.cdLvNo ?? 1); +} + +export function createSavePayloadBuilder( + config: SavePayloadBuilderConfig, +) { + 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; + }; +} diff --git a/apps/as/src/pages/code-management/lib/configs/acntg-config.ts b/apps/as/src/pages/code-management/lib/configs/acntg-config.ts new file mode 100644 index 000000000..718919d8b --- /dev/null +++ b/apps/as/src/pages/code-management/lib/configs/acntg-config.ts @@ -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({ + 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, + 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, +}; diff --git a/apps/as/src/pages/code-management/lib/configs/cnstp-config.ts b/apps/as/src/pages/code-management/lib/configs/cnstp-config.ts new file mode 100644 index 000000000..26bcc8000 --- /dev/null +++ b/apps/as/src/pages/code-management/lib/configs/cnstp-config.ts @@ -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({ + 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, + 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, +}; diff --git a/apps/as/src/pages/code-management/lib/configs/fnrs-shar-config.ts b/apps/as/src/pages/code-management/lib/configs/fnrs-shar-config.ts new file mode 100644 index 000000000..9039693d8 --- /dev/null +++ b/apps/as/src/pages/code-management/lib/configs/fnrs-shar-config.ts @@ -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({ + 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, + 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, +}; diff --git a/apps/as/src/pages/code-management/lib/grid-types.ts b/apps/as/src/pages/code-management/lib/grid-types.ts new file mode 100644 index 000000000..9acaaad4d --- /dev/null +++ b/apps/as/src/pages/code-management/lib/grid-types.ts @@ -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, +> { + baseByKey: Record; + structure: DisplaySlot[]; + drafts: Record; + patches: Record; + deletedKeys: Set; +} + +export interface SeDisplayRow { + 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, + 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) => TDraft; + assembleDraftRow: (draft: TDraft, parentCode: string | undefined) => TRow; + buildDisplayRows: (rows: TRow[]) => SeDisplayRow[]; + validateRow: (row: TRow) => string | null; + buildSavePayload: (rows: TRow[]) => TSaveDto[]; +} diff --git a/apps/as/src/pages/code-management/lib/grid-utils.ts b/apps/as/src/pages/code-management/lib/grid-utils.ts new file mode 100644 index 000000000..29b812017 --- /dev/null +++ b/apps/as/src/pages/code-management/lib/grid-utils.ts @@ -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, + TSaveDto, +>(config: SeGridConfig) { + function createEmptyGridState(): SeGridState { + 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): 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 { + const baseByKey: Record = {}; + + 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, + 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, + 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): 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): 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 { + const result = new Set([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, + ): DisplaySlot[] { + return structure.filter((slot) => !rowKeysToRemove.has(slot.rowKey)); + } + + function patchGridRow( + state: SeGridState, + rowKey: string, + patch: Partial, + ): SeGridState { + 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, + rootRowKey: string, + ): SeGridState { + 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, + 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, + rowKey: string, + ): SeGridState { + 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, + }; +} diff --git a/apps/as/src/pages/code-management/lib/use-se-grid-rows.ts b/apps/as/src/pages/code-management/lib/use-se-grid-rows.ts new file mode 100644 index 000000000..623baf310 --- /dev/null +++ b/apps/as/src/pages/code-management/lib/use-se-grid-rows.ts @@ -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, + TSaveDto, +> { + serverContent: TDto[] | undefined; + onAlert: (message: string) => void; + onSave: (payload: TSaveDto[]) => Promise; +} + +export function useSeGridRows< + TDto extends BaseSeDto, + TBaseEntry extends TDto & { rowKey: string }, + TRow extends TBaseEntry & SeRowState, + TDraft extends SeDraftBase, + TPatch extends Partial, + TSaveDto, +>( + config: SeGridConfig, + { + serverContent, + onAlert, + onSave, + }: UseSeGridRowsOptions, +) { + const utils = useMemo(() => createSeGridUtils(config), [config]); + + const [grid, setGrid] = useState>( + 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) => { + 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, + }; +} diff --git a/apps/as/src/pages/acntg-code-management/model/acntg-se-grid-types.ts b/apps/as/src/pages/code-management/model/acntg/acntg-se-grid-types.ts similarity index 95% rename from apps/as/src/pages/acntg-code-management/model/acntg-se-grid-types.ts rename to apps/as/src/pages/code-management/model/acntg/acntg-se-grid-types.ts index a115c122d..5bac897a0 100644 --- a/apps/as/src/pages/acntg-code-management/model/acntg-se-grid-types.ts +++ b/apps/as/src/pages/code-management/model/acntg/acntg-se-grid-types.ts @@ -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; - structure: AcntgDisplaySlot[]; - drafts: Record; - patches: Record>; - deletedKeys: Set; -} - -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; + structure: AcntgDisplaySlot[]; + drafts: Record; + patches: Record>; + deletedKeys: Set; +} + +export interface AcntgSeDisplayItem { + rowKey: string; + depth: number; +} diff --git a/apps/as/src/pages/acntg-code-management/model/acntg-se-row-types.ts b/apps/as/src/pages/code-management/model/acntg/acntg-se-row-types.ts similarity index 100% rename from apps/as/src/pages/acntg-code-management/model/acntg-se-row-types.ts rename to apps/as/src/pages/code-management/model/acntg/acntg-se-row-types.ts diff --git a/apps/as/src/pages/acntg-code-management/model/build-acntg-display-rows.ts b/apps/as/src/pages/code-management/model/acntg/build-acntg-display-rows.ts similarity index 100% rename from apps/as/src/pages/acntg-code-management/model/build-acntg-display-rows.ts rename to apps/as/src/pages/code-management/model/acntg/build-acntg-display-rows.ts diff --git a/apps/as/src/pages/cnstp-code-management/model/build-cnstp-display-rows.ts b/apps/as/src/pages/code-management/model/cnstp/build-cnstp-display-rows.ts similarity index 100% rename from apps/as/src/pages/cnstp-code-management/model/build-cnstp-display-rows.ts rename to apps/as/src/pages/code-management/model/cnstp/build-cnstp-display-rows.ts diff --git a/apps/as/src/pages/cnstp-code-management/model/cnstp-se-grid-types.ts b/apps/as/src/pages/code-management/model/cnstp/cnstp-se-grid-types.ts similarity index 95% rename from apps/as/src/pages/cnstp-code-management/model/cnstp-se-grid-types.ts rename to apps/as/src/pages/code-management/model/cnstp/cnstp-se-grid-types.ts index 7cff8922a..9c2d37c5d 100644 --- a/apps/as/src/pages/cnstp-code-management/model/cnstp-se-grid-types.ts +++ b/apps/as/src/pages/code-management/model/cnstp/cnstp-se-grid-types.ts @@ -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; - structure: CnstpDisplaySlot[]; - drafts: Record; - patches: Record>; - deletedKeys: Set; -} +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; + structure: CnstpDisplaySlot[]; + drafts: Record; + patches: Record>; + deletedKeys: Set; +} diff --git a/apps/as/src/pages/cnstp-code-management/model/cnstp-se-row-types.ts b/apps/as/src/pages/code-management/model/cnstp/cnstp-se-row-types.ts similarity index 100% rename from apps/as/src/pages/cnstp-code-management/model/cnstp-se-row-types.ts rename to apps/as/src/pages/code-management/model/cnstp/cnstp-se-row-types.ts diff --git a/apps/as/src/pages/code-management/model/fnrs-shar/build-fnrs-shar-display-rows.ts b/apps/as/src/pages/code-management/model/fnrs-shar/build-fnrs-shar-display-rows.ts new file mode 100644 index 000000000..9ab931371 --- /dev/null +++ b/apps/as/src/pages/code-management/model/fnrs-shar/build-fnrs-shar-display-rows.ts @@ -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, + 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(); + + 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; +} diff --git a/apps/as/src/pages/code-management/model/fnrs-shar/fnrs-shar-se-grid-types.ts b/apps/as/src/pages/code-management/model/fnrs-shar/fnrs-shar-se-grid-types.ts new file mode 100644 index 000000000..e0aadf7ae --- /dev/null +++ b/apps/as/src/pages/code-management/model/fnrs-shar/fnrs-shar-se-grid-types.ts @@ -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; + structure: FnrsSharDisplaySlot[]; + drafts: Record; + patches: Record>; + deletedKeys: Set; +} + +export interface FnrsSharSeDisplayItem { + rowKey: string; + depth: number; +} diff --git a/apps/as/src/pages/code-management/model/fnrs-shar/fnrs-shar-se-row-types.ts b/apps/as/src/pages/code-management/model/fnrs-shar/fnrs-shar-se-row-types.ts new file mode 100644 index 000000000..14425c9cc --- /dev/null +++ b/apps/as/src/pages/code-management/model/fnrs-shar/fnrs-shar-se-row-types.ts @@ -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; +} diff --git a/apps/as/src/pages/code-management/ui/acntg-code-tab-panel.tsx b/apps/as/src/pages/code-management/ui/acntg-code-tab-panel.tsx new file mode 100644 index 000000000..0baaa2813 --- /dev/null +++ b/apps/as/src/pages/code-management/ui/acntg-code-tab-panel.tsx @@ -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[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 ( +
+
+ + +
+ + {pageQuery.isPending ? ( +
+ + +
+ ) : displayRows.length === 0 ? ( +

+ 등록된 코드가 없습니다. [코드 추가]로 1단계 코드를 등록하세요. +

+ ) : ( +
+ + + + + + + + + + + + + 코드 + 코드명 + 설명 + 사용 여부 + 정렬순서 + 구분 + 관리 + + + + {displayRows.map(({ row, depth }) => ( + updateRow(row.rowKey, patch)} + onAddChild={() => addChildRow(row)} + onDelete={() => handleDelete(row)} + /> + ))} + +
+
+ )} +
+ ); +} + +export { AcntgCodeTabPanel }; diff --git a/apps/as/src/pages/acntg-code-management/ui/acntg-se-row.tsx b/apps/as/src/pages/code-management/ui/acntg-se-row.tsx similarity index 83% rename from apps/as/src/pages/acntg-code-management/ui/acntg-se-row.tsx rename to apps/as/src/pages/code-management/ui/acntg-se-row.tsx index 69b53ec60..2ec5a7b16 100644 --- a/apps/as/src/pages/acntg-code-management/ui/acntg-se-row.tsx +++ b/apps/as/src/pages/code-management/ui/acntg-se-row.tsx @@ -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 + + + onChange({ sortSeq: e.target.value ? Number(e.target.value) : undefined }) + } + className="w-20" + /> + {levelLabel}
diff --git a/apps/as/src/pages/code-management/ui/cnstp-code-tab-panel.tsx b/apps/as/src/pages/code-management/ui/cnstp-code-tab-panel.tsx new file mode 100644 index 000000000..2db182456 --- /dev/null +++ b/apps/as/src/pages/code-management/ui/cnstp-code-tab-panel.tsx @@ -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[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 ( +
+
+ + +
+ + {pageQuery.isPending ? ( +
+ + +
+ ) : displayRows.length === 0 ? ( +

+ 등록된 코드가 없습니다. [코드 추가]로 1단계 코드를 등록하세요. +

+ ) : ( +
+ + + + + + + + + + + + + 코드 + 코드명 + 설명 + 사용 여부 + 정렬순서 + 구분 + 관리 + + + + {displayRows.map(({ row, depth }) => ( + updateRow(row.rowKey, patch)} + onAddChild={() => addChildRow(row)} + onDelete={() => handleDelete(row)} + /> + ))} + +
+
+ )} +
+ ); +} + +export { CnstpCodeTabPanel }; diff --git a/apps/as/src/pages/cnstp-code-management/ui/cnstp-se-row.tsx b/apps/as/src/pages/code-management/ui/cnstp-se-row.tsx similarity index 83% rename from apps/as/src/pages/cnstp-code-management/ui/cnstp-se-row.tsx rename to apps/as/src/pages/code-management/ui/cnstp-se-row.tsx index 090e7d2ba..299894821 100644 --- a/apps/as/src/pages/cnstp-code-management/ui/cnstp-se-row.tsx +++ b/apps/as/src/pages/code-management/ui/cnstp-se-row.tsx @@ -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
+ + + onChange({ sortSeq: e.target.value ? Number(e.target.value) : undefined }) + } + className="w-20" + /> + {levelLabel}
diff --git a/apps/as/src/pages/code-management/ui/code-management-page.tsx b/apps/as/src/pages/code-management/ui/code-management-page.tsx new file mode 100644 index 000000000..6f647eaff --- /dev/null +++ b/apps/as/src/pages/code-management/ui/code-management-page.tsx @@ -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(TAB.ACNTG); + + return ( +
+
+
+

코드 관리

+
+ + + + + 회계코드 + + + 공사코드 + + + 재원분담구분코드 + + + + + + + + + + + + + +
+
+ ); +} + +export { CodeManagementPage }; diff --git a/apps/as/src/pages/code-management/ui/fnrs-shar-code-tab-panel.tsx b/apps/as/src/pages/code-management/ui/fnrs-shar-code-tab-panel.tsx new file mode 100644 index 000000000..3f7092c59 --- /dev/null +++ b/apps/as/src/pages/code-management/ui/fnrs-shar-code-tab-panel.tsx @@ -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[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 ( +
+
+ + +
+ + {pageQuery.isPending ? ( +
+ + +
+ ) : displayRows.length === 0 ? ( +

+ 등록된 코드가 없습니다. [코드 추가]로 1단계 코드를 등록하세요. +

+ ) : ( +
+ + + + + + + + + + + + + 코드 + 코드명 + 설명 + 사용 여부 + 정렬순서 + 구분 + 관리 + + + + {displayRows.map(({ row, depth }) => ( + updateRow(row.rowKey, patch)} + onAddChild={() => addChildRow(row)} + onDelete={() => handleDelete(row)} + /> + ))} + +
+
+ )} +
+ ); +} + +export { FnrsSharCodeTabPanel }; diff --git a/apps/as/src/pages/code-management/ui/fnrs-shar-se-row.tsx b/apps/as/src/pages/code-management/ui/fnrs-shar-se-row.tsx new file mode 100644 index 000000000..071ac16e2 --- /dev/null +++ b/apps/as/src/pages/code-management/ui/fnrs-shar-se-row.tsx @@ -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) => void; + onAddChild: () => void; + onDelete: () => void; +} + +function FnrsSharSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: FnrsSharSeRowProps) { + const levelLabel = formatCdLvNo(row.cdLvNo); + const branchPrefix = formatTreeBranchPrefix(depth); + + return ( + + +
+ {branchPrefix ? ( + {branchPrefix} + ) : null} + onChange({ fnrsSharSeCd: e.target.value })} + placeholder="코드" + maxLength={10} + disabled={!row.isNew} + className="flex-1" + /> +
+
+ + onChange({ fnrsSharSeCdNm: e.target.value })} + placeholder="코드명" + maxLength={100} + /> + + + onChange({ expln: e.target.value })} + placeholder="설명" + maxLength={1000} + /> + + + onChange({ useYn: v })} + className="flex justify-center gap-4" + > +
+ + +
+
+ + +
+
+
+ + + onChange({ sortSeq: e.target.value ? Number(e.target.value) : undefined }) + } + className="w-20" + /> + + {levelLabel} + +
+ + +
+
+
+ ); +} + +export { FnrsSharSeRowComponent }; diff --git a/apps/as/src/pages/fnrs-cnsltn/index.ts b/apps/as/src/pages/fnrs-cnsltn/index.ts new file mode 100644 index 000000000..0193bd7c8 --- /dev/null +++ b/apps/as/src/pages/fnrs-cnsltn/index.ts @@ -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"; diff --git a/apps/as/src/pages/fnrs-cnsltn/lib/approval-utils.ts b/apps/as/src/pages/fnrs-cnsltn/lib/approval-utils.ts new file mode 100644 index 000000000..d60d20b1c --- /dev/null +++ b/apps/as/src/pages/fnrs-cnsltn/lib/approval-utils.ts @@ -0,0 +1,28 @@ +import type { ElaprDocPrgrsClientResponseDto } from "@/entities/fnrs-cnsltn"; + +export interface ApproverStatusConfig { + label: string; + badgeClass: string; +} + +export const APPROVER_STATUS_MAP: Record = { + 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", + } + ); +} diff --git a/apps/as/src/pages/fnrs-cnsltn/lib/business-cost-utils.ts b/apps/as/src/pages/fnrs-cnsltn/lib/business-cost-utils.ts new file mode 100644 index 000000000..6f3c14851 --- /dev/null +++ b/apps/as/src/pages/fnrs-cnsltn/lib/business-cost-utils.ts @@ -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): 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, + allNodes: Pick[], +): boolean { + const childPrefix = `${node.treePath}${ACNTG_TREE_PATH_SEPARATOR}`; + return !allNodes.some( + (other) => other.acntgSeCd !== node.acntgSeCd && other.treePath.startsWith(childPrefix), + ); +} diff --git a/apps/as/src/pages/fnrs-cnsltn/lib/button-config.ts b/apps/as/src/pages/fnrs-cnsltn/lib/button-config.ts new file mode 100644 index 000000000..cc6d8d4b9 --- /dev/null +++ b/apps/as/src/pages/fnrs-cnsltn/lib/button-config.ts @@ -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> = { + 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 +> = { + 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(); + 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); +} diff --git a/apps/as/src/pages/fnrs-cnsltn/lib/checklist-utils.ts b/apps/as/src/pages/fnrs-cnsltn/lib/checklist-utils.ts new file mode 100644 index 000000000..e6f95db5b --- /dev/null +++ b/apps/as/src/pages/fnrs-cnsltn/lib/checklist-utils.ts @@ -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(); + 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>, + 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, + }; + }); +} diff --git a/apps/as/src/pages/fnrs-cnsltn/lib/fnrs-cnsltn-regist-schema.ts b/apps/as/src/pages/fnrs-cnsltn/lib/fnrs-cnsltn-regist-schema.ts new file mode 100644 index 000000000..332ef9e14 --- /dev/null +++ b/apps/as/src/pages/fnrs-cnsltn/lib/fnrs-cnsltn-regist-schema.ts @@ -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; + +export function formatTaskSeComCdForApi(taskSeComCd: string[]): string { + return taskSeComCd.join(","); +} + +export function parseTaskSeComCdFromApi(taskSeComCd?: string): string[] { + if (!taskSeComCd) return []; + return taskSeComCd.split(",").filter(Boolean); +} diff --git a/apps/as/src/pages/fnrs-cnsltn/lib/form-data-mapper.ts b/apps/as/src/pages/fnrs-cnsltn/lib/form-data-mapper.ts new file mode 100644 index 000000000..d76887394 --- /dev/null +++ b/apps/as/src/pages/fnrs-cnsltn/lib/form-data-mapper.ts @@ -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(); + + 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", + }; +} diff --git a/apps/as/src/pages/fnrs-cnsltn/lib/review-opinion-utils.ts b/apps/as/src/pages/fnrs-cnsltn/lib/review-opinion-utils.ts new file mode 100644 index 000000000..f4c11c40a --- /dev/null +++ b/apps/as/src/pages/fnrs-cnsltn/lib/review-opinion-utils.ts @@ -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; +} diff --git a/apps/as/src/pages/fnrs-cnsltn/lib/review-step-utils.ts b/apps/as/src/pages/fnrs-cnsltn/lib/review-step-utils.ts new file mode 100644 index 000000000..bce365857 --- /dev/null +++ b/apps/as/src/pages/fnrs-cnsltn/lib/review-step-utils.ts @@ -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; +} diff --git a/apps/as/src/pages/fnrs-cnsltn/lib/save-payload-utils.ts b/apps/as/src/pages/fnrs-cnsltn/lib/save-payload-utils.ts new file mode 100644 index 000000000..3c6b5e3a6 --- /dev/null +++ b/apps/as/src/pages/fnrs-cnsltn/lib/save-payload-utils.ts @@ -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(); + + 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: [], + }; +} diff --git a/apps/as/src/pages/fnrs-cnsltn/lib/use-checklist-form-state.ts b/apps/as/src/pages/fnrs-cnsltn/lib/use-checklist-form-state.ts new file mode 100644 index 000000000..6adca3b74 --- /dev/null +++ b/apps/as/src/pages/fnrs-cnsltn/lib/use-checklist-form-state.ts @@ -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({ + 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>, + reviewStep?: ReviewStepCode, + ) => { + setFormState((prev) => patchChecklistFormState(prev, patch, cklstResultList, reviewStep)); + }, + [cklstResultList], + ); + + return { formState, setFormState, patchFormState }; +} + +export { EMPTY_CKLST_RESULT_LIST, useChecklistFormState }; diff --git a/apps/as/src/pages/instl-sncn/ui/components/progress-step-indicator.tsx b/apps/as/src/pages/fnrs-cnsltn/model/columns.tsx similarity index 51% rename from apps/as/src/pages/instl-sncn/ui/components/progress-step-indicator.tsx rename to apps/as/src/pages/fnrs-cnsltn/model/columns.tsx index e3cff5d35..ba660c342 100644 --- a/apps/as/src/pages/instl-sncn/ui/components/progress-step-indicator.tsx +++ b/apps/as/src/pages/fnrs-cnsltn/model/columns.tsx @@ -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(); 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 ( -
-

진행 단계

-
- {badges.map((badge, index) => ( - - {index > 0 && >} - {badge.label} - - ))} -
+
+ {badges.map((badge, index) => ( + + {index > 0 && >} + {badge.label} + + ))}
); } -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 }) => ( + + ), + }), + columnHelper.accessor("regDt", { + header: "신청일", + meta: { align: "center" }, + cell: ({ getValue }) => { + const value = getValue(); + return value ? dayjs(value).format("YYYY-MM-DD") : "-"; + }, + }), + ]; +} diff --git a/apps/as/src/pages/fnrs-cnsltn/model/reviewer-option.ts b/apps/as/src/pages/fnrs-cnsltn/model/reviewer-option.ts new file mode 100644 index 000000000..2c9a72ef8 --- /dev/null +++ b/apps/as/src/pages/fnrs-cnsltn/model/reviewer-option.ts @@ -0,0 +1,5 @@ +export interface ReviewerOption { + uuid: string; + name: string; + deptNm: string; +} diff --git a/apps/as/src/pages/fnrs-cnsltn/model/view-types.ts b/apps/as/src/pages/fnrs-cnsltn/model/view-types.ts new file mode 100644 index 000000000..db60bd696 --- /dev/null +++ b/apps/as/src/pages/fnrs-cnsltn/model/view-types.ts @@ -0,0 +1,86 @@ +import type { + AtflMngClientResponseDto, + BizCstTreeDto, + BizSclTreeDto, + CklstRvwRsltTreeDto, + ElaprDocPrgrsClientResponseDto, + EsntlAtchFileMngTreeResponseDto, + FnrsCnsltnMngDetResponseDto, + FnrsCnsltnMngResponseDto, + FnrsCnsltnRvwOpnnResponseDto, +} from "@/entities/fnrs-cnsltn"; + +export type ReviewStepCode = "LOCAL" | "ENV" | "KECO"; + +export type { + AtflMngClientResponseDto, + BizCstTreeDto, + BizSclTreeDto, + CklstRvwRsltTreeDto, + ElaprDocPrgrsClientResponseDto, + EsntlAtchFileMngTreeResponseDto, + FnrsCnsltnMngDetResponseDto, + FnrsCnsltnMngResponseDto, + FnrsCnsltnRvwOpnnResponseDto, +}; + +export type ChecklistRowKey = string; + +export interface ChecklistProgress { + total: number; + completed: number; + percent: number; +} + +export const DEFAULT_CHECKLIST_PROGRESS: ChecklistProgress = { + total: 0, + completed: 0, + percent: 0, +}; + +export interface ChecklistFormState { + results: Record; + comments: Record; + progress: ChecklistProgress; +} + +export type ChecklistStepMap = Record; + +export const REVIEW_STEP_CODES: ReviewStepCode[] = ["LOCAL", "ENV", "KECO"]; + +export const REVIEW_STEP_LABELS: Record = { + LOCAL: "지자체", + ENV: "환경청", + KECO: "환경공단", +}; + +export const TASK_CODE_LABELS: Record = { + RE: "재이용수", + SP: "하수관로", + SF: "하수처리시설", +}; + +export const PBADMS_PROGRESS_ORDER = [ + "SV", + "SL", + "TA", + "ST", + "EP", + "LE", + "TP", + "ER", + "TE", + "TB", +] as const; + +export const ROLE_TO_REVIEW_STEP: Record = { + ROLE_USER05: "LOCAL", + ROLE_USER03: "ENV", + ROLE_USER01: "KECO", +}; + +export const REVIEW_STEP_ROLE_RANK: Record = { + LOCAL: 1, + ENV: 2, + KECO: 3, +}; diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/components/aprvr-picker-dialog.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/components/aprvr-picker-dialog.tsx new file mode 100644 index 000000000..dd241a514 --- /dev/null +++ b/apps/as/src/pages/fnrs-cnsltn/ui/components/aprvr-picker-dialog.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { RvwMnpwMngResponseDto, useRvwMnpwMngPage } from "@/entities/rvw-mnpw"; +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + Skeleton, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@common/components"; + +import { type ReviewerOption } from "../../model/reviewer-option"; + +const PAGE_SIZE = 500; + +interface AprvrPickerDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + atrzSeComCd: string; + onSelect: (reviewer: ReviewerOption) => void; +} + +function AprvrPickerDialog({ open, onOpenChange, atrzSeComCd, onSelect }: AprvrPickerDialogProps) { + const { data, isPending, isError } = useRvwMnpwMngPage( + { + atrzSeComCd, + page: 0, + size: PAGE_SIZE, + sort: "userUuidNm,asc", + }, + { enabled: open }, + ); + + const items = data?.content ?? []; + + function fromRvwMnpwMng(item: RvwMnpwMngResponseDto): ReviewerOption { + return { + uuid: item.userUuid, + name: item.userUuidNm ?? "", + deptNm: item.ognzNm ?? "", + }; + } + + function handleSelect(item: (typeof items)[number]) { + onSelect(fromRvwMnpwMng(item)); + onOpenChange(false); + } + + return ( + + + + 검토인력 선택 + + +
+ {isPending ? ( +
+ + +
+ ) : isError ? ( +

+ 검토인력 목록을 불러오지 못했습니다. +

+ ) : items.length === 0 ? ( +

+ 배치된 검토인력이 없습니다. +

+ ) : ( + + + + + + + + + + + 성명 + 부서 + 직위 + 직급 + 선택 + + + + {items.map((item) => ( + + {item.userUuidNm || item.userUuid} + {item.ognzNm || "-"} + {item.jbpsSeNm || "-"} + {item.jbgdSeNm || "-"} + + + + + ))} + +
+ )} +
+ + + + +
+
+ ); +} + +export { AprvrPickerDialog }; diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/components/checklist-task-panel.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/components/checklist-task-panel.tsx new file mode 100644 index 000000000..4e0100089 --- /dev/null +++ b/apps/as/src/pages/fnrs-cnsltn/ui/components/checklist-task-panel.tsx @@ -0,0 +1,164 @@ +import type { Dispatch, SetStateAction } from "react"; + +import type { CklstRvwRsltTreeDto } from "@/entities/fnrs-cnsltn"; +import { + cn, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Textarea, +} from "@common/components"; + +import { buildChecklistRowKey, patchChecklistFormState } from "../../lib/checklist-utils"; +import type { ReviewStepCode } from "../../model/view-types"; +import { type ChecklistFormState, ROLE_TO_REVIEW_STEP } from "../../model/view-types"; + +interface ChecklistTaskPanelProps { + selectedReviewStep: ReviewStepCode; + selectedRows: CklstRvwRsltTreeDto[]; + allRows: CklstRvwRsltTreeDto[]; + formState: ChecklistFormState; + setFormState: Dispatch>; + userRoleCode?: string; +} + +function ChecklistTaskPanel({ + selectedReviewStep, + selectedRows, + allRows, + formState, + setFormState, + userRoleCode, +}: ChecklistTaskPanelProps) { + const currentUserReviewStep = userRoleCode ? ROLE_TO_REVIEW_STEP[userRoleCode] : undefined; + const isStepEditable = currentUserReviewStep === selectedReviewStep; + + return ( + <> +
+ + + + + 검토항목 + + 주요 검토내용 + 검토 결과 + {selectedReviewStep !== "LOCAL" ? ( + + 기술검토 의견 + + ) : null} + + + 예 + 아니오 + + + + {selectedRows.length === 0 ? ( + + + 조회된 체크리스트 항목이 없습니다. + + + ) : ( + selectedRows.map((row) => { + const key = buildChecklistRowKey(row, selectedReviewStep); + const isFieldVisible = row.bchckUseYn === "Y"; + const isLocalReviewStep = selectedReviewStep === "LOCAL"; + + return ( + + {row.cklstArtclNm} + + {row.rvwCn} + + {isFieldVisible && ( + <> + + { + if (!isStepEditable) return; + setFormState((prev) => + patchChecklistFormState( + prev, + { results: { ...prev.results, [key]: "Y" } }, + allRows, + selectedReviewStep, + ), + ); + }} + className={cn(isStepEditable ? "cursor-pointer" : "cursor-not-allowed")} + /> + + + { + if (!isStepEditable) return; + setFormState((prev) => + patchChecklistFormState( + prev, + { results: { ...prev.results, [key]: "N" } }, + allRows, + selectedReviewStep, + ), + ); + }} + className={cn(isStepEditable ? "cursor-pointer" : "cursor-not-allowed")} + /> + + + )} + {!isLocalReviewStep && isFieldVisible && ( + +