Files
invyone/frontend/components/admin/CodeDetailPanel.tsx
T
DDD1542 2348800e68
Build & Deploy to K8s / build-and-deploy (push) Successful in 9m22s
refactor(common-code): 마스터-디테일 재설계 — code_info(그룹) + code_detail(재귀 트리)
카테고리/캐스케이딩 시스템 (B/C/D) 전부 폐기:
- BE: mapper/Service/Controller 9세트 삭제 (cascading*, categoryTree, tableCategoryValue, categoryValueCascading, codeMerge)
- FE: 페이지 3 + API 8 + hooks 2 + 폐기 컴포넌트 6 삭제, 14곳 의존성 정리
- DB: 12 테이블 DROP, TABLE_TYPE_COLUMNS.CODE_CATEGORY → CODE_INFO rename

신설 commonCode 마스터-디테일:
- code_info: 1레벨 그룹 마스터
- code_detail: 2~∞ depth 재귀 트리 (parent_detail_id self-FK, depth 자동 계산)
- API: /api/common-codes/{info,detail}
- CodeCategoryFormModal/Panel → CodeInfoFormModal/Panel rename
- code_category 컬럼명 전부 code_info 로 치환 (mapper/Java/FE)
- 옛 commonCode API URL (/categories/...) → getCodeOptions 어댑터 + /detail?code_info=... 전환

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:50:50 +09:00

327 lines
12 KiB
TypeScript

