ERP-node/frontend/components/screen/ScreenGroupTreeView.tsx

1464 lines
58 KiB
TypeScript
Raw Normal View History

"use client";
import { useState, useEffect } from "react";
import { cn } from "@/lib/utils";
import {
ChevronRight,
ChevronDown,
Monitor,
FolderOpen,
Folder,
Plus,
MoreVertical,
Edit,
Trash2,
FolderInput,
Copy,
FolderTree,
Loader2,
} from "lucide-react";
import { ScreenDefinition } from "@/types/screen";
import {
ScreenGroup,
getScreenGroups,
deleteScreenGroup,
addScreenToGroup,
removeScreenFromGroup,
} from "@/lib/api/screenGroup";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
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;
}
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,
}: ScreenGroupTreeViewProps) {
const [groups, setGroups] = useState<ScreenGroup[]>([]);
const [loading, setLoading] = useState(true);
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const [groupScreensMap, setGroupScreensMap] = useState<Map<number, number[]>>(new Map());
// 그룹 모달 상태
const [isGroupModalOpen, setIsGroupModalOpen] = useState(false);
const [editingGroup, setEditingGroup] = useState<ScreenGroup | null>(null);
// 삭제 확인 다이얼로그 상태
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [deletingGroup, setDeletingGroup] = useState<ScreenGroup | null>(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<ScreenDefinition | null>(null);
const [isScreenDeleting, setIsScreenDeleting] = useState(false); // 화면 삭제 진행 중
// 화면 수정 모달 상태 (이름 변경 + 그룹 이동 통합)
const [editingScreen, setEditingScreen] = useState<ScreenDefinition | null>(null);
const [isEditScreenModalOpen, setIsEditScreenModalOpen] = useState(false);
const [editScreenName, setEditScreenName] = useState<string>("");
const [selectedGroupForMove, setSelectedGroupForMove] = useState<number | null>(null);
const [screenRole, setScreenRole] = useState<string>("");
const [isGroupSelectOpen, setIsGroupSelectOpen] = useState(false);
const [displayOrder, setDisplayOrder] = useState<number>(1);
// 화면 복제 모달 상태 (CopyScreenModal 사용)
const [isCopyModalOpen, setIsCopyModalOpen] = useState(false);
const [copyingScreen, setCopyingScreen] = useState<ScreenDefinition | null>(null);
const [copyTargetGroupId, setCopyTargetGroupId] = useState<number | null>(null);
const [copyMode, setCopyMode] = useState<"screen" | "group">("screen");
// 그룹 복제 모달 상태 (CopyScreenModal 그룹 모드 사용)
const [copyingGroup, setCopyingGroup] = useState<ScreenGroup | null>(null);
// 컨텍스트 메뉴 상태 (화면용)
const [contextMenuScreen, setContextMenuScreen] = useState<ScreenDefinition | null>(null);
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null);
// 그룹 컨텍스트 메뉴 상태
const [contextMenuGroup, setContextMenuGroup] = useState<ScreenGroup | null>(null);
const [contextMenuGroupPosition, setContextMenuGroupPosition] = useState<{ x: number; y: number } | null>(null);
// 그룹 목록 및 그룹별 화면 로드
useEffect(() => {
loadGroupsData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [companyCode]);
// 그룹에 속한 화면 ID들을 가져오기
const getGroupedScreenIds = (): Set<number> => {
const ids = new Set<number>();
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 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 loadGroupsData = async () => {
try {
setLoading(true);
const response = await getScreenGroups({ size: 1000 }); // 모든 그룹 가져오기
if (response.success && response.data) {
setGroups(response.data);
// 각 그룹별 화면 목록 매핑
const screenMap = new Map<number, number[]>();
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 (
<div className="flex items-center justify-center p-8">
<div className="text-sm text-muted-foreground"> ...</div>
</div>
);
}
const ungroupedScreens = getUngroupedScreens();
return (
<div className="h-full flex flex-col overflow-hidden">
{/* 그룹 추가 버튼 */}
<div className="flex-shrink-0 border-b p-2">
<Button
onClick={handleAddGroup}
variant="outline"
size="sm"
className="w-full gap-2"
>
<Plus className="h-4 w-4" />
</Button>
</div>
{/* 트리 목록 */}
<div className="flex-1 overflow-auto p-2">
{/* 그룹화된 화면들 (대분류만 먼저 렌더링) */}
{groups
.filter((g) => !(g as any).parent_group_id) // 대분류만 (parent_group_id가 null)
.map((group) => {
const groupId = String(group.id);
const isExpanded = expandedGroups.has(groupId);
const groupScreens = getScreensInGroup(group.id);
// 하위 그룹들 찾기
const childGroups = groups.filter((g) => (g as any).parent_group_id === group.id);
return (
<div key={groupId} className="mb-1">
{/* 그룹 헤더 */}
<div
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer hover:bg-accent transition-colors",
"text-sm font-medium group/item"
)}
onClick={() => toggleGroup(groupId)}
onContextMenu={(e) => handleGroupContextMenu(e, group)}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
{isExpanded ? (
<FolderOpen className="h-4 w-4 shrink-0 text-amber-500" />
) : (
<Folder className="h-4 w-4 shrink-0 text-amber-500" />
)}
<span className="truncate flex-1">{group.group_name}</span>
<Badge variant="secondary" className="text-xs">
{groupScreens.length}
</Badge>
{/* 그룹 메뉴 버튼 */}
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover/item:opacity-100"
>
<MoreVertical className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={(e) => handleEditGroup(group, e as any)}>
<Edit className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => handleDeleteGroup(group, e as any)}
className="text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* 그룹 내 하위 그룹들 */}
{isExpanded && childGroups.length > 0 && (
<div className="ml-6 mt-1 space-y-0.5">
{childGroups.map((childGroup) => {
const childGroupId = String(childGroup.id);
const isChildExpanded = expandedGroups.has(childGroupId);
const childScreens = getScreensInGroup(childGroup.id);
// 손자 그룹들 (3단계)
const grandChildGroups = groups.filter((g) => (g as any).parent_group_id === childGroup.id);
return (
<div key={childGroupId}>
{/* 중분류 헤더 */}
<div
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer hover:bg-accent transition-colors",
"text-xs font-medium group/item"
)}
onClick={() => toggleGroup(childGroupId)}
onContextMenu={(e) => handleGroupContextMenu(e, childGroup)}
>
{isChildExpanded ? (
<ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />
)}
{isChildExpanded ? (
<FolderOpen className="h-3 w-3 shrink-0 text-blue-500" />
) : (
<Folder className="h-3 w-3 shrink-0 text-blue-500" />
)}
<span className="truncate flex-1">{childGroup.group_name}</span>
<Badge variant="secondary" className="text-[10px] h-4">
{childScreens.length}
</Badge>
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 opacity-0 group-hover/item:opacity-100"
>
<MoreVertical className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={(e) => handleEditGroup(childGroup, e as any)}>
<Edit className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => handleDeleteGroup(childGroup, e as any)}
className="text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* 중분류 내 손자 그룹들 (소분류) */}
{isChildExpanded && grandChildGroups.length > 0 && (
<div className="ml-6 mt-1 space-y-0.5">
{grandChildGroups.map((grandChild) => {
const grandChildId = String(grandChild.id);
const isGrandExpanded = expandedGroups.has(grandChildId);
const grandScreens = getScreensInGroup(grandChild.id);
return (
<div key={grandChildId}>
{/* 소분류 헤더 */}
<div
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer hover:bg-accent transition-colors",
"text-xs group/item"
)}
onClick={() => toggleGroup(grandChildId)}
onContextMenu={(e) => handleGroupContextMenu(e, grandChild)}
>
{isGrandExpanded ? (
<ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />
)}
{isGrandExpanded ? (
<FolderOpen className="h-3 w-3 shrink-0 text-green-500" />
) : (
<Folder className="h-3 w-3 shrink-0 text-green-500" />
)}
<span className="truncate flex-1">{grandChild.group_name}</span>
<Badge variant="outline" className="text-[10px] h-4">
{grandScreens.length}
</Badge>
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 opacity-0 group-hover/item:opacity-100"
>
<MoreVertical className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={(e) => handleEditGroup(grandChild, e as any)}>
<Edit className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => handleDeleteGroup(grandChild, e as any)}
className="text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* 소분류 내 화면들 */}
{isGrandExpanded && (
<div className="ml-6 mt-1 space-y-0.5">
{grandScreens.length === 0 ? (
<div className="pl-6 py-2 text-xs text-muted-foreground">
</div>
) : (
grandScreens.map((screen) => (
<div
key={screen.screenId}
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors",
"text-xs hover:bg-accent",
selectedScreen?.screenId === screen.screenId && "bg-accent"
)}
onClick={() => handleScreenClickInGroup(screen, grandChild)}
onDoubleClick={() => handleScreenDoubleClick(screen)}
onContextMenu={(e) => handleContextMenu(e, screen)}
>
<Monitor className="h-3 w-3 shrink-0 text-blue-500" />
<span className="truncate flex-1">{screen.screenName}</span>
<span className="text-[10px] text-muted-foreground truncate max-w-[80px]">
{screen.screenCode}
</span>
</div>
))
)}
</div>
)}
</div>
);
})}
</div>
)}
{/* 중분류 내 화면들 */}
{isChildExpanded && (
<div className="ml-6 mt-1 space-y-0.5">
{childScreens.length === 0 && grandChildGroups.length === 0 ? (
<div className="pl-6 py-2 text-xs text-muted-foreground">
</div>
) : (
childScreens.map((screen) => (
<div
key={screen.screenId}
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors",
"text-xs hover:bg-accent",
selectedScreen?.screenId === screen.screenId && "bg-accent"
)}
onClick={() => handleScreenClickInGroup(screen, childGroup)}
onDoubleClick={() => handleScreenDoubleClick(screen)}
onContextMenu={(e) => handleContextMenu(e, screen)}
>
<Monitor className="h-3 w-3 shrink-0 text-blue-500" />
<span className="truncate flex-1">{screen.screenName}</span>
<span className="text-[10px] text-muted-foreground truncate max-w-[80px]">
{screen.screenCode}
</span>
</div>
))
)}
</div>
)}
</div>
);
})}
</div>
)}
{/* 그룹 내 화면들 (대분류 직속) */}
{isExpanded && (
<div className="ml-4 mt-1 space-y-0.5">
{groupScreens.length === 0 && childGroups.length === 0 ? (
<div className="pl-6 py-2 text-xs text-muted-foreground">
</div>
) : (
groupScreens.map((screen) => (
<div
key={screen.screenId}
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors",
"text-sm hover:bg-accent group/screen",
selectedScreen?.screenId === screen.screenId && "bg-accent"
)}
onClick={() => handleScreenClickInGroup(screen, group)}
onDoubleClick={() => handleScreenDoubleClick(screen)}
onContextMenu={(e) => handleContextMenu(e, screen)}
>
<Monitor className="h-4 w-4 shrink-0 text-blue-500" />
<span className="truncate flex-1">{screen.screenName}</span>
<span className="text-xs text-muted-foreground truncate max-w-[100px]">
{screen.screenCode}
</span>
</div>
))
)}
</div>
)}
</div>
);
})}
{/* 미분류 화면들 */}
{ungroupedScreens.length > 0 && (
<div className="mb-1">
<div
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer hover:bg-accent transition-colors",
"text-sm font-medium text-muted-foreground"
)}
onClick={() => toggleGroup("ungrouped")}
>
{expandedGroups.has("ungrouped") ? (
<ChevronDown className="h-4 w-4 shrink-0" />
) : (
<ChevronRight className="h-4 w-4 shrink-0" />
)}
<Folder className="h-4 w-4 shrink-0" />
<span className="truncate flex-1"></span>
<Badge variant="outline" className="text-xs">
{ungroupedScreens.length}
</Badge>
</div>
{expandedGroups.has("ungrouped") && (
<div className="ml-4 mt-1 space-y-0.5">
{ungroupedScreens.map((screen) => (
<div
key={screen.screenId}
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors",
"text-sm hover:bg-accent",
selectedScreen?.screenId === screen.screenId && "bg-accent"
)}
onClick={() => handleScreenClick(screen)}
onDoubleClick={() => handleScreenDoubleClick(screen)}
onContextMenu={(e) => handleContextMenu(e, screen)}
>
<Monitor className="h-4 w-4 shrink-0 text-blue-500" />
<span className="truncate flex-1">{screen.screenName}</span>
<span className="text-xs text-muted-foreground truncate max-w-[100px]">
{screen.screenCode}
</span>
</div>
))}
</div>
)}
</div>
)}
{groups.length === 0 && ungroupedScreens.length === 0 && (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Monitor className="h-12 w-12 text-muted-foreground/50 mb-2" />
<p className="text-sm text-muted-foreground"> </p>
</div>
)}
</div>
{/* 그룹 추가/수정 모달 */}
<ScreenGroupModal
isOpen={isGroupModalOpen}
onClose={() => {
setIsGroupModalOpen(false);
setEditingGroup(null);
}}
onSuccess={loadGroupsData}
group={editingGroup}
/>
{/* 화면/그룹 복제 모달 (CopyScreenModal 사용) */}
<CopyScreenModal
isOpen={isCopyModalOpen}
onClose={() => {
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}
/>
{/* 그룹 삭제 확인 다이얼로그 */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent className="max-w-[95vw] sm:max-w-[500px]">
<AlertDialogHeader>
<AlertDialogTitle className="text-base sm:text-lg"> </AlertDialogTitle>
<AlertDialogDescription className="text-xs sm:text-sm">
"{deletingGroup?.group_name}" ?
<br />
{deleteScreensWithGroup
? <span className="text-destructive font-medium"> .</span>
: "그룹에 속한 화면들은 미분류로 이동됩니다."
}
</AlertDialogDescription>
</AlertDialogHeader>
{/* 그룹 정보 표시 */}
{deletingGroup && (
<div className="rounded-md border bg-muted/50 p-3 text-xs space-y-1">
<div className="flex justify-between">
<span className="text-muted-foreground"> :</span>
<span className="font-medium">{getAllChildGroupIds(deletingGroup.id).length}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"> ( ):</span>
<span className="font-medium">{getAllScreensInGroupRecursively(deletingGroup.id).length}</span>
</div>
</div>
)}
{/* 화면도 함께 삭제 체크박스 */}
{deletingGroup && getAllScreensInGroupRecursively(deletingGroup.id).length > 0 && (
<div className="flex items-center space-x-2 py-2">
<input
type="checkbox"
id="deleteScreensWithGroup"
checked={deleteScreensWithGroup}
onChange={(e) => setDeleteScreensWithGroup(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-destructive focus:ring-destructive"
/>
<label
htmlFor="deleteScreensWithGroup"
className="text-sm text-muted-foreground cursor-pointer"
>
({getAllScreensInGroupRecursively(deletingGroup.id).length})
</label>
</div>
)}
{/* 로딩 오버레이 */}
{isDeleting && (
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center rounded-lg bg-background/90 backdrop-blur-sm">
<Loader2 className="h-10 w-10 animate-spin text-destructive" />
<p className="mt-4 text-sm font-medium">{deleteProgress.message}</p>
{deleteProgress.total > 0 && (
<>
<div className="mt-3 h-2 w-48 overflow-hidden rounded-full bg-secondary">
<div
className="h-full bg-destructive transition-all duration-300"
style={{ width: `${Math.round((deleteProgress.current / deleteProgress.total) * 100)}%` }}
/>
</div>
<p className="mt-2 text-xs text-muted-foreground">
{deleteProgress.current} / {deleteProgress.total}
</p>
</>
)}
</div>
)}
<AlertDialogFooter className="gap-2 sm:gap-0">
<AlertDialogCancel
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
disabled={isDeleting}
>
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
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 ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"삭제"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 단일 화면 삭제 확인 다이얼로그 */}
<AlertDialog open={isScreenDeleteDialogOpen} onOpenChange={setIsScreenDeleteDialogOpen}>
<AlertDialogContent className="max-w-[95vw] sm:max-w-[500px]">
{/* 로딩 오버레이 */}
{isScreenDeleting && (
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center rounded-lg bg-background/90 backdrop-blur-sm">
<Loader2 className="h-8 w-8 animate-spin text-destructive" />
<p className="mt-3 text-sm font-medium"> ...</p>
</div>
)}
<AlertDialogHeader>
<AlertDialogTitle className="text-base sm:text-lg"> </AlertDialogTitle>
<AlertDialogDescription className="text-xs sm:text-sm">
"{deletingScreen?.screenName}" ?
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="gap-2 sm:gap-0">
<AlertDialogCancel
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
disabled={isScreenDeleting}
>
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
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 ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"삭제"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 화면 수정 모달 (이름 변경 + 그룹 이동) */}
<Dialog open={isEditScreenModalOpen} onOpenChange={setIsEditScreenModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[400px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
{/* 화면 이름 */}
<div>
<Label htmlFor="edit-screen-name" className="text-xs sm:text-sm">
*
</Label>
<Input
id="edit-screen-name"
value={editScreenName}
onChange={(e) => setEditScreenName(e.target.value)}
placeholder="화면 이름을 입력하세요"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
{/* 그룹 선택 (트리 구조 + 검색) */}
<div>
<Label htmlFor="target-group" className="text-xs sm:text-sm">
*
</Label>
<Popover open={isGroupSelectOpen} onOpenChange={setIsGroupSelectOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={isGroupSelectOpen}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
>
{selectedGroupForMove === null
? "미분류"
: getGroupPath(selectedGroupForMove) || "그룹 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput
placeholder="그룹 검색..."
className="text-xs sm:text-sm"
/>
<CommandList>
<CommandEmpty className="text-xs sm:text-sm py-2 text-center">
</CommandEmpty>
<CommandGroup>
{/* 미분류 옵션 */}
<CommandItem
value="none"
onSelect={() => {
setSelectedGroupForMove(null);
setIsGroupSelectOpen(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedGroupForMove === null ? "opacity-100" : "opacity-0"
)}
/>
<Folder className="mr-2 h-4 w-4 text-muted-foreground" />
</CommandItem>
{/* 계층 구조로 그룹 표시 */}
{getSortedGroups().map((group) => (
<CommandItem
key={group.id}
value={`${group.group_name} ${getGroupPath(group.id)}`}
onSelect={() => {
setSelectedGroupForMove(group.id);
setIsGroupSelectOpen(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedGroupForMove === group.id ? "opacity-100" : "opacity-0"
)}
/>
{/* 들여쓰기로 계층 표시 */}
<span
style={{ marginLeft: `${((group.group_level || 1) - 1) * 16}px` }}
className="flex items-center"
>
<Folder className="mr-2 h-4 w-4 text-muted-foreground" />
{group.group_name}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
. .
</p>
</div>
{/* 화면 역할 입력 (그룹이 선택된 경우만) */}
{selectedGroupForMove !== null && (
<>
<div>
<Label htmlFor="screen-role" className="text-xs sm:text-sm">
()
</Label>
<Input
id="screen-role"
value={screenRole}
onChange={(e) => setScreenRole(e.target.value)}
placeholder="예: 목록, 등록, 조회, 팝업..."
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
</p>
</div>
<div>
<Label htmlFor="display-order" className="text-xs sm:text-sm">
*
</Label>
<Input
id="display-order"
type="number"
value={displayOrder}
onChange={(e) => setDisplayOrder(parseInt(e.target.value) || 1)}
min={1}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
(1: 메인 2: 등록 3: 팝업)
</p>
</div>
</>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => {
setIsEditScreenModalOpen(false);
setEditingScreen(null);
setEditScreenName("");
setSelectedGroupForMove(null);
setScreenRole("");
setDisplayOrder(1);
}}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={saveScreenEdit}
disabled={!editScreenName.trim()}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 커스텀 컨텍스트 메뉴 */}
{contextMenuPosition && contextMenuScreen && (
<>
{/* 백드롭 - 클릭 시 메뉴 닫기 */}
<div
className="fixed inset-0 z-40"
onClick={closeContextMenu}
onContextMenu={(e) => {
e.preventDefault();
closeContextMenu();
}}
/>
{/* 컨텍스트 메뉴 */}
<div
className="fixed z-50 min-w-[150px] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
style={{
left: contextMenuPosition.x,
top: contextMenuPosition.y,
}}
>
<div
className="flex cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
onClick={() => {
handleOpenCopyModal(contextMenuScreen);
}}
>
<Copy className="mr-2 h-4 w-4" />
</div>
<div className="my-1 h-px bg-border" />
<div
className="flex cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
onClick={() => {
handleOpenEditScreenModal(contextMenuScreen);
closeContextMenu();
}}
>
<Edit className="mr-2 h-4 w-4" />
</div>
<div className="my-1 h-px bg-border" />
<div
className="flex cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm text-destructive hover:bg-accent"
onClick={() => {
handleDeleteScreen(contextMenuScreen);
closeContextMenu();
}}
>
<Trash2 className="mr-2 h-4 w-4" />
</div>
</div>
</>
)}
{/* 그룹 컨텍스트 메뉴 */}
{contextMenuGroupPosition && contextMenuGroup && (
<>
{/* 백드롭 - 클릭 시 메뉴 닫기 */}
<div
className="fixed inset-0 z-40"
onClick={closeGroupContextMenu}
onContextMenu={(e) => {
e.preventDefault();
closeGroupContextMenu();
}}
/>
{/* 컨텍스트 메뉴 */}
<div
className="fixed z-50 min-w-[150px] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
style={{
left: contextMenuGroupPosition.x,
top: contextMenuGroupPosition.y,
}}
>
<div
className="flex cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
onClick={() => handleOpenGroupCopyModal(contextMenuGroup)}
>
<Copy className="mr-2 h-4 w-4" />
</div>
<div className="my-1 h-px bg-border" />
<div
className="flex cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
onClick={() => {
setEditingGroup(contextMenuGroup);
setIsGroupModalOpen(true);
closeGroupContextMenu();
}}
>
<Edit className="mr-2 h-4 w-4" />
</div>
<div className="my-1 h-px bg-border" />
<div
className="flex cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm text-destructive hover:bg-accent"
onClick={() => {
handleDeleteGroup(contextMenuGroup);
closeGroupContextMenu();
}}
>
<Trash2 className="mr-2 h-4 w-4" />
</div>
</div>
</>
)}
</div>
);
}