Merge branch 'iipbs/as/dev'
Deploy Frontend / deploy (push) Successful in 6m20s

This commit is contained in:
2026-06-04 17:49:42 +09:00
30 changed files with 1684 additions and 678 deletions
+17 -11
View File
@@ -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;
+8 -4
View File
@@ -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 });
+3 -1
View File
@@ -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>;
}
+17 -11
View File
@@ -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;
+8 -4
View File
@@ -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 });
+3 -1
View File
@@ -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 };