"use client"; import { useState, useEffect, useMemo } from "react"; import { cn } from "@/lib/utils"; import { ChevronRight, ChevronDown, Monitor, FolderOpen, Folder, Plus, MoreVertical, Edit, Trash2, FolderInput, Copy, FolderTree, Loader2, RefreshCw, Building2, } from "lucide-react"; import { ScreenDefinition } from "@/types/screen"; import { ScreenGroup, getScreenGroups, deleteScreenGroup, addScreenToGroup, removeScreenFromGroup, getMenuScreenSyncStatus, syncScreenGroupsToMenu, syncMenuToScreenGroups, syncAllCompanies, SyncStatus, AllCompaniesSyncResult, } from "@/lib/api/screenGroup"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { useAuth } from "@/hooks/useAuth"; import { getCompanyList, Company } from "@/lib/api/company"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Input } from "@/components/ui/input"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Check, ChevronsUpDown } from "lucide-react"; import { ScreenGroupModal } from "./ScreenGroupModal"; import CopyScreenModal from "./CopyScreenModal"; import { toast } from "sonner"; import { screenApi } from "@/lib/api/screen"; interface ScreenGroupTreeViewProps { screens: ScreenDefinition[]; selectedScreen: ScreenDefinition | null; onScreenSelect: (screen: ScreenDefinition) => void; onScreenDesign: (screen: ScreenDefinition) => void; onGroupSelect?: (group: { id: number; name: string; company_code?: string } | null) => void; onScreenSelectInGroup?: (group: { id: number; name: string; company_code?: string }, screenId: number) => void; companyCode?: string; searchTerm?: string; // 검색어 (띄어쓰기로 구분된 여러 키워드) } interface TreeNode { type: "group" | "screen"; id: string; name: string; data?: ScreenDefinition | ScreenGroup; children?: TreeNode[]; expanded?: boolean; } export function ScreenGroupTreeView({ screens, selectedScreen, onScreenSelect, onScreenDesign, onGroupSelect, onScreenSelectInGroup, companyCode, searchTerm = "", }: ScreenGroupTreeViewProps) { const [groups, setGroups] = useState([]); const [loading, setLoading] = useState(true); const [expandedGroups, setExpandedGroups] = useState>(new Set()); const [groupScreensMap, setGroupScreensMap] = useState>(new Map()); // 그룹 모달 상태 const [isGroupModalOpen, setIsGroupModalOpen] = useState(false); const [editingGroup, setEditingGroup] = useState(null); // 삭제 확인 다이얼로그 상태 const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [deletingGroup, setDeletingGroup] = useState(null); const [deleteScreensWithGroup, setDeleteScreensWithGroup] = useState(false); // 화면도 함께 삭제 체크박스 const [isDeleting, setIsDeleting] = useState(false); // 삭제 진행 중 상태 const [deleteProgress, setDeleteProgress] = useState({ current: 0, total: 0, message: "" }); // 삭제 진행 상태 // 단일 화면 삭제 상태 const [isScreenDeleteDialogOpen, setIsScreenDeleteDialogOpen] = useState(false); const [deletingScreen, setDeletingScreen] = useState(null); const [isScreenDeleting, setIsScreenDeleting] = useState(false); // 화면 삭제 진행 중 // 화면 수정 모달 상태 (이름 변경 + 그룹 이동 통합) const [editingScreen, setEditingScreen] = useState(null); const [isEditScreenModalOpen, setIsEditScreenModalOpen] = useState(false); const [editScreenName, setEditScreenName] = useState(""); const [selectedGroupForMove, setSelectedGroupForMove] = useState(null); const [screenRole, setScreenRole] = useState(""); const [isGroupSelectOpen, setIsGroupSelectOpen] = useState(false); const [displayOrder, setDisplayOrder] = useState(1); // 화면 복제 모달 상태 (CopyScreenModal 사용) const [isCopyModalOpen, setIsCopyModalOpen] = useState(false); const [copyingScreen, setCopyingScreen] = useState(null); const [copyTargetGroupId, setCopyTargetGroupId] = useState(null); const [copyMode, setCopyMode] = useState<"screen" | "group">("screen"); // 그룹 복제 모달 상태 (CopyScreenModal 그룹 모드 사용) const [copyingGroup, setCopyingGroup] = useState(null); // 컨텍스트 메뉴 상태 (화면용) const [contextMenuScreen, setContextMenuScreen] = useState(null); const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null); // 그룹 컨텍스트 메뉴 상태 const [contextMenuGroup, setContextMenuGroup] = useState(null); const [contextMenuGroupPosition, setContextMenuGroupPosition] = useState<{ x: number; y: number } | null>(null); // 메뉴-화면그룹 동기화 상태 const [isSyncDialogOpen, setIsSyncDialogOpen] = useState(false); const [syncStatus, setSyncStatus] = useState(null); const [isSyncing, setIsSyncing] = useState(false); const [syncDirection, setSyncDirection] = useState<"screen-to-menu" | "menu-to-screen" | "all" | null>(null); // 회사 선택 (최고 관리자용) const { user } = useAuth(); const [companies, setCompanies] = useState([]); const [selectedCompanyCode, setSelectedCompanyCode] = useState(""); const [isSyncCompanySelectOpen, setIsSyncCompanySelectOpen] = useState(false); // 현재 사용자가 최고 관리자인지 확인 const isSuperAdmin = user?.companyCode === "*"; // 실제 사용할 회사 코드 (props → 선택 → 사용자 기본값) const effectiveCompanyCode = companyCode || selectedCompanyCode || (isSuperAdmin ? "" : user?.companyCode) || ""; // 그룹 목록 및 그룹별 화면 로드 useEffect(() => { loadGroupsData(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [companyCode]); // 그룹에 속한 화면 ID들을 가져오기 const getGroupedScreenIds = (): Set => { const ids = new Set(); groupScreensMap.forEach((screenIds) => { screenIds.forEach((id) => ids.add(id)); }); return ids; }; // 미분류 화면들 (어떤 그룹에도 속하지 않은 화면) const getUngroupedScreens = (): ScreenDefinition[] => { const groupedIds = getGroupedScreenIds(); return screens.filter((screen) => !groupedIds.has(screen.screenId)); }; // 그룹에 속한 화면들 (display_order 오름차순 정렬) const getScreensInGroup = (groupId: number): ScreenDefinition[] => { const group = groups.find((g) => g.id === groupId); if (!group?.screens) { const screenIds = groupScreensMap.get(groupId) || []; return screens.filter((screen) => screenIds.includes(screen.screenId)); } // 그룹의 screens 배열에서 display_order 정보를 가져와서 정렬 const sortedScreenIds = [...group.screens] .sort((a, b) => (a.display_order || 999) - (b.display_order || 999)) .map((s) => s.screen_id); return sortedScreenIds .map((id) => screens.find((screen) => screen.screenId === id)) .filter((screen): screen is ScreenDefinition => screen !== undefined); }; const toggleGroup = (groupId: string) => { const newExpanded = new Set(expandedGroups); if (newExpanded.has(groupId)) { newExpanded.delete(groupId); // 그룹 접으면 선택 해제 if (onGroupSelect) { onGroupSelect(null); } } else { newExpanded.add(groupId); // 그룹 펼치면 해당 그룹 선택 if (onGroupSelect && groupId !== "ungrouped") { const group = groups.find((g) => String(g.id) === groupId); if (group) { onGroupSelect({ id: group.id, name: group.group_name, company_code: group.company_code }); } } } setExpandedGroups(newExpanded); }; const handleScreenClick = (screen: ScreenDefinition) => { onScreenSelect(screen); }; // 그룹 내 화면 클릭 핸들러 (그룹 전체 표시 + 해당 화면 포커스) const handleScreenClickInGroup = (screen: ScreenDefinition, group: ScreenGroup) => { if (onScreenSelectInGroup) { onScreenSelectInGroup( { id: group.id, name: group.group_name, company_code: group.company_code }, screen.screenId ); } else { // fallback: 기존 동작 onScreenSelect(screen); } }; const handleScreenDoubleClick = (screen: ScreenDefinition) => { onScreenDesign(screen); }; // 그룹 추가 버튼 클릭 const handleAddGroup = () => { setEditingGroup(null); setIsGroupModalOpen(true); }; // 동기화 다이얼로그 열기 const handleOpenSyncDialog = async () => { setIsSyncDialogOpen(true); setSyncStatus(null); setSyncDirection(null); setSelectedCompanyCode(""); // 최고 관리자일 때 회사 목록 로드 if (isSuperAdmin && companies.length === 0) { try { const companiesList = await getCompanyList(); // 최고 관리자(*)용 회사는 제외 const filteredCompanies = companiesList.filter(c => c.company_code !== "*"); setCompanies(filteredCompanies); } catch (error) { console.error("회사 목록 로드 실패:", error); } } // 최고 관리자가 아니면 바로 상태 조회 if (!isSuperAdmin && user?.companyCode) { const response = await getMenuScreenSyncStatus(user.companyCode); if (response.success && response.data) { setSyncStatus(response.data); } } }; // 회사 선택 시 상태 조회 const handleCompanySelect = async (companyCode: string) => { setSelectedCompanyCode(companyCode); setIsSyncCompanySelectOpen(false); setSyncStatus(null); if (companyCode) { const response = await getMenuScreenSyncStatus(companyCode); if (response.success && response.data) { setSyncStatus(response.data); } else { toast.error(response.error || "동기화 상태 조회 실패"); } } }; // 동기화 실행 const handleSync = async (direction: "screen-to-menu" | "menu-to-screen") => { // 사용할 회사 코드 결정 const targetCompanyCode = isSuperAdmin ? selectedCompanyCode : user?.companyCode; if (!targetCompanyCode) { toast.error("회사를 선택해주세요."); return; } setIsSyncing(true); setSyncDirection(direction); try { const response = direction === "screen-to-menu" ? await syncScreenGroupsToMenu(targetCompanyCode) : await syncMenuToScreenGroups(targetCompanyCode); if (response.success) { const data = response.data; toast.success( `동기화 완료: 생성 ${data?.created || 0}개, 연결 ${data?.linked || 0}개, 스킵 ${data?.skipped || 0}개` ); // 그룹 데이터 새로고침 await loadGroupsData(); // 동기화 상태 새로고침 const statusResponse = await getMenuScreenSyncStatus(targetCompanyCode); if (statusResponse.success && statusResponse.data) { setSyncStatus(statusResponse.data); } } else { toast.error(`동기화 실패: ${response.error || "알 수 없는 오류"}`); } } catch (error: any) { toast.error(`동기화 실패: ${error.message}`); } finally { setIsSyncing(false); setSyncDirection(null); } }; // 전체 회사 동기화 (최고 관리자만) const handleSyncAll = async () => { if (!isSuperAdmin) { toast.error("전체 동기화는 최고 관리자만 수행할 수 있습니다."); return; } setIsSyncing(true); setSyncDirection("all"); try { const response = await syncAllCompanies(); if (response.success && response.data) { const data = response.data; toast.success( `전체 동기화 완료: ${data.totalCompanies}개 회사, 생성 ${data.totalCreated}개, 연결 ${data.totalLinked}개` ); // 그룹 데이터 새로고침 await loadGroupsData(); // 동기화 다이얼로그 닫기 setIsSyncDialogOpen(false); } else { toast.error(`전체 동기화 실패: ${response.error || "알 수 없는 오류"}`); } } catch (error: any) { toast.error(`전체 동기화 실패: ${error.message}`); } finally { setIsSyncing(false); setSyncDirection(null); } }; // 그룹 수정 버튼 클릭 const handleEditGroup = (group: ScreenGroup, e: React.MouseEvent) => { e.stopPropagation(); setEditingGroup(group); setIsGroupModalOpen(true); }; // 그룹 삭제 버튼 클릭 const handleDeleteGroup = (group: ScreenGroup, e?: React.MouseEvent) => { e?.stopPropagation(); setDeletingGroup(group); setDeleteScreensWithGroup(false); // 기본값: 화면 삭제 안함 setIsDeleteDialogOpen(true); }; // 그룹과 모든 하위 그룹의 화면을 재귀적으로 수집 const getAllScreensInGroupRecursively = (groupId: number): ScreenDefinition[] => { const result: ScreenDefinition[] = []; // 현재 그룹의 화면들 const currentGroupScreens = getScreensInGroup(groupId); result.push(...currentGroupScreens); // 하위 그룹들 찾기 const childGroups = groups.filter((g) => (g as any).parent_group_id === groupId); for (const childGroup of childGroups) { const childScreens = getAllScreensInGroupRecursively(childGroup.id); result.push(...childScreens); } return result; }; // 모든 하위 그룹 ID를 재귀적으로 수집 (삭제 순서: 자식 → 부모) const getAllChildGroupIds = (groupId: number): number[] => { const result: number[] = []; const childGroups = groups.filter((g) => (g as any).parent_group_id === groupId); for (const childGroup of childGroups) { // 자식의 자식들을 먼저 수집 (깊은 곳부터) const grandChildIds = getAllChildGroupIds(childGroup.id); result.push(...grandChildIds); result.push(childGroup.id); } return result; }; // 그룹 삭제 확인 const confirmDeleteGroup = async () => { if (!deletingGroup) return; // 삭제 전 통계 수집 (화면 수는 삭제 전에 계산) const totalScreensToDelete = getAllScreensInGroupRecursively(deletingGroup.id).length; const childGroupIds = getAllChildGroupIds(deletingGroup.id); // 총 작업 수 계산 (화면 + 하위 그룹 + 현재 그룹) const totalSteps = totalScreensToDelete + childGroupIds.length + 1; let currentStep = 0; try { setIsDeleting(true); setDeleteProgress({ current: 0, total: totalSteps, message: "삭제 준비 중..." }); // 화면도 함께 삭제하는 경우 if (deleteScreensWithGroup) { // 현재 그룹 + 모든 하위 그룹의 화면을 재귀적으로 수집 const allScreens = getAllScreensInGroupRecursively(deletingGroup.id); if (allScreens.length > 0) { const { screenApi } = await import("@/lib/api/screen"); // 화면을 하나씩 삭제하면서 진행률 업데이트 for (let i = 0; i < allScreens.length; i++) { const screen = allScreens[i]; currentStep++; setDeleteProgress({ current: currentStep, total: totalSteps, message: `화면 삭제 중: ${screen.screenName}` }); await screenApi.deleteScreen(screen.screenId, "그룹 삭제와 함께 삭제"); } console.log(`✅ 그룹 및 하위 그룹 내 화면 ${allScreens.length}개 삭제 완료`); } } // 하위 그룹들을 먼저 삭제 (자식 → 부모 순서) for (let i = 0; i < childGroupIds.length; i++) { const childId = childGroupIds[i]; const childGroup = groups.find(g => g.id === childId); currentStep++; setDeleteProgress({ current: currentStep, total: totalSteps, message: `하위 그룹 삭제 중: ${childGroup?.group_name || childId}` }); await deleteScreenGroup(childId); console.log(`✅ 하위 그룹 ${childId} 삭제 완료`); } // 최종적으로 대상 그룹 삭제 currentStep++; setDeleteProgress({ current: currentStep, total: totalSteps, message: "그룹 삭제 완료 중..." }); const response = await deleteScreenGroup(deletingGroup.id); if (response.success) { toast.success( deleteScreensWithGroup ? `그룹과 화면 ${totalScreensToDelete}개가 삭제되었습니다` : "그룹이 삭제되었습니다" ); await loadGroupsData(); window.dispatchEvent(new CustomEvent("screen-list-refresh")); } else { toast.error(response.message || "그룹 삭제에 실패했습니다"); } } catch (error) { console.error("그룹 삭제 실패:", error); toast.error("그룹 삭제에 실패했습니다"); } finally { setIsDeleting(false); setDeleteProgress({ current: 0, total: 0, message: "" }); setIsDeleteDialogOpen(false); setDeletingGroup(null); setDeleteScreensWithGroup(false); } }; // 단일 화면 삭제 버튼 클릭 const handleDeleteScreen = (screen: ScreenDefinition) => { setDeletingScreen(screen); setIsScreenDeleteDialogOpen(true); }; // 단일 화면 삭제 확인 const confirmDeleteScreen = async () => { if (!deletingScreen) return; try { setIsScreenDeleting(true); const { screenApi } = await import("@/lib/api/screen"); await screenApi.deleteScreen(deletingScreen.screenId, "사용자 요청으로 삭제"); toast.success(`"${deletingScreen.screenName}" 화면이 삭제되었습니다`); await loadGroupsData(); window.dispatchEvent(new CustomEvent("screen-list-refresh")); } catch (error) { console.error("화면 삭제 실패:", error); toast.error("화면 삭제에 실패했습니다"); } finally { setIsScreenDeleting(false); setIsScreenDeleteDialogOpen(false); setDeletingScreen(null); } }; // 화면 수정 모달 열기 (이름 변경 + 그룹 이동) const handleOpenEditScreenModal = (screen: ScreenDefinition) => { setEditingScreen(screen); setEditScreenName(screen.screenName); // 현재 화면이 속한 그룹 정보 찾기 let currentGroupId: number | null = null; let currentScreenRole: string = ""; let currentDisplayOrder: number = 1; // 현재 화면이 속한 그룹 찾기 for (const group of groups) { if (group.screens && Array.isArray(group.screens)) { const screenInfo = group.screens.find((s: any) => Number(s.screen_id) === Number(screen.screenId)); if (screenInfo) { currentGroupId = group.id; currentScreenRole = screenInfo.screen_role || ""; currentDisplayOrder = screenInfo.display_order || 1; break; } } } setSelectedGroupForMove(currentGroupId); setScreenRole(currentScreenRole); setDisplayOrder(currentDisplayOrder); setIsEditScreenModalOpen(true); }; // 화면 복제 모달 열기 (CopyScreenModal 사용) const handleOpenCopyModal = (screen: ScreenDefinition) => { // 현재 화면이 속한 그룹 찾기 (기본값으로 설정) let currentGroupId: number | null = null; for (const group of groups) { if (group.screens && Array.isArray(group.screens)) { const found = group.screens.find((s: any) => Number(s.screen_id) === Number(screen.screenId)); if (found) { currentGroupId = group.id; break; } } } setCopyingScreen(screen); setCopyTargetGroupId(currentGroupId); setCopyMode("screen"); setIsCopyModalOpen(true); setContextMenuPosition(null); // 컨텍스트 메뉴 닫기 }; // 그룹 복제 모달 열기 (CopyScreenModal 그룹 모드 사용) const handleOpenGroupCopyModal = (group: ScreenGroup) => { setCopyingGroup(group); setCopyMode("group"); setIsCopyModalOpen(true); closeGroupContextMenu(); // 그룹 컨텍스트 메뉴 닫기 }; // 복제 성공 콜백 const handleCopySuccess = async () => { console.log("🔄 복제 성공 - 새로고침 시작"); // 그룹 목록 새로고침 await loadGroupsData(); console.log("✅ 그룹 목록 새로고침 완료"); // 화면 목록 새로고침 window.dispatchEvent(new CustomEvent("screen-list-refresh")); console.log("✅ 화면 목록 새로고침 이벤트 발송 완료"); }; // 컨텍스트 메뉴 열기 const handleContextMenu = (e: React.MouseEvent, screen: ScreenDefinition) => { e.preventDefault(); e.stopPropagation(); setContextMenuScreen(screen); setContextMenuPosition({ x: e.clientX, y: e.clientY }); }; // 컨텍스트 메뉴 닫기 const closeContextMenu = () => { setContextMenuPosition(null); setContextMenuScreen(null); }; // 그룹 컨텍스트 메뉴 열기 const handleGroupContextMenu = (e: React.MouseEvent, group: ScreenGroup) => { e.preventDefault(); e.stopPropagation(); setContextMenuGroup(group); setContextMenuGroupPosition({ x: e.clientX, y: e.clientY }); }; // 그룹 컨텍스트 메뉴 닫기 const closeGroupContextMenu = () => { setContextMenuGroupPosition(null); setContextMenuGroup(null); }; // 화면 수정 저장 (이름 변경 + 그룹 이동) const saveScreenEdit = async () => { if (!editingScreen) return; try { // 1. 화면 이름이 변경되었으면 업데이트 if (editScreenName.trim() && editScreenName !== editingScreen.screenName) { await screenApi.updateScreen(editingScreen.screenId, { screenName: editScreenName.trim(), }); } // 2. 현재 그룹에서 제거 const currentGroupId = Array.from(groupScreensMap.entries()).find(([_, screenIds]) => screenIds.includes(editingScreen.screenId) )?.[0]; if (currentGroupId) { // screen_group_screens에서 해당 연결 찾아서 삭제 const currentGroup = groups.find((g) => g.id === currentGroupId); if (currentGroup && currentGroup.screens) { const screenGroupScreen = currentGroup.screens.find( (s: any) => s.screen_id === editingScreen.screenId ); if (screenGroupScreen) { await removeScreenFromGroup(screenGroupScreen.id); } } } // 3. 새 그룹에 추가 (미분류가 아닌 경우) if (selectedGroupForMove !== null) { await addScreenToGroup({ group_id: selectedGroupForMove, screen_id: editingScreen.screenId, screen_role: screenRole, display_order: displayOrder, is_default: "N", }); } toast.success("화면이 수정되었습니다"); loadGroupsData(); window.dispatchEvent(new CustomEvent("screen-list-refresh")); } catch (error) { console.error("화면 수정 실패:", error); toast.error("화면 수정에 실패했습니다"); } finally { setIsEditScreenModalOpen(false); setEditingScreen(null); setEditScreenName(""); setSelectedGroupForMove(null); setScreenRole(""); setDisplayOrder(1); } }; // 그룹 경로 가져오기 (계층 구조 표시용) const getGroupPath = (groupId: number): string => { const group = groups.find((g) => g.id === groupId); if (!group) return ""; const path: string[] = [group.group_name]; let currentGroup = group; while (currentGroup.parent_group_id) { const parent = groups.find((g) => g.id === currentGroup.parent_group_id); if (parent) { path.unshift(parent.group_name); currentGroup = parent; } else { break; } } return path.join(" > "); }; // 그룹 레벨 가져오기 (들여쓰기용) const getGroupLevel = (groupId: number): number => { const group = groups.find((g) => g.id === groupId); return group?.group_level || 1; }; // 그룹을 계층 구조로 정렬 const getSortedGroups = (): typeof groups => { const result: typeof groups = []; const addChildren = (parentId: number | null, level: number) => { const children = groups .filter((g) => g.parent_group_id === parentId) .sort((a, b) => (a.display_order || 0) - (b.display_order || 0)); for (const child of children) { result.push({ ...child, group_level: level }); addChildren(child.id, level + 1); } }; addChildren(null, 1); return result; }; // 검색어로 그룹 필터링 (띄어쓰기로 구분된 여러 키워드 - 계층적 검색) const getFilteredGroups = useMemo(() => { if (!searchTerm.trim()) { return groups; // 검색어가 없으면 모든 그룹 반환 } // 검색어를 띄어쓰기로 분리하고 빈 문자열 제거 const keywords = searchTerm.toLowerCase().trim().split(/\s+/).filter(k => k.length > 0); if (keywords.length === 0) { return groups; } // 그룹의 조상 ID들을 가져오는 함수 const getAncestorIds = (groupId: number): Set => { const ancestors = new Set(); let current = groups.find(g => g.id === groupId); while (current?.parent_group_id) { ancestors.add(current.parent_group_id); current = groups.find(g => g.id === current!.parent_group_id); } return ancestors; }; // 첫 번째 키워드와 일치하는 그룹 찾기 let currentMatchingIds = new Set(); for (const group of groups) { const groupName = group.group_name.toLowerCase(); if (groupName.includes(keywords[0])) { currentMatchingIds.add(group.id); } } // 일치하는 그룹이 없으면 빈 배열 반환 if (currentMatchingIds.size === 0) { return []; } // 나머지 키워드들을 순차적으로 처리 (계층적 검색) for (let i = 1; i < keywords.length; i++) { const keyword = keywords[i]; const nextMatchingIds = new Set(); for (const group of groups) { const groupName = group.group_name.toLowerCase(); if (groupName.includes(keyword)) { // 이 그룹의 조상 중에 이전 키워드와 일치하는 그룹이 있는지 확인 const ancestors = getAncestorIds(group.id); const hasMatchingAncestor = Array.from(currentMatchingIds).some(id => ancestors.has(id) || id === group.id ); if (hasMatchingAncestor) { nextMatchingIds.add(group.id); } } } // 매칭되는 게 있으면 업데이트, 없으면 이전 결과 유지 if (nextMatchingIds.size > 0) { // 이전 키워드 매칭도 유지 (상위 폴더 표시를 위해) nextMatchingIds.forEach(id => currentMatchingIds.add(id)); currentMatchingIds = nextMatchingIds; } } // 최종 매칭 결과 const finalMatchingIds = currentMatchingIds; // 표시할 그룹 ID 집합 const groupsToShow = new Set(); // 일치하는 그룹의 상위 그룹들도 포함 (계층 유지를 위해) const addParents = (groupId: number) => { const group = groups.find(g => g.id === groupId); if (group) { groupsToShow.add(group.id); if (group.parent_group_id) { addParents(group.parent_group_id); } } }; // 하위 그룹들을 추가하는 함수 const addChildren = (groupId: number) => { const children = groups.filter(g => g.parent_group_id === groupId); for (const child of children) { groupsToShow.add(child.id); addChildren(child.id); } }; // 최종 매칭 그룹들의 상위 추가 for (const groupId of finalMatchingIds) { addParents(groupId); } // 마지막 키워드와 일치하는 그룹의 하위만 추가 for (const groupId of finalMatchingIds) { addChildren(groupId); } // 필터링된 그룹만 반환 return groups.filter(g => groupsToShow.has(g.id)); }, [groups, searchTerm]); // 검색 시 해당 그룹이 일치하는지 확인 (하이라이트용) const isGroupMatchingSearch = (groupName: string): boolean => { if (!searchTerm.trim()) return false; const keywords = searchTerm.toLowerCase().trim().split(/\s+/).filter(k => k.length > 0); const name = groupName.toLowerCase(); return keywords.some(keyword => name.includes(keyword)); }; // 검색 시 해당 그룹이 자동으로 펼쳐져야 하는지 확인 // (검색어와 일치하는 그룹의 상위 + 마지막 검색어와 일치하는 그룹도 자동 펼침) const shouldAutoExpandForSearch = useMemo(() => { if (!searchTerm.trim()) return new Set(); const keywords = searchTerm.toLowerCase().trim().split(/\s+/).filter(k => k.length > 0); if (keywords.length === 0) return new Set(); // 그룹의 조상 ID들을 가져오는 함수 const getAncestorIds = (groupId: number): Set => { const ancestors = new Set(); let current = groups.find(g => g.id === groupId); while (current?.parent_group_id) { ancestors.add(current.parent_group_id); current = groups.find(g => g.id === current!.parent_group_id); } return ancestors; }; // 계층적 검색으로 최종 일치 그룹 찾기 (getFilteredGroups와 동일한 로직) let currentMatchingIds = new Set(); for (const group of groups) { const groupName = group.group_name.toLowerCase(); if (groupName.includes(keywords[0])) { currentMatchingIds.add(group.id); } } for (let i = 1; i < keywords.length; i++) { const keyword = keywords[i]; const nextMatchingIds = new Set(); for (const group of groups) { const groupName = group.group_name.toLowerCase(); if (groupName.includes(keyword)) { const ancestors = getAncestorIds(group.id); const hasMatchingAncestor = Array.from(currentMatchingIds).some(id => ancestors.has(id) || id === group.id ); if (hasMatchingAncestor) { nextMatchingIds.add(group.id); } } } if (nextMatchingIds.size > 0) { nextMatchingIds.forEach(id => currentMatchingIds.add(id)); currentMatchingIds = nextMatchingIds; } } // 자동 펼침 대상: 일치 그룹의 상위 + 일치 그룹 자체 const autoExpandIds = new Set(); const addParents = (groupId: number) => { const group = groups.find(g => g.id === groupId); if (group?.parent_group_id) { autoExpandIds.add(group.parent_group_id); addParents(group.parent_group_id); } }; for (const groupId of currentMatchingIds) { autoExpandIds.add(groupId); // 일치하는 그룹 자체도 펼침 (화면 표시를 위해) addParents(groupId); } return autoExpandIds; }, [groups, searchTerm]); // 그룹 데이터 새로고침 const loadGroupsData = async () => { try { setLoading(true); const response = await getScreenGroups({ size: 1000 }); // 모든 그룹 가져오기 if (response.success && response.data) { setGroups(response.data); // 각 그룹별 화면 목록 매핑 const screenMap = new Map(); for (const group of response.data) { if (group.screens && Array.isArray(group.screens)) { screenMap.set( group.id, group.screens.map((s: any) => s.screen_id) ); } } setGroupScreensMap(screenMap); } } catch (error) { console.error("그룹 목록 로드 실패:", error); } finally { setLoading(false); } }; if (loading) { return (
로딩 중...
); } const ungroupedScreens = getUngroupedScreens(); return (
{/* 그룹 추가 & 동기화 버튼 */}
{/* 트리 목록 */}
{/* 검색 결과 없음 표시 */} {searchTerm.trim() && getFilteredGroups.length === 0 && (
"{searchTerm}"와 일치하는 폴더가 없습니다
)} {/* 그룹화된 화면들 (대분류만 먼저 렌더링) */} {getFilteredGroups .filter((g) => !(g as any).parent_group_id) // 대분류만 (parent_group_id가 null) .map((group) => { const groupId = String(group.id); const isExpanded = expandedGroups.has(groupId) || shouldAutoExpandForSearch.has(group.id); // 검색 시 상위 그룹만 자동 확장 const groupScreens = getScreensInGroup(group.id); const isMatching = isGroupMatchingSearch(group.group_name); // 검색어 일치 여부 // 하위 그룹들 찾기 (필터링된 그룹에서만) const childGroups = getFilteredGroups.filter((g) => (g as any).parent_group_id === group.id); return (
{/* 그룹 헤더 */}
toggleGroup(groupId)} onContextMenu={(e) => handleGroupContextMenu(e, group)} > {isExpanded ? ( ) : ( )} {isExpanded ? ( ) : ( )} {group.group_name} {groupScreens.length} {/* 그룹 메뉴 버튼 */} e.stopPropagation()}> handleEditGroup(group, e as any)}> 수정 handleDeleteGroup(group, e as any)} className="text-destructive" > 삭제
{/* 그룹 내 하위 그룹들 */} {isExpanded && childGroups.length > 0 && (
{childGroups.map((childGroup) => { const childGroupId = String(childGroup.id); const isChildExpanded = expandedGroups.has(childGroupId) || shouldAutoExpandForSearch.has(childGroup.id); // 검색 시 상위 그룹만 자동 확장 const childScreens = getScreensInGroup(childGroup.id); const isChildMatching = isGroupMatchingSearch(childGroup.group_name); // 손자 그룹들 (3단계) - 필터링된 그룹에서만 const grandChildGroups = getFilteredGroups.filter((g) => (g as any).parent_group_id === childGroup.id); return (
{/* 중분류 헤더 */}
toggleGroup(childGroupId)} onContextMenu={(e) => handleGroupContextMenu(e, childGroup)} > {isChildExpanded ? ( ) : ( )} {isChildExpanded ? ( ) : ( )} {childGroup.group_name} {childScreens.length} e.stopPropagation()}> handleEditGroup(childGroup, e as any)}> 수정 handleDeleteGroup(childGroup, e as any)} className="text-destructive" > 삭제
{/* 중분류 내 손자 그룹들 (소분류) */} {isChildExpanded && grandChildGroups.length > 0 && (
{grandChildGroups.map((grandChild) => { const grandChildId = String(grandChild.id); const isGrandExpanded = expandedGroups.has(grandChildId) || shouldAutoExpandForSearch.has(grandChild.id); // 검색 시 상위 그룹만 자동 확장 const grandScreens = getScreensInGroup(grandChild.id); const isGrandMatching = isGroupMatchingSearch(grandChild.group_name); return (
{/* 소분류 헤더 */}
toggleGroup(grandChildId)} onContextMenu={(e) => handleGroupContextMenu(e, grandChild)} > {isGrandExpanded ? ( ) : ( )} {isGrandExpanded ? ( ) : ( )} {grandChild.group_name} {grandScreens.length} e.stopPropagation()}> handleEditGroup(grandChild, e as any)}> 수정 handleDeleteGroup(grandChild, e as any)} className="text-destructive" > 삭제
{/* 소분류 내 화면들 */} {isGrandExpanded && (
{grandScreens.length === 0 ? (
화면이 없습니다
) : ( grandScreens.map((screen) => (
handleScreenClickInGroup(screen, grandChild)} onDoubleClick={() => handleScreenDoubleClick(screen)} onContextMenu={(e) => handleContextMenu(e, screen)} > {screen.screenName} {screen.screenCode}
)) )}
)}
); })}
)} {/* 중분류 내 화면들 */} {isChildExpanded && (
{childScreens.length === 0 && grandChildGroups.length === 0 ? (
화면이 없습니다
) : ( childScreens.map((screen) => (
handleScreenClickInGroup(screen, childGroup)} onDoubleClick={() => handleScreenDoubleClick(screen)} onContextMenu={(e) => handleContextMenu(e, screen)} > {screen.screenName} {screen.screenCode}
)) )}
)}
); })}
)} {/* 그룹 내 화면들 (대분류 직속) */} {isExpanded && (
{groupScreens.length === 0 && childGroups.length === 0 ? (
화면이 없습니다
) : ( groupScreens.map((screen) => (
handleScreenClickInGroup(screen, group)} onDoubleClick={() => handleScreenDoubleClick(screen)} onContextMenu={(e) => handleContextMenu(e, screen)} > {screen.screenName} {screen.screenCode}
)) )}
)}
); })} {/* 미분류 화면들 */} {ungroupedScreens.length > 0 && (
toggleGroup("ungrouped")} > {expandedGroups.has("ungrouped") ? ( ) : ( )} 미분류 {ungroupedScreens.length}
{expandedGroups.has("ungrouped") && (
{ungroupedScreens.map((screen) => (
handleScreenClick(screen)} onDoubleClick={() => handleScreenDoubleClick(screen)} onContextMenu={(e) => handleContextMenu(e, screen)} > {screen.screenName} {screen.screenCode}
))}
)}
)} {groups.length === 0 && ungroupedScreens.length === 0 && (

등록된 화면이 없습니다

)}
{/* 그룹 추가/수정 모달 */} { setIsGroupModalOpen(false); setEditingGroup(null); }} onSuccess={loadGroupsData} group={editingGroup} /> {/* 화면/그룹 복제 모달 (CopyScreenModal 사용) */} { setIsCopyModalOpen(false); setCopyingScreen(null); setCopyingGroup(null); }} sourceScreen={copyMode === "screen" ? copyingScreen : null} onCopySuccess={handleCopySuccess} mode={copyMode} sourceGroup={copyMode === "group" ? copyingGroup : null} groups={groups} targetGroupId={copyTargetGroupId} allScreens={screens} /> {/* 그룹 삭제 확인 다이얼로그 */} 그룹 삭제 "{deletingGroup?.group_name}" 그룹을 삭제하시겠습니까?
{deleteScreensWithGroup ? 그룹에 속한 화면들도 함께 삭제됩니다. : "그룹에 속한 화면들은 미분류로 이동됩니다." }
{/* 그룹 정보 표시 */} {deletingGroup && (
하위 그룹 수: {getAllChildGroupIds(deletingGroup.id).length}개
총 화면 수 (하위 포함): {getAllScreensInGroupRecursively(deletingGroup.id).length}개
)} {/* 화면도 함께 삭제 체크박스 */} {deletingGroup && getAllScreensInGroupRecursively(deletingGroup.id).length > 0 && (
setDeleteScreensWithGroup(e.target.checked)} className="h-4 w-4 rounded border-gray-300 text-destructive focus:ring-destructive" />
)} {/* 로딩 오버레이 */} {isDeleting && (

{deleteProgress.message}

{deleteProgress.total > 0 && ( <>

{deleteProgress.current} / {deleteProgress.total}

)}
)} 취소 { e.preventDefault(); // 자동 닫힘 방지 confirmDeleteGroup(); }} disabled={isDeleting} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm bg-destructive hover:bg-destructive/90" > {isDeleting ? ( <> 삭제 중... ) : ( "삭제" )} {/* 단일 화면 삭제 확인 다이얼로그 */} {/* 로딩 오버레이 */} {isScreenDeleting && (

화면 삭제 중...

)} 화면 삭제 "{deletingScreen?.screenName}" 화면을 삭제하시겠습니까?
삭제된 화면은 휴지통으로 이동됩니다.
취소 { e.preventDefault(); // 자동 닫힘 방지 confirmDeleteScreen(); }} disabled={isScreenDeleting} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm bg-destructive hover:bg-destructive/90" > {isScreenDeleting ? ( <> 삭제 중... ) : ( "삭제" )}
{/* 화면 수정 모달 (이름 변경 + 그룹 이동) */} 화면 수정 화면 정보를 수정하세요
{/* 화면 이름 */}
setEditScreenName(e.target.value)} placeholder="화면 이름을 입력하세요" className="h-8 text-xs sm:h-10 sm:text-sm" />
{/* 그룹 선택 (트리 구조 + 검색) */}
그룹을 찾을 수 없습니다 {/* 미분류 옵션 */} { setSelectedGroupForMove(null); setIsGroupSelectOpen(false); }} className="text-xs sm:text-sm" > 미분류 {/* 계층 구조로 그룹 표시 */} {getSortedGroups().map((group) => ( { setSelectedGroupForMove(group.id); setIsGroupSelectOpen(false); }} className="text-xs sm:text-sm" > {/* 들여쓰기로 계층 표시 */} {group.group_name} ))}

