From aee34495787c075622b7f249725daf43221d089f Mon Sep 17 00:00:00 2001 From: bicochan Date: Thu, 4 Jun 2026 17:49:00 +0900 Subject: [PATCH] =?UTF-8?q?[as]=20feat:=20=EA=B3=B5=EC=A2=85=EC=BD=94?= =?UTF-8?q?=EB=93=9C=EA=B4=80=EB=A6=AC,=20=ED=9A=8C=EA=B3=84=EC=BD=94?= =?UTF-8?q?=EB=93=9C=EA=B4=80=EB=A6=AC=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/entities/acntg/api/acntg-service.ts | 28 ++- apps/as/src/entities/acntg/api/queries.ts | 12 +- apps/as/src/entities/acntg/index.ts | 4 +- .../entities/acntg/model/acntg-interface.ts | 23 +- .../src/entities/cnstp/api/cnstp-service.ts | 28 ++- apps/as/src/entities/cnstp/api/queries.ts | 12 +- apps/as/src/entities/cnstp/index.ts | 4 +- .../entities/cnstp/model/cnstp-interface.ts | 23 +- .../lib/acntg-se-grid.ts | 226 ++++++++++++++++++ .../lib/build-save-multi-payload.ts | 62 +++++ .../acntg-code-management/lib/row-state.ts | 5 + .../lib/use-acntg-se-rows.ts | 172 +++++++++++++ .../model/acntg-se-grid-types.ts | 34 +++ .../model/acntg-se-row-types.ts | 8 + .../model/acntg-se-table-columns.tsx | 61 ----- .../model/build-acntg-display-rows.ts | 77 ++++++ .../ui/acntg-code-management-page.tsx | 221 ++++++++--------- .../ui/acntg-se-form-dialog.tsx | 151 ------------ .../acntg-code-management/ui/acntg-se-row.tsx | 102 ++++++++ .../lib/build-save-multi-payload.ts | 62 +++++ .../lib/cnstp-se-grid.ts | 226 ++++++++++++++++++ .../cnstp-code-management/lib/row-state.ts | 5 + .../lib/use-cnstp-se-rows.ts | 167 +++++++++++++ .../model/build-cnstp-display-rows.ts | 77 ++++++ .../model/cnstp-se-grid-types.ts | 29 +++ .../model/cnstp-se-row-types.ts | 8 + .../model/cnstp-se-table-columns.tsx | 61 ----- .../ui/cnstp-code-management-page.tsx | 221 ++++++++--------- .../ui/cnstp-se-form-dialog.tsx | 151 ------------ .../cnstp-code-management/ui/cnstp-se-row.tsx | 102 ++++++++ 30 files changed, 1684 insertions(+), 678 deletions(-) create mode 100644 apps/as/src/pages/acntg-code-management/lib/acntg-se-grid.ts create mode 100644 apps/as/src/pages/acntg-code-management/lib/build-save-multi-payload.ts create mode 100644 apps/as/src/pages/acntg-code-management/lib/row-state.ts create mode 100644 apps/as/src/pages/acntg-code-management/lib/use-acntg-se-rows.ts create mode 100644 apps/as/src/pages/acntg-code-management/model/acntg-se-grid-types.ts create mode 100644 apps/as/src/pages/acntg-code-management/model/acntg-se-row-types.ts delete mode 100644 apps/as/src/pages/acntg-code-management/model/acntg-se-table-columns.tsx create mode 100644 apps/as/src/pages/acntg-code-management/model/build-acntg-display-rows.ts delete mode 100644 apps/as/src/pages/acntg-code-management/ui/acntg-se-form-dialog.tsx create mode 100644 apps/as/src/pages/acntg-code-management/ui/acntg-se-row.tsx create mode 100644 apps/as/src/pages/cnstp-code-management/lib/build-save-multi-payload.ts create mode 100644 apps/as/src/pages/cnstp-code-management/lib/cnstp-se-grid.ts create mode 100644 apps/as/src/pages/cnstp-code-management/lib/row-state.ts create mode 100644 apps/as/src/pages/cnstp-code-management/lib/use-cnstp-se-rows.ts create mode 100644 apps/as/src/pages/cnstp-code-management/model/build-cnstp-display-rows.ts create mode 100644 apps/as/src/pages/cnstp-code-management/model/cnstp-se-grid-types.ts create mode 100644 apps/as/src/pages/cnstp-code-management/model/cnstp-se-row-types.ts delete mode 100644 apps/as/src/pages/cnstp-code-management/model/cnstp-se-table-columns.tsx delete mode 100644 apps/as/src/pages/cnstp-code-management/ui/cnstp-se-form-dialog.tsx create mode 100644 apps/as/src/pages/cnstp-code-management/ui/cnstp-se-row.tsx diff --git a/apps/as/src/entities/acntg/api/acntg-service.ts b/apps/as/src/entities/acntg/api/acntg-service.ts index 354ad35f5..957113dec 100644 --- a/apps/as/src/entities/acntg/api/acntg-service.ts +++ b/apps/as/src/entities/acntg/api/acntg-service.ts @@ -2,12 +2,14 @@ import { client } from "@/shared/api"; import { ENDPOINT } from "@/shared/config"; import type { PageResponse } from "@/shared/model"; -import type { - AcntgSeMngResponseDto, - AcntgSeMngSaveRequestDto, - AcntgSeMngSearchDto, - AcntgSeMngService, - AcntgSeMngTreeDto, +import { + ACNTG_SE_SAVE_MODE, + type AcntgSeMngPutRequestDto, + type AcntgSeMngResponseDto, + type AcntgSeMngSaveRequestDto, + type AcntgSeMngSearchDto, + type AcntgSeMngService, + type AcntgSeMngTreeDto, } from "../model/acntg-interface"; const BASE = ENDPOINT.AS_COMMON_SERVICE.ACNTG_SE; @@ -27,13 +29,17 @@ async function findById(acntgSeCd: string): Promise { return response.data; } -async function save(body: AcntgSeMngSaveRequestDto): Promise { - const response = await client.post(BASE, body); +async function saveMulti(body: AcntgSeMngSaveRequestDto[]): Promise { + const response = await client.post(BASE, body); return response.data; } -async function update(acntgSeCd: string, body: AcntgSeMngSaveRequestDto): Promise { - const response = await client.put(`${BASE}/${acntgSeCd}`, body); +async function update(acntgSeCd: string, body: AcntgSeMngPutRequestDto): Promise { + const response = await client.put(`${BASE}/${acntgSeCd}`, { + ...body, + acntgSeCd, + mode: ACNTG_SE_SAVE_MODE.UPDATE, + }); return response.data; } @@ -46,7 +52,7 @@ export const acntgSeMngService = { findAll, findTree, findById, - save, + saveMulti, update, delete: deleteAcntgSeCd, } satisfies AcntgSeMngService; diff --git a/apps/as/src/entities/acntg/api/queries.ts b/apps/as/src/entities/acntg/api/queries.ts index ad1527b71..c2bcc7f9f 100644 --- a/apps/as/src/entities/acntg/api/queries.ts +++ b/apps/as/src/entities/acntg/api/queries.ts @@ -2,7 +2,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ENDPOINT } from "@/shared/config"; -import type { AcntgSeMngSaveRequestDto, AcntgSeMngSearchDto } from "../model/acntg-interface"; +import type { + AcntgSeMngPutRequestDto, + AcntgSeMngSaveRequestDto, + AcntgSeMngSearchDto, +} from "../model/acntg-interface"; import { acntgSeMngService } from "./acntg-service"; @@ -31,11 +35,11 @@ export function useAcntgSeMng(acntgSeCd: string | null | undefined) { }); } -export function useSaveAcntgSeMng() { +export function useSaveMultiAcntgSeMng() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (body: AcntgSeMngSaveRequestDto) => acntgSeMngService.save(body), + mutationFn: (body: AcntgSeMngSaveRequestDto[]) => acntgSeMngService.saveMulti(body), onSuccess: () => { queryClient.invalidateQueries({ queryKey: acntgSeMngBaseKey }); }, @@ -46,7 +50,7 @@ export function useUpdateAcntgSeMng() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ acntgSeCd, body }: { acntgSeCd: string; body: AcntgSeMngSaveRequestDto }) => + mutationFn: ({ acntgSeCd, body }: { acntgSeCd: string; body: AcntgSeMngPutRequestDto }) => acntgSeMngService.update(acntgSeCd, body), onSuccess: (_data, { acntgSeCd }) => { queryClient.invalidateQueries({ queryKey: acntgSeMngBaseKey }); diff --git a/apps/as/src/entities/acntg/index.ts b/apps/as/src/entities/acntg/index.ts index 48f14aa1d..bd5083917 100644 --- a/apps/as/src/entities/acntg/index.ts +++ b/apps/as/src/entities/acntg/index.ts @@ -4,7 +4,7 @@ export { useAcntgSeMngPage, useAcntgSeMngTree, useDeleteAcntgSeMng, - useSaveAcntgSeMng, + useSaveMultiAcntgSeMng, useUpdateAcntgSeMng, } from "./api/queries"; export type { @@ -13,5 +13,7 @@ export type { AcntgSeMngSearchDto, AcntgSeMngService, AcntgSeMngTreeDto, + AcntgSeSaveMode, BizCstTreeDto, } from "./model/acntg-interface"; +export { ACNTG_SE_SAVE_MODE } from "./model/acntg-interface"; diff --git a/apps/as/src/entities/acntg/model/acntg-interface.ts b/apps/as/src/entities/acntg/model/acntg-interface.ts index 006472152..c1457208d 100644 --- a/apps/as/src/entities/acntg/model/acntg-interface.ts +++ b/apps/as/src/entities/acntg/model/acntg-interface.ts @@ -1,5 +1,13 @@ import type { PageableParams, PageResponse } from "@/shared/model"; +export const ACNTG_SE_SAVE_MODE = { + CREATE: "C", + UPDATE: "U", + DELETE: "D", +} as const; + +export type AcntgSeSaveMode = (typeof ACNTG_SE_SAVE_MODE)[keyof typeof ACNTG_SE_SAVE_MODE]; + /** 백엔드 AcntgSeMngResponseDto */ export interface AcntgSeMngResponseDto { acntgSeCd: string; @@ -24,6 +32,17 @@ export interface AcntgSeMngSearchDto extends PageableParams { /** 백엔드 AcntgSeMngSaveRequestDto */ export interface AcntgSeMngSaveRequestDto { + acntgSeCd: string; + mode: AcntgSeSaveMode; + acntgSeCdNm: string; + upAcntgSeCd?: string; + cdLvNo: number; + useYn?: string; + expln?: string; +} + +/** PUT /{acntgSeCd} 요청 (mode는 서비스에서 U로 설정) */ +export interface AcntgSeMngPutRequestDto { acntgSeCd: string; acntgSeCdNm: string; upAcntgSeCd?: string; @@ -61,7 +80,7 @@ export interface AcntgSeMngService { findAll(params: AcntgSeMngSearchDto): Promise>; findTree(): Promise; findById(acntgSeCd: string): Promise; - save(body: AcntgSeMngSaveRequestDto): Promise; - update(acntgSeCd: string, body: AcntgSeMngSaveRequestDto): Promise; + saveMulti(body: AcntgSeMngSaveRequestDto[]): Promise; + update(acntgSeCd: string, body: AcntgSeMngPutRequestDto): Promise; delete(acntgSeCd: string): Promise; } diff --git a/apps/as/src/entities/cnstp/api/cnstp-service.ts b/apps/as/src/entities/cnstp/api/cnstp-service.ts index 1d40eee1a..f39be006a 100644 --- a/apps/as/src/entities/cnstp/api/cnstp-service.ts +++ b/apps/as/src/entities/cnstp/api/cnstp-service.ts @@ -2,12 +2,14 @@ import { client } from "@/shared/api"; import { ENDPOINT } from "@/shared/config"; import type { PageResponse } from "@/shared/model"; -import type { - CnstpSeMngResponseDto, - CnstpSeMngSaveRequestDto, - CnstpSeMngSearchDto, - CnstpSeMngService, - CnstpSeMngTreeDto, +import { + CNSTP_SE_SAVE_MODE, + type CnstpSeMngPutRequestDto, + type CnstpSeMngResponseDto, + type CnstpSeMngSaveRequestDto, + type CnstpSeMngSearchDto, + type CnstpSeMngService, + type CnstpSeMngTreeDto, } from "../model/cnstp-interface"; const BASE = ENDPOINT.AS_COMMON_SERVICE.CNSTP_SE; @@ -27,13 +29,17 @@ async function findById(cnstpSeCd: string): Promise { return response.data; } -async function save(body: CnstpSeMngSaveRequestDto): Promise { - const response = await client.post(BASE, body); +async function saveMulti(body: CnstpSeMngSaveRequestDto[]): Promise { + const response = await client.post(BASE, body); return response.data; } -async function update(cnstpSeCd: string, body: CnstpSeMngSaveRequestDto): Promise { - const response = await client.put(`${BASE}/${cnstpSeCd}`, body); +async function update(cnstpSeCd: string, body: CnstpSeMngPutRequestDto): Promise { + const response = await client.put(`${BASE}/${cnstpSeCd}`, { + ...body, + cnstpSeCd, + mode: CNSTP_SE_SAVE_MODE.UPDATE, + }); return response.data; } @@ -46,7 +52,7 @@ export const cnstpSeMngService = { findAll, findTree, findById, - save, + saveMulti, update, delete: deleteCnstpSeCd, } satisfies CnstpSeMngService; diff --git a/apps/as/src/entities/cnstp/api/queries.ts b/apps/as/src/entities/cnstp/api/queries.ts index 703f82679..2ebb44dea 100644 --- a/apps/as/src/entities/cnstp/api/queries.ts +++ b/apps/as/src/entities/cnstp/api/queries.ts @@ -2,7 +2,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ENDPOINT } from "@/shared/config"; -import type { CnstpSeMngSaveRequestDto, CnstpSeMngSearchDto } from "../model/cnstp-interface"; +import type { + CnstpSeMngPutRequestDto, + CnstpSeMngSaveRequestDto, + CnstpSeMngSearchDto, +} from "../model/cnstp-interface"; import { cnstpSeMngService } from "./cnstp-service"; @@ -31,11 +35,11 @@ export function useCnstpSeMng(cnstpSeCd: string | null | undefined) { }); } -export function useSaveCnstpSeMng() { +export function useSaveMultiCnstpSeMng() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (body: CnstpSeMngSaveRequestDto) => cnstpSeMngService.save(body), + mutationFn: (body: CnstpSeMngSaveRequestDto[]) => cnstpSeMngService.saveMulti(body), onSuccess: () => { queryClient.invalidateQueries({ queryKey: cnstpSeMngBaseKey }); }, @@ -46,7 +50,7 @@ export function useUpdateCnstpSeMng() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ cnstpSeCd, body }: { cnstpSeCd: string; body: CnstpSeMngSaveRequestDto }) => + mutationFn: ({ cnstpSeCd, body }: { cnstpSeCd: string; body: CnstpSeMngPutRequestDto }) => cnstpSeMngService.update(cnstpSeCd, body), onSuccess: (_data, { cnstpSeCd }) => { queryClient.invalidateQueries({ queryKey: cnstpSeMngBaseKey }); diff --git a/apps/as/src/entities/cnstp/index.ts b/apps/as/src/entities/cnstp/index.ts index 3f585c1d6..ea76468b3 100644 --- a/apps/as/src/entities/cnstp/index.ts +++ b/apps/as/src/entities/cnstp/index.ts @@ -4,7 +4,7 @@ export { useCnstpSeMngPage, useCnstpSeMngTree, useDeleteCnstpSeMng, - useSaveCnstpSeMng, + useSaveMultiCnstpSeMng, useUpdateCnstpSeMng, } from "./api/queries"; export type { @@ -14,4 +14,6 @@ export type { CnstpSeMngSearchDto, CnstpSeMngService, CnstpSeMngTreeDto, + CnstpSeSaveMode, } from "./model/cnstp-interface"; +export { CNSTP_SE_SAVE_MODE } from "./model/cnstp-interface"; diff --git a/apps/as/src/entities/cnstp/model/cnstp-interface.ts b/apps/as/src/entities/cnstp/model/cnstp-interface.ts index fad6d983d..def16901a 100644 --- a/apps/as/src/entities/cnstp/model/cnstp-interface.ts +++ b/apps/as/src/entities/cnstp/model/cnstp-interface.ts @@ -1,5 +1,13 @@ import type { PageableParams, PageResponse } from "@/shared/model"; +export const CNSTP_SE_SAVE_MODE = { + CREATE: "C", + UPDATE: "U", + DELETE: "D", +} as const; + +export type CnstpSeSaveMode = (typeof CNSTP_SE_SAVE_MODE)[keyof typeof CNSTP_SE_SAVE_MODE]; + /** 백엔드 CnstpSeMngResponseDto */ export interface CnstpSeMngResponseDto { cnstpSeCd: string; @@ -24,6 +32,17 @@ export interface CnstpSeMngSearchDto extends PageableParams { /** 백엔드 CnstpSeMngSaveRequestDto */ export interface CnstpSeMngSaveRequestDto { + cnstpSeCd: string; + mode: CnstpSeSaveMode; + cnstpSeCdNm?: string; + upCnstpCd?: string; + cdLvNo?: number; + useYn?: string; + expln?: string; +} + +/** PUT /{cnstpSeCd} 요청 (mode는 서비스에서 U로 설정) */ +export interface CnstpSeMngPutRequestDto { cnstpSeCd: string; cnstpSeCdNm?: string; upCnstpCd?: string; @@ -61,7 +80,7 @@ export interface CnstpSeMngService { findAll(params: CnstpSeMngSearchDto): Promise>; findTree(): Promise; findById(cnstpSeCd: string): Promise; - save(body: CnstpSeMngSaveRequestDto): Promise; - update(cnstpSeCd: string, body: CnstpSeMngSaveRequestDto): Promise; + saveMulti(body: CnstpSeMngSaveRequestDto[]): Promise; + update(cnstpSeCd: string, body: CnstpSeMngPutRequestDto): Promise; delete(cnstpSeCd: string): Promise; } 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 new file mode 100644 index 000000000..1b7eaa6bf --- /dev/null +++ b/apps/as/src/pages/acntg-code-management/lib/acntg-se-grid.ts @@ -0,0 +1,226 @@ +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 insertChildSlot( + structure: AcntgDisplaySlot[], + parentRowKey: string, + slot: AcntgDisplaySlot, +): AcntgDisplaySlot[] { + const parentIndex = structure.findIndex((item) => item.rowKey === parentRowKey); + if (parentIndex === -1) return [...structure, slot]; + + const parentDepth = structure[parentIndex].depth; + let insertAt = parentIndex + 1; + while (insertAt < structure.length && structure[insertAt].depth > parentDepth) { + insertAt += 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 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 new file mode 100644 index 000000000..f18634b51 --- /dev/null +++ b/apps/as/src/pages/acntg-code-management/lib/build-save-multi-payload.ts @@ -0,0 +1,62 @@ +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 new file mode 100644 index 000000000..908df1f70 --- /dev/null +++ b/apps/as/src/pages/acntg-code-management/lib/row-state.ts @@ -0,0 +1,5 @@ +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 new file mode 100644 index 000000000..a8c9ebc72 --- /dev/null +++ b/apps/as/src/pages/acntg-code-management/lib/use-acntg-se-rows.ts @@ -0,0 +1,172 @@ +"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, + markAcntgRowsDeleted, + mergeAcntgRow, + mergeAllAcntgRows, + patchAcntgGridRow, + removeDraftFromGrid, +} 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: [...prev.structure, { rowKey, depth: 0, parentRowKey: null }], + })); + }, []); + + const addChildRow = useCallback( + (parent: AcntgSeRow) => { + if (parent.isNew) { + onAlert("상위 행을 먼저 저장한 후 하위 항목을 추가할 수 있습니다."); + return; + } + + 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, + }), + }; + }); + }, + [onAlert], + ); + + 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 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/model/acntg-se-grid-types.ts b/apps/as/src/pages/acntg-code-management/model/acntg-se-grid-types.ts new file mode 100644 index 000000000..a115c122d --- /dev/null +++ b/apps/as/src/pages/acntg-code-management/model/acntg-se-grid-types.ts @@ -0,0 +1,34 @@ +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; +} diff --git a/apps/as/src/pages/acntg-code-management/model/acntg-se-row-types.ts b/apps/as/src/pages/acntg-code-management/model/acntg-se-row-types.ts new file mode 100644 index 000000000..7ee397014 --- /dev/null +++ b/apps/as/src/pages/acntg-code-management/model/acntg-se-row-types.ts @@ -0,0 +1,8 @@ +import type { AcntgSeMngResponseDto } from "@/entities/acntg"; + +export interface AcntgSeRow extends AcntgSeMngResponseDto { + rowKey: string; + isNew: boolean; + isDirty: boolean; + isDeleted: boolean; +} diff --git a/apps/as/src/pages/acntg-code-management/model/acntg-se-table-columns.tsx b/apps/as/src/pages/acntg-code-management/model/acntg-se-table-columns.tsx deleted file mode 100644 index 2e4cc96f0..000000000 --- a/apps/as/src/pages/acntg-code-management/model/acntg-se-table-columns.tsx +++ /dev/null @@ -1,61 +0,0 @@ -"use client"; - -import { createColumnHelper } from "@tanstack/react-table"; - -import type { AcntgSeMngResponseDto } from "@/entities/acntg"; -import { Button } from "@common/components"; - -const columnHelper = createColumnHelper(); - -interface CreateAcntgSeColumnsOptions { - onEdit: (row: AcntgSeMngResponseDto) => void; - onDelete: (row: AcntgSeMngResponseDto) => void; -} - -function formatUseYn(value: string | undefined) { - if (value === "Y") return "사용"; - if (value === "N") return "미사용"; - return "-"; -} - -function formatOptional(value: string | undefined) { - return value?.trim() ? value : "-"; -} - -export function createAcntgSeColumns({ onEdit, onDelete }: CreateAcntgSeColumnsOptions) { - return [ - columnHelper.accessor("acntgSeCd", { - header: "코드", - meta: { align: "center" }, - }), - columnHelper.accessor("acntgSeCdNm", { - header: "코드명", - meta: { align: "left" }, - cell: ({ getValue }) => formatOptional(getValue()), - }), - columnHelper.accessor("expln", { - header: "설명", - meta: { align: "left" }, - cell: ({ getValue }) => formatOptional(getValue()), - }), - columnHelper.accessor("useYn", { - header: "사용여부", - meta: { align: "center" }, - cell: ({ getValue }) => formatUseYn(getValue()), - }), - columnHelper.display({ - id: "actions", - header: "관리", - cell: ({ row }) => ( -
- - -
- ), - }), - ]; -} diff --git a/apps/as/src/pages/acntg-code-management/model/build-acntg-display-rows.ts b/apps/as/src/pages/acntg-code-management/model/build-acntg-display-rows.ts new file mode 100644 index 000000000..e0e55d02a --- /dev/null +++ b/apps/as/src/pages/acntg-code-management/model/build-acntg-display-rows.ts @@ -0,0 +1,77 @@ +import type { AcntgSeRow } from "./acntg-se-row-types"; + +export interface AcntgSeDisplayRow { + row: AcntgSeRow; + 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: AcntgSeRow): boolean { + return (row.cdLvNo ?? 1) === 1 && !row.upAcntgSeCd?.trim(); +} + +function rowOrderIndex(rows: AcntgSeRow[], row: AcntgSeRow): number { + return rows.findIndex((candidate) => candidate.rowKey === row.rowKey); +} + +function pushDisplayRow( + result: AcntgSeDisplayRow[], + displayedRowKeys: Set, + row: AcntgSeRow, + depth: number, +) { + if (displayedRowKeys.has(row.rowKey)) return; + displayedRowKeys.add(row.rowKey); + result.push({ row, depth }); +} + +export function buildAcntgDisplayRows(rows: AcntgSeRow[]): AcntgSeDisplayRow[] { + const result: AcntgSeDisplayRow[] = []; + const displayedRowKeys = new Set(); + + const roots = rows.filter(isRootRow); + + function appendSubtree(parent: AcntgSeRow, depth: number) { + const children = rows + .filter( + (row) => + row.rowKey !== parent.rowKey && + row.upAcntgSeCd != null && + row.upAcntgSeCd === parent.acntgSeCd && + row.acntgSeCd !== parent.acntgSeCd, + ) + .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/acntg-code-management/ui/acntg-code-management-page.tsx b/apps/as/src/pages/acntg-code-management/ui/acntg-code-management-page.tsx index d3fe311a5..5cf1163b4 100644 --- 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 @@ -1,145 +1,148 @@ "use client"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback } from "react"; import { useConfirm } from "@frontend-opensource/use-react-hooks"; -import { getCoreRowModel, type PaginationState } from "@tanstack/react-table"; -import { useDataTable } from "@/shared/lib"; +import { useAlertStore } from "@/shared/lib"; +import { useAcntgSeMngPage, useSaveMultiAcntgSeMng } from "@/entities/acntg"; import { - type AcntgSeMngResponseDto, - useAcntgSeMng, - useAcntgSeMngPage, - useDeleteAcntgSeMng, - useSaveAcntgSeMng, - useUpdateAcntgSeMng, -} from "@/entities/acntg"; -import { Button } from "@common/components"; + Button, + Skeleton, + Table, + TableBody, + TableHead, + TableHeader, + TableRow, +} from "@common/components"; -import { createAcntgSeColumns } from "../model/acntg-se-table-columns"; +import { useAcntgSeRows } from "../lib/use-acntg-se-rows"; +import type { AcntgSeRow } from "../model/acntg-se-row-types"; -import { AcntgSeFormDialog, type AcntgSeFormValues } from "./acntg-se-form-dialog"; - -const DEFAULT_PAGE_INDEX = 0; -const DEFAULT_PAGE_SIZE = 10; +import { AcntgSeRowComponent } from "./acntg-se-row"; function AcntgCodeManagementPage() { const { confirm } = useConfirm(); - const [pagination, setPagination] = useState({ - pageIndex: DEFAULT_PAGE_INDEX, - pageSize: DEFAULT_PAGE_SIZE, - }); - const [dialogMode, setDialogMode] = useState<"create" | "edit" | null>(null); - const [editingCd, setEditingCd] = useState(null); + const alert = useAlertStore((state) => state.setMessage); const pageQuery = useAcntgSeMngPage({ - page: pagination.pageIndex, - size: pagination.pageSize, + page: 0, + size: 500, + sort: "cdLvNo,asc", }); - const detailQuery = useAcntgSeMng(dialogMode === "edit" ? editingCd : null); - const saveMutation = useSaveAcntgSeMng(); - const updateMutation = useUpdateAcntgSeMng(); - const deleteMutation = useDeleteAcntgSeMng(); + const saveMultiMutation = useSaveMultiAcntgSeMng(); - const handleDelete = useCallback( - async (row: AcntgSeMngResponseDto) => { - if (!(await confirm("삭제하시겠습니까?"))) return; - await deleteMutation.mutateAsync(row.acntgSeCd); + const handlePersist = useCallback( + async (payload: Parameters[0]) => { + await saveMultiMutation.mutateAsync(payload); }, - [confirm, deleteMutation], + [saveMultiMutation], ); - const columns = useMemo( - () => - createAcntgSeColumns({ - onEdit: (row) => { - setEditingCd(row.acntgSeCd); - setDialogMode("edit"); - }, - onDelete: handleDelete, - }), - [handleDelete], - ); - - const { tableComponent } = useDataTable({ - query: pageQuery, - tableOptions: { - columns, - state: { pagination }, - onPaginationChange: (updater) => { - const next = typeof updater === "function" ? updater(pagination) : updater; - setPagination(next); - }, - getCoreRowModel: getCoreRowModel(), - manualPagination: true, - }, - dataTableOptions: { - viewPagination: true, - }, + const { + displayRows, + canSave, + updateRow, + addRootRow, + addChildRow, + removeDraft, + markDeleted, + resetFromServer, + save, + } = useAcntgSeRows({ + serverContent: pageQuery.data?.content, + onAlert: alert, + onSave: handlePersist, }); - const handleFormSubmit = async (values: AcntgSeFormValues) => { - if (!values.acntgSeCd.trim() || !values.acntgSeCdNm.trim()) return; + const handleDelete = async (row: AcntgSeRow) => { + if (!(await confirm("삭제하시겠습니까?"))) return; - if (dialogMode === "create") { - if (!(await confirm("저장하시겠습니까?"))) return; - await saveMutation.mutateAsync({ - acntgSeCd: values.acntgSeCd.trim(), - acntgSeCdNm: values.acntgSeCdNm.trim(), - expln: values.expln.trim() || undefined, - useYn: values.useYn, - cdLvNo: 1, - }); - } else if (dialogMode === "edit" && detailQuery.data) { - if (!(await confirm("수정하시겠습니까?"))) return; - await updateMutation.mutateAsync({ - acntgSeCd: detailQuery.data.acntgSeCd, - body: { - acntgSeCd: detailQuery.data.acntgSeCd, - acntgSeCdNm: values.acntgSeCdNm.trim(), - expln: values.expln.trim() || undefined, - useYn: values.useYn, - upAcntgSeCd: detailQuery.data.upAcntgSeCd, - cdLvNo: detailQuery.data.cdLvNo, - }, - }); + if (row.isNew) { + removeDraft(row.rowKey); + return; } - setDialogMode(null); - setEditingCd(null); + markDeleted(row.rowKey); }; - const handleOpenCreate = () => { - setEditingCd(null); - setDialogMode("create"); + 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 (
-
+

회계코드 관리

- +
+ + +
- {tableComponent} - {dialogMode ? ( - { - if (!open) { - setDialogMode(null); - setEditingCd(null); - } - }} - isSubmitting={saveMutation.isPending || updateMutation.isPending} - onSubmit={handleFormSubmit} - /> - ) : null} + + {pageQuery.isPending ? ( +
+ + +
+ ) : displayRows.length === 0 ? ( +

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

+ ) : ( + + + + + + + + + + + + 코드 + 코드명 + 설명 + 사용 여부 + 구분 + 관리 + + + + {displayRows.map(({ row, depth }) => ( + updateRow(row.rowKey, patch)} + onAddChild={() => addChildRow(row)} + onDelete={() => handleDelete(row)} + /> + ))} + +
+ )}
); diff --git a/apps/as/src/pages/acntg-code-management/ui/acntg-se-form-dialog.tsx b/apps/as/src/pages/acntg-code-management/ui/acntg-se-form-dialog.tsx deleted file mode 100644 index 5d6afc414..000000000 --- a/apps/as/src/pages/acntg-code-management/ui/acntg-se-form-dialog.tsx +++ /dev/null @@ -1,151 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import { useForm } from "react-hook-form"; - -import { useAcntgSeMng } from "@/entities/acntg"; -import { - Button, - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - Input, - Label, - RadioGroup, - RadioGroupItem, - Skeleton, -} from "@common/components"; - -export interface AcntgSeFormValues { - acntgSeCd: string; - acntgSeCdNm: string; - expln: string; - useYn: "Y" | "N"; -} - -interface AcntgSeFormDialogProps { - mode: "create" | "edit"; - open: boolean; - acntgSeCd?: string | null; - onOpenChange: (open: boolean) => void; - isSubmitting?: boolean; - onSubmit: (values: AcntgSeFormValues) => void; -} - -function AcntgSeFormDialog({ - mode, - open, - acntgSeCd, - onOpenChange, - isSubmitting, - onSubmit, -}: AcntgSeFormDialogProps) { - const detailQuery = useAcntgSeMng(mode === "edit" ? acntgSeCd : null); - - const { register, handleSubmit, reset, setValue, watch } = useForm({ - defaultValues: { - acntgSeCd: "", - acntgSeCdNm: "", - expln: "", - useYn: "Y", - }, - }); - - const useYn = watch("useYn"); - - useEffect(() => { - if (!open) return; - - if (mode === "edit") { - if (!detailQuery.data) return; - reset({ - acntgSeCd: detailQuery.data.acntgSeCd, - acntgSeCdNm: detailQuery.data.acntgSeCdNm ?? "", - expln: detailQuery.data.expln ?? "", - useYn: detailQuery.data.useYn === "N" ? "N" : "Y", - }); - return; - } - - reset({ - acntgSeCd: "", - acntgSeCdNm: "", - expln: "", - useYn: "Y", - }); - }, [open, mode, detailQuery.data, reset]); - - const isLoading = mode === "edit" && detailQuery.isPending; - - return ( - - - - {mode === "create" ? "회계코드 등록" : "회계코드 수정"} - - {isLoading ? ( -
- - - -
- ) : ( -
-
- - -
-
- - -
-
- - -
-
- - setValue("useYn", v as "Y" | "N")} - className="flex gap-6" - > -
- - -
-
- - -
-
-
- - - - -
- )} -
-
- ); -} - -export { AcntgSeFormDialog }; diff --git a/apps/as/src/pages/acntg-code-management/ui/acntg-se-row.tsx b/apps/as/src/pages/acntg-code-management/ui/acntg-se-row.tsx new file mode 100644 index 000000000..a660f86ad --- /dev/null +++ b/apps/as/src/pages/acntg-code-management/ui/acntg-se-row.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { + Button, + Input, + Label, + RadioGroup, + RadioGroupItem, + TableCell, + TableRow, +} from "@common/components"; + +import type { AcntgSeRow } from "../model/acntg-se-row-types"; +import { + canAddChildRow, + formatCdLvNo, + formatTreeBranchPrefix, +} from "../model/build-acntg-display-rows"; + +interface AcntgSeRowProps { + row: AcntgSeRow; + depth: number; + onChange: (patch: Partial) => void; + onAddChild: () => void; + onDelete: () => void; +} + +function AcntgSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: AcntgSeRowProps) { + const levelLabel = formatCdLvNo(row.cdLvNo); + const branchPrefix = formatTreeBranchPrefix(depth); + + return ( + + +
+ {branchPrefix ? ( + {branchPrefix} + ) : null} + onChange({ acntgSeCd: e.target.value })} + placeholder="코드" + maxLength={10} + readOnly={!row.isNew} + className="flex-1" + /> +
+
+ + onChange({ acntgSeCdNm: e.target.value })} + placeholder="코드명" + maxLength={100} + /> + + + onChange({ expln: e.target.value })} + placeholder="설명" + maxLength={1000} + /> + + + onChange({ useYn: v })} + className="flex justify-center gap-4" + > +
+ + +
+
+ + +
+
+
+ {levelLabel} + +
+ + +
+
+
+ ); +} + +export { AcntgSeRowComponent }; 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 new file mode 100644 index 000000000..20ec66853 --- /dev/null +++ b/apps/as/src/pages/cnstp-code-management/lib/build-save-multi-payload.ts @@ -0,0 +1,62 @@ +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 new file mode 100644 index 000000000..63d9bedbd --- /dev/null +++ b/apps/as/src/pages/cnstp-code-management/lib/cnstp-se-grid.ts @@ -0,0 +1,226 @@ +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 insertChildSlot( + structure: CnstpDisplaySlot[], + parentRowKey: string, + slot: CnstpDisplaySlot, +): CnstpDisplaySlot[] { + const parentIndex = structure.findIndex((item) => item.rowKey === parentRowKey); + if (parentIndex === -1) return [...structure, slot]; + + const parentDepth = structure[parentIndex].depth; + let insertAt = parentIndex + 1; + while (insertAt < structure.length && structure[insertAt].depth > parentDepth) { + insertAt += 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 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 new file mode 100644 index 000000000..b7a98aca7 --- /dev/null +++ b/apps/as/src/pages/cnstp-code-management/lib/row-state.ts @@ -0,0 +1,5 @@ +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 new file mode 100644 index 000000000..fe1976e13 --- /dev/null +++ b/apps/as/src/pages/cnstp-code-management/lib/use-cnstp-se-rows.ts @@ -0,0 +1,167 @@ +"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, + markCnstpRowsDeleted, + mergeAllCnstpRows, + mergeCnstpRow, + patchCnstpGridRow, + removeDraftFromGrid, +} 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: [...prev.structure, { rowKey, depth: 0, parentRowKey: null }], + })); + }, []); + + const addChildRow = useCallback( + (parent: CnstpSeRow) => { + if (parent.isNew) { + onAlert("상위 행을 먼저 저장한 후 하위 항목을 추가할 수 있습니다."); + return; + } + + 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, + }), + }; + }); + }, + [onAlert], + ); + + 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 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/model/build-cnstp-display-rows.ts b/apps/as/src/pages/cnstp-code-management/model/build-cnstp-display-rows.ts new file mode 100644 index 000000000..1667b2aa4 --- /dev/null +++ b/apps/as/src/pages/cnstp-code-management/model/build-cnstp-display-rows.ts @@ -0,0 +1,77 @@ +import type { CnstpSeRow } from "./cnstp-se-row-types"; + +export interface CnstpSeDisplayRow { + row: CnstpSeRow; + 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: CnstpSeRow): boolean { + return (row.cdLvNo ?? 1) === 1 && !row.upCnstpCd?.trim(); +} + +function rowOrderIndex(rows: CnstpSeRow[], row: CnstpSeRow): number { + return rows.findIndex((candidate) => candidate.rowKey === row.rowKey); +} + +function pushDisplayRow( + result: CnstpSeDisplayRow[], + displayedRowKeys: Set, + row: CnstpSeRow, + depth: number, +) { + if (displayedRowKeys.has(row.rowKey)) return; + displayedRowKeys.add(row.rowKey); + result.push({ row, depth }); +} + +export function buildCnstpDisplayRows(rows: CnstpSeRow[]): CnstpSeDisplayRow[] { + const result: CnstpSeDisplayRow[] = []; + const displayedRowKeys = new Set(); + + const roots = rows.filter(isRootRow); + + function appendSubtree(parent: CnstpSeRow, depth: number) { + const children = rows + .filter( + (row) => + row.rowKey !== parent.rowKey && + row.upCnstpCd != null && + row.upCnstpCd === parent.cnstpSeCd && + row.cnstpSeCd !== parent.cnstpSeCd, + ) + .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/cnstp-code-management/model/cnstp-se-grid-types.ts b/apps/as/src/pages/cnstp-code-management/model/cnstp-se-grid-types.ts new file mode 100644 index 000000000..7cff8922a --- /dev/null +++ b/apps/as/src/pages/cnstp-code-management/model/cnstp-se-grid-types.ts @@ -0,0 +1,29 @@ +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; +} diff --git a/apps/as/src/pages/cnstp-code-management/model/cnstp-se-row-types.ts b/apps/as/src/pages/cnstp-code-management/model/cnstp-se-row-types.ts new file mode 100644 index 000000000..509662c27 --- /dev/null +++ b/apps/as/src/pages/cnstp-code-management/model/cnstp-se-row-types.ts @@ -0,0 +1,8 @@ +import type { CnstpSeMngResponseDto } from "@/entities/cnstp"; + +export interface CnstpSeRow extends CnstpSeMngResponseDto { + rowKey: string; + isNew: boolean; + isDirty: boolean; + isDeleted: boolean; +} diff --git a/apps/as/src/pages/cnstp-code-management/model/cnstp-se-table-columns.tsx b/apps/as/src/pages/cnstp-code-management/model/cnstp-se-table-columns.tsx deleted file mode 100644 index 45dcadff8..000000000 --- a/apps/as/src/pages/cnstp-code-management/model/cnstp-se-table-columns.tsx +++ /dev/null @@ -1,61 +0,0 @@ -"use client"; - -import { createColumnHelper } from "@tanstack/react-table"; - -import type { CnstpSeMngResponseDto } from "@/entities/cnstp"; -import { Button } from "@common/components"; - -const columnHelper = createColumnHelper(); - -interface CreateCnstpSeColumnsOptions { - onEdit: (row: CnstpSeMngResponseDto) => void; - onDelete: (row: CnstpSeMngResponseDto) => void; -} - -function formatUseYn(value: string | undefined) { - if (value === "Y") return "사용"; - if (value === "N") return "미사용"; - return "-"; -} - -function formatOptional(value: string | undefined) { - return value?.trim() ? value : "-"; -} - -export function createCnstpSeColumns({ onEdit, onDelete }: CreateCnstpSeColumnsOptions) { - return [ - columnHelper.accessor("cnstpSeCd", { - header: "코드", - meta: { align: "center" }, - }), - columnHelper.accessor("cnstpSeCdNm", { - header: "코드명", - meta: { align: "left" }, - cell: ({ getValue }) => formatOptional(getValue()), - }), - columnHelper.accessor("expln", { - header: "설명", - meta: { align: "left" }, - cell: ({ getValue }) => formatOptional(getValue()), - }), - columnHelper.accessor("useYn", { - header: "사용여부", - meta: { align: "center" }, - cell: ({ getValue }) => formatUseYn(getValue()), - }), - columnHelper.display({ - id: "actions", - header: "관리", - cell: ({ row }) => ( -
- - -
- ), - }), - ]; -} 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 index 6244337b8..f278829fb 100644 --- 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 @@ -1,145 +1,148 @@ "use client"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback } from "react"; import { useConfirm } from "@frontend-opensource/use-react-hooks"; -import { getCoreRowModel, type PaginationState } from "@tanstack/react-table"; -import { useDataTable } from "@/shared/lib"; +import { useAlertStore } from "@/shared/lib"; +import { useCnstpSeMngPage, useSaveMultiCnstpSeMng } from "@/entities/cnstp"; import { - type CnstpSeMngResponseDto, - useCnstpSeMng, - useCnstpSeMngPage, - useDeleteCnstpSeMng, - useSaveCnstpSeMng, - useUpdateCnstpSeMng, -} from "@/entities/cnstp"; -import { Button } from "@common/components"; + Button, + Skeleton, + Table, + TableBody, + TableHead, + TableHeader, + TableRow, +} from "@common/components"; -import { createCnstpSeColumns } from "../model/cnstp-se-table-columns"; +import { useCnstpSeRows } from "../lib/use-cnstp-se-rows"; +import type { CnstpSeRow } from "../model/cnstp-se-row-types"; -import { CnstpSeFormDialog, type CnstpSeFormValues } from "./cnstp-se-form-dialog"; - -const DEFAULT_PAGE_INDEX = 0; -const DEFAULT_PAGE_SIZE = 10; +import { CnstpSeRowComponent } from "./cnstp-se-row"; function CnstpCodeManagementPage() { const { confirm } = useConfirm(); - const [pagination, setPagination] = useState({ - pageIndex: DEFAULT_PAGE_INDEX, - pageSize: DEFAULT_PAGE_SIZE, - }); - const [dialogMode, setDialogMode] = useState<"create" | "edit" | null>(null); - const [editingCd, setEditingCd] = useState(null); + const alert = useAlertStore((state) => state.setMessage); const pageQuery = useCnstpSeMngPage({ - page: pagination.pageIndex, - size: pagination.pageSize, + page: 0, + size: 500, + sort: "cdLvNo,asc", }); - const detailQuery = useCnstpSeMng(dialogMode === "edit" ? editingCd : null); - const saveMutation = useSaveCnstpSeMng(); - const updateMutation = useUpdateCnstpSeMng(); - const deleteMutation = useDeleteCnstpSeMng(); + const saveMultiMutation = useSaveMultiCnstpSeMng(); - const handleDelete = useCallback( - async (row: CnstpSeMngResponseDto) => { - if (!(await confirm("삭제하시겠습니까?"))) return; - await deleteMutation.mutateAsync(row.cnstpSeCd); + const handlePersist = useCallback( + async (payload: Parameters[0]) => { + await saveMultiMutation.mutateAsync(payload); }, - [confirm, deleteMutation], + [saveMultiMutation], ); - const columns = useMemo( - () => - createCnstpSeColumns({ - onEdit: (row) => { - setEditingCd(row.cnstpSeCd); - setDialogMode("edit"); - }, - onDelete: handleDelete, - }), - [handleDelete], - ); - - const { tableComponent } = useDataTable({ - query: pageQuery, - tableOptions: { - columns, - state: { pagination }, - onPaginationChange: (updater) => { - const next = typeof updater === "function" ? updater(pagination) : updater; - setPagination(next); - }, - getCoreRowModel: getCoreRowModel(), - manualPagination: true, - }, - dataTableOptions: { - viewPagination: true, - }, + const { + displayRows, + canSave, + updateRow, + addRootRow, + addChildRow, + removeDraft, + markDeleted, + resetFromServer, + save, + } = useCnstpSeRows({ + serverContent: pageQuery.data?.content, + onAlert: alert, + onSave: handlePersist, }); - const handleFormSubmit = async (values: CnstpSeFormValues) => { - if (!values.cnstpSeCd.trim()) return; + const handleDelete = async (row: CnstpSeRow) => { + if (!(await confirm("삭제하시겠습니까?"))) return; - if (dialogMode === "create") { - if (!(await confirm("저장하시겠습니까?"))) return; - await saveMutation.mutateAsync({ - cnstpSeCd: values.cnstpSeCd.trim(), - cnstpSeCdNm: values.cnstpSeCdNm.trim() || undefined, - expln: values.expln.trim() || undefined, - useYn: values.useYn, - cdLvNo: 1, - }); - } else if (dialogMode === "edit" && detailQuery.data) { - if (!(await confirm("수정하시겠습니까?"))) return; - await updateMutation.mutateAsync({ - cnstpSeCd: detailQuery.data.cnstpSeCd, - body: { - cnstpSeCd: detailQuery.data.cnstpSeCd, - cnstpSeCdNm: values.cnstpSeCdNm.trim() || undefined, - expln: values.expln.trim() || undefined, - useYn: values.useYn, - upCnstpCd: detailQuery.data.upCnstpCd, - cdLvNo: detailQuery.data.cdLvNo, - }, - }); + if (row.isNew) { + removeDraft(row.rowKey); + return; } - setDialogMode(null); - setEditingCd(null); + markDeleted(row.rowKey); }; - const handleOpenCreate = () => { - setEditingCd(null); - setDialogMode("create"); + 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 (
-
+

공종코드 관리

- +
+ + +
- {tableComponent} - {dialogMode ? ( - { - if (!open) { - setDialogMode(null); - setEditingCd(null); - } - }} - isSubmitting={saveMutation.isPending || updateMutation.isPending} - onSubmit={handleFormSubmit} - /> - ) : null} + + {pageQuery.isPending ? ( +
+ + +
+ ) : displayRows.length === 0 ? ( +

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

+ ) : ( + + + + + + + + + + + + 코드 + 코드명 + 설명 + 사용 여부 + 구분 + 관리 + + + + {displayRows.map(({ row, depth }) => ( + updateRow(row.rowKey, patch)} + onAddChild={() => addChildRow(row)} + onDelete={() => handleDelete(row)} + /> + ))} + +
+ )}
); diff --git a/apps/as/src/pages/cnstp-code-management/ui/cnstp-se-form-dialog.tsx b/apps/as/src/pages/cnstp-code-management/ui/cnstp-se-form-dialog.tsx deleted file mode 100644 index 362728cb9..000000000 --- a/apps/as/src/pages/cnstp-code-management/ui/cnstp-se-form-dialog.tsx +++ /dev/null @@ -1,151 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import { useForm } from "react-hook-form"; - -import { useCnstpSeMng } from "@/entities/cnstp"; -import { - Button, - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - Input, - Label, - RadioGroup, - RadioGroupItem, - Skeleton, -} from "@common/components"; - -export interface CnstpSeFormValues { - cnstpSeCd: string; - cnstpSeCdNm: string; - expln: string; - useYn: "Y" | "N"; -} - -interface CnstpSeFormDialogProps { - mode: "create" | "edit"; - open: boolean; - cnstpSeCd?: string | null; - onOpenChange: (open: boolean) => void; - isSubmitting?: boolean; - onSubmit: (values: CnstpSeFormValues) => void; -} - -function CnstpSeFormDialog({ - mode, - open, - cnstpSeCd, - onOpenChange, - isSubmitting, - onSubmit, -}: CnstpSeFormDialogProps) { - const detailQuery = useCnstpSeMng(mode === "edit" ? cnstpSeCd : null); - - const { register, handleSubmit, reset, setValue, watch } = useForm({ - defaultValues: { - cnstpSeCd: "", - cnstpSeCdNm: "", - expln: "", - useYn: "Y", - }, - }); - - const useYn = watch("useYn"); - - useEffect(() => { - if (!open) return; - - if (mode === "edit") { - if (!detailQuery.data) return; - reset({ - cnstpSeCd: detailQuery.data.cnstpSeCd, - cnstpSeCdNm: detailQuery.data.cnstpSeCdNm ?? "", - expln: detailQuery.data.expln ?? "", - useYn: detailQuery.data.useYn === "N" ? "N" : "Y", - }); - return; - } - - reset({ - cnstpSeCd: "", - cnstpSeCdNm: "", - expln: "", - useYn: "Y", - }); - }, [open, mode, detailQuery.data, reset]); - - const isLoading = mode === "edit" && detailQuery.isPending; - - return ( - - - - {mode === "create" ? "공종코드 등록" : "공종코드 수정"} - - {isLoading ? ( -
- - - -
- ) : ( -
-
- - -
-
- - -
-
- - -
-
- - setValue("useYn", v as "Y" | "N")} - className="flex gap-6" - > -
- - -
-
- - -
-
-
- - - - -
- )} -
-
- ); -} - -export { CnstpSeFormDialog }; diff --git a/apps/as/src/pages/cnstp-code-management/ui/cnstp-se-row.tsx b/apps/as/src/pages/cnstp-code-management/ui/cnstp-se-row.tsx new file mode 100644 index 000000000..397621a7c --- /dev/null +++ b/apps/as/src/pages/cnstp-code-management/ui/cnstp-se-row.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { + Button, + Input, + Label, + RadioGroup, + RadioGroupItem, + TableCell, + TableRow, +} from "@common/components"; + +import { + canAddChildRow, + formatCdLvNo, + formatTreeBranchPrefix, +} from "../model/build-cnstp-display-rows"; +import type { CnstpSeRow } from "../model/cnstp-se-row-types"; + +interface CnstpSeRowProps { + row: CnstpSeRow; + depth: number; + onChange: (patch: Partial) => void; + onAddChild: () => void; + onDelete: () => void; +} + +function CnstpSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: CnstpSeRowProps) { + const levelLabel = formatCdLvNo(row.cdLvNo); + const branchPrefix = formatTreeBranchPrefix(depth); + + return ( + + +
+ {branchPrefix ? ( + {branchPrefix} + ) : null} + onChange({ cnstpSeCd: e.target.value })} + placeholder="코드" + maxLength={10} + readOnly={!row.isNew} + className="flex-1" + /> +
+
+ + onChange({ cnstpSeCdNm: e.target.value })} + placeholder="코드명" + maxLength={100} + /> + + + onChange({ expln: e.target.value })} + placeholder="설명" + maxLength={1000} + /> + + + onChange({ useYn: v })} + className="flex justify-center gap-4" + > +
+ + +
+
+ + +
+
+
+ {levelLabel} + +
+ + +
+
+
+ ); +} + +export { CnstpSeRowComponent };