"use client"; import { useState, useEffect, useCallback, useMemo } from "react"; import { cn } from "@/lib/utils"; import { ChevronRight, ChevronDown, ChevronUp, Folder, FolderOpen, Monitor, Plus, MoreVertical, Edit, Trash2, Loader2, RefreshCw, FolderPlus, MoveRight, ArrowUp, ArrowDown, Search, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { ScrollArea } from "@/components/ui/scroll-area"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } 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 { ScreenDefinition } from "@/types/screen"; import { apiClient } from "@/lib/api/client"; import { PopScreenGroup, getPopScreenGroups, createPopScreenGroup, updatePopScreenGroup, deletePopScreenGroup, ensurePopRootGroup, buildPopGroupTree, } from "@/lib/api/popScreenGroup"; // ============================================================ // 타입 정의 // ============================================================ interface PopCategoryTreeProps { screens: ScreenDefinition[]; // POP 레이아웃이 있는 화면 목록 selectedScreen: ScreenDefinition | null; onScreenSelect: (screen: ScreenDefinition) => void; onScreenDesign: (screen: ScreenDefinition) => void; onGroupSelect?: (group: PopScreenGroup | null) => void; searchTerm?: string; } interface TreeNodeProps { group: PopScreenGroup; level: number; expandedGroups: Set; onToggle: (groupId: number) => void; selectedGroupId: number | null; selectedScreenId: number | null; onGroupSelect: (group: PopScreenGroup) => void; onScreenSelect: (screen: ScreenDefinition) => void; onScreenDesign: (screen: ScreenDefinition) => void; onEditGroup: (group: PopScreenGroup) => void; onDeleteGroup: (group: PopScreenGroup) => void; onAddSubGroup: (parentGroup: PopScreenGroup) => void; screensMap: Map; // 화면 이동/삭제 관련 onOpenMoveModal: (screen: ScreenDefinition, fromGroupId: number | null) => void; onRemoveScreenFromGroup: (screen: ScreenDefinition, groupId: number) => void; // 순서 변경 관련 siblingGroups: PopScreenGroup[]; // 같은 레벨의 그룹들 onMoveGroupUp: (group: PopScreenGroup) => void; onMoveGroupDown: (group: PopScreenGroup) => void; onMoveScreenUp: (screen: ScreenDefinition, groupId: number) => void; onMoveScreenDown: (screen: ScreenDefinition, groupId: number) => void; onDeleteScreen: (screen: ScreenDefinition) => void; } // ============================================================ // 트리 노드 컴포넌트 // ============================================================ function TreeNode({ group, level, onOpenMoveModal, onRemoveScreenFromGroup, siblingGroups, onMoveGroupUp, onMoveGroupDown, onMoveScreenUp, onMoveScreenDown, onDeleteScreen, expandedGroups, onToggle, selectedGroupId, selectedScreenId, onGroupSelect, onScreenSelect, onScreenDesign, onEditGroup, onDeleteGroup, onAddSubGroup, screensMap, }: TreeNodeProps) { const isExpanded = expandedGroups.has(group.id); const hasChildren = (group.children && group.children.length > 0) || (group.screens && group.screens.length > 0); const isSelected = selectedGroupId === group.id; // 그룹에 연결된 화면 목록 const groupScreens = useMemo(() => { if (!group.screens) return []; return group.screens .map((gs) => screensMap.get(gs.screen_id)) .filter((s): s is ScreenDefinition => s !== undefined); }, [group.screens, screensMap]); // 루트 레벨(POP 화면)인지 확인 const isRootLevel = level === 0; // 그룹 순서 변경 가능 여부 계산 const groupIndex = siblingGroups.findIndex((g) => g.id === group.id); const canMoveGroupUp = groupIndex > 0; const canMoveGroupDown = groupIndex < siblingGroups.length - 1; return (
{/* 그룹 노드 */}
onGroupSelect(group)} > {/* 트리 연결 표시 (하위 레벨만) */} {level > 0 && ( )} {/* 확장/축소 버튼 */} {/* 폴더 아이콘 - 루트는 다른 색상 */} {isExpanded && hasChildren ? ( ) : ( )} {/* 그룹명 - 루트는 볼드체 */} {group.group_name} {/* 화면 수 배지 */} {group.screen_count && group.screen_count > 0 && ( {group.screen_count} )} {/* 더보기 메뉴 */} onAddSubGroup(group)}> 하위 그룹 추가 onEditGroup(group)}> 수정 onMoveGroupUp(group)} disabled={!canMoveGroupUp} > 위로 이동 onMoveGroupDown(group)} disabled={!canMoveGroupDown} > 아래로 이동 onDeleteGroup(group)} > 삭제
{/* 확장된 경우 하위 요소 렌더링 */} {isExpanded && ( <> {/* 하위 그룹 */} {group.children?.map((child) => ( ))} {/* 그룹에 연결된 화면 */} {groupScreens.map((screen, screenIndex) => { const canMoveScreenUp = screenIndex > 0; const canMoveScreenDown = screenIndex < groupScreens.length - 1; return (
onScreenSelect(screen)} onDoubleClick={() => onScreenDesign(screen)} > {/* 트리 연결 표시 */} {screen.screenName} #{screen.screenId} {/* 더보기 메뉴 (폴더와 동일한 스타일) */} onScreenDesign(screen)}> 설계 onMoveScreenUp(screen, group.id)} disabled={!canMoveScreenUp} > 위로 이동 onMoveScreenDown(screen, group.id)} disabled={!canMoveScreenDown} > 아래로 이동 onOpenMoveModal(screen, group.id)}> 다른 카테고리로 이동 onRemoveScreenFromGroup(screen, group.id)} > 그룹에서 제거 onDeleteScreen(screen)} > 화면 삭제
); })} )}
); } // ============================================================ // 메인 컴포넌트 // ============================================================ export function PopCategoryTree({ screens, selectedScreen, onScreenSelect, onScreenDesign, onGroupSelect, searchTerm = "", }: PopCategoryTreeProps) { // 상태 관리 const [groups, setGroups] = useState([]); const [loading, setLoading] = useState(true); const [expandedGroups, setExpandedGroups] = useState>(new Set()); const [selectedGroupId, setSelectedGroupId] = useState(null); // 그룹 모달 상태 const [isGroupModalOpen, setIsGroupModalOpen] = useState(false); const [editingGroup, setEditingGroup] = useState(null); const [parentGroupId, setParentGroupId] = useState(null); const [groupFormData, setGroupFormData] = useState({ group_name: "", group_code: "", description: "", icon: "", }); // 그룹 삭제 다이얼로그 상태 const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [deletingGroup, setDeletingGroup] = useState(null); // 화면 삭제 다이얼로그 상태 const [isScreenDeleteDialogOpen, setIsScreenDeleteDialogOpen] = useState(false); const [deletingScreen, setDeletingScreen] = useState(null); // 이동 모달 상태 const [isMoveModalOpen, setIsMoveModalOpen] = useState(false); const [movingScreen, setMovingScreen] = useState(null); const [movingFromGroupId, setMovingFromGroupId] = useState(null); const [moveSearchTerm, setMoveSearchTerm] = useState(""); // 화면 맵 생성 (screen_id로 빠르게 조회) const screensMap = useMemo(() => { const map = new Map(); screens.forEach((s) => map.set(s.screenId, s)); return map; }, [screens]); // 그룹 데이터 로드 const loadGroups = useCallback(async () => { try { setLoading(true); // 먼저 POP 루트 그룹 확보 await ensurePopRootGroup(); // 그룹 목록 조회 const data = await getPopScreenGroups(searchTerm); setGroups(data); // 첫 로드 시 루트 그룹들 자동 확장 if (expandedGroups.size === 0 && data.length > 0) { const rootIds = data .filter((g) => g.hierarchy_path === "POP" || g.hierarchy_path?.split("/").length === 2) .map((g) => g.id); setExpandedGroups(new Set(rootIds)); } } catch (error) { console.error("POP 그룹 로드 실패:", error); toast.error("그룹 목록 로드에 실패했습니다."); } finally { setLoading(false); } }, [searchTerm]); useEffect(() => { loadGroups(); }, [loadGroups]); // 트리 구조로 변환 const treeData = useMemo(() => buildPopGroupTree(groups), [groups]); // 그룹 토글 const handleToggle = (groupId: number) => { setExpandedGroups((prev) => { const next = new Set(prev); if (next.has(groupId)) { next.delete(groupId); } else { next.add(groupId); } return next; }); }; // 그룹 선택 const handleGroupSelect = (group: PopScreenGroup) => { setSelectedGroupId(group.id); onGroupSelect?.(group); }; // 그룹 생성/수정 모달 열기 const openGroupModal = (parentGroup?: PopScreenGroup, editGroup?: PopScreenGroup) => { if (editGroup) { setEditingGroup(editGroup); setParentGroupId(editGroup.parent_group_id || null); setGroupFormData({ group_name: editGroup.group_name, group_code: editGroup.group_code, description: editGroup.description || "", icon: editGroup.icon || "", }); } else { setEditingGroup(null); setParentGroupId(parentGroup?.id || null); setGroupFormData({ group_name: "", group_code: "", description: "", icon: "", }); } setIsGroupModalOpen(true); }; // 그룹 저장 const handleSaveGroup = async () => { if (!groupFormData.group_name || !groupFormData.group_code) { toast.error("그룹명과 그룹코드는 필수입니다."); return; } try { if (editingGroup) { // 수정 const result = await updatePopScreenGroup(editingGroup.id, { group_name: groupFormData.group_name, description: groupFormData.description, icon: groupFormData.icon, }); if (result.success) { toast.success("그룹이 수정되었습니다."); loadGroups(); } else { toast.error(result.message || "수정에 실패했습니다."); } } else { // 생성 const result = await createPopScreenGroup({ group_name: groupFormData.group_name, group_code: groupFormData.group_code, description: groupFormData.description, icon: groupFormData.icon, parent_group_id: parentGroupId, }); if (result.success) { toast.success("그룹이 생성되었습니다."); loadGroups(); } else { toast.error(result.message || "생성에 실패했습니다."); } } setIsGroupModalOpen(false); } catch (error) { console.error("그룹 저장 실패:", error); toast.error("그룹 저장에 실패했습니다."); } }; // 그룹 삭제 const handleDeleteGroup = async () => { if (!deletingGroup) return; try { const result = await deletePopScreenGroup(deletingGroup.id); if (result.success) { toast.success("그룹이 삭제되었습니다."); loadGroups(); if (selectedGroupId === deletingGroup.id) { setSelectedGroupId(null); onGroupSelect?.(null); } } else { toast.error(result.message || "삭제에 실패했습니다."); } } catch (error) { console.error("그룹 삭제 실패:", error); toast.error("그룹 삭제에 실패했습니다."); } finally { setIsDeleteDialogOpen(false); setDeletingGroup(null); } }; // 화면을 그룹으로 이동 (기존 연결 삭제 후 새 연결 추가) const handleMoveScreenToGroup = async (screen: ScreenDefinition, targetGroup: PopScreenGroup) => { try { // 1. 기존 연결 정보 찾기 (모든 그룹에서 해당 화면의 연결 찾기) let existingLinkId: number | null = null; for (const g of groups) { const screenLink = g.screens?.find((s) => s.screen_id === screen.screenId); if (screenLink) { existingLinkId = screenLink.id; break; } } // 2. 기존 연결이 있으면 삭제 if (existingLinkId) { await apiClient.delete(`/screen-groups/group-screens/${existingLinkId}`); } // 3. 새 그룹에 연결 추가 const response = await apiClient.post("/screen-groups/group-screens", { group_id: targetGroup.id, screen_id: screen.screenId, screen_role: "main", display_order: 0, is_default: false, }); if (response.data.success) { toast.success(`"${screen.screenName}"을(를) "${(targetGroup as any)._displayName || targetGroup.group_name}"으로 이동했습니다.`); loadGroups(); // 그룹 목록 새로고침 } else { throw new Error(response.data.message || "이동 실패"); } } catch (error: any) { console.error("화면 이동 실패:", error); toast.error(error.response?.data?.message || error.message || "화면 이동에 실패했습니다."); } }; // 그룹에서 화면 제거 const handleRemoveScreenFromGroup = async (screen: ScreenDefinition, groupId: number) => { try { // 해당 그룹에서 화면 연결 정보 찾기 const targetGroup = groups.find((g) => g.id === groupId); const screenLink = targetGroup?.screens?.find((s) => s.screen_id === screen.screenId); if (!screenLink) { toast.error("연결 정보를 찾을 수 없습니다."); return; } await apiClient.delete(`/screen-groups/group-screens/${screenLink.id}`); toast.success(`"${screen.screenName}"을(를) 그룹에서 제거했습니다.`); loadGroups(); } catch (error: any) { console.error("화면 제거 실패:", error); toast.error(error.response?.data?.message || error.message || "화면 제거에 실패했습니다."); } }; // 화면 삭제 다이얼로그 열기 const handleDeleteScreen = (screen: ScreenDefinition) => { setDeletingScreen(screen); setIsScreenDeleteDialogOpen(true); }; // 화면 삭제 확인 const confirmDeleteScreen = async () => { if (!deletingScreen) return; try { // 화면 삭제 API 호출 (휴지통으로 이동) await apiClient.delete(`/screen-management/screens/${deletingScreen.screenId}`); toast.success(`"${deletingScreen.screenName}" 화면이 휴지통으로 이동되었습니다.`); // 화면 목록 새로고침 (부모 컴포넌트에서 처리해야 함) loadGroups(); // 삭제된 화면이 선택된 상태였다면 선택 해제 if (selectedScreen?.screenId === deletingScreen.screenId) { onScreenSelect(null as any); // 선택 해제 } } catch (error: any) { console.error("화면 삭제 실패:", error); toast.error(error.response?.data?.message || error.message || "화면 삭제에 실패했습니다."); } finally { setIsScreenDeleteDialogOpen(false); setDeletingScreen(null); } }; // 그룹 순서 위로 이동 const handleMoveGroupUp = async (targetGroup: PopScreenGroup) => { try { // 같은 부모의 형제 그룹들 찾기 const parentId = targetGroup.parent_id; const siblingGroups = groups .filter((g) => g.parent_id === parentId) .sort((a, b) => (a.display_order || 0) - (b.display_order || 0)); const currentIndex = siblingGroups.findIndex((g) => g.id === targetGroup.id); if (currentIndex <= 0) return; const prevGroup = siblingGroups[currentIndex - 1]; // 두 그룹의 display_order 교환 await Promise.all([ apiClient.put(`/screen-groups/groups/${targetGroup.id}`, { display_order: prevGroup.display_order || currentIndex - 1 }), apiClient.put(`/screen-groups/groups/${prevGroup.id}`, { display_order: targetGroup.display_order || currentIndex }), ]); loadGroups(); } catch (error: any) { console.error("그룹 순서 변경 실패:", error); toast.error("순서 변경에 실패했습니다."); } }; // 그룹 순서 아래로 이동 const handleMoveGroupDown = async (targetGroup: PopScreenGroup) => { try { // 같은 부모의 형제 그룹들 찾기 const parentId = targetGroup.parent_id; const siblingGroups = groups .filter((g) => g.parent_id === parentId) .sort((a, b) => (a.display_order || 0) - (b.display_order || 0)); const currentIndex = siblingGroups.findIndex((g) => g.id === targetGroup.id); if (currentIndex >= siblingGroups.length - 1) return; const nextGroup = siblingGroups[currentIndex + 1]; // 두 그룹의 display_order 교환 await Promise.all([ apiClient.put(`/screen-groups/groups/${targetGroup.id}`, { display_order: nextGroup.display_order || currentIndex + 1 }), apiClient.put(`/screen-groups/groups/${nextGroup.id}`, { display_order: targetGroup.display_order || currentIndex }), ]); loadGroups(); } catch (error: any) { console.error("그룹 순서 변경 실패:", error); toast.error("순서 변경에 실패했습니다."); } }; // 화면 순서 위로 이동 const handleMoveScreenUp = async (screen: ScreenDefinition, groupId: number) => { try { const targetGroup = groups.find((g) => g.id === groupId); if (!targetGroup?.screens) return; const sortedScreens = [...targetGroup.screens].sort( (a, b) => (a.display_order || 0) - (b.display_order || 0) ); const currentIndex = sortedScreens.findIndex((s) => s.screen_id === screen.screenId); if (currentIndex <= 0) return; const currentLink = sortedScreens[currentIndex]; const prevLink = sortedScreens[currentIndex - 1]; // 두 화면의 display_order 교환 await Promise.all([ apiClient.put(`/screen-groups/group-screens/${currentLink.id}`, { display_order: prevLink.display_order || currentIndex - 1 }), apiClient.put(`/screen-groups/group-screens/${prevLink.id}`, { display_order: currentLink.display_order || currentIndex }), ]); loadGroups(); } catch (error: any) { console.error("화면 순서 변경 실패:", error); toast.error("순서 변경에 실패했습니다."); } }; // 화면 순서 아래로 이동 const handleMoveScreenDown = async (screen: ScreenDefinition, groupId: number) => { try { const targetGroup = groups.find((g) => g.id === groupId); if (!targetGroup?.screens) return; const sortedScreens = [...targetGroup.screens].sort( (a, b) => (a.display_order || 0) - (b.display_order || 0) ); const currentIndex = sortedScreens.findIndex((s) => s.screen_id === screen.screenId); if (currentIndex >= sortedScreens.length - 1) return; const currentLink = sortedScreens[currentIndex]; const nextLink = sortedScreens[currentIndex + 1]; // 두 화면의 display_order 교환 await Promise.all([ apiClient.put(`/screen-groups/group-screens/${currentLink.id}`, { display_order: nextLink.display_order || currentIndex + 1 }), apiClient.put(`/screen-groups/group-screens/${nextLink.id}`, { display_order: currentLink.display_order || currentIndex }), ]); loadGroups(); } catch (error: any) { console.error("화면 순서 변경 실패:", error); toast.error("순서 변경에 실패했습니다."); } }; // 미분류 화면 (그룹에 연결되지 않은 화면) const ungroupedScreens = useMemo(() => { const groupedScreenIds = new Set(); groups.forEach((g) => { g.screens?.forEach((gs) => groupedScreenIds.add(gs.screen_id)); }); return screens.filter((s) => !groupedScreenIds.has(s.screenId)); }, [groups, screens]); // 전체 그룹 평탄화 (이동 드롭다운용) const flattenedGroups = useMemo(() => { const result: PopScreenGroup[] = []; const flatten = (groups: PopScreenGroup[], parentName?: string) => { groups.forEach((g) => { // 표시 이름에 부모 경로 추가 const displayGroup = { ...g, _displayName: parentName ? `${parentName} > ${g.group_name}` : g.group_name }; result.push(displayGroup); if (g.children && g.children.length > 0) { flatten(g.children, displayGroup._displayName); } }); }; flatten(treeData); return result; }, [treeData]); // 이동 모달 열기 const openMoveModal = (screen: ScreenDefinition, fromGroupId: number | null) => { setMovingScreen(screen); setMovingFromGroupId(fromGroupId); setMoveSearchTerm(""); setIsMoveModalOpen(true); }; // 이동 모달에서 그룹 선택 처리 const handleMoveToSelectedGroup = async (targetGroup: PopScreenGroup) => { if (!movingScreen) return; await handleMoveScreenToGroup(movingScreen, targetGroup); setIsMoveModalOpen(false); setMovingScreen(null); setMovingFromGroupId(null); }; // 이동 모달용 필터링된 그룹 목록 const filteredMoveGroups = useMemo(() => { if (!moveSearchTerm) return flattenedGroups; const searchLower = moveSearchTerm.toLowerCase(); return flattenedGroups.filter((g: any) => (g._displayName || g.group_name).toLowerCase().includes(searchLower) ); }, [flattenedGroups, moveSearchTerm]); if (loading) { return (
); } return (
{/* 헤더 */}

POP 카테고리

{/* 트리 영역 */}
{treeData.length === 0 && ungroupedScreens.length === 0 ? (
카테고리가 없습니다.
) : ( <> {/* 트리 렌더링 */} {treeData.map((group) => ( openGroupModal(undefined, g)} onDeleteGroup={(g) => { setDeletingGroup(g); setIsDeleteDialogOpen(true); }} onAddSubGroup={(g) => openGroupModal(g)} screensMap={screensMap} onOpenMoveModal={openMoveModal} onRemoveScreenFromGroup={handleRemoveScreenFromGroup} siblingGroups={treeData} onMoveGroupUp={handleMoveGroupUp} onMoveGroupDown={handleMoveGroupDown} onMoveScreenUp={handleMoveScreenUp} onMoveScreenDown={handleMoveScreenDown} onDeleteScreen={handleDeleteScreen} /> ))} {/* 미분류 화면 */} {ungroupedScreens.length > 0 && (
미분류 ({ungroupedScreens.length})
{ungroupedScreens.map((screen) => (
onScreenSelect(screen)} onDoubleClick={() => onScreenDesign(screen)} > {screen.screenName} #{screen.screenId} {/* 더보기 메뉴 */} onScreenDesign(screen)}> 설계 openMoveModal(screen, null)}> 카테고리로 이동 handleDeleteScreen(screen)} > 화면 삭제
))}
)} )}
{/* 그룹 생성/수정 모달 */} {editingGroup ? "카테고리 수정" : "새 카테고리"} {editingGroup ? "카테고리 정보를 수정합니다." : "POP 화면을 분류할 카테고리를 추가합니다."}
setGroupFormData((prev) => ({ ...prev, group_name: e.target.value }))} placeholder="예: 생산관리" className="h-8 text-xs sm:h-10 sm:text-sm" />
{!editingGroup && (
setGroupFormData((prev) => ({ ...prev, group_code: e.target.value.toUpperCase() }))} placeholder="예: PRODUCTION" className="h-8 text-xs sm:h-10 sm:text-sm" />

영문 대문자와 밑줄만 사용 가능합니다.

)}
setGroupFormData((prev) => ({ ...prev, description: e.target.value }))} placeholder="카테고리에 대한 설명" className="h-8 text-xs sm:h-10 sm:text-sm" />
{/* 삭제 확인 다이얼로그 */} 카테고리 삭제 "{deletingGroup?.group_name}" 카테고리를 삭제하시겠습니까?
이 작업은 되돌릴 수 없습니다.
취소 삭제
{/* 화면 이동 모달 */} 카테고리로 이동 "{movingScreen?.screenName}" 화면을 이동할 카테고리를 선택하세요. {/* 검색 입력 */}
setMoveSearchTerm(e.target.value)} className="pl-9 h-9 text-sm" />
{/* 카테고리 트리 목록 */}
{filteredMoveGroups.length === 0 ? (
검색 결과가 없습니다.
) : ( filteredMoveGroups.map((group: any) => { const isCurrentGroup = group.id === movingFromGroupId; const displayName = group._displayName || group.group_name; const depth = (displayName.match(/>/g) || []).length; return ( ); }) )}
{/* 화면 삭제 확인 다이얼로그 */} 화면 삭제 "{deletingScreen?.screenName}" 화면을 삭제하시겠습니까?
삭제된 화면은 휴지통으로 이동되며, 나중에 복원할 수 있습니다.
취소 삭제
); }