- 화면 그룹 삭제 시 연결된 메뉴를 정리하는 로직을 추가하여, 삭제될 그룹에 연결된 메뉴를 자동으로 삭제하도록 하였습니다. - 메뉴 삭제 시 관련된 화면 및 플로우 데이터도 함께 정리하여 데이터 일관성을 유지하였습니다. - 복제 화면 모달에서 원본 회사와 동일한 회사 선택 시 자동으로 다른 회사로 변경하는 기능을 추가하였습니다. - 삭제 확인 다이얼로그에 경고 메시지를 추가하여 사용자에게 삭제 작업의 영향을 명확히 안내하였습니다.
2135 lines
86 KiB
TypeScript
2135 lines
86 KiB
TypeScript
"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,
|
||
AlertTriangle,
|
||
} 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 [syncProgress, setSyncProgress] = useState<{ message: string; detail?: string } | 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);
|
||
}
|
||
}
|
||
};
|
||
|
||
// 동기화 실행
|
||
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);
|
||
setSyncProgress({
|
||
message: direction === "screen-to-menu"
|
||
? "화면관리 → 메뉴 동기화 중..."
|
||
: "메뉴 → 화면관리 동기화 중...",
|
||
detail: "데이터를 분석하고 있습니다..."
|
||
});
|
||
|
||
try {
|
||
setSyncProgress({
|
||
message: direction === "screen-to-menu"
|
||
? "화면관리 → 메뉴 동기화 중..."
|
||
: "메뉴 → 화면관리 동기화 중...",
|
||
detail: "동기화 작업을 수행하고 있습니다..."
|
||
});
|
||
|
||
const response = direction === "screen-to-menu"
|
||
? await syncScreenGroupsToMenu(targetCompanyCode)
|
||
: await syncMenuToScreenGroups(targetCompanyCode);
|
||
|
||
if (response.success) {
|
||
const data = response.data;
|
||
setSyncProgress({
|
||
message: "동기화 완료!",
|
||
detail: `생성 ${data?.created || 0}개, 연결 ${data?.linked || 0}개, 스킵 ${data?.skipped || 0}개`
|
||
});
|
||
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 {
|
||
setSyncProgress(null);
|
||
toast.error(`동기화 실패: ${response.error || "알 수 없는 오류"}`);
|
||
}
|
||
} catch (error: any) {
|
||
setSyncProgress(null);
|
||
toast.error(`동기화 실패: ${error.message}`);
|
||
} finally {
|
||
setIsSyncing(false);
|
||
setSyncDirection(null);
|
||
// 3초 후 진행 메시지 초기화
|
||
setTimeout(() => setSyncProgress(null), 3000);
|
||
}
|
||
};
|
||
|
||
// 전체 회사 동기화 (최고 관리자만)
|
||
const handleSyncAll = async () => {
|
||
if (!isSuperAdmin) {
|
||
toast.error("전체 동기화는 최고 관리자만 수행할 수 있습니다.");
|
||
return;
|
||
}
|
||
|
||
setIsSyncing(true);
|
||
setSyncDirection("all");
|
||
setSyncProgress({
|
||
message: "전체 회사 동기화 중...",
|
||
detail: "모든 회사의 데이터를 분석하고 있습니다..."
|
||
});
|
||
|
||
try {
|
||
setSyncProgress({
|
||
message: "전체 회사 동기화 중...",
|
||
detail: "양방향 동기화 작업을 수행하고 있습니다..."
|
||
});
|
||
|
||
const response = await syncAllCompanies();
|
||
|
||
if (response.success && response.data) {
|
||
const data = response.data;
|
||
setSyncProgress({
|
||
message: "전체 동기화 완료!",
|
||
detail: `${data.totalCompanies}개 회사, 생성 ${data.totalCreated}개, 연결 ${data.totalLinked}개`
|
||
});
|
||
toast.success(
|
||
`전체 동기화 완료: ${data.totalCompanies}개 회사, 생성 ${data.totalCreated}개, 연결 ${data.totalLinked}개`
|
||
);
|
||
// 그룹 데이터 새로고침
|
||
await loadGroupsData();
|
||
} else {
|
||
setSyncProgress(null);
|
||
toast.error(`전체 동기화 실패: ${response.error || "알 수 없는 오류"}`);
|
||
}
|
||
} catch (error: any) {
|
||
setSyncProgress(null);
|
||
toast.error(`전체 동기화 실패: ${error.message}`);
|
||
} finally {
|
||
setIsSyncing(false);
|
||
setSyncDirection(null);
|
||
// 3초 후 진행 메시지 초기화
|
||
setTimeout(() => setSyncProgress(null), 3000);
|
||
}
|
||
};
|
||
|
||
// 그룹 수정 버튼 클릭
|
||
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, targetCompanyCode?: string): ScreenDefinition[] => {
|
||
const result: ScreenDefinition[] = [];
|
||
// 부모 그룹의 company_code 확인
|
||
const parentGroup = groups.find(g => g.id === groupId);
|
||
const companyCode = targetCompanyCode || parentGroup?.company_code;
|
||
|
||
// 현재 그룹의 화면들
|
||
const currentGroupScreens = getScreensInGroup(groupId);
|
||
result.push(...currentGroupScreens);
|
||
|
||
// 같은 회사 + 같은 부모를 가진 하위 그룹들 찾기
|
||
const childGroups = groups.filter((g) =>
|
||
(g as any).parent_group_id === groupId &&
|
||
(!companyCode || g.company_code === companyCode)
|
||
);
|
||
for (const childGroup of childGroups) {
|
||
const childScreens = getAllScreensInGroupRecursively(childGroup.id, companyCode);
|
||
result.push(...childScreens);
|
||
}
|
||
|
||
return result;
|
||
};
|
||
|
||
// 모든 하위 그룹 ID를 재귀적으로 수집 (삭제 순서: 자식 → 부모)
|
||
// 같은 회사의 그룹만 필터링하여 다른 회사 그룹이 잘못 삭제되는 것을 방지
|
||
const getAllChildGroupIds = (groupId: number, targetCompanyCode?: string): number[] => {
|
||
const result: number[] = [];
|
||
// 부모 그룹의 company_code 확인
|
||
const parentGroup = groups.find(g => g.id === groupId);
|
||
const companyCode = targetCompanyCode || parentGroup?.company_code;
|
||
|
||
// 같은 회사 + 같은 부모를 가진 그룹만 필터링
|
||
const childGroups = groups.filter((g) =>
|
||
(g as any).parent_group_id === groupId &&
|
||
(!companyCode || g.company_code === companyCode)
|
||
);
|
||
|
||
for (const childGroup of childGroups) {
|
||
// 자식의 자식들을 먼저 수집 (깊은 곳부터)
|
||
const grandChildIds = getAllChildGroupIds(childGroup.id, companyCode);
|
||
result.push(...grandChildIds);
|
||
result.push(childGroup.id);
|
||
}
|
||
|
||
return result;
|
||
};
|
||
|
||
// 그룹 삭제 확인
|
||
const confirmDeleteGroup = async () => {
|
||
if (!deletingGroup) return;
|
||
|
||
// 🔍 디버깅: 삭제 대상 그룹 정보
|
||
console.log("========== 그룹 삭제 디버깅 ==========");
|
||
console.log("삭제 대상 그룹:", {
|
||
id: deletingGroup.id,
|
||
name: deletingGroup.group_name,
|
||
company_code: deletingGroup.company_code,
|
||
parent_group_id: (deletingGroup as any).parent_group_id
|
||
});
|
||
|
||
// 🔍 디버깅: 전체 groups 배열에서 같은 회사 그룹 출력
|
||
const sameCompanyGroups = groups.filter(g => g.company_code === deletingGroup.company_code);
|
||
console.log("같은 회사 그룹들:", sameCompanyGroups.map(g => ({
|
||
id: g.id,
|
||
name: g.group_name,
|
||
parent_group_id: (g as any).parent_group_id
|
||
})));
|
||
|
||
// 삭제 전 통계 수집 (화면 수는 삭제 전에 계산)
|
||
const totalScreensToDelete = getAllScreensInGroupRecursively(deletingGroup.id).length;
|
||
const childGroupIds = getAllChildGroupIds(deletingGroup.id);
|
||
|
||
// 🔍 디버깅: 수집된 하위 그룹 ID들
|
||
console.log("수집된 하위 그룹 ID들:", childGroupIds);
|
||
console.log("하위 그룹 상세:", childGroupIds.map(id => {
|
||
const g = groups.find(grp => grp.id === id);
|
||
return g ? { id: g.id, name: g.group_name, parent_group_id: (g as any).parent_group_id } : { id, name: "NOT_FOUND" };
|
||
}));
|
||
console.log("==========================================");
|
||
|
||
// 총 작업 수 계산 (화면 + 하위 그룹 + 현재 그룹)
|
||
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, "그룹 삭제와 함께 삭제", true); // force: true로 의존성 무시
|
||
}
|
||
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>
|
||
{isSuperAdmin && (
|
||
<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">
|
||
"{searchTerm}"와 일치하는 폴더가 없습니다
|
||
</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] border-destructive/50">
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle className="text-base sm:text-lg flex items-center gap-2 text-destructive">
|
||
<AlertTriangle className="h-5 w-5" />
|
||
그룹 삭제 경고
|
||
</AlertDialogTitle>
|
||
<AlertDialogDescription asChild>
|
||
<div className="text-xs sm:text-sm">
|
||
<div className="mt-2 rounded-md bg-destructive/10 border border-destructive/30 p-3">
|
||
<p className="font-semibold text-destructive">
|
||
"{deletingGroup?.group_name}" 그룹을 정말 삭제하시겠습니까?
|
||
</p>
|
||
<p className="mt-2 text-destructive/80">
|
||
{deleteScreensWithGroup
|
||
? "⚠️ 그룹에 속한 모든 화면, 플로우, 관련 데이터가 영구적으로 삭제됩니다. 이 작업은 되돌릴 수 없습니다."
|
||
: "그룹에 속한 화면들은 미분류로 이동됩니다."
|
||
}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</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 flex items-center gap-2 text-destructive">
|
||
<AlertTriangle className="h-5 w-5" />
|
||
화면 삭제 경고
|
||
</AlertDialogTitle>
|
||
<AlertDialogDescription asChild>
|
||
<div className="text-xs sm:text-sm">
|
||
<div className="mt-2 rounded-md bg-destructive/10 border border-destructive/30 p-3">
|
||
<p className="font-semibold text-destructive">
|
||
"{deletingScreen?.screenName}" 화면을 정말 삭제하시겠습니까?
|
||
</p>
|
||
<p className="mt-2 text-destructive/80">
|
||
⚠️ 화면과 연결된 플로우, 레이아웃 데이터가 모두 삭제됩니다. 삭제된 화면은 휴지통으로 이동됩니다.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</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] overflow-hidden">
|
||
{/* 동기화 진행 중 오버레이 (삭제와 동일한 스타일) */}
|
||
{isSyncing && (
|
||
<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-primary" />
|
||
<p className="mt-4 text-sm font-medium">{syncProgress?.message || "동기화 중..."}</p>
|
||
{syncProgress?.detail && (
|
||
<p className="mt-1 text-xs text-muted-foreground">{syncProgress.detail}</p>
|
||
)}
|
||
<div className="mt-3 h-2 w-48 overflow-hidden rounded-full bg-secondary">
|
||
<div
|
||
className="h-full bg-primary animate-pulse"
|
||
style={{ width: "100%" }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<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>
|
||
);
|
||
} |