[as] feat: 코드관리 페이지 통합

This commit is contained in:
2026-07-02 15:35:56 +09:00
parent 0e7c9b4a96
commit 716a20cc22
39 changed files with 1677 additions and 1330 deletions
@@ -1 +0,0 @@
export { AcntgCodeManagementPage as default } from "@/pages/acntg-code-management";
@@ -1 +0,0 @@
export { CnstpCodeManagementPage as default } from "@/pages/cnstp-code-management";
@@ -0,0 +1 @@
export { CodeManagementPage as default } from "@/pages/code-management";
@@ -1 +0,0 @@
export { AcntgCodeManagementPage } from "./ui/acntg-code-management-page";
@@ -1,245 +0,0 @@
import type { AcntgSeMngResponseDto } from "@/entities/acntg";
import type {
AcntgDisplaySlot,
AcntgSeBaseEntry,
AcntgSeGridState,
} from "../model/acntg-se-grid-types";
import type { AcntgSeRow } from "../model/acntg-se-row-types";
import { buildAcntgDisplayRows } from "../model/build-acntg-display-rows";
export function createLoadedRowKey(index: number, acntgSeCd: string): string {
return `loaded-${index}-${acntgSeCd}`;
}
export function createDraftRowKey(): string {
return `draft-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
export function createEmptyGridState(): AcntgSeGridState {
return {
baseByKey: {},
structure: [],
drafts: {},
patches: {},
deletedKeys: new Set(),
};
}
function toInitialRow(entry: AcntgSeBaseEntry): AcntgSeRow {
return {
...entry,
isNew: false,
isDirty: false,
isDeleted: false,
};
}
export function buildStructureFromBaseRows(
baseByKey: Record<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 insertRootSlot(
structure: AcntgDisplaySlot[],
slot: AcntgDisplaySlot,
): AcntgDisplaySlot[] {
const firstRootIndex = structure.findIndex((item) => item.parentRowKey === null);
if (firstRootIndex === -1) return [slot, ...structure];
return [...structure.slice(0, firstRootIndex), slot, ...structure.slice(firstRootIndex)];
}
export function insertChildSlot(
structure: AcntgDisplaySlot[],
parentRowKey: string,
slot: AcntgDisplaySlot,
): AcntgDisplaySlot[] {
const parentIndex = structure.findIndex((item) => item.rowKey === parentRowKey);
if (parentIndex === -1) return [...structure, slot];
const insertAt = parentIndex + 1;
return [...structure.slice(0, insertAt), slot, ...structure.slice(insertAt)];
}
export function removeSlotsFromStructure(
structure: AcntgDisplaySlot[],
rowKeysToRemove: Set<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 validateDraftParentAcntgSeCd(
state: AcntgSeGridState,
rowKey: string,
): string | null {
const draft = state.drafts[rowKey];
if (!draft?.parentRowKey) return null;
const parent = mergeAcntgRow(state, draft.parentRowKey);
if (!parent?.acntgSeCd?.trim()) {
return "하위 항목을 추가하려면 상위 코드를 먼저 입력하세요.";
}
return null;
}
export function removeDraftFromGrid(state: AcntgSeGridState, rowKey: string): AcntgSeGridState {
const { [rowKey]: _removed, ...restDrafts } = state.drafts;
const keysToRemove = collectDescendantRowKeys(state.structure, rowKey);
return {
...state,
drafts: restDrafts,
structure: removeSlotsFromStructure(state.structure, keysToRemove),
};
}
@@ -1,62 +0,0 @@
import { ACNTG_SE_SAVE_MODE, type AcntgSeMngSaveRequestDto } from "@/entities/acntg";
import type { AcntgSeRow } from "../model/acntg-se-row-types";
import { getVisibleRows } from "./row-state";
function compareCdLvNoDesc(a: AcntgSeRow, b: AcntgSeRow): number {
return (b.cdLvNo ?? 1) - (a.cdLvNo ?? 1);
}
function compareCdLvNoAsc(a: AcntgSeRow, b: AcntgSeRow): number {
return (a.cdLvNo ?? 1) - (b.cdLvNo ?? 1);
}
export function buildSaveMultiPayload(rows: AcntgSeRow[]): AcntgSeMngSaveRequestDto[] {
const payload: AcntgSeMngSaveRequestDto[] = [];
const deletedRows = rows.filter((row) => row.isDeleted && !row.isNew).sort(compareCdLvNoDesc);
for (const row of deletedRows) {
payload.push({
mode: ACNTG_SE_SAVE_MODE.DELETE,
acntgSeCd: row.acntgSeCd,
acntgSeCdNm: row.acntgSeCdNm?.trim() || "",
upAcntgSeCd: row.upAcntgSeCd,
cdLvNo: row.cdLvNo,
useYn: row.useYn,
expln: row.expln?.trim() || undefined,
});
}
const visibleRows = getVisibleRows(rows).sort(compareCdLvNoAsc);
for (const row of visibleRows) {
if (row.isNew) {
payload.push({
mode: ACNTG_SE_SAVE_MODE.CREATE,
acntgSeCd: row.acntgSeCd.trim(),
acntgSeCdNm: row.acntgSeCdNm.trim(),
upAcntgSeCd: row.upAcntgSeCd,
cdLvNo: row.cdLvNo,
useYn: row.useYn,
expln: row.expln?.trim() || undefined,
});
continue;
}
if (row.isDirty) {
payload.push({
mode: ACNTG_SE_SAVE_MODE.UPDATE,
acntgSeCd: row.acntgSeCd.trim(),
acntgSeCdNm: row.acntgSeCdNm.trim(),
upAcntgSeCd: row.upAcntgSeCd,
cdLvNo: row.cdLvNo,
useYn: row.useYn,
expln: row.expln?.trim() || undefined,
});
}
}
return payload;
}
@@ -1,5 +0,0 @@
import type { AcntgSeRow } from "../model/acntg-se-row-types";
export function getVisibleRows(rows: AcntgSeRow[]): AcntgSeRow[] {
return rows.filter((row) => !row.isDeleted);
}
@@ -1,172 +0,0 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { AcntgSeMngResponseDto } from "@/entities/acntg";
import type { AcntgSeGridState } from "../model/acntg-se-grid-types";
import type { AcntgSeRow } from "../model/acntg-se-row-types";
import {
createDraftRowKey,
createEmptyGridState,
hasAcntgGridPendingChanges,
hydrateGridFromServer,
insertChildSlot,
insertRootSlot,
markAcntgRowsDeleted,
mergeAcntgRow,
mergeAllAcntgRows,
patchAcntgGridRow,
removeDraftFromGrid,
validateDraftParentAcntgSeCd,
} from "./acntg-se-grid";
import { buildSaveMultiPayload } from "./build-save-multi-payload";
function validateAcntgSeRow(row: AcntgSeRow): string | null {
if (!row.acntgSeCd.trim()) return "코드를 입력하세요.";
if (!row.acntgSeCdNm?.trim()) return "코드명을 입력하세요.";
const parentCd = row.upAcntgSeCd?.trim();
if (parentCd && row.acntgSeCd.trim() === parentCd) {
return "상위 코드와 동일한 코드는 사용할 수 없습니다.";
}
return null;
}
interface UseAcntgSeRowsOptions {
serverContent: AcntgSeMngResponseDto[] | undefined;
onAlert: (message: string) => void;
onSave: (payload: ReturnType<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: insertRootSlot(prev.structure, { rowKey, depth: 0, parentRowKey: null }),
}));
}, []);
const addChildRow = useCallback((parent: AcntgSeRow) => {
const rowKey = createDraftRowKey();
const slot = {
rowKey,
depth: 0,
parentRowKey: parent.rowKey,
};
setGrid((prev) => {
const parentSlot = prev.structure.find((item) => item.rowKey === parent.rowKey);
const depth = (parentSlot?.depth ?? 0) + 1;
return {
...prev,
drafts: {
...prev.drafts,
[rowKey]: {
rowKey,
parentRowKey: parent.rowKey,
cdLvNo: (parent.cdLvNo ?? 1) + 1,
acntgSeCd: "",
acntgSeCdNm: "",
expln: "",
useYn: "Y",
},
},
structure: insertChildSlot(prev.structure, parent.rowKey, {
...slot,
depth,
}),
};
});
}, []);
const removeDraft = useCallback((rowKey: string) => {
setGrid((prev) => removeDraftFromGrid(prev, rowKey));
}, []);
const markDeleted = useCallback((rowKey: string) => {
setGrid((prev) => markAcntgRowsDeleted(prev, rowKey));
}, []);
const resetFromServer = useCallback((content: AcntgSeMngResponseDto[]) => {
setGrid(hydrateGridFromServer(content));
}, []);
const save = useCallback(async () => {
const mergedRows = mergeAllAcntgRows(grid);
const payload = buildSaveMultiPayload(mergedRows);
if (payload.length === 0) return false;
for (const row of mergedRows) {
if (!row.isDeleted && (row.isNew || row.isDirty)) {
const parentCodeError = validateDraftParentAcntgSeCd(grid, row.rowKey);
if (parentCodeError) {
onAlert(parentCodeError);
return false;
}
const fieldError = validateAcntgSeRow(row);
if (fieldError) {
onAlert(fieldError);
return false;
}
}
}
await onSave(payload);
return true;
}, [grid, onAlert, onSave]);
return {
displayRows,
canSave,
updateRow,
addRootRow,
addChildRow,
removeDraft,
markDeleted,
resetFromServer,
save,
};
}
@@ -1,148 +0,0 @@
"use client";
import { useCallback } from "react";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useAlertStore } from "@/shared/lib";
import { useAcntgSeMngPage, useSaveMultiAcntgSeMng } from "@/entities/acntg";
import {
Button,
Skeleton,
Table,
TableBody,
TableHead,
TableHeader,
TableRow,
} from "@common/components";
import { useAcntgSeRows } from "../lib/use-acntg-se-rows";
import type { AcntgSeRow } from "../model/acntg-se-row-types";
import { AcntgSeRowComponent } from "./acntg-se-row";
function AcntgCodeManagementPage() {
const { confirm } = useConfirm();
const alert = useAlertStore((state) => state.setMessage);
const pageQuery = useAcntgSeMngPage({
page: 0,
size: 500,
sort: "cdLvNo,asc",
});
const saveMultiMutation = useSaveMultiAcntgSeMng();
const handlePersist = useCallback(
async (payload: Parameters<typeof saveMultiMutation.mutateAsync>[0]) => {
await saveMultiMutation.mutateAsync(payload);
},
[saveMultiMutation],
);
const {
displayRows,
canSave,
updateRow,
addRootRow,
addChildRow,
removeDraft,
markDeleted,
resetFromServer,
save,
} = useAcntgSeRows({
serverContent: pageQuery.data?.content,
onAlert: alert,
onSave: handlePersist,
});
const handleDelete = async (row: AcntgSeRow) => {
if (!(await confirm("삭제하시겠습니까?"))) return;
if (row.isNew) {
removeDraft(row.rowKey);
return;
}
markDeleted(row.rowKey);
};
const handleSave = async () => {
if (!(await confirm("저장하시겠습니까?"))) return;
const saved = await save();
if (!saved) return;
alert("저장되었습니다.");
const result = await pageQuery.refetch();
resetFromServer(result.data?.content ?? []);
};
const isSaving = saveMultiMutation.isPending;
return (
<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 flex-wrap items-center justify-between gap-3">
<h3 className="text-2xl font-semibold">회계코드 관리</h3>
<div className="flex flex-wrap items-center gap-3">
<Button type="button" color="green" onClick={addRootRow}>
코드 추가
</Button>
<Button type="button" disabled={!canSave || isSaving} onClick={handleSave}>
저장
</Button>
</div>
</div>
{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>
) : (
<div className="max-h-[60rem] overflow-y-auto pt-3">
<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>
</div>
)}
</section>
</div>
);
}
export { AcntgCodeManagementPage };
@@ -1 +0,0 @@
export { CnstpCodeManagementPage } from "./ui/cnstp-code-management-page";
@@ -1,62 +0,0 @@
import { CNSTP_SE_SAVE_MODE, type CnstpSeMngSaveRequestDto } from "@/entities/cnstp";
import type { CnstpSeRow } from "../model/cnstp-se-row-types";
import { getVisibleRows } from "./row-state";
function compareCdLvNoDesc(a: CnstpSeRow, b: CnstpSeRow): number {
return (b.cdLvNo ?? 1) - (a.cdLvNo ?? 1);
}
function compareCdLvNoAsc(a: CnstpSeRow, b: CnstpSeRow): number {
return (a.cdLvNo ?? 1) - (b.cdLvNo ?? 1);
}
export function buildSaveMultiPayload(rows: CnstpSeRow[]): CnstpSeMngSaveRequestDto[] {
const payload: CnstpSeMngSaveRequestDto[] = [];
const deletedRows = rows.filter((row) => row.isDeleted && !row.isNew).sort(compareCdLvNoDesc);
for (const row of deletedRows) {
payload.push({
mode: CNSTP_SE_SAVE_MODE.DELETE,
cnstpSeCd: row.cnstpSeCd,
cnstpSeCdNm: row.cnstpSeCdNm?.trim() || undefined,
upCnstpCd: row.upCnstpCd,
cdLvNo: row.cdLvNo,
useYn: row.useYn,
expln: row.expln?.trim() || undefined,
});
}
const visibleRows = getVisibleRows(rows).sort(compareCdLvNoAsc);
for (const row of visibleRows) {
if (row.isNew) {
payload.push({
mode: CNSTP_SE_SAVE_MODE.CREATE,
cnstpSeCd: row.cnstpSeCd.trim(),
cnstpSeCdNm: row.cnstpSeCdNm?.trim() || undefined,
upCnstpCd: row.upCnstpCd,
cdLvNo: row.cdLvNo,
useYn: row.useYn,
expln: row.expln?.trim() || undefined,
});
continue;
}
if (row.isDirty) {
payload.push({
mode: CNSTP_SE_SAVE_MODE.UPDATE,
cnstpSeCd: row.cnstpSeCd.trim(),
cnstpSeCdNm: row.cnstpSeCdNm?.trim() || undefined,
upCnstpCd: row.upCnstpCd,
cdLvNo: row.cdLvNo,
useYn: row.useYn,
expln: row.expln?.trim() || undefined,
});
}
}
return payload;
}
@@ -1,245 +0,0 @@
import type { CnstpSeMngResponseDto } from "@/entities/cnstp";
import { buildCnstpDisplayRows } from "../model/build-cnstp-display-rows";
import type {
CnstpDisplaySlot,
CnstpSeBaseEntry,
CnstpSeGridState,
} from "../model/cnstp-se-grid-types";
import type { CnstpSeRow } from "../model/cnstp-se-row-types";
export function createLoadedRowKey(index: number, cnstpSeCd: string): string {
return `loaded-${index}-${cnstpSeCd}`;
}
export function createDraftRowKey(): string {
return `draft-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
export function createEmptyGridState(): CnstpSeGridState {
return {
baseByKey: {},
structure: [],
drafts: {},
patches: {},
deletedKeys: new Set(),
};
}
function toInitialRow(entry: CnstpSeBaseEntry): CnstpSeRow {
return {
...entry,
isNew: false,
isDirty: false,
isDeleted: false,
};
}
export function buildStructureFromBaseRows(
baseByKey: Record<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 insertRootSlot(
structure: CnstpDisplaySlot[],
slot: CnstpDisplaySlot,
): CnstpDisplaySlot[] {
const firstRootIndex = structure.findIndex((item) => item.parentRowKey === null);
if (firstRootIndex === -1) return [slot, ...structure];
return [...structure.slice(0, firstRootIndex), slot, ...structure.slice(firstRootIndex)];
}
export function insertChildSlot(
structure: CnstpDisplaySlot[],
parentRowKey: string,
slot: CnstpDisplaySlot,
): CnstpDisplaySlot[] {
const parentIndex = structure.findIndex((item) => item.rowKey === parentRowKey);
if (parentIndex === -1) return [...structure, slot];
const insertAt = parentIndex + 1;
return [...structure.slice(0, insertAt), slot, ...structure.slice(insertAt)];
}
export function removeSlotsFromStructure(
structure: CnstpDisplaySlot[],
rowKeysToRemove: Set<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 validateDraftParentCnstpSeCd(
state: CnstpSeGridState,
rowKey: string,
): string | null {
const draft = state.drafts[rowKey];
if (!draft?.parentRowKey) return null;
const parent = mergeCnstpRow(state, draft.parentRowKey);
if (!parent?.cnstpSeCd?.trim()) {
return "하위 항목을 추가하려면 상위 코드를 먼저 입력하세요.";
}
return null;
}
export function removeDraftFromGrid(state: CnstpSeGridState, rowKey: string): CnstpSeGridState {
const { [rowKey]: _removed, ...restDrafts } = state.drafts;
const keysToRemove = collectDescendantRowKeys(state.structure, rowKey);
return {
...state,
drafts: restDrafts,
structure: removeSlotsFromStructure(state.structure, keysToRemove),
};
}
@@ -1,5 +0,0 @@
import type { CnstpSeRow } from "../model/cnstp-se-row-types";
export function getVisibleRows(rows: CnstpSeRow[]): CnstpSeRow[] {
return rows.filter((row) => !row.isDeleted);
}
@@ -1,167 +0,0 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { CnstpSeMngResponseDto } from "@/entities/cnstp";
import type { CnstpSeGridState } from "../model/cnstp-se-grid-types";
import type { CnstpSeRow } from "../model/cnstp-se-row-types";
import { buildSaveMultiPayload } from "./build-save-multi-payload";
import {
createDraftRowKey,
createEmptyGridState,
hasCnstpGridPendingChanges,
hydrateGridFromServer,
insertChildSlot,
insertRootSlot,
markCnstpRowsDeleted,
mergeAllCnstpRows,
mergeCnstpRow,
patchCnstpGridRow,
removeDraftFromGrid,
validateDraftParentCnstpSeCd,
} from "./cnstp-se-grid";
function validateCnstpSeRow(row: CnstpSeRow): string | null {
if (!row.cnstpSeCd.trim()) return "코드를 입력하세요.";
const parentCd = row.upCnstpCd?.trim();
if (parentCd && row.cnstpSeCd.trim() === parentCd) {
return "상위 코드와 동일한 코드는 사용할 수 없습니다.";
}
return null;
}
interface UseCnstpSeRowsOptions {
serverContent: CnstpSeMngResponseDto[] | undefined;
onAlert: (message: string) => void;
onSave: (payload: ReturnType<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: insertRootSlot(prev.structure, { rowKey, depth: 0, parentRowKey: null }),
}));
}, []);
const addChildRow = useCallback((parent: CnstpSeRow) => {
const rowKey = createDraftRowKey();
setGrid((prev) => {
const parentSlot = prev.structure.find((item) => item.rowKey === parent.rowKey);
const depth = (parentSlot?.depth ?? 0) + 1;
return {
...prev,
drafts: {
...prev.drafts,
[rowKey]: {
rowKey,
parentRowKey: parent.rowKey,
cdLvNo: (parent.cdLvNo ?? 1) + 1,
cnstpSeCd: "",
cnstpSeCdNm: "",
expln: "",
useYn: "Y",
},
},
structure: insertChildSlot(prev.structure, parent.rowKey, {
rowKey,
depth,
parentRowKey: parent.rowKey,
}),
};
});
}, []);
const removeDraft = useCallback((rowKey: string) => {
setGrid((prev) => removeDraftFromGrid(prev, rowKey));
}, []);
const markDeleted = useCallback((rowKey: string) => {
setGrid((prev) => markCnstpRowsDeleted(prev, rowKey));
}, []);
const resetFromServer = useCallback((content: CnstpSeMngResponseDto[]) => {
setGrid(hydrateGridFromServer(content));
}, []);
const save = useCallback(async () => {
const mergedRows = mergeAllCnstpRows(grid);
const payload = buildSaveMultiPayload(mergedRows);
if (payload.length === 0) return false;
for (const row of mergedRows) {
if (!row.isDeleted && (row.isNew || row.isDirty)) {
const parentCodeError = validateDraftParentCnstpSeCd(grid, row.rowKey);
if (parentCodeError) {
onAlert(parentCodeError);
return false;
}
const fieldError = validateCnstpSeRow(row);
if (fieldError) {
onAlert(fieldError);
return false;
}
}
}
await onSave(payload);
return true;
}, [grid, onAlert, onSave]);
return {
displayRows,
canSave,
updateRow,
addRootRow,
addChildRow,
removeDraft,
markDeleted,
resetFromServer,
save,
};
}
@@ -1,148 +0,0 @@
"use client";
import { useCallback } from "react";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useAlertStore } from "@/shared/lib";
import { useCnstpSeMngPage, useSaveMultiCnstpSeMng } from "@/entities/cnstp";
import {
Button,
Skeleton,
Table,
TableBody,
TableHead,
TableHeader,
TableRow,
} from "@common/components";
import { useCnstpSeRows } from "../lib/use-cnstp-se-rows";
import type { CnstpSeRow } from "../model/cnstp-se-row-types";
import { CnstpSeRowComponent } from "./cnstp-se-row";
function CnstpCodeManagementPage() {
const { confirm } = useConfirm();
const alert = useAlertStore((state) => state.setMessage);
const pageQuery = useCnstpSeMngPage({
page: 0,
size: 500,
sort: "cdLvNo,asc",
});
const saveMultiMutation = useSaveMultiCnstpSeMng();
const handlePersist = useCallback(
async (payload: Parameters<typeof saveMultiMutation.mutateAsync>[0]) => {
await saveMultiMutation.mutateAsync(payload);
},
[saveMultiMutation],
);
const {
displayRows,
canSave,
updateRow,
addRootRow,
addChildRow,
removeDraft,
markDeleted,
resetFromServer,
save,
} = useCnstpSeRows({
serverContent: pageQuery.data?.content,
onAlert: alert,
onSave: handlePersist,
});
const handleDelete = async (row: CnstpSeRow) => {
if (!(await confirm("삭제하시겠습니까?"))) return;
if (row.isNew) {
removeDraft(row.rowKey);
return;
}
markDeleted(row.rowKey);
};
const handleSave = async () => {
if (!(await confirm("저장하시겠습니까?"))) return;
const saved = await save();
if (!saved) return;
alert("저장되었습니다.");
const result = await pageQuery.refetch();
resetFromServer(result.data?.content ?? []);
};
const isSaving = saveMultiMutation.isPending;
return (
<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 flex-wrap items-center justify-between gap-3">
<h3 className="text-2xl font-semibold">공종코드 관리</h3>
<div className="flex flex-wrap items-center gap-3">
<Button type="button" color="green" onClick={addRootRow}>
코드 추가
</Button>
<Button type="button" disabled={!canSave || isSaving} onClick={handleSave}>
저장
</Button>
</div>
</div>
{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>
) : (
<div className="max-h-[60rem] overflow-y-auto pt-3">
<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>
</div>
)}
</section>
</div>
);
}
export { CnstpCodeManagementPage };
@@ -0,0 +1 @@
export { CodeManagementPage } from "./ui/code-management-page";
@@ -0,0 +1,60 @@
export interface SaveModeMap {
CREATE: string;
UPDATE: string;
DELETE: string;
}
export interface SavePayloadBuilderConfig<TRow, TSaveDto> {
saveModes: SaveModeMap;
toDeleteDto: (row: TRow) => TSaveDto;
toCreateDto: (row: TRow) => TSaveDto;
toUpdateDto: (row: TRow) => TSaveDto;
}
interface RowWithState {
cdLvNo?: number;
isNew: boolean;
isDirty: boolean;
isDeleted: boolean;
}
function getVisibleRows<T extends { isDeleted: boolean }>(rows: T[]): T[] {
return rows.filter((row) => !row.isDeleted);
}
function compareCdLvNoDesc<T extends RowWithState>(a: T, b: T): number {
return (b.cdLvNo ?? 1) - (a.cdLvNo ?? 1);
}
function compareCdLvNoAsc<T extends RowWithState>(a: T, b: T): number {
return (a.cdLvNo ?? 1) - (b.cdLvNo ?? 1);
}
export function createSavePayloadBuilder<TRow extends RowWithState, TSaveDto>(
config: SavePayloadBuilderConfig<TRow, TSaveDto>,
) {
return function buildSavePayload(rows: TRow[]): TSaveDto[] {
const payload: TSaveDto[] = [];
const deletedRows = rows.filter((row) => row.isDeleted && !row.isNew).sort(compareCdLvNoDesc);
for (const row of deletedRows) {
payload.push(config.toDeleteDto(row));
}
const visibleRows = getVisibleRows(rows).sort(compareCdLvNoAsc);
for (const row of visibleRows) {
if (row.isNew) {
payload.push(config.toCreateDto(row));
continue;
}
if (row.isDirty) {
payload.push(config.toUpdateDto(row));
}
}
return payload;
};
}
@@ -0,0 +1,99 @@
import {
ACNTG_SE_SAVE_MODE,
type AcntgSeMngResponseDto,
type AcntgSeMngSaveRequestDto,
} from "@/entities/acntg";
import type { AcntgSeDraft, AcntgSeGridState } from "../../model/acntg/acntg-se-grid-types";
import type { AcntgSeRow } from "../../model/acntg/acntg-se-row-types";
import { buildAcntgDisplayRows } from "../../model/acntg/build-acntg-display-rows";
import { createSavePayloadBuilder } from "../build-save-payload";
import type { SeGridConfig } from "../grid-types";
const buildSavePayload = createSavePayloadBuilder<AcntgSeRow, AcntgSeMngSaveRequestDto>({
saveModes: ACNTG_SE_SAVE_MODE,
toDeleteDto: (row) => ({
mode: ACNTG_SE_SAVE_MODE.DELETE,
acntgSeCd: row.acntgSeCd,
acntgSeCdNm: row.acntgSeCdNm?.trim() || "",
upAcntgSeCd: row.upAcntgSeCd,
cdLvNo: row.cdLvNo,
useYn: row.useYn,
expln: row.expln?.trim() || undefined,
sortSeq: row.sortSeq,
}),
toCreateDto: (row) => ({
mode: ACNTG_SE_SAVE_MODE.CREATE,
acntgSeCd: row.acntgSeCd.trim(),
acntgSeCdNm: row.acntgSeCdNm.trim(),
upAcntgSeCd: row.upAcntgSeCd,
cdLvNo: row.cdLvNo,
useYn: row.useYn,
expln: row.expln?.trim() || undefined,
sortSeq: row.sortSeq,
}),
toUpdateDto: (row) => ({
mode: ACNTG_SE_SAVE_MODE.UPDATE,
acntgSeCd: row.acntgSeCd.trim(),
acntgSeCdNm: row.acntgSeCdNm.trim(),
upAcntgSeCd: row.upAcntgSeCd,
cdLvNo: row.cdLvNo,
useYn: row.useYn,
expln: row.expln?.trim() || undefined,
sortSeq: row.sortSeq,
}),
});
export const acntgGridConfig: SeGridConfig<
AcntgSeMngResponseDto,
AcntgSeGridState["baseByKey"][string],
AcntgSeRow,
AcntgSeDraft,
Partial<AcntgSeMngResponseDto>,
AcntgSeMngSaveRequestDto
> = {
getCode: (item) => item.acntgSeCd,
getName: (item) => item.acntgSeCdNm,
getParentCode: (item) => item.upAcntgSeCd,
createEmptyDraft: ({ rowKey, parentRowKey, cdLvNo }) => ({
rowKey,
parentRowKey,
cdLvNo,
acntgSeCd: "",
acntgSeCdNm: "",
expln: "",
useYn: "Y",
}),
applyDraftPatch: (draft, patch) => ({
...draft,
...(patch.acntgSeCd !== undefined ? { acntgSeCd: patch.acntgSeCd } : {}),
...(patch.acntgSeCdNm !== undefined ? { acntgSeCdNm: patch.acntgSeCdNm ?? "" } : {}),
...(patch.expln !== undefined ? { expln: patch.expln ?? "" } : {}),
...(patch.useYn !== undefined ? { useYn: patch.useYn } : {}),
...(patch.sortSeq !== undefined ? { sortSeq: patch.sortSeq } : {}),
}),
assembleDraftRow: (draft, parentCode) => ({
rowKey: draft.rowKey,
acntgSeCd: draft.acntgSeCd,
acntgSeCdNm: draft.acntgSeCdNm,
expln: draft.expln,
useYn: draft.useYn,
cdLvNo: draft.cdLvNo,
upAcntgSeCd: parentCode,
sortSeq: draft.sortSeq,
isNew: true,
isDirty: true,
isDeleted: false,
}),
buildDisplayRows: buildAcntgDisplayRows,
validateRow: (row) => {
if (!row.acntgSeCd.trim()) return "코드를 입력하세요.";
if (!row.acntgSeCdNm?.trim()) return "코드명을 입력하세요.";
const parentCd = row.upAcntgSeCd?.trim();
if (parentCd && row.acntgSeCd.trim() === parentCd) {
return "상위 코드와 동일한 코드는 사용할 수 없습니다.";
}
return null;
},
buildSavePayload,
};
@@ -0,0 +1,98 @@
import {
CNSTP_SE_SAVE_MODE,
type CnstpSeMngResponseDto,
type CnstpSeMngSaveRequestDto,
} from "@/entities/cnstp";
import { buildCnstpDisplayRows } from "../../model/cnstp/build-cnstp-display-rows";
import type { CnstpSeDraft, CnstpSeGridState } from "../../model/cnstp/cnstp-se-grid-types";
import type { CnstpSeRow } from "../../model/cnstp/cnstp-se-row-types";
import { createSavePayloadBuilder } from "../build-save-payload";
import type { SeGridConfig } from "../grid-types";
const buildSavePayload = createSavePayloadBuilder<CnstpSeRow, CnstpSeMngSaveRequestDto>({
saveModes: CNSTP_SE_SAVE_MODE,
toDeleteDto: (row) => ({
mode: CNSTP_SE_SAVE_MODE.DELETE,
cnstpSeCd: row.cnstpSeCd,
cnstpSeCdNm: row.cnstpSeCdNm?.trim() || undefined,
upCnstpCd: row.upCnstpCd,
cdLvNo: row.cdLvNo,
useYn: row.useYn,
expln: row.expln?.trim() || undefined,
sortSeq: row.sortSeq,
}),
toCreateDto: (row) => ({
mode: CNSTP_SE_SAVE_MODE.CREATE,
cnstpSeCd: row.cnstpSeCd.trim(),
cnstpSeCdNm: row.cnstpSeCdNm?.trim() || undefined,
upCnstpCd: row.upCnstpCd,
cdLvNo: row.cdLvNo,
useYn: row.useYn,
expln: row.expln?.trim() || undefined,
sortSeq: row.sortSeq,
}),
toUpdateDto: (row) => ({
mode: CNSTP_SE_SAVE_MODE.UPDATE,
cnstpSeCd: row.cnstpSeCd.trim(),
cnstpSeCdNm: row.cnstpSeCdNm?.trim() || undefined,
upCnstpCd: row.upCnstpCd,
cdLvNo: row.cdLvNo,
useYn: row.useYn,
expln: row.expln?.trim() || undefined,
sortSeq: row.sortSeq,
}),
});
export const cnstpGridConfig: SeGridConfig<
CnstpSeMngResponseDto,
CnstpSeGridState["baseByKey"][string],
CnstpSeRow,
CnstpSeDraft,
Partial<CnstpSeMngResponseDto>,
CnstpSeMngSaveRequestDto
> = {
getCode: (item) => item.cnstpSeCd,
getName: (item) => item.cnstpSeCdNm,
getParentCode: (item) => item.upCnstpCd,
createEmptyDraft: ({ rowKey, parentRowKey, cdLvNo }) => ({
rowKey,
parentRowKey,
cdLvNo,
cnstpSeCd: "",
cnstpSeCdNm: "",
expln: "",
useYn: "Y",
}),
applyDraftPatch: (draft, patch) => ({
...draft,
...(patch.cnstpSeCd !== undefined ? { cnstpSeCd: patch.cnstpSeCd } : {}),
...(patch.cnstpSeCdNm !== undefined ? { cnstpSeCdNm: patch.cnstpSeCdNm ?? "" } : {}),
...(patch.expln !== undefined ? { expln: patch.expln ?? "" } : {}),
...(patch.useYn !== undefined ? { useYn: patch.useYn } : {}),
...(patch.sortSeq !== undefined ? { sortSeq: patch.sortSeq } : {}),
}),
assembleDraftRow: (draft, parentCode) => ({
rowKey: draft.rowKey,
cnstpSeCd: draft.cnstpSeCd,
cnstpSeCdNm: draft.cnstpSeCdNm,
expln: draft.expln,
useYn: draft.useYn,
cdLvNo: draft.cdLvNo,
upCnstpCd: parentCode,
sortSeq: draft.sortSeq,
isNew: true,
isDirty: true,
isDeleted: false,
}),
buildDisplayRows: buildCnstpDisplayRows,
validateRow: (row) => {
if (!row.cnstpSeCd.trim()) return "코드를 입력하세요.";
const parentCd = row.upCnstpCd?.trim();
if (parentCd && row.cnstpSeCd.trim() === parentCd) {
return "상위 코드와 동일한 코드는 사용할 수 없습니다.";
}
return null;
},
buildSavePayload,
};
@@ -0,0 +1,102 @@
import {
FNRS_SHAR_SE_SAVE_MODE,
type FnrsSharSeMngResponseDto,
type FnrsSharSeMngSaveRequestDto,
} from "@/entities/fnrs-shar-se";
import { buildFnrsSharDisplayRows } from "../../model/fnrs-shar/build-fnrs-shar-display-rows";
import type {
FnrsSharSeDraft,
FnrsSharSeGridState,
} from "../../model/fnrs-shar/fnrs-shar-se-grid-types";
import type { FnrsSharSeRow } from "../../model/fnrs-shar/fnrs-shar-se-row-types";
import { createSavePayloadBuilder } from "../build-save-payload";
import type { SeGridConfig } from "../grid-types";
const buildSavePayload = createSavePayloadBuilder<FnrsSharSeRow, FnrsSharSeMngSaveRequestDto>({
saveModes: FNRS_SHAR_SE_SAVE_MODE,
toDeleteDto: (row) => ({
mode: FNRS_SHAR_SE_SAVE_MODE.DELETE,
fnrsSharSeCd: row.fnrsSharSeCd,
fnrsSharSeCdNm: row.fnrsSharSeCdNm?.trim() || "",
upFnrsSharSeCd: row.upFnrsSharSeCd,
cdLvNo: row.cdLvNo,
useYn: row.useYn,
expln: row.expln?.trim() || undefined,
sortSeq: row.sortSeq,
}),
toCreateDto: (row) => ({
mode: FNRS_SHAR_SE_SAVE_MODE.CREATE,
fnrsSharSeCd: row.fnrsSharSeCd.trim(),
fnrsSharSeCdNm: row.fnrsSharSeCdNm.trim(),
upFnrsSharSeCd: row.upFnrsSharSeCd,
cdLvNo: row.cdLvNo,
useYn: row.useYn,
expln: row.expln?.trim() || undefined,
sortSeq: row.sortSeq,
}),
toUpdateDto: (row) => ({
mode: FNRS_SHAR_SE_SAVE_MODE.UPDATE,
fnrsSharSeCd: row.fnrsSharSeCd.trim(),
fnrsSharSeCdNm: row.fnrsSharSeCdNm.trim(),
upFnrsSharSeCd: row.upFnrsSharSeCd,
cdLvNo: row.cdLvNo,
useYn: row.useYn,
expln: row.expln?.trim() || undefined,
sortSeq: row.sortSeq,
}),
});
export const fnrsSharGridConfig: SeGridConfig<
FnrsSharSeMngResponseDto,
FnrsSharSeGridState["baseByKey"][string],
FnrsSharSeRow,
FnrsSharSeDraft,
Partial<FnrsSharSeMngResponseDto>,
FnrsSharSeMngSaveRequestDto
> = {
getCode: (item) => item.fnrsSharSeCd,
getName: (item) => item.fnrsSharSeCdNm,
getParentCode: (item) => item.upFnrsSharSeCd,
createEmptyDraft: ({ rowKey, parentRowKey, cdLvNo }) => ({
rowKey,
parentRowKey,
cdLvNo,
fnrsSharSeCd: "",
fnrsSharSeCdNm: "",
expln: "",
useYn: "Y",
}),
applyDraftPatch: (draft, patch) => ({
...draft,
...(patch.fnrsSharSeCd !== undefined ? { fnrsSharSeCd: patch.fnrsSharSeCd } : {}),
...(patch.fnrsSharSeCdNm !== undefined ? { fnrsSharSeCdNm: patch.fnrsSharSeCdNm ?? "" } : {}),
...(patch.expln !== undefined ? { expln: patch.expln ?? "" } : {}),
...(patch.useYn !== undefined ? { useYn: patch.useYn } : {}),
...(patch.sortSeq !== undefined ? { sortSeq: patch.sortSeq } : {}),
}),
assembleDraftRow: (draft, parentCode) => ({
rowKey: draft.rowKey,
fnrsSharSeCd: draft.fnrsSharSeCd,
fnrsSharSeCdNm: draft.fnrsSharSeCdNm,
expln: draft.expln,
useYn: draft.useYn,
cdLvNo: draft.cdLvNo,
upFnrsSharSeCd: parentCode,
sortSeq: draft.sortSeq,
isNew: true,
isDirty: true,
isDeleted: false,
}),
buildDisplayRows: buildFnrsSharDisplayRows,
validateRow: (row) => {
if (!row.fnrsSharSeCd.trim()) return "코드를 입력하세요.";
if (!row.fnrsSharSeCdNm?.trim()) return "코드명을 입력하세요.";
const parentCd = row.upFnrsSharSeCd?.trim();
if (parentCd && row.fnrsSharSeCd.trim() === parentCd) {
return "상위 코드와 동일한 코드는 사용할 수 없습니다.";
}
return null;
},
buildSavePayload,
};
@@ -0,0 +1,69 @@
export interface BaseSeDto {
cdLvNo: number;
useYn: string;
expln?: string;
sortSeq?: number;
}
export interface SeRowState {
rowKey: string;
isNew: boolean;
isDirty: boolean;
isDeleted: boolean;
}
export interface DisplaySlot {
rowKey: string;
depth: number;
parentRowKey: string | null;
}
export interface SeDraftBase {
rowKey: string;
parentRowKey: string | null;
cdLvNo: number;
expln: string;
useYn: string;
sortSeq?: number;
}
export interface SeGridState<
TDto extends BaseSeDto,
TBaseEntry extends TDto & { rowKey: string },
TDraft extends SeDraftBase,
TPatch extends Partial<TDto>,
> {
baseByKey: Record<string, TBaseEntry>;
structure: DisplaySlot[];
drafts: Record<string, TDraft>;
patches: Record<string, TPatch>;
deletedKeys: Set<string>;
}
export interface SeDisplayRow<TRow> {
row: TRow;
depth: number;
}
export interface SeGridConfig<
TDto extends BaseSeDto,
TBaseEntry extends TDto & { rowKey: string },
TRow extends TBaseEntry & SeRowState,
TDraft extends SeDraftBase,
_TPatch extends Partial<TDto>,
TSaveDto,
> {
getCode: (item: TDto | TRow | TDraft) => string;
getName: (item: TDto | TRow | TDraft) => string | undefined;
getParentCode: (item: TDto | TRow) => string | undefined;
createEmptyDraft: (params: {
rowKey: string;
parentRowKey: string | null;
cdLvNo: number;
}) => TDraft;
applyDraftPatch: (draft: TDraft, patch: Partial<TRow>) => TDraft;
assembleDraftRow: (draft: TDraft, parentCode: string | undefined) => TRow;
buildDisplayRows: (rows: TRow[]) => SeDisplayRow<TRow>[];
validateRow: (row: TRow) => string | null;
buildSavePayload: (rows: TRow[]) => TSaveDto[];
}
@@ -0,0 +1,254 @@
import type {
BaseSeDto,
DisplaySlot,
SeDraftBase,
SeGridConfig,
SeGridState,
SeRowState,
} from "./grid-types";
export function createLoadedRowKey(index: number, code: string): string {
return `loaded-${index}-${code}`;
}
export function createDraftRowKey(): string {
return `draft-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
export function createSeGridUtils<
TDto extends BaseSeDto,
TBaseEntry extends TDto & { rowKey: string },
TRow extends TBaseEntry & SeRowState,
TDraft extends SeDraftBase,
TPatch extends Partial<TDto>,
TSaveDto,
>(config: SeGridConfig<TDto, TBaseEntry, TRow, TDraft, TPatch, TSaveDto>) {
function createEmptyGridState(): SeGridState<TDto, TBaseEntry, TDraft, TPatch> {
return {
baseByKey: {},
structure: [],
drafts: {},
patches: {},
deletedKeys: new Set(),
};
}
function toInitialRow(entry: TBaseEntry): TRow {
return {
...entry,
isNew: false,
isDirty: false,
isDeleted: false,
} as unknown as TRow;
}
function buildStructureFromBaseRows(baseByKey: Record<string, TBaseEntry>): DisplaySlot[] {
const rows = Object.values(baseByKey).map(toInitialRow);
const display = config.buildDisplayRows(rows);
const cdToRowKey = new Map(rows.map((row) => [config.getCode(row), row.rowKey]));
return display.map(({ row, depth }) => ({
rowKey: row.rowKey,
depth,
parentRowKey: config.getParentCode(row)?.trim()
? (cdToRowKey.get(config.getParentCode(row)!.trim()) ?? null)
: null,
}));
}
function hydrateGridFromServer(content: TDto[]): SeGridState<TDto, TBaseEntry, TDraft, TPatch> {
const baseByKey: Record<string, TBaseEntry> = {};
content.forEach((item, index) => {
const rowKey = createLoadedRowKey(index, config.getCode(item));
baseByKey[rowKey] = { ...item, rowKey } as TBaseEntry;
});
return {
baseByKey,
structure: buildStructureFromBaseRows(baseByKey),
drafts: {},
patches: {},
deletedKeys: new Set(),
};
}
function resolveParentCode(
state: SeGridState<TDto, TBaseEntry, TDraft, TPatch>,
parentRowKey: string | null,
): string | undefined {
if (!parentRowKey) return undefined;
const parent = mergeRow(state, parentRowKey);
const code = parent ? config.getCode(parent).trim() : "";
return code || undefined;
}
function mergeRow(
state: SeGridState<TDto, TBaseEntry, TDraft, TPatch>,
rowKey: string,
): TRow | null {
const draft = state.drafts[rowKey];
if (draft) {
const parentCode = resolveParentCode(state, draft.parentRowKey);
return config.assembleDraftRow(draft, parentCode);
}
const base = state.baseByKey[rowKey];
if (!base) return null;
const patch = state.patches[rowKey] ?? ({} as TPatch);
const hasPatch = Object.keys(patch).length > 0;
return {
...base,
...patch,
rowKey,
isNew: false,
isDirty: hasPatch,
isDeleted: state.deletedKeys.has(rowKey),
} as unknown as TRow;
}
function mergeAllRows(state: SeGridState<TDto, TBaseEntry, TDraft, TPatch>): TRow[] {
const keys = new Set([...Object.keys(state.baseByKey), ...Object.keys(state.drafts)]);
return [...keys]
.map((rowKey) => mergeRow(state, rowKey))
.filter((row): row is TRow => row != null);
}
function hasGridPendingChanges(state: SeGridState<TDto, TBaseEntry, TDraft, TPatch>): boolean {
return (
state.deletedKeys.size > 0 ||
Object.keys(state.drafts).length > 0 ||
Object.keys(state.patches).length > 0
);
}
function collectDescendantRowKeys(structure: DisplaySlot[], rootRowKey: string): Set<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;
}
function insertRootSlot(structure: DisplaySlot[], slot: DisplaySlot): DisplaySlot[] {
const firstRootIndex = structure.findIndex((item) => item.parentRowKey === null);
if (firstRootIndex === -1) return [slot, ...structure];
return [...structure.slice(0, firstRootIndex), slot, ...structure.slice(firstRootIndex)];
}
function insertChildSlot(
structure: DisplaySlot[],
parentRowKey: string,
slot: DisplaySlot,
): DisplaySlot[] {
const parentIndex = structure.findIndex((item) => item.rowKey === parentRowKey);
if (parentIndex === -1) return [...structure, slot];
const insertAt = parentIndex + 1;
return [...structure.slice(0, insertAt), slot, ...structure.slice(insertAt)];
}
function removeSlotsFromStructure(
structure: DisplaySlot[],
rowKeysToRemove: Set<string>,
): DisplaySlot[] {
return structure.filter((slot) => !rowKeysToRemove.has(slot.rowKey));
}
function patchGridRow(
state: SeGridState<TDto, TBaseEntry, TDraft, TPatch>,
rowKey: string,
patch: Partial<TRow>,
): SeGridState<TDto, TBaseEntry, TDraft, TPatch> {
const draft = state.drafts[rowKey];
if (draft) {
return {
...state,
drafts: {
...state.drafts,
[rowKey]: config.applyDraftPatch(draft, patch),
},
};
}
const { rowKey: _rk, isNew: _n, isDirty: _d, isDeleted: _del, ...dtoPatch } = patch;
return {
...state,
patches: {
...state.patches,
[rowKey]: { ...state.patches[rowKey], ...dtoPatch } as TPatch,
},
};
}
function markRowsDeleted(
state: SeGridState<TDto, TBaseEntry, TDraft, TPatch>,
rootRowKey: string,
): SeGridState<TDto, TBaseEntry, TDraft, TPatch> {
const keysToDelete = collectDescendantRowKeys(state.structure, rootRowKey);
const deletedKeys = new Set(state.deletedKeys);
for (const key of keysToDelete) {
deletedKeys.add(key);
}
return { ...state, deletedKeys };
}
function validateDraftParentCode(
state: SeGridState<TDto, TBaseEntry, TDraft, TPatch>,
rowKey: string,
): string | null {
const draft = state.drafts[rowKey];
if (!draft?.parentRowKey) return null;
const parent = mergeRow(state, draft.parentRowKey);
if (!parent || !config.getCode(parent).trim()) {
return "하위 항목을 추가하려면 상위 코드를 먼저 입력하세요.";
}
return null;
}
function removeDraftFromGrid(
state: SeGridState<TDto, TBaseEntry, TDraft, TPatch>,
rowKey: string,
): SeGridState<TDto, TBaseEntry, TDraft, TPatch> {
const { [rowKey]: _removed, ...restDrafts } = state.drafts;
const keysToRemove = collectDescendantRowKeys(state.structure, rowKey);
return {
...state,
drafts: restDrafts,
structure: removeSlotsFromStructure(state.structure, keysToRemove),
};
}
return {
createEmptyGridState,
hydrateGridFromServer,
mergeRow,
mergeAllRows,
hasGridPendingChanges,
collectDescendantRowKeys,
insertRootSlot,
insertChildSlot,
removeSlotsFromStructure,
patchGridRow,
markRowsDeleted,
validateDraftParentCode,
removeDraftFromGrid,
resolveParentCode,
};
}
@@ -0,0 +1,176 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { BaseSeDto, SeDraftBase, SeGridConfig, SeGridState, SeRowState } from "./grid-types";
import { createDraftRowKey, createSeGridUtils } from "./grid-utils";
interface UseSeGridRowsOptions<
TDto extends BaseSeDto,
TBaseEntry extends TDto & { rowKey: string },
_TRow extends TBaseEntry & SeRowState,
_TDraft extends SeDraftBase,
_TPatch extends Partial<TDto>,
TSaveDto,
> {
serverContent: TDto[] | undefined;
onAlert: (message: string) => void;
onSave: (payload: TSaveDto[]) => Promise<void>;
}
export function useSeGridRows<
TDto extends BaseSeDto,
TBaseEntry extends TDto & { rowKey: string },
TRow extends TBaseEntry & SeRowState,
TDraft extends SeDraftBase,
TPatch extends Partial<TDto>,
TSaveDto,
>(
config: SeGridConfig<TDto, TBaseEntry, TRow, TDraft, TPatch, TSaveDto>,
{
serverContent,
onAlert,
onSave,
}: UseSeGridRowsOptions<TDto, TBaseEntry, TRow, TDraft, TPatch, TSaveDto>,
) {
const utils = useMemo(() => createSeGridUtils(config), [config]);
const [grid, setGrid] = useState<SeGridState<TDto, TBaseEntry, TDraft, TPatch>>(
utils.createEmptyGridState,
);
const hasPendingRef = useRef(false);
hasPendingRef.current = utils.hasGridPendingChanges(grid);
useEffect(() => {
if (serverContent == null) return;
if (hasPendingRef.current) return;
setGrid(utils.hydrateGridFromServer(serverContent));
}, [serverContent, utils]);
const displayRows = useMemo(() => {
return grid.structure
.filter((slot) => !grid.deletedKeys.has(slot.rowKey))
.map((slot) => {
const row = utils.mergeRow(grid, slot.rowKey);
return row ? { row, depth: slot.depth } : null;
})
.filter((item): item is { row: TRow; depth: number } => item != null);
}, [grid, utils]);
const canSave = utils.hasGridPendingChanges(grid);
const updateRow = useCallback(
(rowKey: string, patch: Partial<TRow>) => {
setGrid((prev) => utils.patchGridRow(prev, rowKey, patch));
},
[utils],
);
const addRootRow = useCallback(() => {
const rowKey = createDraftRowKey();
setGrid((prev) => ({
...prev,
drafts: {
...prev.drafts,
[rowKey]: config.createEmptyDraft({
rowKey,
parentRowKey: null,
cdLvNo: 1,
}),
},
structure: utils.insertRootSlot(prev.structure, {
rowKey,
depth: 0,
parentRowKey: null,
}),
}));
}, [config, utils]);
const addChildRow = useCallback(
(parent: TRow) => {
const rowKey = createDraftRowKey();
setGrid((prev) => {
const parentSlot = prev.structure.find((item) => item.rowKey === parent.rowKey);
const depth = (parentSlot?.depth ?? 0) + 1;
return {
...prev,
drafts: {
...prev.drafts,
[rowKey]: config.createEmptyDraft({
rowKey,
parentRowKey: parent.rowKey,
cdLvNo: (parent.cdLvNo ?? 1) + 1,
}),
},
structure: utils.insertChildSlot(prev.structure, parent.rowKey, {
rowKey,
depth,
parentRowKey: parent.rowKey,
}),
};
});
},
[config, utils],
);
const removeDraft = useCallback(
(rowKey: string) => {
setGrid((prev) => utils.removeDraftFromGrid(prev, rowKey));
},
[utils],
);
const markDeleted = useCallback(
(rowKey: string) => {
setGrid((prev) => utils.markRowsDeleted(prev, rowKey));
},
[utils],
);
const resetFromServer = useCallback(
(content: TDto[]) => {
setGrid(utils.hydrateGridFromServer(content));
},
[utils],
);
const save = useCallback(async () => {
const mergedRows = utils.mergeAllRows(grid);
const payload = config.buildSavePayload(mergedRows);
if (payload.length === 0) return false;
for (const row of mergedRows) {
if (!row.isDeleted && (row.isNew || row.isDirty)) {
const parentCodeError = utils.validateDraftParentCode(grid, row.rowKey);
if (parentCodeError) {
onAlert(parentCodeError);
return false;
}
const fieldError = config.validateRow(row);
if (fieldError) {
onAlert(fieldError);
return false;
}
}
}
await onSave(payload);
return true;
}, [config, grid, onAlert, onSave, utils]);
return {
displayRows,
canSave,
updateRow,
addRootRow,
addChildRow,
removeDraft,
markDeleted,
resetFromServer,
save,
};
}
@@ -1,34 +1,35 @@
import type { AcntgSeMngResponseDto } from "@/entities/acntg";
export interface AcntgDisplaySlot {
rowKey: string;
depth: number;
parentRowKey: string | null;
}
export interface AcntgSeDraft {
rowKey: string;
parentRowKey: string | null;
cdLvNo: number;
acntgSeCd: string;
acntgSeCdNm: string;
expln: string;
useYn: string;
}
export interface AcntgSeBaseEntry extends AcntgSeMngResponseDto {
rowKey: string;
}
export interface AcntgSeGridState {
baseByKey: Record<string, AcntgSeBaseEntry>;
structure: AcntgDisplaySlot[];
drafts: Record<string, AcntgSeDraft>;
patches: Record<string, Partial<AcntgSeMngResponseDto>>;
deletedKeys: Set<string>;
}
export interface AcntgSeDisplayItem {
rowKey: string;
depth: number;
}
import type { AcntgSeMngResponseDto } from "@/entities/acntg";
export interface AcntgDisplaySlot {
rowKey: string;
depth: number;
parentRowKey: string | null;
}
export interface AcntgSeDraft {
rowKey: string;
parentRowKey: string | null;
cdLvNo: number;
acntgSeCd: string;
acntgSeCdNm: string;
expln: string;
useYn: string;
sortSeq?: number;
}
export interface AcntgSeBaseEntry extends AcntgSeMngResponseDto {
rowKey: string;
}
export interface AcntgSeGridState {
baseByKey: Record<string, AcntgSeBaseEntry>;
structure: AcntgDisplaySlot[];
drafts: Record<string, AcntgSeDraft>;
patches: Record<string, Partial<AcntgSeMngResponseDto>>;
deletedKeys: Set<string>;
}
export interface AcntgSeDisplayItem {
rowKey: string;
depth: number;
}
@@ -1,29 +1,30 @@
import type { CnstpSeMngResponseDto } from "@/entities/cnstp";
export interface CnstpDisplaySlot {
rowKey: string;
depth: number;
parentRowKey: string | null;
}
export interface CnstpSeDraft {
rowKey: string;
parentRowKey: string | null;
cdLvNo: number;
cnstpSeCd: string;
cnstpSeCdNm: string;
expln: string;
useYn: string;
}
export interface CnstpSeBaseEntry extends CnstpSeMngResponseDto {
rowKey: string;
}
export interface CnstpSeGridState {
baseByKey: Record<string, CnstpSeBaseEntry>;
structure: CnstpDisplaySlot[];
drafts: Record<string, CnstpSeDraft>;
patches: Record<string, Partial<CnstpSeMngResponseDto>>;
deletedKeys: Set<string>;
}
import type { CnstpSeMngResponseDto } from "@/entities/cnstp";
export interface CnstpDisplaySlot {
rowKey: string;
depth: number;
parentRowKey: string | null;
}
export interface CnstpSeDraft {
rowKey: string;
parentRowKey: string | null;
cdLvNo: number;
cnstpSeCd: string;
cnstpSeCdNm: string;
expln: string;
useYn: string;
sortSeq?: number;
}
export interface CnstpSeBaseEntry extends CnstpSeMngResponseDto {
rowKey: string;
}
export interface CnstpSeGridState {
baseByKey: Record<string, CnstpSeBaseEntry>;
structure: CnstpDisplaySlot[];
drafts: Record<string, CnstpSeDraft>;
patches: Record<string, Partial<CnstpSeMngResponseDto>>;
deletedKeys: Set<string>;
}
@@ -0,0 +1,77 @@
import type { FnrsSharSeRow } from "./fnrs-shar-se-row-types";
export interface FnrsSharSeDisplayRow {
row: FnrsSharSeRow;
depth: number;
}
export function formatCdLvNo(lvNo?: number): string {
return `${lvNo ?? 1}단계`;
}
export function canAddChildRow(): boolean {
return true;
}
export function formatTreeBranchPrefix(depth: number): string {
if (depth <= 0) return "";
const vertical = "\u2502 ";
const branch = "\u251C\u2500";
return vertical.repeat(depth - 1) + branch;
}
function isRootRow(row: FnrsSharSeRow): boolean {
return (row.cdLvNo ?? 1) === 1 && !row.upFnrsSharSeCd?.trim();
}
function rowOrderIndex(rows: FnrsSharSeRow[], row: FnrsSharSeRow): number {
return rows.findIndex((candidate) => candidate.rowKey === row.rowKey);
}
function pushDisplayRow(
result: FnrsSharSeDisplayRow[],
displayedRowKeys: Set<string>,
row: FnrsSharSeRow,
depth: number,
) {
if (displayedRowKeys.has(row.rowKey)) return;
displayedRowKeys.add(row.rowKey);
result.push({ row, depth });
}
export function buildFnrsSharDisplayRows(rows: FnrsSharSeRow[]): FnrsSharSeDisplayRow[] {
const result: FnrsSharSeDisplayRow[] = [];
const displayedRowKeys = new Set<string>();
const roots = rows.filter(isRootRow);
function appendSubtree(parent: FnrsSharSeRow, depth: number) {
const children = rows
.filter(
(row) =>
row.rowKey !== parent.rowKey &&
row.upFnrsSharSeCd != null &&
row.upFnrsSharSeCd === parent.fnrsSharSeCd &&
row.fnrsSharSeCd !== parent.fnrsSharSeCd,
)
.sort((a, b) => rowOrderIndex(rows, a) - rowOrderIndex(rows, b));
for (const child of children) {
pushDisplayRow(result, displayedRowKeys, child, depth);
appendSubtree(child, depth + 1);
}
}
for (const root of roots) {
pushDisplayRow(result, displayedRowKeys, root, 0);
appendSubtree(root, 1);
}
const orphans = rows.filter((row) => !displayedRowKeys.has(row.rowKey));
for (const orphan of orphans) {
pushDisplayRow(result, displayedRowKeys, orphan, Math.max(0, (orphan.cdLvNo ?? 1) - 1));
}
return result;
}
@@ -0,0 +1,35 @@
import type { FnrsSharSeMngResponseDto } from "@/entities/fnrs-shar-se";
export interface FnrsSharDisplaySlot {
rowKey: string;
depth: number;
parentRowKey: string | null;
}
export interface FnrsSharSeDraft {
rowKey: string;
parentRowKey: string | null;
cdLvNo: number;
fnrsSharSeCd: string;
fnrsSharSeCdNm: string;
expln: string;
useYn: string;
sortSeq?: number;
}
export interface FnrsSharSeBaseEntry extends FnrsSharSeMngResponseDto {
rowKey: string;
}
export interface FnrsSharSeGridState {
baseByKey: Record<string, FnrsSharSeBaseEntry>;
structure: FnrsSharDisplaySlot[];
drafts: Record<string, FnrsSharSeDraft>;
patches: Record<string, Partial<FnrsSharSeMngResponseDto>>;
deletedKeys: Set<string>;
}
export interface FnrsSharSeDisplayItem {
rowKey: string;
depth: number;
}
@@ -0,0 +1,8 @@
import type { FnrsSharSeMngResponseDto } from "@/entities/fnrs-shar-se";
export interface FnrsSharSeRow extends FnrsSharSeMngResponseDto {
rowKey: string;
isNew: boolean;
isDirty: boolean;
isDeleted: boolean;
}
@@ -0,0 +1,146 @@
"use client";
import { useCallback } from "react";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useAlertStore } from "@/shared/lib";
import { useAcntgSeMngPage, useSaveMultiAcntgSeMng } from "@/entities/acntg";
import {
Button,
Skeleton,
Table,
TableBody,
TableHead,
TableHeader,
TableRow,
} from "@common/components";
import { acntgGridConfig } from "../lib/configs/acntg-config";
import { useSeGridRows } from "../lib/use-se-grid-rows";
import type { AcntgSeRow } from "../model/acntg/acntg-se-row-types";
import { AcntgSeRowComponent } from "./acntg-se-row";
function AcntgCodeTabPanel() {
const { confirm } = useConfirm();
const alert = useAlertStore((state) => state.setMessage);
const pageQuery = useAcntgSeMngPage({
page: 0,
size: 500,
sort: "sortSeq,asc",
});
const saveMultiMutation = useSaveMultiAcntgSeMng();
const handlePersist = useCallback(
async (payload: Parameters<typeof saveMultiMutation.mutateAsync>[0]) => {
await saveMultiMutation.mutateAsync(payload);
},
[saveMultiMutation],
);
const {
displayRows,
canSave,
updateRow,
addRootRow,
addChildRow,
removeDraft,
markDeleted,
resetFromServer,
save,
} = useSeGridRows(acntgGridConfig, {
serverContent: pageQuery.data?.content,
onAlert: alert,
onSave: handlePersist,
});
const handleDelete = async (row: AcntgSeRow) => {
if (!(await confirm("삭제하시겠습니까?"))) return;
if (row.isNew) {
removeDraft(row.rowKey);
return;
}
markDeleted(row.rowKey);
};
const handleSave = async () => {
if (!(await confirm("저장하시겠습니까?"))) return;
const saved = await save();
if (!saved) return;
alert("저장되었습니다.");
const result = await pageQuery.refetch();
resetFromServer(result.data?.content ?? []);
};
const isSaving = saveMultiMutation.isPending;
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-end gap-3 px-4">
<Button type="button" color="green" onClick={addRootRow}>
코드 추가
</Button>
<Button type="button" disabled={!canSave || isSaving} onClick={handleSave}>
저장
</Button>
</div>
{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>
) : (
<div className="max-h-[60rem] overflow-y-auto px-4 pb-4 pt-1">
<Table className="w-full">
<colgroup>
<col width="14%" />
<col width="16%" />
<col width="26%" />
<col width="11%" />
<col width="8%" />
<col width="8%" />
<col width="11%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>코드</TableHead>
<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>
</div>
)}
</div>
);
}
export { AcntgCodeTabPanel };
@@ -10,12 +10,12 @@ import {
TableRow,
} from "@common/components";
import type { AcntgSeRow } from "../model/acntg-se-row-types";
import type { AcntgSeRow } from "../model/acntg/acntg-se-row-types";
import {
canAddChildRow,
formatCdLvNo,
formatTreeBranchPrefix,
} from "../model/build-acntg-display-rows";
} from "../model/acntg/build-acntg-display-rows";
interface AcntgSeRowProps {
row: AcntgSeRow;
@@ -78,6 +78,17 @@ function AcntgSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: Acn
</div>
</RadioGroup>
</TableCell>
<TableCell align="center">
<Input
type="number"
min={0}
value={row.sortSeq ?? ""}
onChange={(e) =>
onChange({ sortSeq: e.target.value ? Number(e.target.value) : undefined })
}
className="w-20"
/>
</TableCell>
<TableCell align="center">{levelLabel}</TableCell>
<TableCell align="center">
<div className="flex justify-center gap-2">
@@ -0,0 +1,146 @@
"use client";
import { useCallback } from "react";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useAlertStore } from "@/shared/lib";
import { useCnstpSeMngPage, useSaveMultiCnstpSeMng } from "@/entities/cnstp";
import {
Button,
Skeleton,
Table,
TableBody,
TableHead,
TableHeader,
TableRow,
} from "@common/components";
import { cnstpGridConfig } from "../lib/configs/cnstp-config";
import { useSeGridRows } from "../lib/use-se-grid-rows";
import type { CnstpSeRow } from "../model/cnstp/cnstp-se-row-types";
import { CnstpSeRowComponent } from "./cnstp-se-row";
function CnstpCodeTabPanel() {
const { confirm } = useConfirm();
const alert = useAlertStore((state) => state.setMessage);
const pageQuery = useCnstpSeMngPage({
page: 0,
size: 500,
sort: "sortSeq,asc",
});
const saveMultiMutation = useSaveMultiCnstpSeMng();
const handlePersist = useCallback(
async (payload: Parameters<typeof saveMultiMutation.mutateAsync>[0]) => {
await saveMultiMutation.mutateAsync(payload);
},
[saveMultiMutation],
);
const {
displayRows,
canSave,
updateRow,
addRootRow,
addChildRow,
removeDraft,
markDeleted,
resetFromServer,
save,
} = useSeGridRows(cnstpGridConfig, {
serverContent: pageQuery.data?.content,
onAlert: alert,
onSave: handlePersist,
});
const handleDelete = async (row: CnstpSeRow) => {
if (!(await confirm("삭제하시겠습니까?"))) return;
if (row.isNew) {
removeDraft(row.rowKey);
return;
}
markDeleted(row.rowKey);
};
const handleSave = async () => {
if (!(await confirm("저장하시겠습니까?"))) return;
const saved = await save();
if (!saved) return;
alert("저장되었습니다.");
const result = await pageQuery.refetch();
resetFromServer(result.data?.content ?? []);
};
const isSaving = saveMultiMutation.isPending;
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-end gap-3 px-4">
<Button type="button" color="green" onClick={addRootRow}>
코드 추가
</Button>
<Button type="button" disabled={!canSave || isSaving} onClick={handleSave}>
저장
</Button>
</div>
{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>
) : (
<div className="max-h-[60rem] overflow-y-auto px-4 pb-4 pt-1">
<Table className="w-full">
<colgroup>
<col width="14%" />
<col width="16%" />
<col width="26%" />
<col width="11%" />
<col width="8%" />
<col width="8%" />
<col width="11%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>코드</TableHead>
<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>
</div>
)}
</div>
);
}
export { CnstpCodeTabPanel };
@@ -14,8 +14,8 @@ import {
canAddChildRow,
formatCdLvNo,
formatTreeBranchPrefix,
} from "../model/build-cnstp-display-rows";
import type { CnstpSeRow } from "../model/cnstp-se-row-types";
} from "../model/cnstp/build-cnstp-display-rows";
import type { CnstpSeRow } from "../model/cnstp/cnstp-se-row-types";
interface CnstpSeRowProps {
row: CnstpSeRow;
@@ -78,6 +78,17 @@ function CnstpSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: Cns
</div>
</RadioGroup>
</TableCell>
<TableCell align="center">
<Input
type="number"
min={0}
value={row.sortSeq ?? ""}
onChange={(e) =>
onChange({ sortSeq: e.target.value ? Number(e.target.value) : undefined })
}
className="w-20"
/>
</TableCell>
<TableCell align="center">{levelLabel}</TableCell>
<TableCell align="center">
<div className="flex justify-center gap-2">
@@ -0,0 +1,55 @@
"use client";
import { useState } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@common/components";
import { AcntgCodeTabPanel } from "./acntg-code-tab-panel";
import { CnstpCodeTabPanel } from "./cnstp-code-tab-panel";
import { FnrsSharCodeTabPanel } from "./fnrs-shar-code-tab-panel";
const TAB = {
ACNTG: "acntg",
CNSTP: "cnstp",
FNRS_SHAR: "fnrs-shar",
} as const;
function CodeManagementPage() {
const [activeTab, setActiveTab] = useState<string>(TAB.ACNTG);
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">
<h3 className="text-2xl font-semibold">코드 관리</h3>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="pt-2">
<TabsList>
<TabsTrigger value={TAB.ACNTG} className="!px-6 !py-3 text-xl">
회계코드
</TabsTrigger>
<TabsTrigger value={TAB.CNSTP} className="!px-6 !py-3 text-xl">
공사코드
</TabsTrigger>
<TabsTrigger value={TAB.FNRS_SHAR} className="!px-6 !py-3 text-xl">
재원분담구분코드
</TabsTrigger>
</TabsList>
<TabsContent value={TAB.ACNTG} className="mt-0 border-0 p-0 shadow-none ring-0">
<AcntgCodeTabPanel />
</TabsContent>
<TabsContent value={TAB.CNSTP} className="mt-0 border-0 p-0 shadow-none ring-0">
<CnstpCodeTabPanel />
</TabsContent>
<TabsContent value={TAB.FNRS_SHAR} className="mt-0 border-0 p-0 shadow-none ring-0">
<FnrsSharCodeTabPanel />
</TabsContent>
</Tabs>
</section>
</div>
);
}
export { CodeManagementPage };
@@ -0,0 +1,146 @@
"use client";
import { useCallback } from "react";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useAlertStore } from "@/shared/lib";
import { useFnrsSharSeMngPage, useSaveMultiFnrsSharSeMng } from "@/entities/fnrs-shar-se";
import {
Button,
Skeleton,
Table,
TableBody,
TableHead,
TableHeader,
TableRow,
} from "@common/components";
import { fnrsSharGridConfig } from "../lib/configs/fnrs-shar-config";
import { useSeGridRows } from "../lib/use-se-grid-rows";
import type { FnrsSharSeRow } from "../model/fnrs-shar/fnrs-shar-se-row-types";
import { FnrsSharSeRowComponent } from "./fnrs-shar-se-row";
function FnrsSharCodeTabPanel() {
const { confirm } = useConfirm();
const alert = useAlertStore((state) => state.setMessage);
const pageQuery = useFnrsSharSeMngPage({
page: 0,
size: 500,
sort: "sortSeq,asc",
});
const saveMultiMutation = useSaveMultiFnrsSharSeMng();
const handlePersist = useCallback(
async (payload: Parameters<typeof saveMultiMutation.mutateAsync>[0]) => {
await saveMultiMutation.mutateAsync(payload);
},
[saveMultiMutation],
);
const {
displayRows,
canSave,
updateRow,
addRootRow,
addChildRow,
removeDraft,
markDeleted,
resetFromServer,
save,
} = useSeGridRows(fnrsSharGridConfig, {
serverContent: pageQuery.data?.content,
onAlert: alert,
onSave: handlePersist,
});
const handleDelete = async (row: FnrsSharSeRow) => {
if (!(await confirm("삭제하시겠습니까?"))) return;
if (row.isNew) {
removeDraft(row.rowKey);
return;
}
markDeleted(row.rowKey);
};
const handleSave = async () => {
if (!(await confirm("저장하시겠습니까?"))) return;
const saved = await save();
if (!saved) return;
alert("저장되었습니다.");
const result = await pageQuery.refetch();
resetFromServer(result.data?.content ?? []);
};
const isSaving = saveMultiMutation.isPending;
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-end gap-3 px-4">
<Button type="button" color="green" onClick={addRootRow}>
코드 추가
</Button>
<Button type="button" disabled={!canSave || isSaving} onClick={handleSave}>
저장
</Button>
</div>
{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>
) : (
<div className="max-h-[60rem] overflow-y-auto px-4 pb-4 pt-1">
<Table className="w-full">
<colgroup>
<col width="14%" />
<col width="16%" />
<col width="26%" />
<col width="11%" />
<col width="8%" />
<col width="8%" />
<col width="11%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>코드</TableHead>
<TableHead>코드명</TableHead>
<TableHead>설명</TableHead>
<TableHead>사용 여부</TableHead>
<TableHead>정렬순서</TableHead>
<TableHead>구분</TableHead>
<TableHead>관리</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{displayRows.map(({ row, depth }) => (
<FnrsSharSeRowComponent
key={row.rowKey}
row={row}
depth={depth}
onChange={(patch) => updateRow(row.rowKey, patch)}
onAddChild={() => addChildRow(row)}
onDelete={() => handleDelete(row)}
/>
))}
</TableBody>
</Table>
</div>
)}
</div>
);
}
export { FnrsSharCodeTabPanel };
@@ -0,0 +1,113 @@
"use client";
import {
Button,
Input,
Label,
RadioGroup,
RadioGroupItem,
TableCell,
TableRow,
} from "@common/components";
import {
canAddChildRow,
formatCdLvNo,
formatTreeBranchPrefix,
} from "../model/fnrs-shar/build-fnrs-shar-display-rows";
import type { FnrsSharSeRow } from "../model/fnrs-shar/fnrs-shar-se-row-types";
interface FnrsSharSeRowProps {
row: FnrsSharSeRow;
depth: number;
onChange: (patch: Partial<FnrsSharSeRow>) => void;
onAddChild: () => void;
onDelete: () => void;
}
function FnrsSharSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: FnrsSharSeRowProps) {
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.fnrsSharSeCd}
onChange={(e) => onChange({ fnrsSharSeCd: e.target.value })}
placeholder="코드"
maxLength={10}
disabled={!row.isNew}
className="flex-1"
/>
</div>
</TableCell>
<TableCell>
<Input
value={row.fnrsSharSeCdNm ?? ""}
onChange={(e) => onChange({ fnrsSharSeCdNm: 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">
<Input
type="number"
min={0}
value={row.sortSeq ?? ""}
onChange={(e) =>
onChange({ sortSeq: e.target.value ? Number(e.target.value) : undefined })
}
className="w-20"
/>
</TableCell>
<TableCell align="center">{levelLabel}</TableCell>
<TableCell align="center">
<div className="flex justify-center gap-2">
<Button
type="button"
size="sm"
color="white"
disabled={!canAddChildRow()}
onClick={onAddChild}
>
하위추가
</Button>
<Button type="button" size="sm" color="gray" onClick={onDelete}>
삭제
</Button>
</div>
</TableCell>
</TableRow>
);
}
export { FnrsSharSeRowComponent };