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

2019 lines
81 KiB
TypeScript
Raw Normal View History

"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<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);
// 메뉴-화면그룹 동기화 상태
const [isSyncDialogOpen, setIsSyncDialogOpen] = useState(false);
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(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<Company[]>([]);
const [selectedCompanyCode, setSelectedCompanyCode] = useState<string>("");
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<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 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<number> => {
const ancestors = new Set<number>();
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<number>();
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<number>();
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<number>();
// 일치하는 그룹의 상위 그룹들도 포함 (계층 유지를 위해)
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<number>();
const keywords = searchTerm.toLowerCase().trim().split(/\s+/).filter(k => k.length > 0);
if (keywords.length === 0) return new Set<number>();
// 그룹의 조상 ID들을 가져오는 함수
const getAncestorIds = (groupId: number): Set<number> => {
const ancestors = new Set<number>();
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<number>();
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<number>();
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<number>();
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<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 space-y-2">
<Button
onClick={handleAddGroup}
variant="outline"
size="sm"
className="w-full gap-2"
>
<Plus className="h-4 w-4" />
</Button>
<Button
onClick={handleOpenSyncDialog}
variant="ghost"
size="sm"
className="w-full gap-2 text-muted-foreground"
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
{/* 트리 목록 */}
<div className="flex-1 overflow-auto p-2">
{/* 검색 결과 없음 표시 */}
{searchTerm.trim() && getFilteredGroups.length === 0 && (
<div className="py-8 text-center text-sm text-muted-foreground">
&quot;{searchTerm}&quot;
</div>
)}
{/* 그룹화된 화면들 (대분류만 먼저 렌더링) */}
{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 (
<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",
isMatching && "bg-primary/5 dark:bg-primary/10" // 검색 일치 하이라이트 (연한 배경)
)}
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={cn("truncate flex-1", isMatching && "font-medium text-primary/80")}>{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) || 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 (
<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",
isChildMatching && "bg-primary/5 dark:bg-primary/10"
)}
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={cn("truncate flex-1", isChildMatching && "font-medium text-primary/80")}>{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) || shouldAutoExpandForSearch.has(grandChild.id); // 검색 시 상위 그룹만 자동 확장
const grandScreens = getScreensInGroup(grandChild.id);
const isGrandMatching = isGroupMatchingSearch(grandChild.group_name);
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",
isGrandMatching && "bg-primary/5 dark:bg-primary/10"
)}
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={cn("truncate flex-1", isGrandMatching && "font-medium text-primary/80")}>{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>
</>
)}
{/* 메뉴-화면그룹 동기화 다이얼로그 */}
<Dialog open={isSyncDialogOpen} onOpenChange={setIsSyncDialogOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">- </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
.
</DialogDescription>
</DialogHeader>
{/* 최고 관리자: 회사 선택 */}
{isSuperAdmin && (
<div className="space-y-2">
<Label className="text-xs sm:text-sm">
<Building2 className="inline-block h-4 w-4 mr-1" />
</Label>
<Popover open={isSyncCompanySelectOpen} onOpenChange={setIsSyncCompanySelectOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={isSyncCompanySelectOpen}
className="h-10 w-full justify-between text-sm"
>
{selectedCompanyCode
? companies.find((c) => c.company_code === selectedCompanyCode)?.company_name || selectedCompanyCode
: "회사를 선택하세요"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-full" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="회사 검색..." className="text-sm" />
<CommandList>
<CommandEmpty className="text-sm py-2 text-center"> .</CommandEmpty>
<CommandGroup>
{companies.map((company) => (
<CommandItem
key={company.company_code}
value={company.company_code}
onSelect={() => handleCompanySelect(company.company_code)}
className="text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedCompanyCode === company.company_code ? "opacity-100" : "opacity-0"
)}
/>
{company.company_name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
{/* 현재 상태 표시 */}
{syncStatus ? (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="rounded-md border p-3">
<div className="text-xs text-muted-foreground mb-1"></div>
<div className="text-lg font-semibold">{syncStatus.screenGroups.total}</div>
<div className="text-xs text-muted-foreground">
: {syncStatus.screenGroups.linked} / : {syncStatus.screenGroups.unlinked}
</div>
</div>
<div className="rounded-md border p-3">
<div className="text-xs text-muted-foreground mb-1"> </div>
<div className="text-lg font-semibold">{syncStatus.menuItems.total}</div>
<div className="text-xs text-muted-foreground">
: {syncStatus.menuItems.linked} / : {syncStatus.menuItems.unlinked}
</div>
</div>
</div>
{syncStatus.potentialMatches.length > 0 && (
<div className="rounded-md border p-3 bg-muted/50">
<div className="text-xs font-medium mb-2"> ({syncStatus.potentialMatches.length})</div>
<div className="text-xs text-muted-foreground space-y-1 max-h-24 overflow-auto">
{syncStatus.potentialMatches.slice(0, 5).map((match, i) => (
<div key={i}>
{match.menuName} = {match.groupName}
</div>
))}
{syncStatus.potentialMatches.length > 5 && (
<div>... {syncStatus.potentialMatches.length - 5}</div>
)}
</div>
</div>
)}
{/* 동기화 버튼 */}
<div className="space-y-2">
<Button
onClick={() => handleSync("screen-to-menu")}
disabled={isSyncing}
variant="outline"
className="w-full justify-start gap-2 border-blue-200 bg-blue-50/50 hover:bg-blue-100/70 hover:border-blue-300"
>
{isSyncing && syncDirection === "screen-to-menu" ? (
<Loader2 className="h-4 w-4 animate-spin text-blue-600" />
) : (
<FolderTree className="h-4 w-4 text-blue-600" />
)}
<span className="flex-1 text-left text-blue-700"> </span>
<span className="text-xs text-blue-500/70">
</span>
</Button>
<Button
onClick={() => handleSync("menu-to-screen")}
disabled={isSyncing}
variant="outline"
className="w-full justify-start gap-2 border-emerald-200 bg-emerald-50/50 hover:bg-emerald-100/70 hover:border-emerald-300"
>
{isSyncing && syncDirection === "menu-to-screen" ? (
<Loader2 className="h-4 w-4 animate-spin text-emerald-600" />
) : (
<FolderInput className="h-4 w-4 text-emerald-600" />
)}
<span className="flex-1 text-left text-emerald-700"> </span>
<span className="text-xs text-emerald-500/70">
</span>
</Button>
</div>
{/* 전체 동기화 (최고 관리자만) */}
{isSuperAdmin && (
<div className="border-t pt-3 mt-3">
<Button
onClick={handleSyncAll}
disabled={isSyncing}
variant="default"
className="w-full justify-start gap-2"
>
{isSyncing && syncDirection === "all" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
<span className="flex-1 text-left"> </span>
<span className="text-xs text-primary-foreground/70">
</span>
</Button>
</div>
)}
</div>
) : isSuperAdmin && !selectedCompanyCode ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Building2 className="h-10 w-10 text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground mb-4">
.
</p>
{/* 전체 회사 동기화 버튼 (회사 선택 없이도 표시) */}
<div className="w-full border-t pt-4">
<Button
onClick={handleSyncAll}
disabled={isSyncing}
variant="default"
className="w-full justify-start gap-2"
>
{isSyncing && syncDirection === "all" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
<span className="flex-1 text-left"> </span>
<span className="text-xs text-primary-foreground/70">
</span>
</Button>
</div>
</div>
) : (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
<DialogFooter>
<Button
variant="ghost"
onClick={() => setIsSyncDialogOpen(false)}
disabled={isSyncing}
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}