ERP-node/frontend/components/table-category/CategoryValueManagerTree.tsx

729 lines
23 KiB
TypeScript

"use client";
/**
* 카테고리 값 관리 - 트리 구조 버전
* - 3단계 트리 구조 지원 (대분류/중분류/소분류)
*/
import React, { useState, useEffect, useCallback } from "react";
import {
ChevronRight,
ChevronDown,
Plus,
Pencil,
Trash2,
Folder,
FolderOpen,
Tag,
Search,
RefreshCw,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import {
CategoryValue,
getCategoryTree,
createCategoryValue,
updateCategoryValue,
deleteCategoryValue,
CreateCategoryValueInput,
} from "@/lib/api/categoryTree";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import { Switch } from "@/components/ui/switch";
interface CategoryValueManagerTreeProps {
tableName: string;
columnName: string;
columnLabel: string;
onValueCountChange?: (count: number) => void;
}
// 트리 노드 컴포넌트
interface TreeNodeProps {
node: CategoryValue;
level: number;
expandedNodes: Set<number>;
selectedValueId?: number;
searchQuery: string;
onToggle: (valueId: number) => void;
onSelect: (value: CategoryValue) => void;
onAdd: (parentValue: CategoryValue | null) => void;
onEdit: (value: CategoryValue) => void;
onDelete: (value: CategoryValue) => void;
}
// 검색어가 노드 또는 하위에 매칭되는지 확인
const nodeMatchesSearch = (node: CategoryValue, query: string): boolean => {
if (!query) return true;
const lowerQuery = query.toLowerCase();
if (node.valueLabel.toLowerCase().includes(lowerQuery)) return true;
if (node.valueCode.toLowerCase().includes(lowerQuery)) return true;
if (node.children) {
return node.children.some((child) => nodeMatchesSearch(child, query));
}
return false;
};
const TreeNode: React.FC<TreeNodeProps> = ({
node,
level,
expandedNodes,
selectedValueId,
searchQuery,
onToggle,
onSelect,
onAdd,
onEdit,
onDelete,
}) => {
const hasChildren = node.children && node.children.length > 0;
const isExpanded = expandedNodes.has(node.valueId);
const isSelected = selectedValueId === node.valueId;
const canAddChild = node.depth < 3;
// 검색 필터링
if (searchQuery && !nodeMatchesSearch(node, searchQuery)) {
return null;
}
// 깊이별 아이콘
const getIcon = () => {
if (hasChildren) {
return isExpanded ? (
<FolderOpen className="h-4 w-4 text-amber-500" />
) : (
<Folder className="h-4 w-4 text-amber-500" />
);
}
return <Tag className="h-4 w-4 text-blue-500" />;
};
// 깊이별 라벨
const getDepthLabel = () => {
switch (node.depth) {
case 1:
return "대분류";
case 2:
return "중분류";
case 3:
return "소분류";
default:
return "";
}
};
return (
<div>
<div
className={cn(
"group flex items-center gap-1 rounded-md px-2 py-2 transition-colors",
isSelected ? "border-primary bg-primary/10 border-l-2" : "hover:bg-muted/50",
"cursor-pointer",
)}
style={{ paddingLeft: `${level * 20 + 8}px` }}
onClick={() => onSelect(node)}
>
{/* 확장 토글 */}
<button
type="button"
className="hover:bg-muted flex h-6 w-6 items-center justify-center rounded"
onClick={(e) => {
e.stopPropagation();
if (hasChildren) {
onToggle(node.valueId);
}
}}
>
{hasChildren ? (
isExpanded ? (
<ChevronDown className="text-muted-foreground h-4 w-4" />
) : (
<ChevronRight className="text-muted-foreground h-4 w-4" />
)
) : (
<span className="h-4 w-4" />
)}
</button>
{/* 아이콘 */}
{getIcon()}
{/* 라벨 */}
<div className="flex flex-1 items-center gap-2">
<span className={cn("text-sm", node.depth === 1 && "font-medium")}>{node.valueLabel}</span>
<span className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px]">{getDepthLabel()}</span>
</div>
{/* 비활성 표시 */}
{!node.isActive && (
<span className="bg-destructive/10 text-destructive rounded px-1.5 py-0.5 text-[10px]"></span>
)}
{/* 액션 버튼 */}
<div className="flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
{canAddChild && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(e) => {
e.stopPropagation();
onAdd(node);
}}
title="하위 추가"
>
<Plus className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(e) => {
e.stopPropagation();
onEdit(node);
}}
title="수정"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive h-7 w-7"
onClick={(e) => {
e.stopPropagation();
onDelete(node);
}}
title="삭제"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* 자식 노드 */}
{hasChildren && isExpanded && (
<div>
{node.children!.map((child) => (
<TreeNode
key={child.valueId}
node={child}
level={level + 1}
expandedNodes={expandedNodes}
selectedValueId={selectedValueId}
searchQuery={searchQuery}
onToggle={onToggle}
onSelect={onSelect}
onAdd={onAdd}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
</div>
)}
</div>
);
};
export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> = ({
tableName,
columnName,
columnLabel,
onValueCountChange,
}) => {
// 상태
const [tree, setTree] = useState<CategoryValue[]>([]);
const [loading, setLoading] = useState(false);
const [expandedNodes, setExpandedNodes] = useState<Set<number>>(new Set());
const [selectedValue, setSelectedValue] = useState<CategoryValue | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [showInactive, setShowInactive] = useState(false);
// 모달 상태
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [parentValue, setParentValue] = useState<CategoryValue | null>(null);
const [editingValue, setEditingValue] = useState<CategoryValue | null>(null);
const [deletingValue, setDeletingValue] = useState<CategoryValue | null>(null);
// 폼 상태
const [formData, setFormData] = useState({
valueCode: "",
valueLabel: "",
description: "",
color: "",
isActive: true,
});
// 전체 값 개수 계산
const countAllValues = useCallback((nodes: CategoryValue[]): number => {
let count = nodes.length;
for (const node of nodes) {
if (node.children) {
count += countAllValues(node.children);
}
}
return count;
}, []);
// 하위 항목 개수만 계산 (자기 자신 제외)
const countAllDescendants = useCallback((node: CategoryValue): number => {
if (!node.children || node.children.length === 0) {
return 0;
}
return countAllValues(node.children);
}, [countAllValues]);
// 활성 노드만 필터링
const filterActiveNodes = useCallback((nodes: CategoryValue[]): CategoryValue[] => {
return nodes
.filter((node) => node.isActive !== false)
.map((node) => ({
...node,
children: node.children ? filterActiveNodes(node.children) : undefined,
}));
}, []);
// 데이터 로드
const loadTree = useCallback(async () => {
if (!tableName || !columnName) return;
setLoading(true);
try {
const response = await getCategoryTree(tableName, columnName);
if (response.success && response.data) {
let filteredTree = response.data;
// 비활성 필터링
if (!showInactive) {
filteredTree = filterActiveNodes(response.data);
}
setTree(filteredTree);
// 1단계 노드는 기본 펼침
const rootIds = new Set(filteredTree.map((n) => n.valueId));
setExpandedNodes(rootIds);
// 전체 개수 업데이트
const totalCount = countAllValues(response.data);
onValueCountChange?.(totalCount);
}
} catch (error) {
console.error("카테고리 트리 로드 오류:", error);
} finally {
setLoading(false);
}
}, [tableName, columnName, showInactive, countAllValues, filterActiveNodes, onValueCountChange]);
useEffect(() => {
loadTree();
}, [loadTree]);
// 모든 노드 펼치기
const expandAll = () => {
const allIds = new Set<number>();
const collectIds = (nodes: CategoryValue[]) => {
for (const node of nodes) {
allIds.add(node.valueId);
if (node.children) {
collectIds(node.children);
}
}
};
collectIds(tree);
setExpandedNodes(allIds);
};
// 모든 노드 접기
const collapseAll = () => {
setExpandedNodes(new Set());
};
// 토글 핸들러
const handleToggle = (valueId: number) => {
setExpandedNodes((prev) => {
const next = new Set(prev);
if (next.has(valueId)) {
next.delete(valueId);
} else {
next.add(valueId);
}
return next;
});
};
// 추가 모달 열기
const handleOpenAddModal = (parent: CategoryValue | null) => {
setParentValue(parent);
setFormData({
valueCode: "",
valueLabel: "",
description: "",
color: "",
isActive: true,
});
setIsAddModalOpen(true);
};
// 수정 모달 열기
const handleOpenEditModal = (value: CategoryValue) => {
setEditingValue(value);
setFormData({
valueCode: value.valueCode,
valueLabel: value.valueLabel,
description: value.description || "",
color: value.color || "",
isActive: value.isActive,
});
setIsEditModalOpen(true);
};
// 삭제 다이얼로그 열기
const handleOpenDeleteDialog = (value: CategoryValue) => {
setDeletingValue(value);
setIsDeleteDialogOpen(true);
};
// 코드 자동 생성 함수
const generateCode = () => {
const timestamp = Date.now().toString(36).toUpperCase();
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
return `CAT_${timestamp}_${random}`;
};
// 추가 처리
const handleAdd = async () => {
if (!formData.valueLabel) {
toast.error("이름은 필수입니다");
return;
}
try {
// 코드 자동 생성
const autoCode = generateCode();
const input: CreateCategoryValueInput = {
tableName,
columnName,
valueCode: autoCode,
valueLabel: formData.valueLabel,
parentValueId: parentValue?.valueId || null,
description: formData.description || undefined,
color: formData.color || undefined,
isActive: formData.isActive,
};
const response = await createCategoryValue(input);
if (response.success) {
toast.success("카테고리가 추가되었습니다");
setIsAddModalOpen(false);
loadTree();
if (parentValue) {
setExpandedNodes((prev) => new Set([...prev, parentValue.valueId]));
}
} else {
toast.error(response.error || "추가 실패");
}
} catch (error) {
console.error("카테고리 추가 오류:", error);
toast.error("카테고리 추가 중 오류가 발생했습니다");
}
};
// 수정 처리
const handleEdit = async () => {
if (!editingValue) return;
try {
// 코드는 변경하지 않음 (기존 코드 유지)
const response = await updateCategoryValue(editingValue.valueId, {
valueLabel: formData.valueLabel,
description: formData.description || undefined,
color: formData.color || undefined,
isActive: formData.isActive,
});
if (response.success) {
toast.success("카테고리가 수정되었습니다");
setIsEditModalOpen(false);
loadTree();
} else {
toast.error(response.error || "수정 실패");
}
} catch (error) {
console.error("카테고리 수정 오류:", error);
toast.error("카테고리 수정 중 오류가 발생했습니다");
}
};
// 삭제 처리
const handleDelete = async () => {
if (!deletingValue) return;
try {
const response = await deleteCategoryValue(deletingValue.valueId);
if (response.success) {
toast.success("카테고리가 삭제되었습니다");
setIsDeleteDialogOpen(false);
setSelectedValue(null);
loadTree();
} else {
toast.error(response.error || "삭제 실패");
}
} catch (error) {
console.error("카테고리 삭제 오류:", error);
toast.error("카테고리 삭제 중 오류가 발생했습니다");
}
};
return (
<div className="flex h-full flex-col">
{/* 헤더 */}
<div className="mb-3 flex items-center justify-between border-b pb-3">
<h3 className="text-base font-semibold">{columnLabel} </h3>
<Button variant="default" size="sm" className="h-8 gap-1.5 text-xs" onClick={() => handleOpenAddModal(null)}>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
{/* 툴바 */}
<div className="mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
{/* 검색 */}
<div className="relative max-w-xs flex-1">
<Search className="text-muted-foreground absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="검색..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 pl-8 text-sm"
/>
</div>
{/* 옵션 */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Switch id="showInactive" checked={showInactive} onCheckedChange={setShowInactive} />
<Label htmlFor="showInactive" className="cursor-pointer text-xs">
</Label>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={expandAll}>
</Button>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={collapseAll}>
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={loadTree} title="새로고침">
<RefreshCw className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
{/* 트리 */}
<div className="bg-card min-h-[300px] flex-1 overflow-y-auto rounded-md border">
{loading ? (
<div className="flex h-full items-center justify-center">
<div className="text-muted-foreground text-sm"> ...</div>
</div>
) : tree.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center p-6 text-center">
<Folder className="text-muted-foreground/30 mb-3 h-12 w-12" />
<p className="text-muted-foreground text-sm"> </p>
<p className="text-muted-foreground mt-1 text-xs"> </p>
</div>
) : (
<div className="p-2">
{tree.map((node) => (
<TreeNode
key={node.valueId}
node={node}
level={0}
expandedNodes={expandedNodes}
selectedValueId={selectedValue?.valueId}
searchQuery={searchQuery}
onToggle={handleToggle}
onSelect={setSelectedValue}
onAdd={handleOpenAddModal}
onEdit={handleOpenEditModal}
onDelete={handleOpenDeleteDialog}
/>
))}
</div>
)}
</div>
{/* 추가 모달 */}
<Dialog open={isAddModalOpen} onOpenChange={setIsAddModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[450px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{parentValue ? `"${parentValue.valueLabel}" 하위 추가` : "대분류 추가"}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{parentValue ? `${parentValue.depth + 1}단계 카테고리를 추가합니다` : "1단계 대분류 카테고리를 추가합니다"}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="valueLabel" className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Input
id="valueLabel"
value={formData.valueLabel}
onChange={(e) => setFormData({ ...formData, valueLabel: e.target.value })}
placeholder="카테고리 이름을 입력하세요"
className="h-9 text-sm"
/>
<p className="text-muted-foreground mt-1 text-[10px]"> </p>
</div>
<div>
<Label htmlFor="description" className="text-xs sm:text-sm">
</Label>
<Input
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="선택 사항"
className="h-9 text-sm"
/>
</div>
<div className="flex items-center gap-2">
<Switch
id="isActive"
checked={formData.isActive}
onCheckedChange={(checked) => setFormData({ ...formData, isActive: checked })}
/>
<Label htmlFor="isActive" className="cursor-pointer text-sm">
</Label>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setIsAddModalOpen(false)} className="h-9 flex-1 text-sm sm:flex-none">
</Button>
<Button onClick={handleAdd} className="h-9 flex-1 text-sm sm:flex-none">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 수정 모달 */}
<Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[450px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm"> </DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="editValueLabel" className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Input
id="editValueLabel"
value={formData.valueLabel}
onChange={(e) => setFormData({ ...formData, valueLabel: e.target.value })}
className="h-9 text-sm"
/>
</div>
<div>
<Label htmlFor="editDescription" className="text-xs sm:text-sm">
</Label>
<Input
id="editDescription"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="h-9 text-sm"
/>
</div>
<div className="flex items-center gap-2">
<Switch
id="editIsActive"
checked={formData.isActive}
onCheckedChange={(checked) => setFormData({ ...formData, isActive: checked })}
/>
<Label htmlFor="editIsActive" className="cursor-pointer text-sm">
</Label>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setIsEditModalOpen(false)} className="h-9 flex-1 text-sm sm:flex-none">
</Button>
<Button onClick={handleEdit} className="h-9 flex-1 text-sm sm:flex-none">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
<strong>{deletingValue?.valueLabel}</strong>() ?
{deletingValue && countAllDescendants(deletingValue) > 0 && (
<>
<br />
<span className="text-destructive">
{countAllDescendants(deletingValue)} .
</span>
</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
export default CategoryValueManagerTree;