2026-01-05 10:05:31 +09:00
|
|
|
"use client";
|
|
|
|
|
|
2026-01-16 14:48:15 +09:00
|
|
|
import { useState, useEffect, useMemo } from "react";
|
2026-01-05 10:05:31 +09:00
|
|
|
import { cn } from "@/lib/utils";
|
2026-01-05 18:18:26 +09:00
|
|
|
import {
|
|
|
|
|
ChevronRight,
|
|
|
|
|
ChevronDown,
|
|
|
|
|
Monitor,
|
|
|
|
|
FolderOpen,
|
|
|
|
|
Folder,
|
|
|
|
|
Plus,
|
|
|
|
|
MoreVertical,
|
|
|
|
|
Edit,
|
|
|
|
|
Trash2,
|
|
|
|
|
FolderInput,
|
2026-01-15 14:58:12 +09:00
|
|
|
Copy,
|
|
|
|
|
FolderTree,
|
|
|
|
|
Loader2,
|
2026-01-16 14:48:15 +09:00
|
|
|
RefreshCw,
|
|
|
|
|
Building2,
|
2026-01-05 18:18:26 +09:00
|
|
|
} from "lucide-react";
|
2026-01-05 10:05:31 +09:00
|
|
|
import { ScreenDefinition } from "@/types/screen";
|
2026-01-05 18:18:26 +09:00
|
|
|
import {
|
|
|
|
|
ScreenGroup,
|
|
|
|
|
getScreenGroups,
|
|
|
|
|
deleteScreenGroup,
|
|
|
|
|
addScreenToGroup,
|
|
|
|
|
removeScreenFromGroup,
|
2026-01-16 14:48:15 +09:00
|
|
|
getMenuScreenSyncStatus,
|
|
|
|
|
syncScreenGroupsToMenu,
|
|
|
|
|
syncMenuToScreenGroups,
|
|
|
|
|
syncAllCompanies,
|
|
|
|
|
SyncStatus,
|
|
|
|
|
AllCompaniesSyncResult,
|
2026-01-05 18:18:26 +09:00
|
|
|
} from "@/lib/api/screenGroup";
|
2026-01-05 10:05:31 +09:00
|
|
|
import { Badge } from "@/components/ui/badge";
|
2026-01-05 18:18:26 +09:00
|
|
|
import { Button } from "@/components/ui/button";
|
2026-01-16 14:48:15 +09:00
|
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
|
|
|
import { getCompanyList, Company } from "@/lib/api/company";
|
2026-01-05 18:18:26 +09:00
|
|
|
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";
|
2026-01-15 14:58:12 +09:00
|
|
|
import CopyScreenModal from "./CopyScreenModal";
|
2026-01-05 18:18:26 +09:00
|
|
|
import { toast } from "sonner";
|
2026-01-15 14:58:12 +09:00
|
|
|
import { screenApi } from "@/lib/api/screen";
|
2026-01-05 10:05:31 +09:00
|
|
|
|
|
|
|
|
interface ScreenGroupTreeViewProps {
|
|
|
|
|
screens: ScreenDefinition[];
|
|
|
|
|
selectedScreen: ScreenDefinition | null;
|
|
|
|
|
onScreenSelect: (screen: ScreenDefinition) => void;
|
|
|
|
|
onScreenDesign: (screen: ScreenDefinition) => void;
|
2026-01-09 18:26:37 +09:00
|
|
|
onGroupSelect?: (group: { id: number; name: string; company_code?: string } | null) => void;
|
|
|
|
|
onScreenSelectInGroup?: (group: { id: number; name: string; company_code?: string }, screenId: number) => void;
|
2026-01-05 10:05:31 +09:00
|
|
|
companyCode?: string;
|
2026-01-16 14:48:15 +09:00
|
|
|
searchTerm?: string; // 검색어 (띄어쓰기로 구분된 여러 키워드)
|
2026-01-05 10:05:31 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface TreeNode {
|
|
|
|
|
type: "group" | "screen";
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
data?: ScreenDefinition | ScreenGroup;
|
|
|
|
|
children?: TreeNode[];
|
|
|
|
|
expanded?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function ScreenGroupTreeView({
|
|
|
|
|
screens,
|
|
|
|
|
selectedScreen,
|
|
|
|
|
onScreenSelect,
|
|
|
|
|
onScreenDesign,
|
2026-01-05 18:18:26 +09:00
|
|
|
onGroupSelect,
|
|
|
|
|
onScreenSelectInGroup,
|
2026-01-05 10:05:31 +09:00
|
|
|
companyCode,
|
2026-01-16 14:48:15 +09:00
|
|
|
searchTerm = "",
|
2026-01-05 10:05:31 +09:00
|
|
|
}: 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());
|
|
|
|
|
|
2026-01-05 18:18:26 +09:00
|
|
|
// 그룹 모달 상태
|
|
|
|
|
const [isGroupModalOpen, setIsGroupModalOpen] = useState(false);
|
|
|
|
|
const [editingGroup, setEditingGroup] = useState<ScreenGroup | null>(null);
|
|
|
|
|
|
|
|
|
|
// 삭제 확인 다이얼로그 상태
|
|
|
|
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
|
|
|
|
const [deletingGroup, setDeletingGroup] = useState<ScreenGroup | null>(null);
|
2026-01-15 14:58:12 +09:00
|
|
|
const [deleteScreensWithGroup, setDeleteScreensWithGroup] = useState(false); // 화면도 함께 삭제 체크박스
|
|
|
|
|
const [isDeleting, setIsDeleting] = useState(false); // 삭제 진행 중 상태
|
|
|
|
|
const [deleteProgress, setDeleteProgress] = useState({ current: 0, total: 0, message: "" }); // 삭제 진행 상태
|
2026-01-05 18:18:26 +09:00
|
|
|
|
2026-01-15 14:58:12 +09:00
|
|
|
// 단일 화면 삭제 상태
|
|
|
|
|
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>("");
|
2026-01-05 18:18:26 +09:00
|
|
|
const [selectedGroupForMove, setSelectedGroupForMove] = useState<number | null>(null);
|
|
|
|
|
const [screenRole, setScreenRole] = useState<string>("");
|
|
|
|
|
const [isGroupSelectOpen, setIsGroupSelectOpen] = useState(false);
|
|
|
|
|
const [displayOrder, setDisplayOrder] = useState<number>(1);
|
2026-01-05 10:05:31 +09:00
|
|
|
|
2026-01-15 14:58:12 +09:00
|
|
|
// 화면 복제 모달 상태 (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);
|
|
|
|
|
|
2026-01-16 14:48:15 +09:00
|
|
|
// 메뉴-화면그룹 동기화 상태
|
|
|
|
|
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);
|
2026-01-16 17:41:19 +09:00
|
|
|
const [syncProgress, setSyncProgress] = useState<{ message: string; detail?: string } | null>(null);
|
2026-01-16 14:48:15 +09:00
|
|
|
|
|
|
|
|
// 회사 선택 (최고 관리자용)
|
|
|
|
|
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) || "";
|
|
|
|
|
|
2026-01-05 18:18:26 +09:00
|
|
|
// 그룹 목록 및 그룹별 화면 로드
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
loadGroupsData();
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
2026-01-05 10:05:31 +09:00
|
|
|
}, [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));
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-05 18:18:26 +09:00
|
|
|
// 그룹에 속한 화면들 (display_order 오름차순 정렬)
|
2026-01-05 10:05:31 +09:00
|
|
|
const getScreensInGroup = (groupId: number): ScreenDefinition[] => {
|
2026-01-05 18:18:26 +09:00
|
|
|
const group = groups.find((g) => g.id === groupId);
|
|
|
|
|
if (!group?.screens) {
|
2026-01-08 14:24:33 +09:00
|
|
|
const screenIds = groupScreensMap.get(groupId) || [];
|
|
|
|
|
return screens.filter((screen) => screenIds.includes(screen.screenId));
|
2026-01-05 18:18:26 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 그룹의 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);
|
2026-01-05 10:05:31 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const toggleGroup = (groupId: string) => {
|
|
|
|
|
const newExpanded = new Set(expandedGroups);
|
|
|
|
|
if (newExpanded.has(groupId)) {
|
|
|
|
|
newExpanded.delete(groupId);
|
2026-01-05 18:18:26 +09:00
|
|
|
// 그룹 접으면 선택 해제
|
|
|
|
|
if (onGroupSelect) {
|
|
|
|
|
onGroupSelect(null);
|
|
|
|
|
}
|
2026-01-05 10:05:31 +09:00
|
|
|
} else {
|
|
|
|
|
newExpanded.add(groupId);
|
2026-01-05 18:18:26 +09:00
|
|
|
// 그룹 펼치면 해당 그룹 선택
|
|
|
|
|
if (onGroupSelect && groupId !== "ungrouped") {
|
|
|
|
|
const group = groups.find((g) => String(g.id) === groupId);
|
|
|
|
|
if (group) {
|
2026-01-09 18:26:37 +09:00
|
|
|
onGroupSelect({ id: group.id, name: group.group_name, company_code: group.company_code });
|
2026-01-05 18:18:26 +09:00
|
|
|
}
|
|
|
|
|
}
|
2026-01-05 10:05:31 +09:00
|
|
|
}
|
|
|
|
|
setExpandedGroups(newExpanded);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleScreenClick = (screen: ScreenDefinition) => {
|
|
|
|
|
onScreenSelect(screen);
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-05 18:18:26 +09:00
|
|
|
// 그룹 내 화면 클릭 핸들러 (그룹 전체 표시 + 해당 화면 포커스)
|
|
|
|
|
const handleScreenClickInGroup = (screen: ScreenDefinition, group: ScreenGroup) => {
|
|
|
|
|
if (onScreenSelectInGroup) {
|
|
|
|
|
onScreenSelectInGroup(
|
2026-01-09 18:26:37 +09:00
|
|
|
{ id: group.id, name: group.group_name, company_code: group.company_code },
|
2026-01-05 18:18:26 +09:00
|
|
|
screen.screenId
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
// fallback: 기존 동작
|
|
|
|
|
onScreenSelect(screen);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-05 10:05:31 +09:00
|
|
|
const handleScreenDoubleClick = (screen: ScreenDefinition) => {
|
|
|
|
|
onScreenDesign(screen);
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-05 18:18:26 +09:00
|
|
|
// 그룹 추가 버튼 클릭
|
|
|
|
|
const handleAddGroup = () => {
|
|
|
|
|
setEditingGroup(null);
|
|
|
|
|
setIsGroupModalOpen(true);
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-16 14:48:15 +09:00
|
|
|
// 동기화 다이얼로그 열기
|
|
|
|
|
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);
|
2026-01-16 17:41:19 +09:00
|
|
|
setSyncProgress({
|
|
|
|
|
message: direction === "screen-to-menu"
|
|
|
|
|
? "화면관리 → 메뉴 동기화 중..."
|
|
|
|
|
: "메뉴 → 화면관리 동기화 중...",
|
|
|
|
|
detail: "데이터를 분석하고 있습니다..."
|
|
|
|
|
});
|
2026-01-16 14:48:15 +09:00
|
|
|
|
|
|
|
|
try {
|
2026-01-16 17:41:19 +09:00
|
|
|
setSyncProgress({
|
|
|
|
|
message: direction === "screen-to-menu"
|
|
|
|
|
? "화면관리 → 메뉴 동기화 중..."
|
|
|
|
|
: "메뉴 → 화면관리 동기화 중...",
|
|
|
|
|
detail: "동기화 작업을 수행하고 있습니다..."
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-16 14:48:15 +09:00
|
|
|
const response = direction === "screen-to-menu"
|
|
|
|
|
? await syncScreenGroupsToMenu(targetCompanyCode)
|
|
|
|
|
: await syncMenuToScreenGroups(targetCompanyCode);
|
|
|
|
|
|
|
|
|
|
if (response.success) {
|
|
|
|
|
const data = response.data;
|
2026-01-16 17:41:19 +09:00
|
|
|
setSyncProgress({
|
|
|
|
|
message: "동기화 완료!",
|
|
|
|
|
detail: `생성 ${data?.created || 0}개, 연결 ${data?.linked || 0}개, 스킵 ${data?.skipped || 0}개`
|
|
|
|
|
});
|
2026-01-16 14:48:15 +09:00
|
|
|
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 {
|
2026-01-16 17:41:19 +09:00
|
|
|
setSyncProgress(null);
|
2026-01-16 14:48:15 +09:00
|
|
|
toast.error(`동기화 실패: ${response.error || "알 수 없는 오류"}`);
|
|
|
|
|
}
|
|
|
|
|
} catch (error: any) {
|
2026-01-16 17:41:19 +09:00
|
|
|
setSyncProgress(null);
|
2026-01-16 14:48:15 +09:00
|
|
|
toast.error(`동기화 실패: ${error.message}`);
|
|
|
|
|
} finally {
|
|
|
|
|
setIsSyncing(false);
|
|
|
|
|
setSyncDirection(null);
|
2026-01-16 17:41:19 +09:00
|
|
|
// 3초 후 진행 메시지 초기화
|
|
|
|
|
setTimeout(() => setSyncProgress(null), 3000);
|
2026-01-16 14:48:15 +09:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 전체 회사 동기화 (최고 관리자만)
|
|
|
|
|
const handleSyncAll = async () => {
|
|
|
|
|
if (!isSuperAdmin) {
|
|
|
|
|
toast.error("전체 동기화는 최고 관리자만 수행할 수 있습니다.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setIsSyncing(true);
|
|
|
|
|
setSyncDirection("all");
|
2026-01-16 17:41:19 +09:00
|
|
|
setSyncProgress({
|
|
|
|
|
message: "전체 회사 동기화 중...",
|
|
|
|
|
detail: "모든 회사의 데이터를 분석하고 있습니다..."
|
|
|
|
|
});
|
2026-01-16 14:48:15 +09:00
|
|
|
|
|
|
|
|
try {
|
2026-01-16 17:41:19 +09:00
|
|
|
setSyncProgress({
|
|
|
|
|
message: "전체 회사 동기화 중...",
|
|
|
|
|
detail: "양방향 동기화 작업을 수행하고 있습니다..."
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-16 14:48:15 +09:00
|
|
|
const response = await syncAllCompanies();
|
|
|
|
|
|
|
|
|
|
if (response.success && response.data) {
|
|
|
|
|
const data = response.data;
|
2026-01-16 17:41:19 +09:00
|
|
|
setSyncProgress({
|
|
|
|
|
message: "전체 동기화 완료!",
|
|
|
|
|
detail: `${data.totalCompanies}개 회사, 생성 ${data.totalCreated}개, 연결 ${data.totalLinked}개`
|
|
|
|
|
});
|
2026-01-16 14:48:15 +09:00
|
|
|
toast.success(
|
|
|
|
|
`전체 동기화 완료: ${data.totalCompanies}개 회사, 생성 ${data.totalCreated}개, 연결 ${data.totalLinked}개`
|
|
|
|
|
);
|
|
|
|
|
// 그룹 데이터 새로고침
|
|
|
|
|
await loadGroupsData();
|
|
|
|
|
} else {
|
2026-01-16 17:41:19 +09:00
|
|
|
setSyncProgress(null);
|
2026-01-16 14:48:15 +09:00
|
|
|
toast.error(`전체 동기화 실패: ${response.error || "알 수 없는 오류"}`);
|
|
|
|
|
}
|
|
|
|
|
} catch (error: any) {
|
2026-01-16 17:41:19 +09:00
|
|
|
setSyncProgress(null);
|
2026-01-16 14:48:15 +09:00
|
|
|
toast.error(`전체 동기화 실패: ${error.message}`);
|
|
|
|
|
} finally {
|
|
|
|
|
setIsSyncing(false);
|
|
|
|
|
setSyncDirection(null);
|
2026-01-16 17:41:19 +09:00
|
|
|
// 3초 후 진행 메시지 초기화
|
|
|
|
|
setTimeout(() => setSyncProgress(null), 3000);
|
2026-01-16 14:48:15 +09:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-05 18:18:26 +09:00
|
|
|
// 그룹 수정 버튼 클릭
|
|
|
|
|
const handleEditGroup = (group: ScreenGroup, e: React.MouseEvent) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
setEditingGroup(group);
|
|
|
|
|
setIsGroupModalOpen(true);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 그룹 삭제 버튼 클릭
|
2026-01-15 14:58:12 +09:00
|
|
|
const handleDeleteGroup = (group: ScreenGroup, e?: React.MouseEvent) => {
|
|
|
|
|
e?.stopPropagation();
|
2026-01-05 18:18:26 +09:00
|
|
|
setDeletingGroup(group);
|
2026-01-15 14:58:12 +09:00
|
|
|
setDeleteScreensWithGroup(false); // 기본값: 화면 삭제 안함
|
2026-01-05 18:18:26 +09:00
|
|
|
setIsDeleteDialogOpen(true);
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-15 14:58:12 +09:00
|
|
|
// 그룹과 모든 하위 그룹의 화면을 재귀적으로 수집
|
|
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-05 18:18:26 +09:00
|
|
|
// 그룹 삭제 확인
|
|
|
|
|
const confirmDeleteGroup = async () => {
|
|
|
|
|
if (!deletingGroup) return;
|
|
|
|
|
|
2026-01-15 14:58:12 +09:00
|
|
|
// 삭제 전 통계 수집 (화면 수는 삭제 전에 계산)
|
|
|
|
|
const totalScreensToDelete = getAllScreensInGroupRecursively(deletingGroup.id).length;
|
|
|
|
|
const childGroupIds = getAllChildGroupIds(deletingGroup.id);
|
|
|
|
|
|
|
|
|
|
// 총 작업 수 계산 (화면 + 하위 그룹 + 현재 그룹)
|
|
|
|
|
const totalSteps = totalScreensToDelete + childGroupIds.length + 1;
|
|
|
|
|
let currentStep = 0;
|
|
|
|
|
|
2026-01-05 18:18:26 +09:00
|
|
|
try {
|
2026-01-15 14:58:12 +09:00
|
|
|
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: "그룹 삭제 완료 중..." });
|
2026-01-05 18:18:26 +09:00
|
|
|
const response = await deleteScreenGroup(deletingGroup.id);
|
|
|
|
|
if (response.success) {
|
2026-01-15 14:58:12 +09:00
|
|
|
toast.success(
|
|
|
|
|
deleteScreensWithGroup
|
|
|
|
|
? `그룹과 화면 ${totalScreensToDelete}개가 삭제되었습니다`
|
|
|
|
|
: "그룹이 삭제되었습니다"
|
|
|
|
|
);
|
|
|
|
|
await loadGroupsData();
|
|
|
|
|
window.dispatchEvent(new CustomEvent("screen-list-refresh"));
|
2026-01-05 18:18:26 +09:00
|
|
|
} else {
|
|
|
|
|
toast.error(response.message || "그룹 삭제에 실패했습니다");
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("그룹 삭제 실패:", error);
|
|
|
|
|
toast.error("그룹 삭제에 실패했습니다");
|
|
|
|
|
} finally {
|
2026-01-15 14:58:12 +09:00
|
|
|
setIsDeleting(false);
|
|
|
|
|
setDeleteProgress({ current: 0, total: 0, message: "" });
|
2026-01-05 18:18:26 +09:00
|
|
|
setIsDeleteDialogOpen(false);
|
|
|
|
|
setDeletingGroup(null);
|
2026-01-15 14:58:12 +09:00
|
|
|
setDeleteScreensWithGroup(false);
|
2026-01-05 18:18:26 +09:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-15 14:58:12 +09:00
|
|
|
// 단일 화면 삭제 버튼 클릭
|
|
|
|
|
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);
|
2026-01-05 18:18:26 +09:00
|
|
|
|
|
|
|
|
// 현재 화면이 속한 그룹 정보 찾기
|
|
|
|
|
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);
|
2026-01-15 14:58:12 +09:00
|
|
|
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);
|
2026-01-05 18:18:26 +09:00
|
|
|
};
|
|
|
|
|
|
2026-01-15 14:58:12 +09:00
|
|
|
// 화면 수정 저장 (이름 변경 + 그룹 이동)
|
|
|
|
|
const saveScreenEdit = async () => {
|
|
|
|
|
if (!editingScreen) return;
|
2026-01-05 18:18:26 +09:00
|
|
|
|
|
|
|
|
try {
|
2026-01-15 14:58:12 +09:00
|
|
|
// 1. 화면 이름이 변경되었으면 업데이트
|
|
|
|
|
if (editScreenName.trim() && editScreenName !== editingScreen.screenName) {
|
|
|
|
|
await screenApi.updateScreen(editingScreen.screenId, {
|
|
|
|
|
screenName: editScreenName.trim(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. 현재 그룹에서 제거
|
2026-01-05 18:18:26 +09:00
|
|
|
const currentGroupId = Array.from(groupScreensMap.entries()).find(([_, screenIds]) =>
|
2026-01-15 14:58:12 +09:00
|
|
|
screenIds.includes(editingScreen.screenId)
|
2026-01-05 18:18:26 +09:00
|
|
|
)?.[0];
|
|
|
|
|
|
|
|
|
|
if (currentGroupId) {
|
|
|
|
|
// screen_group_screens에서 해당 연결 찾아서 삭제
|
|
|
|
|
const currentGroup = groups.find((g) => g.id === currentGroupId);
|
|
|
|
|
if (currentGroup && currentGroup.screens) {
|
|
|
|
|
const screenGroupScreen = currentGroup.screens.find(
|
2026-01-15 14:58:12 +09:00
|
|
|
(s: any) => s.screen_id === editingScreen.screenId
|
2026-01-05 18:18:26 +09:00
|
|
|
);
|
|
|
|
|
if (screenGroupScreen) {
|
|
|
|
|
await removeScreenFromGroup(screenGroupScreen.id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 14:58:12 +09:00
|
|
|
// 3. 새 그룹에 추가 (미분류가 아닌 경우)
|
|
|
|
|
if (selectedGroupForMove !== null) {
|
2026-01-05 18:18:26 +09:00
|
|
|
await addScreenToGroup({
|
2026-01-15 14:58:12 +09:00
|
|
|
group_id: selectedGroupForMove,
|
|
|
|
|
screen_id: editingScreen.screenId,
|
2026-01-05 18:18:26 +09:00
|
|
|
screen_role: screenRole,
|
|
|
|
|
display_order: displayOrder,
|
|
|
|
|
is_default: "N",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 14:58:12 +09:00
|
|
|
toast.success("화면이 수정되었습니다");
|
2026-01-05 18:18:26 +09:00
|
|
|
loadGroupsData();
|
2026-01-15 14:58:12 +09:00
|
|
|
window.dispatchEvent(new CustomEvent("screen-list-refresh"));
|
2026-01-05 18:18:26 +09:00
|
|
|
} catch (error) {
|
2026-01-15 14:58:12 +09:00
|
|
|
console.error("화면 수정 실패:", error);
|
|
|
|
|
toast.error("화면 수정에 실패했습니다");
|
2026-01-05 18:18:26 +09:00
|
|
|
} finally {
|
2026-01-15 14:58:12 +09:00
|
|
|
setIsEditScreenModalOpen(false);
|
|
|
|
|
setEditingScreen(null);
|
|
|
|
|
setEditScreenName("");
|
2026-01-05 18:18:26 +09:00
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-16 14:48:15 +09:00
|
|
|
// 검색어로 그룹 필터링 (띄어쓰기로 구분된 여러 키워드 - 계층적 검색)
|
|
|
|
|
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]);
|
|
|
|
|
|
2026-01-05 18:18:26 +09:00
|
|
|
// 그룹 데이터 새로고침
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-05 10:05:31 +09:00
|
|
|
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 (
|
2026-01-05 18:18:26 +09:00
|
|
|
<div className="h-full flex flex-col overflow-hidden">
|
2026-01-16 14:48:15 +09:00
|
|
|
{/* 그룹 추가 & 동기화 버튼 */}
|
|
|
|
|
<div className="flex-shrink-0 border-b p-2 space-y-2">
|
2026-01-05 18:18:26 +09:00
|
|
|
<Button
|
|
|
|
|
onClick={handleAddGroup}
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="w-full gap-2"
|
|
|
|
|
>
|
|
|
|
|
<Plus className="h-4 w-4" />
|
|
|
|
|
그룹 추가
|
|
|
|
|
</Button>
|
2026-01-16 17:41:19 +09:00
|
|
|
{isSuperAdmin && (
|
|
|
|
|
<Button
|
|
|
|
|
onClick={handleOpenSyncDialog}
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="w-full gap-2 text-muted-foreground"
|
|
|
|
|
>
|
|
|
|
|
<RefreshCw className="h-4 w-4" />
|
|
|
|
|
메뉴 동기화
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
2026-01-05 18:18:26 +09:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 트리 목록 */}
|
|
|
|
|
<div className="flex-1 overflow-auto p-2">
|
2026-01-16 14:48:15 +09:00
|
|
|
{/* 검색 결과 없음 표시 */}
|
|
|
|
|
{searchTerm.trim() && getFilteredGroups.length === 0 && (
|
|
|
|
|
<div className="py-8 text-center text-sm text-muted-foreground">
|
|
|
|
|
"{searchTerm}"와 일치하는 폴더가 없습니다
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-01-05 18:18:26 +09:00
|
|
|
{/* 그룹화된 화면들 (대분류만 먼저 렌더링) */}
|
2026-01-16 14:48:15 +09:00
|
|
|
{getFilteredGroups
|
2026-01-05 18:18:26 +09:00
|
|
|
.filter((g) => !(g as any).parent_group_id) // 대분류만 (parent_group_id가 null)
|
|
|
|
|
.map((group) => {
|
2026-01-08 14:24:33 +09:00
|
|
|
const groupId = String(group.id);
|
2026-01-16 14:48:15 +09:00
|
|
|
const isExpanded = expandedGroups.has(groupId) || shouldAutoExpandForSearch.has(group.id); // 검색 시 상위 그룹만 자동 확장
|
2026-01-08 14:24:33 +09:00
|
|
|
const groupScreens = getScreensInGroup(group.id);
|
2026-01-16 14:48:15 +09:00
|
|
|
const isMatching = isGroupMatchingSearch(group.group_name); // 검색어 일치 여부
|
2026-01-05 18:18:26 +09:00
|
|
|
|
2026-01-16 14:48:15 +09:00
|
|
|
// 하위 그룹들 찾기 (필터링된 그룹에서만)
|
|
|
|
|
const childGroups = getFilteredGroups.filter((g) => (g as any).parent_group_id === group.id);
|
2026-01-05 18:18:26 +09:00
|
|
|
|
2026-01-08 14:24:33 +09:00
|
|
|
return (
|
|
|
|
|
<div key={groupId} className="mb-1">
|
2026-01-05 10:05:31 +09:00
|
|
|
{/* 그룹 헤더 */}
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer hover:bg-accent transition-colors",
|
2026-01-16 14:48:15 +09:00
|
|
|
"text-sm font-medium group/item",
|
|
|
|
|
isMatching && "bg-primary/5 dark:bg-primary/10" // 검색 일치 하이라이트 (연한 배경)
|
2026-01-05 10:05:31 +09:00
|
|
|
)}
|
|
|
|
|
onClick={() => toggleGroup(groupId)}
|
2026-01-15 14:58:12 +09:00
|
|
|
onContextMenu={(e) => handleGroupContextMenu(e, group)}
|
2026-01-05 10:05:31 +09:00
|
|
|
>
|
|
|
|
|
{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" />
|
|
|
|
|
)}
|
2026-01-16 14:48:15 +09:00
|
|
|
<span className={cn("truncate flex-1", isMatching && "font-medium text-primary/80")}>{group.group_name}</span>
|
2026-01-05 10:05:31 +09:00
|
|
|
<Badge variant="secondary" className="text-xs">
|
|
|
|
|
{groupScreens.length}
|
|
|
|
|
</Badge>
|
2026-01-05 18:18:26 +09:00
|
|
|
{/* 그룹 메뉴 버튼 */}
|
|
|
|
|
<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>
|
2026-01-05 10:05:31 +09:00
|
|
|
</div>
|
|
|
|
|
|
2026-01-05 18:18:26 +09:00
|
|
|
{/* 그룹 내 하위 그룹들 */}
|
|
|
|
|
{isExpanded && childGroups.length > 0 && (
|
|
|
|
|
<div className="ml-6 mt-1 space-y-0.5">
|
|
|
|
|
{childGroups.map((childGroup) => {
|
|
|
|
|
const childGroupId = String(childGroup.id);
|
2026-01-16 14:48:15 +09:00
|
|
|
const isChildExpanded = expandedGroups.has(childGroupId) || shouldAutoExpandForSearch.has(childGroup.id); // 검색 시 상위 그룹만 자동 확장
|
2026-01-05 18:18:26 +09:00
|
|
|
const childScreens = getScreensInGroup(childGroup.id);
|
2026-01-16 14:48:15 +09:00
|
|
|
const isChildMatching = isGroupMatchingSearch(childGroup.group_name);
|
2026-01-05 18:18:26 +09:00
|
|
|
|
2026-01-16 14:48:15 +09:00
|
|
|
// 손자 그룹들 (3단계) - 필터링된 그룹에서만
|
|
|
|
|
const grandChildGroups = getFilteredGroups.filter((g) => (g as any).parent_group_id === childGroup.id);
|
2026-01-05 18:18:26 +09:00
|
|
|
|
|
|
|
|
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",
|
2026-01-16 14:48:15 +09:00
|
|
|
"text-xs font-medium group/item",
|
|
|
|
|
isChildMatching && "bg-primary/5 dark:bg-primary/10"
|
2026-01-05 18:18:26 +09:00
|
|
|
)}
|
|
|
|
|
onClick={() => toggleGroup(childGroupId)}
|
2026-01-15 14:58:12 +09:00
|
|
|
onContextMenu={(e) => handleGroupContextMenu(e, childGroup)}
|
2026-01-05 18:18:26 +09:00
|
|
|
>
|
|
|
|
|
{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" />
|
|
|
|
|
)}
|
2026-01-16 14:48:15 +09:00
|
|
|
<span className={cn("truncate flex-1", isChildMatching && "font-medium text-primary/80")}>{childGroup.group_name}</span>
|
2026-01-05 18:18:26 +09:00
|
|
|
<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);
|
2026-01-16 14:48:15 +09:00
|
|
|
const isGrandExpanded = expandedGroups.has(grandChildId) || shouldAutoExpandForSearch.has(grandChild.id); // 검색 시 상위 그룹만 자동 확장
|
2026-01-05 18:18:26 +09:00
|
|
|
const grandScreens = getScreensInGroup(grandChild.id);
|
2026-01-16 14:48:15 +09:00
|
|
|
const isGrandMatching = isGroupMatchingSearch(grandChild.group_name);
|
2026-01-05 18:18:26 +09:00
|
|
|
|
|
|
|
|
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",
|
2026-01-16 14:48:15 +09:00
|
|
|
"text-xs group/item",
|
|
|
|
|
isGrandMatching && "bg-primary/5 dark:bg-primary/10"
|
2026-01-05 18:18:26 +09:00
|
|
|
)}
|
|
|
|
|
onClick={() => toggleGroup(grandChildId)}
|
2026-01-15 14:58:12 +09:00
|
|
|
onContextMenu={(e) => handleGroupContextMenu(e, grandChild)}
|
2026-01-05 18:18:26 +09:00
|
|
|
>
|
|
|
|
|
{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" />
|
|
|
|
|
)}
|
2026-01-16 14:48:15 +09:00
|
|
|
<span className={cn("truncate flex-1", isGrandMatching && "font-medium text-primary/80")}>{grandChild.group_name}</span>
|
2026-01-05 18:18:26 +09:00
|
|
|
<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)}
|
2026-01-15 14:58:12 +09:00
|
|
|
onContextMenu={(e) => handleContextMenu(e, screen)}
|
2026-01-05 18:18:26 +09:00
|
|
|
>
|
|
|
|
|
<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)}
|
2026-01-15 14:58:12 +09:00
|
|
|
onContextMenu={(e) => handleContextMenu(e, screen)}
|
2026-01-05 18:18:26 +09:00
|
|
|
>
|
|
|
|
|
<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>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 그룹 내 화면들 (대분류 직속) */}
|
2026-01-05 10:05:31 +09:00
|
|
|
{isExpanded && (
|
|
|
|
|
<div className="ml-4 mt-1 space-y-0.5">
|
2026-01-05 18:18:26 +09:00
|
|
|
{groupScreens.length === 0 && childGroups.length === 0 ? (
|
2026-01-05 10:05:31 +09:00
|
|
|
<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",
|
2026-01-05 18:18:26 +09:00
|
|
|
"text-sm hover:bg-accent group/screen",
|
2026-01-05 10:05:31 +09:00
|
|
|
selectedScreen?.screenId === screen.screenId && "bg-accent"
|
|
|
|
|
)}
|
2026-01-05 18:18:26 +09:00
|
|
|
onClick={() => handleScreenClickInGroup(screen, group)}
|
2026-01-05 10:05:31 +09:00
|
|
|
onDoubleClick={() => handleScreenDoubleClick(screen)}
|
2026-01-15 14:58:12 +09:00
|
|
|
onContextMenu={(e) => handleContextMenu(e, screen)}
|
2026-01-05 10:05:31 +09:00
|
|
|
>
|
|
|
|
|
<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)}
|
2026-01-15 14:58:12 +09:00
|
|
|
onContextMenu={(e) => handleContextMenu(e, screen)}
|
2026-01-05 10:05:31 +09:00
|
|
|
>
|
|
|
|
|
<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>
|
2026-01-05 18:18:26 +09:00
|
|
|
|
|
|
|
|
{/* 그룹 추가/수정 모달 */}
|
|
|
|
|
<ScreenGroupModal
|
|
|
|
|
isOpen={isGroupModalOpen}
|
|
|
|
|
onClose={() => {
|
|
|
|
|
setIsGroupModalOpen(false);
|
|
|
|
|
setEditingGroup(null);
|
|
|
|
|
}}
|
|
|
|
|
onSuccess={loadGroupsData}
|
|
|
|
|
group={editingGroup}
|
|
|
|
|
/>
|
|
|
|
|
|
2026-01-15 14:58:12 +09:00
|
|
|
{/* 화면/그룹 복제 모달 (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}
|
|
|
|
|
/>
|
|
|
|
|
|
2026-01-05 18:18:26 +09:00
|
|
|
{/* 그룹 삭제 확인 다이얼로그 */}
|
|
|
|
|
<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 />
|
2026-01-15 14:58:12 +09:00
|
|
|
{deleteScreensWithGroup
|
|
|
|
|
? <span className="text-destructive font-medium">그룹에 속한 화면들도 함께 삭제됩니다.</span>
|
|
|
|
|
: "그룹에 속한 화면들은 미분류로 이동됩니다."
|
|
|
|
|
}
|
2026-01-05 18:18:26 +09:00
|
|
|
</AlertDialogDescription>
|
|
|
|
|
</AlertDialogHeader>
|
2026-01-15 14:58:12 +09:00
|
|
|
|
|
|
|
|
{/* 그룹 정보 표시 */}
|
|
|
|
|
{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>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-01-05 18:18:26 +09:00
|
|
|
<AlertDialogFooter className="gap-2 sm:gap-0">
|
2026-01-15 14:58:12 +09:00
|
|
|
<AlertDialogCancel
|
|
|
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
|
|
|
disabled={isDeleting}
|
|
|
|
|
>
|
2026-01-05 18:18:26 +09:00
|
|
|
취소
|
|
|
|
|
</AlertDialogCancel>
|
|
|
|
|
<AlertDialogAction
|
2026-01-15 14:58:12 +09:00
|
|
|
onClick={(e) => {
|
|
|
|
|
e.preventDefault(); // 자동 닫힘 방지
|
|
|
|
|
confirmDeleteGroup();
|
|
|
|
|
}}
|
|
|
|
|
disabled={isDeleting}
|
2026-01-05 18:18:26 +09:00
|
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm bg-destructive hover:bg-destructive/90"
|
|
|
|
|
>
|
2026-01-15 14:58:12 +09:00
|
|
|
{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" />
|
|
|
|
|
삭제 중...
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
"삭제"
|
|
|
|
|
)}
|
2026-01-05 18:18:26 +09:00
|
|
|
</AlertDialogAction>
|
|
|
|
|
</AlertDialogFooter>
|
|
|
|
|
</AlertDialogContent>
|
|
|
|
|
</AlertDialog>
|
|
|
|
|
|
2026-01-15 14:58:12 +09:00
|
|
|
{/* 화면 수정 모달 (이름 변경 + 그룹 이동) */}
|
|
|
|
|
<Dialog open={isEditScreenModalOpen} onOpenChange={setIsEditScreenModalOpen}>
|
2026-01-05 18:18:26 +09:00
|
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[400px]">
|
|
|
|
|
<DialogHeader>
|
2026-01-15 14:58:12 +09:00
|
|
|
<DialogTitle className="text-base sm:text-lg">화면 수정</DialogTitle>
|
2026-01-05 18:18:26 +09:00
|
|
|
<DialogDescription className="text-xs sm:text-sm">
|
2026-01-15 14:58:12 +09:00
|
|
|
화면 정보를 수정하세요
|
2026-01-05 18:18:26 +09:00
|
|
|
</DialogDescription>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-3 sm:space-y-4">
|
2026-01-15 14:58:12 +09:00
|
|
|
{/* 화면 이름 */}
|
|
|
|
|
<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>
|
|
|
|
|
|
2026-01-05 18:18:26 +09:00
|
|
|
{/* 그룹 선택 (트리 구조 + 검색) */}
|
|
|
|
|
<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={() => {
|
2026-01-15 14:58:12 +09:00
|
|
|
setIsEditScreenModalOpen(false);
|
|
|
|
|
setEditingScreen(null);
|
|
|
|
|
setEditScreenName("");
|
2026-01-05 18:18:26 +09:00
|
|
|
setSelectedGroupForMove(null);
|
|
|
|
|
setScreenRole("");
|
|
|
|
|
setDisplayOrder(1);
|
|
|
|
|
}}
|
|
|
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
|
|
|
>
|
|
|
|
|
취소
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
2026-01-15 14:58:12 +09:00
|
|
|
onClick={saveScreenEdit}
|
|
|
|
|
disabled={!editScreenName.trim()}
|
2026-01-05 18:18:26 +09:00
|
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
|
|
|
>
|
2026-01-15 14:58:12 +09:00
|
|
|
저장
|
2026-01-05 18:18:26 +09:00
|
|
|
</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
2026-01-15 14:58:12 +09:00
|
|
|
|
|
|
|
|
{/* 커스텀 컨텍스트 메뉴 */}
|
|
|
|
|
{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>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-01-16 14:48:15 +09:00
|
|
|
{/* 메뉴-화면그룹 동기화 다이얼로그 */}
|
|
|
|
|
<Dialog open={isSyncDialogOpen} onOpenChange={setIsSyncDialogOpen}>
|
2026-01-16 17:41:19 +09:00
|
|
|
<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>
|
|
|
|
|
)}
|
2026-01-16 14:48:15 +09:00
|
|
|
<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>
|
|
|
|
|
|
2026-01-05 10:05:31 +09:00
|
|
|
</div>
|
|
|
|
|
);
|
2026-01-05 18:18:26 +09:00
|
|
|
}
|