2348800e68
Build & Deploy to K8s / build-and-deploy (push) Successful in 9m22s
카테고리/캐스케이딩 시스템 (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>
327 lines
12 KiB
TypeScript
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;
|