계층 구조로 표시됩니다. 검색으로 빠르게 찾을 수 있습니다.

{/* 화면 역할 입력 (그룹이 선택된 경우만) */} {selectedGroupForMove !== null && ( <>
setScreenRole(e.target.value)} placeholder="예: 목록, 등록, 조회, 팝업..." className="h-8 text-xs sm:h-10 sm:text-sm" />

화면의 용도를 자유롭게 입력하세요

setDisplayOrder(parseInt(e.target.value) || 1)} min={1} className="h-8 text-xs sm:h-10 sm:text-sm" />

화면 흐름 순서 (1: 메인 그리드 → 2: 등록 폼 → 3: 팝업)

)}
{/* 커스텀 컨텍스트 메뉴 */} {contextMenuPosition && contextMenuScreen && ( <> {/* 백드롭 - 클릭 시 메뉴 닫기 */}
{ e.preventDefault(); closeContextMenu(); }} /> {/* 컨텍스트 메뉴 */}
{ handleOpenCopyModal(contextMenuScreen); }} > 복제
{ handleOpenEditScreenModal(contextMenuScreen); closeContextMenu(); }} > 수정
{ handleDeleteScreen(contextMenuScreen); closeContextMenu(); }} > 삭제
)} {/* 그룹 컨텍스트 메뉴 */} {contextMenuGroupPosition && contextMenuGroup && ( <> {/* 백드롭 - 클릭 시 메뉴 닫기 */}
{ e.preventDefault(); closeGroupContextMenu(); }} /> {/* 컨텍스트 메뉴 */}
handleOpenGroupCopyModal(contextMenuGroup)} > 그룹 복제
{ setEditingGroup(contextMenuGroup); setIsGroupModalOpen(true); closeGroupContextMenu(); }} > 수정
{ handleDeleteGroup(contextMenuGroup); closeGroupContextMenu(); }} > 그룹 삭제
)} {/* 메뉴-화면그룹 동기화 다이얼로그 */} 메뉴-화면 동기화 화면관리의 폴더 구조와 메뉴관리를 연동합니다. {/* 최고 관리자: 회사 선택 */} {isSuperAdmin && (
회사를 찾을 수 없습니다. {companies.map((company) => ( handleCompanySelect(company.company_code)} className="text-sm" > {company.company_name} ))}
)} {/* 현재 상태 표시 */} {syncStatus ? (
화면관리
{syncStatus.screenGroups.total}개
연결됨: {syncStatus.screenGroups.linked} / 미연결: {syncStatus.screenGroups.unlinked}
사용자 메뉴
{syncStatus.menuItems.total}개
연결됨: {syncStatus.menuItems.linked} / 미연결: {syncStatus.menuItems.unlinked}
{syncStatus.potentialMatches.length > 0 && (
자동 매칭 가능 ({syncStatus.potentialMatches.length}개)
{syncStatus.potentialMatches.slice(0, 5).map((match, i) => (
{match.menuName} = {match.groupName}
))} {syncStatus.potentialMatches.length > 5 && (
...외 {syncStatus.potentialMatches.length - 5}개
)}
)} {/* 동기화 버튼 */}
{/* 전체 동기화 (최고 관리자만) */} {isSuperAdmin && (
)}
) : isSuperAdmin && !selectedCompanyCode ? (

개별 회사 동기화를 하려면 회사를 선택해주세요.

{/* 전체 회사 동기화 버튼 (회사 선택 없이도 표시) */}
) : (
)}
); }