"use client"; /** * 카테고리 값 관리 - 트리 구조 버전 * - 3단계 트리 구조 지원 (대분류/중분류/소분류) * - 체크박스를 통한 다중 선택 및 일괄 삭제 지원 */ import React, { useState, useEffect, useCallback, useMemo } 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 { Checkbox } from "@/components/ui/checkbox"; 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; selectedValueId?: number; searchQuery: string; checkedIds: Set; onToggle: (valueId: number) => void; onSelect: (value: CategoryValue) => void; onAdd: (parentValue: CategoryValue | null) => void; onEdit: (value: CategoryValue) => void; onDelete: (value: CategoryValue) => void; onCheck: (valueId: number, checked: boolean) => 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 = ({ node, level, expandedNodes, selectedValueId, searchQuery, checkedIds, onToggle, onSelect, onAdd, onEdit, onDelete, onCheck, }) => { const hasChildren = node.children && node.children.length > 0; const isExpanded = expandedNodes.has(node.valueId); const isSelected = selectedValueId === node.valueId; const isChecked = checkedIds.has(node.valueId); const canAddChild = node.depth < 3; // 검색 필터링 if (searchQuery && !nodeMatchesSearch(node, searchQuery)) { return null; } // 깊이별 아이콘 const getIcon = () => { if (hasChildren) { return isExpanded ? ( ) : ( ); } return ; }; // 깊이별 라벨 const getDepthLabel = () => { switch (node.depth) { case 1: return "대분류"; case 2: return "중분류"; case 3: return "소분류"; default: return ""; } }; return (
onSelect(node)} > {/* 체크박스 */} { onCheck(node.valueId, checked as boolean); }} onClick={(e) => e.stopPropagation()} className="mr-1" /> {/* 확장 토글 */} {/* 아이콘 */} {getIcon()} {/* 라벨 */}
{node.valueLabel} {getDepthLabel()}
{/* 비활성 표시 */} {!node.isActive && ( 비활성 )} {/* 액션 버튼 */}
{canAddChild && ( )}
{/* 자식 노드 */} {hasChildren && isExpanded && (
{node.children!.map((child) => ( ))}
)}
); }; export const CategoryValueManagerTree: React.FC = ({ tableName, columnName, columnLabel, onValueCountChange, }) => { // 상태 const [tree, setTree] = useState([]); const [loading, setLoading] = useState(false); const [expandedNodes, setExpandedNodes] = useState>(new Set()); const [selectedValue, setSelectedValue] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [showInactive, setShowInactive] = useState(false); const [checkedIds, setCheckedIds] = useState>(new Set()); // 모달 상태 const [isAddModalOpen, setIsAddModalOpen] = useState(false); const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false); const [parentValue, setParentValue] = useState(null); const [editingValue, setEditingValue] = useState(null); const [deletingValue, setDeletingValue] = useState(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], ); // 노드와 모든 하위 항목의 ID 수집 const collectNodeAndDescendantIds = useCallback((node: CategoryValue): number[] => { const ids: number[] = [node.valueId]; if (node.children) { for (const child of node.children) { ids.push(...collectNodeAndDescendantIds(child)); } } return ids; }, []); // 트리에서 valueId로 노드 찾기 const findNodeById = useCallback((nodes: CategoryValue[], valueId: number): CategoryValue | null => { for (const node of nodes) { if (node.valueId === valueId) { return node; } if (node.children) { const found = findNodeById(node.children, valueId); if (found) return found; } } return null; }, []); // 체크된 항목들의 총 삭제 대상 수 계산 (하위 포함) const totalDeleteCount = useMemo(() => { const allIds = new Set(); checkedIds.forEach((id) => { const node = findNodeById(tree, id); if (node) { collectNodeAndDescendantIds(node).forEach((descendantId) => allIds.add(descendantId)); } }); return allIds.size; }, [checkedIds, tree, findNodeById, collectNodeAndDescendantIds]); // 활성 노드만 필터링 const filterActiveNodes = useCallback((nodes: CategoryValue[]): CategoryValue[] => { return nodes .filter((node) => node.isActive !== false) .map((node) => ({ ...node, children: node.children ? filterActiveNodes(node.children) : undefined, })); }, []); // 데이터 로드 (keepExpanded: true면 기존 펼침 상태 유지) const loadTree = useCallback( async (keepExpanded = false) => { 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); // 기존 펼침 상태 유지하지 않으면 모두 접기 (대분류만 표시) if (!keepExpanded) { setExpandedNodes(new Set()); } // 전체 개수 업데이트 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(); 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 handleCheck = useCallback((valueId: number, checked: boolean) => { setCheckedIds((prev) => { const next = new Set(prev); if (checked) { next.add(valueId); } else { next.delete(valueId); } return next; }); }, []); // 전체 선택/해제 const handleSelectAll = useCallback(() => { if (checkedIds.size === countAllValues(tree)) { setCheckedIds(new Set()); } else { const allIds = new Set(); const collectAllIds = (nodes: CategoryValue[]) => { for (const node of nodes) { allIds.add(node.valueId); if (node.children) { collectAllIds(node.children); } } }; collectAllIds(tree); setCheckedIds(allIds); } }, [checkedIds.size, tree, countAllValues]); // 선택 해제 const handleClearSelection = useCallback(() => { setCheckedIds(new Set()); }, []); // 추가 모달 열기 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); // 기존 펼침 상태 유지하면서 데이터 새로고침 await loadTree(true); // 부모 노드만 펼치기 (하위 추가 시) 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(true); // 기존 펼침 상태 유지 } 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); setCheckedIds((prev) => { const next = new Set(prev); next.delete(deletingValue.valueId); return next; }); loadTree(true); // 기존 펼침 상태 유지 } else { toast.error(response.error || "삭제 실패"); } } catch (error) { console.error("카테고리 삭제 오류:", error); toast.error("카테고리 삭제 중 오류가 발생했습니다"); } }; // 다중 삭제 처리 const handleBulkDelete = async () => { if (checkedIds.size === 0) return; try { let successCount = 0; let failCount = 0; // 체크된 항목들을 순차적으로 삭제 (하위는 백엔드에서 자동 삭제) for (const valueId of Array.from(checkedIds)) { try { const response = await deleteCategoryValue(valueId); if (response.success) { successCount++; } else { failCount++; } } catch { failCount++; } } setIsBulkDeleteDialogOpen(false); setCheckedIds(new Set()); setSelectedValue(null); loadTree(true); // 기존 펼침 상태 유지 if (failCount === 0) { toast.success(`${successCount}개 카테고리가 삭제되었습니다 (하위 항목 포함)`); } else { toast.warning(`${successCount}개 삭제 성공, ${failCount}개 삭제 실패`); } } catch (error) { console.error("카테고리 일괄 삭제 오류:", error); toast.error("카테고리 일괄 삭제 중 오류가 발생했습니다"); } }; return (
{/* 헤더 */}

{columnLabel} 카테고리

{checkedIds.size > 0 && ( {checkedIds.size}개 선택 )}
{checkedIds.size > 0 && ( <> )}
{/* 툴바 */}
{/* 검색 */}
setSearchQuery(e.target.value)} className="h-8 pl-8 text-sm" />
{/* 옵션 */}
{/* 트리 */}
{loading ? (
로딩 중...
) : tree.length === 0 ? (

카테고리가 없습니다

상단의 대분류 추가 버튼을 클릭하여 시작하세요

) : (
{tree.map((node) => ( ))}
)}
{/* 추가 모달 */} {parentValue ? `"${parentValue.valueLabel}" 하위 추가` : "대분류 추가"} {parentValue ? `${parentValue.depth + 1}단계 카테고리를 추가합니다` : "1단계 대분류 카테고리를 추가합니다"}
setFormData({ ...formData, valueLabel: e.target.value })} placeholder="카테고리 이름을 입력하세요" className="h-9 text-sm" />

