feat: 화면 서브 테이블 정보 조회 기능 추가
- 화면 그룹에 대한 서브 테이블 관계를 조회하는 API 및 라우트 구현 - 화면 그룹 목록에서 서브 테이블 정보를 포함하여 데이터 흐름을 시각화 - 프론트엔드에서 화면 선택 시 그룹 및 서브 테이블 정보 연동 기능 추가 - 화면 노드 및 관계 시각화 컴포넌트에 서브 테이블 정보 통합
This commit is contained in:
@@ -2,16 +2,86 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronRight, ChevronDown, Monitor, FolderOpen, Folder } from "lucide-react";
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Monitor,
|
||||
FolderOpen,
|
||||
Folder,
|
||||
Plus,
|
||||
MoreVertical,
|
||||
Edit,
|
||||
Trash2,
|
||||
FolderInput,
|
||||
} from "lucide-react";
|
||||
import { ScreenDefinition } from "@/types/screen";
|
||||
import { ScreenGroup, getScreenGroups } from "@/lib/api/screenGroup";
|
||||
import {
|
||||
ScreenGroup,
|
||||
getScreenGroups,
|
||||
deleteScreenGroup,
|
||||
addScreenToGroup,
|
||||
removeScreenFromGroup,
|
||||
} from "@/lib/api/screenGroup";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { ScreenGroupModal } from "./ScreenGroupModal";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface ScreenGroupTreeViewProps {
|
||||
screens: ScreenDefinition[];
|
||||
selectedScreen: ScreenDefinition | null;
|
||||
onScreenSelect: (screen: ScreenDefinition) => void;
|
||||
onScreenDesign: (screen: ScreenDefinition) => void;
|
||||
onGroupSelect?: (group: { id: number; name: string } | null) => void;
|
||||
onScreenSelectInGroup?: (group: { id: number; name: string }, screenId: number) => void;
|
||||
companyCode?: string;
|
||||
}
|
||||
|
||||
@@ -29,6 +99,8 @@ export function ScreenGroupTreeView({
|
||||
selectedScreen,
|
||||
onScreenSelect,
|
||||
onScreenDesign,
|
||||
onGroupSelect,
|
||||
onScreenSelectInGroup,
|
||||
companyCode,
|
||||
}: ScreenGroupTreeViewProps) {
|
||||
const [groups, setGroups] = useState<ScreenGroup[]>([]);
|
||||
@@ -36,23 +108,26 @@ export function ScreenGroupTreeView({
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
const [groupScreensMap, setGroupScreensMap] = useState<Map<number, number[]>>(new Map());
|
||||
|
||||
// 그룹 목록 로드
|
||||
useEffect(() => {
|
||||
const loadGroups = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await getScreenGroups({});
|
||||
if (response.success && response.data) {
|
||||
setGroups(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("그룹 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
// 그룹 모달 상태
|
||||
const [isGroupModalOpen, setIsGroupModalOpen] = useState(false);
|
||||
const [editingGroup, setEditingGroup] = useState<ScreenGroup | null>(null);
|
||||
|
||||
loadGroups();
|
||||
// 삭제 확인 다이얼로그 상태
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [deletingGroup, setDeletingGroup] = useState<ScreenGroup | null>(null);
|
||||
|
||||
// 화면 이동 메뉴 상태
|
||||
const [movingScreen, setMovingScreen] = useState<ScreenDefinition | null>(null);
|
||||
const [isMoveMenuOpen, setIsMoveMenuOpen] = useState(false);
|
||||
const [selectedGroupForMove, setSelectedGroupForMove] = useState<number | null>(null);
|
||||
const [screenRole, setScreenRole] = useState<string>("");
|
||||
const [isGroupSelectOpen, setIsGroupSelectOpen] = useState(false);
|
||||
const [displayOrder, setDisplayOrder] = useState<number>(1);
|
||||
|
||||
// 그룹 목록 및 그룹별 화면 로드
|
||||
useEffect(() => {
|
||||
loadGroupsData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [companyCode]);
|
||||
|
||||
// 그룹에 속한 화면 ID들을 가져오기
|
||||
@@ -70,18 +145,41 @@ export function ScreenGroupTreeView({
|
||||
return screens.filter((screen) => !groupedIds.has(screen.screenId));
|
||||
};
|
||||
|
||||
// 그룹에 속한 화면들
|
||||
// 그룹에 속한 화면들 (display_order 오름차순 정렬)
|
||||
const getScreensInGroup = (groupId: number): ScreenDefinition[] => {
|
||||
const screenIds = groupScreensMap.get(groupId) || [];
|
||||
return screens.filter((screen) => screenIds.includes(screen.screenId));
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
setExpandedGroups(newExpanded);
|
||||
};
|
||||
@@ -90,10 +188,215 @@ export function ScreenGroupTreeView({
|
||||
onScreenSelect(screen);
|
||||
};
|
||||
|
||||
// 그룹 내 화면 클릭 핸들러 (그룹 전체 표시 + 해당 화면 포커스)
|
||||
const handleScreenClickInGroup = (screen: ScreenDefinition, group: ScreenGroup) => {
|
||||
if (onScreenSelectInGroup) {
|
||||
onScreenSelectInGroup(
|
||||
{ id: group.id, name: group.group_name },
|
||||
screen.screenId
|
||||
);
|
||||
} else {
|
||||
// fallback: 기존 동작
|
||||
onScreenSelect(screen);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScreenDoubleClick = (screen: ScreenDefinition) => {
|
||||
onScreenDesign(screen);
|
||||
};
|
||||
|
||||
// 그룹 추가 버튼 클릭
|
||||
const handleAddGroup = () => {
|
||||
setEditingGroup(null);
|
||||
setIsGroupModalOpen(true);
|
||||
};
|
||||
|
||||
// 그룹 수정 버튼 클릭
|
||||
const handleEditGroup = (group: ScreenGroup, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setEditingGroup(group);
|
||||
setIsGroupModalOpen(true);
|
||||
};
|
||||
|
||||
// 그룹 삭제 버튼 클릭
|
||||
const handleDeleteGroup = (group: ScreenGroup, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setDeletingGroup(group);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// 그룹 삭제 확인
|
||||
const confirmDeleteGroup = async () => {
|
||||
if (!deletingGroup) return;
|
||||
|
||||
try {
|
||||
const response = await deleteScreenGroup(deletingGroup.id);
|
||||
if (response.success) {
|
||||
toast.success("그룹이 삭제되었습니다");
|
||||
loadGroupsData();
|
||||
} else {
|
||||
toast.error(response.message || "그룹 삭제에 실패했습니다");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("그룹 삭제 실패:", error);
|
||||
toast.error("그룹 삭제에 실패했습니다");
|
||||
} finally {
|
||||
setIsDeleteDialogOpen(false);
|
||||
setDeletingGroup(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 화면 이동 메뉴 열기
|
||||
const handleMoveScreen = (screen: ScreenDefinition, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setMovingScreen(screen);
|
||||
|
||||
// 현재 화면이 속한 그룹 정보 찾기
|
||||
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);
|
||||
setIsMoveMenuOpen(true);
|
||||
};
|
||||
|
||||
// 화면을 특정 그룹으로 이동
|
||||
const moveScreenToGroup = async (targetGroupId: number | null) => {
|
||||
if (!movingScreen) return;
|
||||
|
||||
try {
|
||||
// 현재 그룹에서 제거
|
||||
const currentGroupId = Array.from(groupScreensMap.entries()).find(([_, screenIds]) =>
|
||||
screenIds.includes(movingScreen.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 === movingScreen.screenId
|
||||
);
|
||||
if (screenGroupScreen) {
|
||||
await removeScreenFromGroup(screenGroupScreen.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 새 그룹에 추가 (미분류가 아닌 경우)
|
||||
if (targetGroupId !== null) {
|
||||
await addScreenToGroup({
|
||||
group_id: targetGroupId,
|
||||
screen_id: movingScreen.screenId,
|
||||
screen_role: screenRole,
|
||||
display_order: displayOrder,
|
||||
is_default: "N",
|
||||
});
|
||||
}
|
||||
|
||||
toast.success("화면이 이동되었습니다");
|
||||
loadGroupsData();
|
||||
} catch (error) {
|
||||
console.error("화면 이동 실패:", error);
|
||||
toast.error("화면 이동에 실패했습니다");
|
||||
} finally {
|
||||
setIsMoveMenuOpen(false);
|
||||
setMovingScreen(null);
|
||||
setSelectedGroupForMove(null);
|
||||
setScreenRole("");
|
||||
setDisplayOrder(1);
|
||||
}
|
||||
};
|
||||
|
||||
// 그룹 경로 가져오기 (계층 구조 표시용)
|
||||
const getGroupPath = (groupId: number): string => {
|
||||
const group = groups.find((g) => g.id === groupId);
|
||||
if (!group) return "";
|
||||
|
||||
const path: string[] = [group.group_name];
|
||||
let currentGroup = group;
|
||||
|
||||
while (currentGroup.parent_group_id) {
|
||||
const parent = groups.find((g) => g.id === currentGroup.parent_group_id);
|
||||
if (parent) {
|
||||
path.unshift(parent.group_name);
|
||||
currentGroup = parent;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return path.join(" > ");
|
||||
};
|
||||
|
||||
// 그룹 레벨 가져오기 (들여쓰기용)
|
||||
const getGroupLevel = (groupId: number): number => {
|
||||
const group = groups.find((g) => g.id === groupId);
|
||||
return group?.group_level || 1;
|
||||
};
|
||||
|
||||
// 그룹을 계층 구조로 정렬
|
||||
const getSortedGroups = (): typeof groups => {
|
||||
const result: typeof groups = [];
|
||||
|
||||
const addChildren = (parentId: number | null, level: number) => {
|
||||
const children = groups
|
||||
.filter((g) => g.parent_group_id === parentId)
|
||||
.sort((a, b) => (a.display_order || 0) - (b.display_order || 0));
|
||||
|
||||
for (const child of children) {
|
||||
result.push({ ...child, group_level: level });
|
||||
addChildren(child.id, level + 1);
|
||||
}
|
||||
};
|
||||
|
||||
addChildren(null, 1);
|
||||
return result;
|
||||
};
|
||||
|
||||
// 그룹 데이터 새로고침
|
||||
const loadGroupsData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await getScreenGroups({ size: 1000 }); // 모든 그룹 가져오기
|
||||
if (response.success && response.data) {
|
||||
setGroups(response.data);
|
||||
|
||||
// 각 그룹별 화면 목록 매핑
|
||||
const screenMap = new Map<number, number[]>();
|
||||
for (const group of response.data) {
|
||||
if (group.screens && Array.isArray(group.screens)) {
|
||||
screenMap.set(
|
||||
group.id,
|
||||
group.screens.map((s: any) => s.screen_id)
|
||||
);
|
||||
}
|
||||
}
|
||||
setGroupScreensMap(screenMap);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("그룹 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
@@ -105,21 +408,40 @@ export function ScreenGroupTreeView({
|
||||
const ungroupedScreens = getUngroupedScreens();
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="p-2">
|
||||
{/* 그룹화된 화면들 */}
|
||||
{groups.map((group) => {
|
||||
const groupId = String(group.id);
|
||||
const isExpanded = expandedGroups.has(groupId);
|
||||
const groupScreens = getScreensInGroup(group.id);
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
{/* 그룹 추가 버튼 */}
|
||||
<div className="flex-shrink-0 border-b p-2">
|
||||
<Button
|
||||
onClick={handleAddGroup}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
그룹 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
return (
|
||||
<div key={groupId} className="mb-1">
|
||||
{/* 트리 목록 */}
|
||||
<div className="flex-1 overflow-auto p-2">
|
||||
{/* 그룹화된 화면들 (대분류만 먼저 렌더링) */}
|
||||
{groups
|
||||
.filter((g) => !(g as any).parent_group_id) // 대분류만 (parent_group_id가 null)
|
||||
.map((group) => {
|
||||
const groupId = String(group.id);
|
||||
const isExpanded = expandedGroups.has(groupId);
|
||||
const groupScreens = getScreensInGroup(group.id);
|
||||
|
||||
// 하위 그룹들 찾기
|
||||
const childGroups = groups.filter((g) => (g as any).parent_group_id === group.id);
|
||||
|
||||
return (
|
||||
<div key={groupId} className="mb-1">
|
||||
{/* 그룹 헤더 */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer hover:bg-accent transition-colors",
|
||||
"text-sm font-medium"
|
||||
"text-sm font-medium group/item"
|
||||
)}
|
||||
onClick={() => toggleGroup(groupId)}
|
||||
>
|
||||
@@ -133,16 +455,235 @@ export function ScreenGroupTreeView({
|
||||
) : (
|
||||
<Folder className="h-4 w-4 shrink-0 text-amber-500" />
|
||||
)}
|
||||
<span className="truncate flex-1">{group.groupName}</span>
|
||||
<span className="truncate flex-1">{group.group_name}</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{groupScreens.length}
|
||||
</Badge>
|
||||
{/* 그룹 메뉴 버튼 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 opacity-0 group-hover/item:opacity-100"
|
||||
>
|
||||
<MoreVertical className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={(e) => handleEditGroup(group, e as any)}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
수정
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => handleDeleteGroup(group, e as any)}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
삭제
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* 그룹 내 화면들 */}
|
||||
{/* 그룹 내 하위 그룹들 */}
|
||||
{isExpanded && childGroups.length > 0 && (
|
||||
<div className="ml-6 mt-1 space-y-0.5">
|
||||
{childGroups.map((childGroup) => {
|
||||
const childGroupId = String(childGroup.id);
|
||||
const isChildExpanded = expandedGroups.has(childGroupId);
|
||||
const childScreens = getScreensInGroup(childGroup.id);
|
||||
|
||||
// 손자 그룹들 (3단계)
|
||||
const grandChildGroups = groups.filter((g) => (g as any).parent_group_id === childGroup.id);
|
||||
|
||||
return (
|
||||
<div key={childGroupId}>
|
||||
{/* 중분류 헤더 */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer hover:bg-accent transition-colors",
|
||||
"text-xs font-medium group/item"
|
||||
)}
|
||||
onClick={() => toggleGroup(childGroupId)}
|
||||
>
|
||||
{isChildExpanded ? (
|
||||
<ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
{isChildExpanded ? (
|
||||
<FolderOpen className="h-3 w-3 shrink-0 text-blue-500" />
|
||||
) : (
|
||||
<Folder className="h-3 w-3 shrink-0 text-blue-500" />
|
||||
)}
|
||||
<span className="truncate flex-1">{childGroup.group_name}</span>
|
||||
<Badge variant="secondary" className="text-[10px] h-4">
|
||||
{childScreens.length}
|
||||
</Badge>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 opacity-0 group-hover/item:opacity-100"
|
||||
>
|
||||
<MoreVertical className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={(e) => handleEditGroup(childGroup, e as any)}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
수정
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => handleDeleteGroup(childGroup, e as any)}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
삭제
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* 중분류 내 손자 그룹들 (소분류) */}
|
||||
{isChildExpanded && grandChildGroups.length > 0 && (
|
||||
<div className="ml-6 mt-1 space-y-0.5">
|
||||
{grandChildGroups.map((grandChild) => {
|
||||
const grandChildId = String(grandChild.id);
|
||||
const isGrandExpanded = expandedGroups.has(grandChildId);
|
||||
const grandScreens = getScreensInGroup(grandChild.id);
|
||||
|
||||
return (
|
||||
<div key={grandChildId}>
|
||||
{/* 소분류 헤더 */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer hover:bg-accent transition-colors",
|
||||
"text-xs group/item"
|
||||
)}
|
||||
onClick={() => toggleGroup(grandChildId)}
|
||||
>
|
||||
{isGrandExpanded ? (
|
||||
<ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
{isGrandExpanded ? (
|
||||
<FolderOpen className="h-3 w-3 shrink-0 text-green-500" />
|
||||
) : (
|
||||
<Folder className="h-3 w-3 shrink-0 text-green-500" />
|
||||
)}
|
||||
<span className="truncate flex-1">{grandChild.group_name}</span>
|
||||
<Badge variant="outline" className="text-[10px] h-4">
|
||||
{grandScreens.length}
|
||||
</Badge>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 opacity-0 group-hover/item:opacity-100"
|
||||
>
|
||||
<MoreVertical className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={(e) => handleEditGroup(grandChild, e as any)}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
수정
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => handleDeleteGroup(grandChild, e as any)}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
삭제
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* 소분류 내 화면들 */}
|
||||
{isGrandExpanded && (
|
||||
<div className="ml-6 mt-1 space-y-0.5">
|
||||
{grandScreens.length === 0 ? (
|
||||
<div className="pl-6 py-2 text-xs text-muted-foreground">
|
||||
화면이 없습니다
|
||||
</div>
|
||||
) : (
|
||||
grandScreens.map((screen) => (
|
||||
<div
|
||||
key={screen.screenId}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors",
|
||||
"text-xs hover:bg-accent",
|
||||
selectedScreen?.screenId === screen.screenId && "bg-accent"
|
||||
)}
|
||||
onClick={() => handleScreenClickInGroup(screen, grandChild)}
|
||||
onDoubleClick={() => handleScreenDoubleClick(screen)}
|
||||
onContextMenu={(e) => handleMoveScreen(screen, e)}
|
||||
>
|
||||
<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) => handleMoveScreen(screen, e)}
|
||||
>
|
||||
<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 ? (
|
||||
{groupScreens.length === 0 && childGroups.length === 0 ? (
|
||||
<div className="pl-6 py-2 text-xs text-muted-foreground">
|
||||
화면이 없습니다
|
||||
</div>
|
||||
@@ -152,11 +693,12 @@ export function ScreenGroupTreeView({
|
||||
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",
|
||||
"text-sm hover:bg-accent group/screen",
|
||||
selectedScreen?.screenId === screen.screenId && "bg-accent"
|
||||
)}
|
||||
onClick={() => handleScreenClick(screen)}
|
||||
onClick={() => handleScreenClickInGroup(screen, group)}
|
||||
onDoubleClick={() => handleScreenDoubleClick(screen)}
|
||||
onContextMenu={(e) => handleMoveScreen(screen, e)}
|
||||
>
|
||||
<Monitor className="h-4 w-4 shrink-0 text-blue-500" />
|
||||
<span className="truncate flex-1">{screen.screenName}</span>
|
||||
@@ -206,6 +748,7 @@ export function ScreenGroupTreeView({
|
||||
)}
|
||||
onClick={() => handleScreenClick(screen)}
|
||||
onDoubleClick={() => handleScreenDoubleClick(screen)}
|
||||
onContextMenu={(e) => handleMoveScreen(screen, e)}
|
||||
>
|
||||
<Monitor className="h-4 w-4 shrink-0 text-blue-500" />
|
||||
<span className="truncate flex-1">{screen.screenName}</span>
|
||||
@@ -226,7 +769,205 @@ export function ScreenGroupTreeView({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 그룹 추가/수정 모달 */}
|
||||
<ScreenGroupModal
|
||||
isOpen={isGroupModalOpen}
|
||||
onClose={() => {
|
||||
setIsGroupModalOpen(false);
|
||||
setEditingGroup(null);
|
||||
}}
|
||||
onSuccess={loadGroupsData}
|
||||
group={editingGroup}
|
||||
/>
|
||||
|
||||
{/* 그룹 삭제 확인 다이얼로그 */}
|
||||
<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 />
|
||||
그룹에 속한 화면들은 미분류로 이동됩니다.
|
||||
</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">
|
||||
취소
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDeleteGroup}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm bg-destructive hover:bg-destructive/90"
|
||||
>
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 화면 이동 메뉴 (다이얼로그) */}
|
||||
<Dialog open={isMoveMenuOpen} onOpenChange={setIsMoveMenuOpen}>
|
||||
<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">
|
||||
"{movingScreen?.screenName}"의 그룹과 역할을 설정하세요
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{/* 그룹 선택 (트리 구조 + 검색) */}
|
||||
<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={() => {
|
||||
setIsMoveMenuOpen(false);
|
||||
setMovingScreen(null);
|
||||
setSelectedGroupForMove(null);
|
||||
setScreenRole("");
|
||||
setDisplayOrder(1);
|
||||
}}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => moveScreenToGroup(selectedGroupForMove)}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
이동
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user