This commit is contained in:
@@ -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<AcntgSeMngResponseDto> {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function save(body: AcntgSeMngSaveRequestDto): Promise<string> {
|
||||
const response = await client.post<string>(BASE, body);
|
||||
async function saveMulti(body: AcntgSeMngSaveRequestDto[]): Promise<AcntgSeMngResponseDto[]> {
|
||||
const response = await client.post<AcntgSeMngResponseDto[]>(BASE, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function update(acntgSeCd: string, body: AcntgSeMngSaveRequestDto): Promise<string> {
|
||||
const response = await client.put<string>(`${BASE}/${acntgSeCd}`, body);
|
||||
async function update(acntgSeCd: string, body: AcntgSeMngPutRequestDto): Promise<string> {
|
||||
const response = await client.put<string>(`${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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<PageResponse<AcntgSeMngResponseDto>>;
|
||||
findTree(): Promise<AcntgSeMngTreeDto[]>;
|
||||
findById(acntgSeCd: string): Promise<AcntgSeMngResponseDto>;
|
||||
save(body: AcntgSeMngSaveRequestDto): Promise<string>;
|
||||
update(acntgSeCd: string, body: AcntgSeMngSaveRequestDto): Promise<string>;
|
||||
saveMulti(body: AcntgSeMngSaveRequestDto[]): Promise<AcntgSeMngResponseDto[]>;
|
||||
update(acntgSeCd: string, body: AcntgSeMngPutRequestDto): Promise<string>;
|
||||
delete(acntgSeCd: string): Promise<string>;
|
||||
}
|
||||
|
||||
@@ -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<CnstpSeMngResponseDto> {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function save(body: CnstpSeMngSaveRequestDto): Promise<string> {
|
||||
const response = await client.post<string>(BASE, body);
|
||||
async function saveMulti(body: CnstpSeMngSaveRequestDto[]): Promise<CnstpSeMngResponseDto[]> {
|
||||
const response = await client.post<CnstpSeMngResponseDto[]>(BASE, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function update(cnstpSeCd: string, body: CnstpSeMngSaveRequestDto): Promise<string> {
|
||||
const response = await client.put<string>(`${BASE}/${cnstpSeCd}`, body);
|
||||
async function update(cnstpSeCd: string, body: CnstpSeMngPutRequestDto): Promise<string> {
|
||||
const response = await client.put<string>(`${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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<PageResponse<CnstpSeMngResponseDto>>;
|
||||
findTree(): Promise<CnstpSeMngTreeDto[]>;
|
||||
findById(cnstpSeCd: string): Promise<CnstpSeMngResponseDto>;
|
||||
save(body: CnstpSeMngSaveRequestDto): Promise<string>;
|
||||
update(cnstpSeCd: string, body: CnstpSeMngSaveRequestDto): Promise<string>;
|
||||
saveMulti(body: CnstpSeMngSaveRequestDto[]): Promise<CnstpSeMngResponseDto[]>;
|
||||
update(cnstpSeCd: string, body: CnstpSeMngPutRequestDto): Promise<string>;
|
||||
delete(cnstpSeCd: string): Promise<string>;
|
||||
}
|
||||
|
||||
@@ -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<string, AcntgSeBaseEntry>,
|
||||
): AcntgDisplaySlot[] {
|
||||
const rows = Object.values(baseByKey).map(toInitialRow);
|
||||
const display = buildAcntgDisplayRows(rows);
|
||||
const cdToRowKey = new Map(rows.map((row) => [row.acntgSeCd, row.rowKey]));
|
||||
|
||||
return display.map(({ row, depth }) => ({
|
||||
rowKey: row.rowKey,
|
||||
depth,
|
||||
parentRowKey: row.upAcntgSeCd?.trim() ? (cdToRowKey.get(row.upAcntgSeCd.trim()) ?? null) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
export function hydrateGridFromServer(content: AcntgSeMngResponseDto[]): AcntgSeGridState {
|
||||
const baseByKey: Record<string, AcntgSeBaseEntry> = {};
|
||||
|
||||
content.forEach((item, index) => {
|
||||
const rowKey = createLoadedRowKey(index, item.acntgSeCd);
|
||||
baseByKey[rowKey] = { ...item, rowKey };
|
||||
});
|
||||
|
||||
return {
|
||||
baseByKey,
|
||||
structure: buildStructureFromBaseRows(baseByKey),
|
||||
drafts: {},
|
||||
patches: {},
|
||||
deletedKeys: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveParentAcntgSeCd(
|
||||
state: AcntgSeGridState,
|
||||
parentRowKey: string | null,
|
||||
): string | undefined {
|
||||
if (!parentRowKey) return undefined;
|
||||
const parent = mergeAcntgRow(state, parentRowKey);
|
||||
const cd = parent?.acntgSeCd?.trim();
|
||||
return cd || undefined;
|
||||
}
|
||||
|
||||
export function mergeAcntgRow(state: AcntgSeGridState, rowKey: string): AcntgSeRow | null {
|
||||
const draft = state.drafts[rowKey];
|
||||
if (draft) {
|
||||
return {
|
||||
rowKey,
|
||||
acntgSeCd: draft.acntgSeCd,
|
||||
acntgSeCdNm: draft.acntgSeCdNm,
|
||||
expln: draft.expln,
|
||||
useYn: draft.useYn,
|
||||
cdLvNo: draft.cdLvNo,
|
||||
upAcntgSeCd: resolveParentAcntgSeCd(state, draft.parentRowKey),
|
||||
isNew: true,
|
||||
isDirty: true,
|
||||
isDeleted: false,
|
||||
};
|
||||
}
|
||||
|
||||
const base = state.baseByKey[rowKey];
|
||||
if (!base) return null;
|
||||
|
||||
const patch = state.patches[rowKey] ?? {};
|
||||
const hasPatch = Object.keys(patch).length > 0;
|
||||
|
||||
return {
|
||||
...base,
|
||||
...patch,
|
||||
rowKey,
|
||||
isNew: false,
|
||||
isDirty: hasPatch,
|
||||
isDeleted: state.deletedKeys.has(rowKey),
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeAllAcntgRows(state: AcntgSeGridState): AcntgSeRow[] {
|
||||
const keys = new Set([...Object.keys(state.baseByKey), ...Object.keys(state.drafts)]);
|
||||
|
||||
return [...keys]
|
||||
.map((rowKey) => mergeAcntgRow(state, rowKey))
|
||||
.filter((row): row is AcntgSeRow => row != null);
|
||||
}
|
||||
|
||||
export function hasAcntgGridPendingChanges(state: AcntgSeGridState): boolean {
|
||||
return (
|
||||
state.deletedKeys.size > 0 ||
|
||||
Object.keys(state.drafts).length > 0 ||
|
||||
Object.keys(state.patches).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function collectDescendantRowKeys(
|
||||
structure: AcntgDisplaySlot[],
|
||||
rootRowKey: string,
|
||||
): Set<string> {
|
||||
const result = new Set<string>([rootRowKey]);
|
||||
const queue = [rootRowKey];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const parentKey = queue.shift()!;
|
||||
for (const slot of structure) {
|
||||
if (slot.parentRowKey === parentKey && !result.has(slot.rowKey)) {
|
||||
result.add(slot.rowKey);
|
||||
queue.push(slot.rowKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function 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<string>,
|
||||
): AcntgDisplaySlot[] {
|
||||
return structure.filter((slot) => !rowKeysToRemove.has(slot.rowKey));
|
||||
}
|
||||
|
||||
export function patchAcntgGridRow(
|
||||
state: AcntgSeGridState,
|
||||
rowKey: string,
|
||||
patch: Partial<AcntgSeRow>,
|
||||
): AcntgSeGridState {
|
||||
const draft = state.drafts[rowKey];
|
||||
if (draft) {
|
||||
return {
|
||||
...state,
|
||||
drafts: {
|
||||
...state.drafts,
|
||||
[rowKey]: {
|
||||
...draft,
|
||||
...(patch.acntgSeCd !== undefined ? { acntgSeCd: patch.acntgSeCd } : {}),
|
||||
...(patch.acntgSeCdNm !== undefined ? { acntgSeCdNm: patch.acntgSeCdNm ?? "" } : {}),
|
||||
...(patch.expln !== undefined ? { expln: patch.expln ?? "" } : {}),
|
||||
...(patch.useYn !== undefined ? { useYn: patch.useYn } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { rowKey: _rk, isNew: _n, isDirty: _d, isDeleted: _del, ...dtoPatch } = patch;
|
||||
|
||||
return {
|
||||
...state,
|
||||
patches: {
|
||||
...state.patches,
|
||||
[rowKey]: { ...state.patches[rowKey], ...dtoPatch },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function markAcntgRowsDeleted(
|
||||
state: AcntgSeGridState,
|
||||
rootRowKey: string,
|
||||
): AcntgSeGridState {
|
||||
const keysToDelete = collectDescendantRowKeys(state.structure, rootRowKey);
|
||||
const deletedKeys = new Set(state.deletedKeys);
|
||||
for (const key of keysToDelete) {
|
||||
deletedKeys.add(key);
|
||||
}
|
||||
return { ...state, deletedKeys };
|
||||
}
|
||||
|
||||
export function 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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<typeof buildSaveMultiPayload>) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useAcntgSeRows({ serverContent, onAlert, onSave }: UseAcntgSeRowsOptions) {
|
||||
const [grid, setGrid] = useState<AcntgSeGridState>(createEmptyGridState);
|
||||
const hasPendingRef = useRef(false);
|
||||
|
||||
hasPendingRef.current = hasAcntgGridPendingChanges(grid);
|
||||
|
||||
useEffect(() => {
|
||||
if (serverContent == null) return;
|
||||
if (hasPendingRef.current) return;
|
||||
setGrid(hydrateGridFromServer(serverContent));
|
||||
}, [serverContent]);
|
||||
|
||||
const displayRows = useMemo(() => {
|
||||
return grid.structure
|
||||
.filter((slot) => !grid.deletedKeys.has(slot.rowKey))
|
||||
.map((slot) => {
|
||||
const row = mergeAcntgRow(grid, slot.rowKey);
|
||||
return row ? { row, depth: slot.depth } : null;
|
||||
})
|
||||
.filter((item): item is { row: AcntgSeRow; depth: number } => item != null);
|
||||
}, [grid]);
|
||||
|
||||
const canSave = hasAcntgGridPendingChanges(grid);
|
||||
|
||||
const updateRow = useCallback((rowKey: string, patch: Partial<AcntgSeRow>) => {
|
||||
setGrid((prev) => patchAcntgGridRow(prev, rowKey, patch));
|
||||
}, []);
|
||||
|
||||
const addRootRow = useCallback(() => {
|
||||
const rowKey = createDraftRowKey();
|
||||
setGrid((prev) => ({
|
||||
...prev,
|
||||
drafts: {
|
||||
...prev.drafts,
|
||||
[rowKey]: {
|
||||
rowKey,
|
||||
parentRowKey: null,
|
||||
cdLvNo: 1,
|
||||
acntgSeCd: "",
|
||||
acntgSeCdNm: "",
|
||||
expln: "",
|
||||
useYn: "Y",
|
||||
},
|
||||
},
|
||||
structure: [...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,
|
||||
};
|
||||
}
|
||||
@@ -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<string, AcntgSeBaseEntry>;
|
||||
structure: AcntgDisplaySlot[];
|
||||
drafts: Record<string, AcntgSeDraft>;
|
||||
patches: Record<string, Partial<AcntgSeMngResponseDto>>;
|
||||
deletedKeys: Set<string>;
|
||||
}
|
||||
|
||||
export interface AcntgSeDisplayItem {
|
||||
rowKey: string;
|
||||
depth: number;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { AcntgSeMngResponseDto } from "@/entities/acntg";
|
||||
|
||||
export interface AcntgSeRow extends AcntgSeMngResponseDto {
|
||||
rowKey: string;
|
||||
isNew: boolean;
|
||||
isDirty: boolean;
|
||||
isDeleted: boolean;
|
||||
}
|
||||
@@ -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<AcntgSeMngResponseDto>();
|
||||
|
||||
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 }) => (
|
||||
<div className="flex justify-center gap-2">
|
||||
<Button type="button" size="sm" color="white" onClick={() => onEdit(row.original)}>
|
||||
수정
|
||||
</Button>
|
||||
<Button type="button" size="sm" color="red" onClick={() => onDelete(row.original)}>
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -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<string>,
|
||||
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<string>();
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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<PaginationState>({
|
||||
pageIndex: DEFAULT_PAGE_INDEX,
|
||||
pageSize: DEFAULT_PAGE_SIZE,
|
||||
});
|
||||
const [dialogMode, setDialogMode] = useState<"create" | "edit" | null>(null);
|
||||
const [editingCd, setEditingCd] = useState<string | null>(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<typeof saveMultiMutation.mutateAsync>[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 (
|
||||
<div className="space-y-6 px-[1.8rem] pb-6">
|
||||
<section className="card card-border mx-0 bg-background pb-6 pt-0">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<div className="card-header flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="text-2xl font-semibold">회계코드 관리</h3>
|
||||
<Button type="button" onClick={handleOpenCreate}>
|
||||
등록
|
||||
</Button>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button type="button" onClick={addRootRow}>
|
||||
코드 추가
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color="green"
|
||||
disabled={!canSave || isSaving}
|
||||
onClick={handleSave}
|
||||
>
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{tableComponent}
|
||||
{dialogMode ? (
|
||||
<AcntgSeFormDialog
|
||||
mode={dialogMode}
|
||||
open={Boolean(dialogMode)}
|
||||
acntgSeCd={editingCd}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDialogMode(null);
|
||||
setEditingCd(null);
|
||||
}
|
||||
}}
|
||||
isSubmitting={saveMutation.isPending || updateMutation.isPending}
|
||||
onSubmit={handleFormSubmit}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{pageQuery.isPending ? (
|
||||
<div className="flex flex-col gap-2 px-4 pb-4">
|
||||
<Skeleton className="h-[4rem]" />
|
||||
<Skeleton className="h-[16rem]" />
|
||||
</div>
|
||||
) : displayRows.length === 0 ? (
|
||||
<p className="py-8 text-center text-lg text-muted-foreground">
|
||||
등록된 코드가 없습니다. [코드 추가]로 1단계 코드를 등록하세요.
|
||||
</p>
|
||||
) : (
|
||||
<Table className="w-full">
|
||||
<colgroup>
|
||||
<col width="15%" />
|
||||
<col width="20%" />
|
||||
<col width="35%" />
|
||||
<col width="12%" />
|
||||
<col width="8%" />
|
||||
<col width="10%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>코드</TableHead>
|
||||
<TableHead>코드명</TableHead>
|
||||
<TableHead>설명</TableHead>
|
||||
<TableHead>사용 여부</TableHead>
|
||||
<TableHead>구분</TableHead>
|
||||
<TableHead>관리</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{displayRows.map(({ row, depth }) => (
|
||||
<AcntgSeRowComponent
|
||||
key={row.rowKey}
|
||||
row={row}
|
||||
depth={depth}
|
||||
onChange={(patch) => updateRow(row.rowKey, patch)}
|
||||
onAddChild={() => addChildRow(row)}
|
||||
onDelete={() => handleDelete(row)}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<AcntgSeFormValues>({
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[52rem]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{mode === "create" ? "회계코드 등록" : "회계코드 수정"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-[4rem]" />
|
||||
<Skeleton className="h-[4rem]" />
|
||||
<Skeleton className="h-[4rem]" />
|
||||
</div>
|
||||
) : (
|
||||
<form className="space-y-6" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="acntgSeCd">코드</Label>
|
||||
<Input
|
||||
id="acntgSeCd"
|
||||
placeholder="코드 입력"
|
||||
maxLength={10}
|
||||
readOnly={mode === "edit"}
|
||||
{...register("acntgSeCd", { required: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="acntgSeCdNm">코드명</Label>
|
||||
<Input
|
||||
id="acntgSeCdNm"
|
||||
placeholder="코드명 입력"
|
||||
maxLength={100}
|
||||
{...register("acntgSeCdNm", { required: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="expln">설명</Label>
|
||||
<Input id="expln" placeholder="설명 입력" maxLength={1000} {...register("expln")} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>사용여부</Label>
|
||||
<RadioGroup
|
||||
value={useYn}
|
||||
onValueChange={(v) => setValue("useYn", v as "Y" | "N")}
|
||||
className="flex gap-6"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="Y" id="acntg-use-y" />
|
||||
<Label htmlFor="acntg-use-y">사용</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="N" id="acntg-use-n" />
|
||||
<Label htmlFor="acntg-use-n">미사용</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" color="white" onClick={() => onOpenChange(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { AcntgSeFormDialog };
|
||||
@@ -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<AcntgSeRow>) => void;
|
||||
onAddChild: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function AcntgSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: AcntgSeRowProps) {
|
||||
const levelLabel = formatCdLvNo(row.cdLvNo);
|
||||
const branchPrefix = formatTreeBranchPrefix(depth);
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className="flex min-w-[10rem] items-center gap-1">
|
||||
{branchPrefix ? (
|
||||
<span className="shrink-0 font-mono text-muted">{branchPrefix}</span>
|
||||
) : null}
|
||||
<Input
|
||||
value={row.acntgSeCd}
|
||||
onChange={(e) => onChange({ acntgSeCd: e.target.value })}
|
||||
placeholder="코드"
|
||||
maxLength={10}
|
||||
readOnly={!row.isNew}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={row.acntgSeCdNm ?? ""}
|
||||
onChange={(e) => onChange({ acntgSeCdNm: e.target.value })}
|
||||
placeholder="코드명"
|
||||
maxLength={100}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={row.expln ?? ""}
|
||||
onChange={(e) => onChange({ expln: e.target.value })}
|
||||
placeholder="설명"
|
||||
maxLength={1000}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<RadioGroup
|
||||
value={row.useYn === "N" ? "N" : "Y"}
|
||||
onValueChange={(v) => onChange({ useYn: v })}
|
||||
className="flex justify-center gap-4"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="Y" id={`${row.rowKey}-use-y`} />
|
||||
<Label htmlFor={`${row.rowKey}-use-y`}>예</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="N" id={`${row.rowKey}-use-n`} />
|
||||
<Label htmlFor={`${row.rowKey}-use-n`}>아니오</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</TableCell>
|
||||
<TableCell align="center">{levelLabel}</TableCell>
|
||||
<TableCell align="center">
|
||||
<div className="flex justify-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="white"
|
||||
disabled={row.isNew || !canAddChildRow()}
|
||||
onClick={onAddChild}
|
||||
>
|
||||
하위추가
|
||||
</Button>
|
||||
<Button type="button" size="sm" color="red" onClick={onDelete}>
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export { AcntgSeRowComponent };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string, CnstpSeBaseEntry>,
|
||||
): CnstpDisplaySlot[] {
|
||||
const rows = Object.values(baseByKey).map(toInitialRow);
|
||||
const display = buildCnstpDisplayRows(rows);
|
||||
const cdToRowKey = new Map(rows.map((row) => [row.cnstpSeCd, row.rowKey]));
|
||||
|
||||
return display.map(({ row, depth }) => ({
|
||||
rowKey: row.rowKey,
|
||||
depth,
|
||||
parentRowKey: row.upCnstpCd?.trim() ? (cdToRowKey.get(row.upCnstpCd.trim()) ?? null) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
export function hydrateGridFromServer(content: CnstpSeMngResponseDto[]): CnstpSeGridState {
|
||||
const baseByKey: Record<string, CnstpSeBaseEntry> = {};
|
||||
|
||||
content.forEach((item, index) => {
|
||||
const rowKey = createLoadedRowKey(index, item.cnstpSeCd);
|
||||
baseByKey[rowKey] = { ...item, rowKey };
|
||||
});
|
||||
|
||||
return {
|
||||
baseByKey,
|
||||
structure: buildStructureFromBaseRows(baseByKey),
|
||||
drafts: {},
|
||||
patches: {},
|
||||
deletedKeys: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveParentCnstpSeCd(
|
||||
state: CnstpSeGridState,
|
||||
parentRowKey: string | null,
|
||||
): string | undefined {
|
||||
if (!parentRowKey) return undefined;
|
||||
const parent = mergeCnstpRow(state, parentRowKey);
|
||||
const cd = parent?.cnstpSeCd?.trim();
|
||||
return cd || undefined;
|
||||
}
|
||||
|
||||
export function mergeCnstpRow(state: CnstpSeGridState, rowKey: string): CnstpSeRow | null {
|
||||
const draft = state.drafts[rowKey];
|
||||
if (draft) {
|
||||
return {
|
||||
rowKey,
|
||||
cnstpSeCd: draft.cnstpSeCd,
|
||||
cnstpSeCdNm: draft.cnstpSeCdNm,
|
||||
expln: draft.expln,
|
||||
useYn: draft.useYn,
|
||||
cdLvNo: draft.cdLvNo,
|
||||
upCnstpCd: resolveParentCnstpSeCd(state, draft.parentRowKey),
|
||||
isNew: true,
|
||||
isDirty: true,
|
||||
isDeleted: false,
|
||||
};
|
||||
}
|
||||
|
||||
const base = state.baseByKey[rowKey];
|
||||
if (!base) return null;
|
||||
|
||||
const patch = state.patches[rowKey] ?? {};
|
||||
const hasPatch = Object.keys(patch).length > 0;
|
||||
|
||||
return {
|
||||
...base,
|
||||
...patch,
|
||||
rowKey,
|
||||
isNew: false,
|
||||
isDirty: hasPatch,
|
||||
isDeleted: state.deletedKeys.has(rowKey),
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeAllCnstpRows(state: CnstpSeGridState): CnstpSeRow[] {
|
||||
const keys = new Set([...Object.keys(state.baseByKey), ...Object.keys(state.drafts)]);
|
||||
|
||||
return [...keys]
|
||||
.map((rowKey) => mergeCnstpRow(state, rowKey))
|
||||
.filter((row): row is CnstpSeRow => row != null);
|
||||
}
|
||||
|
||||
export function hasCnstpGridPendingChanges(state: CnstpSeGridState): boolean {
|
||||
return (
|
||||
state.deletedKeys.size > 0 ||
|
||||
Object.keys(state.drafts).length > 0 ||
|
||||
Object.keys(state.patches).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function collectDescendantRowKeys(
|
||||
structure: CnstpDisplaySlot[],
|
||||
rootRowKey: string,
|
||||
): Set<string> {
|
||||
const result = new Set<string>([rootRowKey]);
|
||||
const queue = [rootRowKey];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const parentKey = queue.shift()!;
|
||||
for (const slot of structure) {
|
||||
if (slot.parentRowKey === parentKey && !result.has(slot.rowKey)) {
|
||||
result.add(slot.rowKey);
|
||||
queue.push(slot.rowKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function 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<string>,
|
||||
): CnstpDisplaySlot[] {
|
||||
return structure.filter((slot) => !rowKeysToRemove.has(slot.rowKey));
|
||||
}
|
||||
|
||||
export function patchCnstpGridRow(
|
||||
state: CnstpSeGridState,
|
||||
rowKey: string,
|
||||
patch: Partial<CnstpSeRow>,
|
||||
): CnstpSeGridState {
|
||||
const draft = state.drafts[rowKey];
|
||||
if (draft) {
|
||||
return {
|
||||
...state,
|
||||
drafts: {
|
||||
...state.drafts,
|
||||
[rowKey]: {
|
||||
...draft,
|
||||
...(patch.cnstpSeCd !== undefined ? { cnstpSeCd: patch.cnstpSeCd } : {}),
|
||||
...(patch.cnstpSeCdNm !== undefined ? { cnstpSeCdNm: patch.cnstpSeCdNm ?? "" } : {}),
|
||||
...(patch.expln !== undefined ? { expln: patch.expln ?? "" } : {}),
|
||||
...(patch.useYn !== undefined ? { useYn: patch.useYn } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { rowKey: _rk, isNew: _n, isDirty: _d, isDeleted: _del, ...dtoPatch } = patch;
|
||||
|
||||
return {
|
||||
...state,
|
||||
patches: {
|
||||
...state.patches,
|
||||
[rowKey]: { ...state.patches[rowKey], ...dtoPatch },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function markCnstpRowsDeleted(
|
||||
state: CnstpSeGridState,
|
||||
rootRowKey: string,
|
||||
): CnstpSeGridState {
|
||||
const keysToDelete = collectDescendantRowKeys(state.structure, rootRowKey);
|
||||
const deletedKeys = new Set(state.deletedKeys);
|
||||
for (const key of keysToDelete) {
|
||||
deletedKeys.add(key);
|
||||
}
|
||||
return { ...state, deletedKeys };
|
||||
}
|
||||
|
||||
export function 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),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<typeof buildSaveMultiPayload>) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useCnstpSeRows({ serverContent, onAlert, onSave }: UseCnstpSeRowsOptions) {
|
||||
const [grid, setGrid] = useState<CnstpSeGridState>(createEmptyGridState);
|
||||
const hasPendingRef = useRef(false);
|
||||
|
||||
hasPendingRef.current = hasCnstpGridPendingChanges(grid);
|
||||
|
||||
useEffect(() => {
|
||||
if (serverContent == null) return;
|
||||
if (hasPendingRef.current) return;
|
||||
setGrid(hydrateGridFromServer(serverContent));
|
||||
}, [serverContent]);
|
||||
|
||||
const displayRows = useMemo(() => {
|
||||
return grid.structure
|
||||
.filter((slot) => !grid.deletedKeys.has(slot.rowKey))
|
||||
.map((slot) => {
|
||||
const row = mergeCnstpRow(grid, slot.rowKey);
|
||||
return row ? { row, depth: slot.depth } : null;
|
||||
})
|
||||
.filter((item): item is { row: CnstpSeRow; depth: number } => item != null);
|
||||
}, [grid]);
|
||||
|
||||
const canSave = hasCnstpGridPendingChanges(grid);
|
||||
|
||||
const updateRow = useCallback((rowKey: string, patch: Partial<CnstpSeRow>) => {
|
||||
setGrid((prev) => patchCnstpGridRow(prev, rowKey, patch));
|
||||
}, []);
|
||||
|
||||
const addRootRow = useCallback(() => {
|
||||
const rowKey = createDraftRowKey();
|
||||
setGrid((prev) => ({
|
||||
...prev,
|
||||
drafts: {
|
||||
...prev.drafts,
|
||||
[rowKey]: {
|
||||
rowKey,
|
||||
parentRowKey: null,
|
||||
cdLvNo: 1,
|
||||
cnstpSeCd: "",
|
||||
cnstpSeCdNm: "",
|
||||
expln: "",
|
||||
useYn: "Y",
|
||||
},
|
||||
},
|
||||
structure: [...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,
|
||||
};
|
||||
}
|
||||
@@ -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<string>,
|
||||
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<string>();
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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<string, CnstpSeBaseEntry>;
|
||||
structure: CnstpDisplaySlot[];
|
||||
drafts: Record<string, CnstpSeDraft>;
|
||||
patches: Record<string, Partial<CnstpSeMngResponseDto>>;
|
||||
deletedKeys: Set<string>;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { CnstpSeMngResponseDto } from "@/entities/cnstp";
|
||||
|
||||
export interface CnstpSeRow extends CnstpSeMngResponseDto {
|
||||
rowKey: string;
|
||||
isNew: boolean;
|
||||
isDirty: boolean;
|
||||
isDeleted: boolean;
|
||||
}
|
||||
@@ -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<CnstpSeMngResponseDto>();
|
||||
|
||||
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 }) => (
|
||||
<div className="flex justify-center gap-2">
|
||||
<Button type="button" size="sm" color="white" onClick={() => onEdit(row.original)}>
|
||||
수정
|
||||
</Button>
|
||||
<Button type="button" size="sm" color="red" onClick={() => onDelete(row.original)}>
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -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<PaginationState>({
|
||||
pageIndex: DEFAULT_PAGE_INDEX,
|
||||
pageSize: DEFAULT_PAGE_SIZE,
|
||||
});
|
||||
const [dialogMode, setDialogMode] = useState<"create" | "edit" | null>(null);
|
||||
const [editingCd, setEditingCd] = useState<string | null>(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<typeof saveMultiMutation.mutateAsync>[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 (
|
||||
<div className="space-y-6 px-[1.8rem] pb-6">
|
||||
<section className="card card-border mx-0 bg-background pb-6 pt-0">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<div className="card-header flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="text-2xl font-semibold">공종코드 관리</h3>
|
||||
<Button type="button" onClick={handleOpenCreate}>
|
||||
등록
|
||||
</Button>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button type="button" onClick={addRootRow}>
|
||||
코드 추가
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color="green"
|
||||
disabled={!canSave || isSaving}
|
||||
onClick={handleSave}
|
||||
>
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{tableComponent}
|
||||
{dialogMode ? (
|
||||
<CnstpSeFormDialog
|
||||
mode={dialogMode}
|
||||
open={Boolean(dialogMode)}
|
||||
cnstpSeCd={editingCd}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDialogMode(null);
|
||||
setEditingCd(null);
|
||||
}
|
||||
}}
|
||||
isSubmitting={saveMutation.isPending || updateMutation.isPending}
|
||||
onSubmit={handleFormSubmit}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{pageQuery.isPending ? (
|
||||
<div className="flex flex-col gap-2 px-4 pb-4">
|
||||
<Skeleton className="h-[4rem]" />
|
||||
<Skeleton className="h-[16rem]" />
|
||||
</div>
|
||||
) : displayRows.length === 0 ? (
|
||||
<p className="py-8 text-center text-lg text-muted-foreground">
|
||||
등록된 코드가 없습니다. [코드 추가]로 1단계 코드를 등록하세요.
|
||||
</p>
|
||||
) : (
|
||||
<Table className="w-full">
|
||||
<colgroup>
|
||||
<col width="15%" />
|
||||
<col width="20%" />
|
||||
<col width="35%" />
|
||||
<col width="12%" />
|
||||
<col width="8%" />
|
||||
<col width="10%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>코드</TableHead>
|
||||
<TableHead>코드명</TableHead>
|
||||
<TableHead>설명</TableHead>
|
||||
<TableHead>사용 여부</TableHead>
|
||||
<TableHead>구분</TableHead>
|
||||
<TableHead>관리</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{displayRows.map(({ row, depth }) => (
|
||||
<CnstpSeRowComponent
|
||||
key={row.rowKey}
|
||||
row={row}
|
||||
depth={depth}
|
||||
onChange={(patch) => updateRow(row.rowKey, patch)}
|
||||
onAddChild={() => addChildRow(row)}
|
||||
onDelete={() => handleDelete(row)}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<CnstpSeFormValues>({
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[52rem]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{mode === "create" ? "공종코드 등록" : "공종코드 수정"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-[4rem]" />
|
||||
<Skeleton className="h-[4rem]" />
|
||||
<Skeleton className="h-[4rem]" />
|
||||
</div>
|
||||
) : (
|
||||
<form className="space-y-6" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cnstpSeCd">코드</Label>
|
||||
<Input
|
||||
id="cnstpSeCd"
|
||||
placeholder="코드 입력"
|
||||
maxLength={10}
|
||||
readOnly={mode === "edit"}
|
||||
{...register("cnstpSeCd", { required: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cnstpSeCdNm">코드명</Label>
|
||||
<Input
|
||||
id="cnstpSeCdNm"
|
||||
placeholder="코드명 입력"
|
||||
maxLength={100}
|
||||
{...register("cnstpSeCdNm")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="expln">설명</Label>
|
||||
<Input id="expln" placeholder="설명 입력" maxLength={1000} {...register("expln")} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>사용여부</Label>
|
||||
<RadioGroup
|
||||
value={useYn}
|
||||
onValueChange={(v) => setValue("useYn", v as "Y" | "N")}
|
||||
className="flex gap-6"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="Y" id="cnstp-use-y" />
|
||||
<Label htmlFor="cnstp-use-y">사용</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="N" id="cnstp-use-n" />
|
||||
<Label htmlFor="cnstp-use-n">미사용</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" color="white" onClick={() => onOpenChange(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { CnstpSeFormDialog };
|
||||
@@ -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<CnstpSeRow>) => void;
|
||||
onAddChild: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function CnstpSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: CnstpSeRowProps) {
|
||||
const levelLabel = formatCdLvNo(row.cdLvNo);
|
||||
const branchPrefix = formatTreeBranchPrefix(depth);
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className="flex min-w-[10rem] items-center gap-1">
|
||||
{branchPrefix ? (
|
||||
<span className="shrink-0 font-mono text-muted">{branchPrefix}</span>
|
||||
) : null}
|
||||
<Input
|
||||
value={row.cnstpSeCd}
|
||||
onChange={(e) => onChange({ cnstpSeCd: e.target.value })}
|
||||
placeholder="코드"
|
||||
maxLength={10}
|
||||
readOnly={!row.isNew}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={row.cnstpSeCdNm ?? ""}
|
||||
onChange={(e) => onChange({ cnstpSeCdNm: e.target.value })}
|
||||
placeholder="코드명"
|
||||
maxLength={100}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={row.expln ?? ""}
|
||||
onChange={(e) => onChange({ expln: e.target.value })}
|
||||
placeholder="설명"
|
||||
maxLength={1000}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<RadioGroup
|
||||
value={row.useYn === "N" ? "N" : "Y"}
|
||||
onValueChange={(v) => onChange({ useYn: v })}
|
||||
className="flex justify-center gap-4"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="Y" id={`${row.rowKey}-use-y`} />
|
||||
<Label htmlFor={`${row.rowKey}-use-y`}>예</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="N" id={`${row.rowKey}-use-n`} />
|
||||
<Label htmlFor={`${row.rowKey}-use-n`}>아니오</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</TableCell>
|
||||
<TableCell align="center">{levelLabel}</TableCell>
|
||||
<TableCell align="center">
|
||||
<div className="flex justify-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="white"
|
||||
disabled={row.isNew || !canAddChildRow()}
|
||||
onClick={onAddChild}
|
||||
>
|
||||
하위추가
|
||||
</Button>
|
||||
<Button type="button" size="sm" color="red" onClick={onDelete}>
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export { CnstpSeRowComponent };
|
||||
Reference in New Issue
Block a user