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/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/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 };