코드는 자동으로 생성됩니다

setFormData({ ...formData, description: e.target.value })} placeholder="선택 사항" className="h-9 text-sm" />
setFormData({ ...formData, isActive: checked })} />
{/* 수정 모달 */} 카테고리 수정 카테고리 정보를 수정합니다
setFormData({ ...formData, valueLabel: e.target.value })} className="h-9 text-sm" />
setFormData({ ...formData, description: e.target.value })} className="h-9 text-sm" />
setFormData({ ...formData, isActive: checked })} />
{/* 삭제 확인 다이얼로그 */} 카테고리 삭제 {deletingValue?.valueLabel}을(를) 삭제하시겠습니까? {deletingValue && countAllDescendants(deletingValue) > 0 && ( <>
하위 카테고리 {countAllDescendants(deletingValue)}개도 모두 함께 삭제됩니다. )}
취소 삭제
{/* 다중 삭제 확인 다이얼로그 */} 카테고리 일괄 삭제 선택한 {checkedIds.size}개 카테고리를 삭제하시겠습니까? {totalDeleteCount > checkedIds.size && ( <>
하위 카테고리 포함 총 {totalDeleteCount}개가 삭제됩니다. )}
삭제된 카테고리는 복구할 수 없습니다.
취소 {totalDeleteCount}개 삭제
); }; export default CategoryValueManagerTree;