"use client";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { CodeFormModal } from "./CodeFormModal";
import { SortableCodeItem } from "./SortableCodeItem";
import { AlertModal } from "@/components/common/AlertModal";
import { Search, Plus } from "lucide-react";
import { useDeleteCodeDetail, useUpdateCodeDetail, useCodeDetailTree } from "@/hooks/queries/useCodeDetail";
import type { CodeDetail } from "@/types/commonCode";
import { DndContext, DragOverlay } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
import { useDragAndDrop } from "@/hooks/useDragAndDrop";
interface CodeDetailPanelProps {
codeInfo: string;
}
/**
* 디테일 트리 패널.
*
* BE 가 `/api/common-codes/detail?code_info=X` 로 depth+sort_order 평탄화 리스트를 내려준다.
* FE 는 parent_detail_id 로 children 맵을 만들어 indent + collapse 처리.
* sort_order 변경은 형제 단위 reorder → 각 행 update PUT 으로 직렬 호출.
*/
export function CodeDetailPanel({ codeInfo }: CodeDetailPanelProps) {
const [searchTerm, setSearchTerm] = useState("");
const [showActiveOnly, setShowActiveOnly] = useState(false);
const treeQuery = useCodeDetailTree(codeInfo || undefined, {
search: searchTerm || undefined,
active: showActiveOnly || undefined,
});
const rows: CodeDetail[] = useMemo(() => treeQuery.data || [], [treeQuery.data]);
const isLoading = treeQuery.isLoading;
const error = treeQuery.error;
const deleteMutation = useDeleteCodeDetail();
const updateMutation = useUpdateCodeDetail();
/** parent_detail_id → children */
const childrenMap = useMemo(() => {
const map = new Map<string, CodeDetail[]>();
for (const row of rows) {
const key = row.parent_detail_id == null ? "ROOT" : String(row.parent_detail_id);
const list = map.get(key) || [];
list.push(row);
map.set(key, list);
}
return map;
}, [rows]);
// 접기/펼치기 (code_detail_id Set)
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const toggleExpand = (id: string) => {
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
// 부모 행 중 하나라도 접혀있으면 자식 숨김
const isRowVisible = (row: CodeDetail): boolean => {
let parentId = row.parent_detail_id;
while (parentId != null) {
if (collapsed.has(String(parentId))) return false;
const parentRow = rows.find((r) => String(r.code_detail_id) === String(parentId));
parentId = parentRow ? parentRow.parent_detail_id : null;
}
return true;
};
const visibleRows = useMemo(() => rows.filter(isRowVisible), [rows, collapsed]);
// 모달 상태
const [showFormModal, setShowFormModal] = useState(false);
const [editingRow, setEditingRow] = useState<CodeDetail | null>(null);
const [defaultParentDetailId, setDefaultParentDetailId] = useState<number | null>(null);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deletingRow, setDeletingRow] = useState<CodeDetail | null>(null);
// sort_order 재할당 (형제 단위) — 드래그 결과를 PUT 으로 직렬 호출
const dragAndDrop = useDragAndDrop<CodeDetail>({
items: visibleRows,
getItemId: (row) => String(row.code_detail_id),
onReorder: async (reordered) => {
// visibleRows 기준으로 재정렬. 형제만 reorder 의미가 있으므로
// parent_detail_id 가 같은 그룹별로 처리.
const idMap = new Map<string, number>();
reordered.forEach(({ id, sortOrder }) => idMap.set(id, sortOrder));
// 같은 부모 안의 형제만 묶어서 정렬
const byParent = new Map<string, CodeDetail[]>();
for (const row of visibleRows) {
const parentKey =
row.parent_detail_id == null ? "ROOT" : String(row.parent_detail_id);
const list = byParent.get(parentKey) || [];
list.push(row);
byParent.set(parentKey, list);
}
for (const list of byParent.values()) {
const sorted = [...list].sort((a, b) => {
const ao = idMap.get(String(a.code_detail_id)) ?? a.sort_order ?? 0;
const bo = idMap.get(String(b.code_detail_id)) ?? b.sort_order ?? 0;
return ao - bo;
});
for (let i = 0; i < sorted.length; i++) {
const row = sorted[i];
const nextOrder = (i + 1) * 10;
if ((row.sort_order ?? 0) === nextOrder) continue;
await updateMutation.mutateAsync({
codeDetailId: row.code_detail_id,
data: {
code_info: row.code_info,
parent_detail_id: row.parent_detail_id ?? null,
code_value: row.code_value,
code_name: row.code_name,
code_name_eng: row.code_name_eng || "",
description: row.description || "",
sort_order: nextOrder,
is_active: row.is_active || "Y",
},
});
}
}
},
});
const handleNew = () => {
setEditingRow(null);
setDefaultParentDetailId(null);
setShowFormModal(true);
};
const handleEdit = (row: CodeDetail) => {
setEditingRow(row);
setDefaultParentDetailId(null);
setShowFormModal(true);
};
const handleAddChild = (parent: CodeDetail) => {
setEditingRow(null);
setDefaultParentDetailId(Number(parent.code_detail_id));
setShowFormModal(true);
};
const handleDelete = (row: CodeDetail) => {
setDeletingRow(row);
setShowDeleteModal(true);
};
const handleConfirmDelete = async () => {
if (!deletingRow) return;
try {
await deleteMutation.mutateAsync(deletingRow.code_detail_id);
setShowDeleteModal(false);
setDeletingRow(null);
} catch (error) {
console.error("디테일 삭제 실패:", error);
}
};
if (!codeInfo) {
return (
<div className="flex h-96 items-center justify-center">
<p className="text-sm text-muted-foreground"> </p>
</div>
);
}
if (error) {
return (
<div className="flex h-96 items-center justify-center">
<div className="text-center">
<p className="text-sm font-semibold text-destructive">
.
</p>
<Button
variant="outline"
onClick={() => window.location.reload()}
className="mt-4 h-10 text-sm font-medium"
>
</Button>
</div>
</div>
);
}
return (
<div className="flex h-full flex-col space-y-4">
<div className="space-y-3">
<div className="flex items-center gap-2">
<div className="relative w-full sm:w-[300px]">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="디테일 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-10 pl-10 text-sm"
/>
</div>
<Button onClick={handleNew} className="h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="activeOnlyDetail"
checked={showActiveOnly}
onChange={(e) => setShowActiveOnly(e.target.checked)}
className="h-4 w-4 rounded border-input"
/>
<label htmlFor="activeOnlyDetail" className="text-sm text-muted-foreground">
</label>
</div>
</div>
<div className="space-y-3">
{isLoading ? (
<div className="flex h-32 items-center justify-center">
<LoadingSpinner />
</div>
) : visibleRows.length === 0 ? (
<div className="flex h-32 items-center justify-center">
<p className="text-sm text-muted-foreground">
{rows.length === 0 ? "디테일이 없습니다." : "검색 결과가 없습니다."}
</p>
</div>
) : (
<DndContext {...dragAndDrop.dndContextProps}>
<SortableContext
items={visibleRows.map((r) => String(r.code_detail_id))}
strategy={verticalListSortingStrategy}
>
{visibleRows.map((row) => {
const idStr = String(row.code_detail_id);
const children = childrenMap.get(idStr) || [];
const hasChildren = children.length > 0;
const isExpanded = !collapsed.has(idStr);
return (
<SortableCodeItem
key={idStr}
row={row}
onEdit={() => handleEdit(row)}
onDelete={() => handleDelete(row)}
onAddChild={() => handleAddChild(row)}
hasChildren={hasChildren}
childCount={children.length}
isExpanded={isExpanded}
onToggleExpand={() => toggleExpand(idStr)}
/>
);
})}
</SortableContext>
<DragOverlay dropAnimation={null}>
{dragAndDrop.activeItem ? (
<div className="cursor-grabbing rounded-lg border bg-card p-4 shadow-lg">
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h4 className="text-sm font-semibold">
{dragAndDrop.activeItem.code_name}
</h4>
<Badge
variant={dragAndDrop.activeItem.is_active === "Y" ? "default" : "secondary"}
>
{dragAndDrop.activeItem.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{dragAndDrop.activeItem.code_value}
</p>
</div>
</div>
</div>
) : null}
</DragOverlay>
</DndContext>
)}
</div>
{showFormModal && (
<CodeFormModal
isOpen={showFormModal}
onClose={() => {
setShowFormModal(false);
setEditingRow(null);
setDefaultParentDetailId(null);
}}
codeInfo={codeInfo}
editingRow={editingRow}
allRows={rows}
defaultParentDetailId={defaultParentDetailId}
/>
)}
{showDeleteModal && (
<AlertModal
isOpen={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
type="error"
title="디테일 삭제"
message="정말로 이 코드를 삭제하시겠습니까? 하위 코드도 모두 함께 삭제됩니다 (CASCADE)."
confirmText="삭제"
onConfirm={handleConfirmDelete}
/>
)}
</div>
);
}
// `arrayMove` import 유지 위해 keep no-op (트리 reorder 로직에서 향후 활용 가능)
void arrayMove;