ERP-node/frontend/components/pop/management/PopCategoryTree.tsx

1176 lines
43 KiB
TypeScript
Raw Normal View History

"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
import { cn } from "@/lib/utils";
import {
ChevronRight,
ChevronDown,
ChevronUp,
Folder,
FolderOpen,
Monitor,
Plus,
MoreVertical,
Edit,
Trash2,
Loader2,
RefreshCw,
FolderPlus,
MoveRight,
ArrowUp,
ArrowDown,
Search,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import { ScreenDefinition } from "@/types/screen";
import { apiClient } from "@/lib/api/client";
import {
PopScreenGroup,
getPopScreenGroups,
createPopScreenGroup,
updatePopScreenGroup,
deletePopScreenGroup,
ensurePopRootGroup,
buildPopGroupTree,
} from "@/lib/api/popScreenGroup";
// ============================================================
// 타입 정의
// ============================================================
interface PopCategoryTreeProps {
screens: ScreenDefinition[]; // POP 레이아웃이 있는 화면 목록
selectedScreen: ScreenDefinition | null;
onScreenSelect: (screen: ScreenDefinition) => void;
onScreenDesign: (screen: ScreenDefinition) => void;
onGroupSelect?: (group: PopScreenGroup | null) => void;
searchTerm?: string;
}
interface TreeNodeProps {
group: PopScreenGroup;
level: number;
expandedGroups: Set<number>;
onToggle: (groupId: number) => void;
selectedGroupId: number | null;
selectedScreenId: number | null;
onGroupSelect: (group: PopScreenGroup) => void;
onScreenSelect: (screen: ScreenDefinition) => void;
onScreenDesign: (screen: ScreenDefinition) => void;
onEditGroup: (group: PopScreenGroup) => void;
onDeleteGroup: (group: PopScreenGroup) => void;
onAddSubGroup: (parentGroup: PopScreenGroup) => void;
screensMap: Map<number, ScreenDefinition>;
// 화면 이동/삭제 관련
onOpenMoveModal: (screen: ScreenDefinition, fromGroupId: number | null) => void;
onRemoveScreenFromGroup: (screen: ScreenDefinition, groupId: number) => void;
// 순서 변경 관련
siblingGroups: PopScreenGroup[]; // 같은 레벨의 그룹들
onMoveGroupUp: (group: PopScreenGroup) => void;
onMoveGroupDown: (group: PopScreenGroup) => void;
onMoveScreenUp: (screen: ScreenDefinition, groupId: number) => void;
onMoveScreenDown: (screen: ScreenDefinition, groupId: number) => void;
onDeleteScreen: (screen: ScreenDefinition) => void;
}
// ============================================================
// 트리 노드 컴포넌트
// ============================================================
function TreeNode({
group,
level,
onOpenMoveModal,
onRemoveScreenFromGroup,
siblingGroups,
onMoveGroupUp,
onMoveGroupDown,
onMoveScreenUp,
onMoveScreenDown,
onDeleteScreen,
expandedGroups,
onToggle,
selectedGroupId,
selectedScreenId,
onGroupSelect,
onScreenSelect,
onScreenDesign,
onEditGroup,
onDeleteGroup,
onAddSubGroup,
screensMap,
}: TreeNodeProps) {
const isExpanded = expandedGroups.has(group.id);
const hasChildren = (group.children && group.children.length > 0) || (group.screens && group.screens.length > 0);
const isSelected = selectedGroupId === group.id;
// 그룹에 연결된 화면 목록
const groupScreens = useMemo(() => {
if (!group.screens) return [];
return group.screens
.map((gs) => screensMap.get(gs.screen_id))
.filter((s): s is ScreenDefinition => s !== undefined);
}, [group.screens, screensMap]);
// 루트 레벨(POP 화면)인지 확인
const isRootLevel = level === 0;
// 그룹 순서 변경 가능 여부 계산
const groupIndex = siblingGroups.findIndex((g) => g.id === group.id);
const canMoveGroupUp = groupIndex > 0;
const canMoveGroupDown = groupIndex < siblingGroups.length - 1;
return (
<div className="select-none">
{/* 그룹 노드 */}
<div
className={cn(
"flex items-center gap-1.5 py-1.5 px-2 rounded-md cursor-pointer transition-colors",
isSelected ? "bg-primary/10 text-primary" : "hover:bg-muted",
"group"
)}
style={{ paddingLeft: `${level * 24 + 8}px` }}
onClick={() => onGroupSelect(group)}
>
{/* 트리 연결 표시 (하위 레벨만) */}
{level > 0 && (
<span className="text-muted-foreground/50 text-xs mr-1"></span>
)}
{/* 확장/축소 버튼 */}
<button
className="p-0.5 hover:bg-muted-foreground/10 rounded shrink-0"
onClick={(e) => {
e.stopPropagation();
onToggle(group.id);
}}
>
{hasChildren ? (
isExpanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)
) : (
<div className="w-4 h-4" />
)}
</button>
{/* 폴더 아이콘 - 루트는 다른 색상 */}
{isExpanded && hasChildren ? (
<FolderOpen className={cn("h-4 w-4 shrink-0", isRootLevel ? "text-orange-500" : "text-amber-500")} />
) : (
<Folder className={cn("h-4 w-4 shrink-0", isRootLevel ? "text-orange-500" : "text-amber-500")} />
)}
{/* 그룹명 - 루트는 볼드체 */}
<span className={cn("flex-1 text-sm truncate", isRootLevel && "font-semibold")}>{group.group_name}</span>
{/* 화면 수 배지 */}
{group.screen_count && group.screen_count > 0 && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-4 shrink-0">
{group.screen_count}
</Badge>
)}
{/* 더보기 메뉴 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 shrink-0"
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem onClick={() => onAddSubGroup(group)}>
<FolderPlus className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEditGroup(group)}>
<Edit className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onMoveGroupUp(group)}
disabled={!canMoveGroupUp}
>
<ArrowUp className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onMoveGroupDown(group)}
disabled={!canMoveGroupDown}
>
<ArrowDown className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => onDeleteGroup(group)}
>
<Trash2 className="h-4 w-4 mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* 확장된 경우 하위 요소 렌더링 */}
{isExpanded && (
<>
{/* 하위 그룹 */}
{group.children?.map((child) => (
<TreeNode
key={`group-${child.id}`}
group={child}
level={level + 1}
expandedGroups={expandedGroups}
onToggle={onToggle}
selectedGroupId={selectedGroupId}
selectedScreenId={selectedScreenId}
onGroupSelect={onGroupSelect}
onScreenSelect={onScreenSelect}
onScreenDesign={onScreenDesign}
onEditGroup={onEditGroup}
onDeleteGroup={onDeleteGroup}
onAddSubGroup={onAddSubGroup}
screensMap={screensMap}
onOpenMoveModal={onOpenMoveModal}
onRemoveScreenFromGroup={onRemoveScreenFromGroup}
siblingGroups={group.children || []}
onMoveGroupUp={onMoveGroupUp}
onMoveGroupDown={onMoveGroupDown}
onMoveScreenUp={onMoveScreenUp}
onMoveScreenDown={onMoveScreenDown}
onDeleteScreen={onDeleteScreen}
/>
))}
{/* 그룹에 연결된 화면 */}
{groupScreens.map((screen, screenIndex) => {
const canMoveScreenUp = screenIndex > 0;
const canMoveScreenDown = screenIndex < groupScreens.length - 1;
return (
<div
key={`screen-${screen.screenId}`}
className={cn(
"flex items-center gap-1.5 py-1.5 px-2 rounded-md cursor-pointer transition-colors",
selectedScreenId === screen.screenId
? "bg-primary/10 text-primary"
: "hover:bg-muted",
"group"
)}
style={{ paddingLeft: `${(level + 1) * 24 + 8}px` }}
onClick={() => onScreenSelect(screen)}
onDoubleClick={() => onScreenDesign(screen)}
>
{/* 트리 연결 표시 */}
<span className="text-muted-foreground/50 text-xs mr-1"></span>
<Monitor className="h-4 w-4 text-blue-500 shrink-0" />
<span className="flex-1 text-sm truncate">{screen.screenName}</span>
<span className="text-[10px] text-muted-foreground shrink-0">#{screen.screenId}</span>
{/* 더보기 메뉴 (폴더와 동일한 스타일) */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 shrink-0"
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={() => onScreenDesign(screen)}>
<Edit className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onMoveScreenUp(screen, group.id)}
disabled={!canMoveScreenUp}
>
<ArrowUp className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onMoveScreenDown(screen, group.id)}
disabled={!canMoveScreenDown}
>
<ArrowDown className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onOpenMoveModal(screen, group.id)}>
<MoveRight className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onRemoveScreenFromGroup(screen, group.id)}
>
<MoveRight className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => onDeleteScreen(screen)}
>
<Trash2 className="h-4 w-4 mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
})}
</>
)}
</div>
);
}
// ============================================================
// 메인 컴포넌트
// ============================================================
export function PopCategoryTree({
screens,
selectedScreen,
onScreenSelect,
onScreenDesign,
onGroupSelect,
searchTerm = "",
}: PopCategoryTreeProps) {
// 상태 관리
const [groups, setGroups] = useState<PopScreenGroup[]>([]);
const [loading, setLoading] = useState(true);
const [expandedGroups, setExpandedGroups] = useState<Set<number>>(new Set());
const [selectedGroupId, setSelectedGroupId] = useState<number | null>(null);
// 그룹 모달 상태
const [isGroupModalOpen, setIsGroupModalOpen] = useState(false);
const [editingGroup, setEditingGroup] = useState<PopScreenGroup | null>(null);
const [parentGroupId, setParentGroupId] = useState<number | null>(null);
const [groupFormData, setGroupFormData] = useState({
group_name: "",
group_code: "",
description: "",
icon: "",
});
// 그룹 삭제 다이얼로그 상태
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [deletingGroup, setDeletingGroup] = useState<PopScreenGroup | null>(null);
// 화면 삭제 다이얼로그 상태
const [isScreenDeleteDialogOpen, setIsScreenDeleteDialogOpen] = useState(false);
const [deletingScreen, setDeletingScreen] = useState<ScreenDefinition | null>(null);
// 이동 모달 상태
const [isMoveModalOpen, setIsMoveModalOpen] = useState(false);
const [movingScreen, setMovingScreen] = useState<ScreenDefinition | null>(null);
const [movingFromGroupId, setMovingFromGroupId] = useState<number | null>(null);
const [moveSearchTerm, setMoveSearchTerm] = useState("");
// 화면 맵 생성 (screen_id로 빠르게 조회)
const screensMap = useMemo(() => {
const map = new Map<number, ScreenDefinition>();
screens.forEach((s) => map.set(s.screenId, s));
return map;
}, [screens]);
// 그룹 데이터 로드
const loadGroups = useCallback(async () => {
try {
setLoading(true);
// 먼저 POP 루트 그룹 확보
await ensurePopRootGroup();
// 그룹 목록 조회
const data = await getPopScreenGroups(searchTerm);
setGroups(data);
// 첫 로드 시 루트 그룹들 자동 확장
if (expandedGroups.size === 0 && data.length > 0) {
const rootIds = data
.filter((g) => g.hierarchy_path === "POP" || g.hierarchy_path?.split("/").length === 2)
.map((g) => g.id);
setExpandedGroups(new Set(rootIds));
}
} catch (error) {
console.error("POP 그룹 로드 실패:", error);
toast.error("그룹 목록 로드에 실패했습니다.");
} finally {
setLoading(false);
}
}, [searchTerm]);
useEffect(() => {
loadGroups();
}, [loadGroups]);
// 트리 구조로 변환
const treeData = useMemo(() => buildPopGroupTree(groups), [groups]);
// 그룹 토글
const handleToggle = (groupId: number) => {
setExpandedGroups((prev) => {
const next = new Set(prev);
if (next.has(groupId)) {
next.delete(groupId);
} else {
next.add(groupId);
}
return next;
});
};
// 그룹 선택
const handleGroupSelect = (group: PopScreenGroup) => {
setSelectedGroupId(group.id);
onGroupSelect?.(group);
};
// 그룹 생성/수정 모달 열기
const openGroupModal = (parentGroup?: PopScreenGroup, editGroup?: PopScreenGroup) => {
if (editGroup) {
setEditingGroup(editGroup);
setParentGroupId(editGroup.parent_group_id || null);
setGroupFormData({
group_name: editGroup.group_name,
group_code: editGroup.group_code,
description: editGroup.description || "",
icon: editGroup.icon || "",
});
} else {
setEditingGroup(null);
setParentGroupId(parentGroup?.id || null);
setGroupFormData({
group_name: "",
group_code: "",
description: "",
icon: "",
});
}
setIsGroupModalOpen(true);
};
// 그룹 저장
const handleSaveGroup = async () => {
if (!groupFormData.group_name || !groupFormData.group_code) {
toast.error("그룹명과 그룹코드는 필수입니다.");
return;
}
try {
if (editingGroup) {
// 수정
const result = await updatePopScreenGroup(editingGroup.id, {
group_name: groupFormData.group_name,
description: groupFormData.description,
icon: groupFormData.icon,
});
if (result.success) {
toast.success("그룹이 수정되었습니다.");
loadGroups();
} else {
toast.error(result.message || "수정에 실패했습니다.");
}
} else {
// 생성
const result = await createPopScreenGroup({
group_name: groupFormData.group_name,
group_code: groupFormData.group_code,
description: groupFormData.description,
icon: groupFormData.icon,
parent_group_id: parentGroupId,
});
if (result.success) {
toast.success("그룹이 생성되었습니다.");
loadGroups();
} else {
toast.error(result.message || "생성에 실패했습니다.");
}
}
setIsGroupModalOpen(false);
} catch (error) {
console.error("그룹 저장 실패:", error);
toast.error("그룹 저장에 실패했습니다.");
}
};
// 그룹 삭제
const handleDeleteGroup = async () => {
if (!deletingGroup) return;
try {
const result = await deletePopScreenGroup(deletingGroup.id);
if (result.success) {
toast.success("그룹이 삭제되었습니다.");
loadGroups();
if (selectedGroupId === deletingGroup.id) {
setSelectedGroupId(null);
onGroupSelect?.(null);
}
} else {
toast.error(result.message || "삭제에 실패했습니다.");
}
} catch (error) {
console.error("그룹 삭제 실패:", error);
toast.error("그룹 삭제에 실패했습니다.");
} finally {
setIsDeleteDialogOpen(false);
setDeletingGroup(null);
}
};
// 화면을 그룹으로 이동 (기존 연결 삭제 후 새 연결 추가)
const handleMoveScreenToGroup = async (screen: ScreenDefinition, targetGroup: PopScreenGroup) => {
try {
// 1. 기존 연결 정보 찾기 (모든 그룹에서 해당 화면의 연결 찾기)
let existingLinkId: number | null = null;
for (const g of groups) {
const screenLink = g.screens?.find((s) => s.screen_id === screen.screenId);
if (screenLink) {
existingLinkId = screenLink.id;
break;
}
}
// 2. 기존 연결이 있으면 삭제
if (existingLinkId) {
await apiClient.delete(`/screen-groups/group-screens/${existingLinkId}`);
}
// 3. 새 그룹에 연결 추가
const response = await apiClient.post("/screen-groups/group-screens", {
group_id: targetGroup.id,
screen_id: screen.screenId,
screen_role: "main",
display_order: 0,
is_default: false,
});
if (response.data.success) {
toast.success(`"${screen.screenName}"을(를) "${(targetGroup as any)._displayName || targetGroup.group_name}"으로 이동했습니다.`);
loadGroups(); // 그룹 목록 새로고침
} else {
throw new Error(response.data.message || "이동 실패");
}
} catch (error: any) {
console.error("화면 이동 실패:", error);
toast.error(error.response?.data?.message || error.message || "화면 이동에 실패했습니다.");
}
};
// 그룹에서 화면 제거
const handleRemoveScreenFromGroup = async (screen: ScreenDefinition, groupId: number) => {
try {
// 해당 그룹에서 화면 연결 정보 찾기
const targetGroup = groups.find((g) => g.id === groupId);
const screenLink = targetGroup?.screens?.find((s) => s.screen_id === screen.screenId);
if (!screenLink) {
toast.error("연결 정보를 찾을 수 없습니다.");
return;
}
await apiClient.delete(`/screen-groups/group-screens/${screenLink.id}`);
toast.success(`"${screen.screenName}"을(를) 그룹에서 제거했습니다.`);
loadGroups();
} catch (error: any) {
console.error("화면 제거 실패:", error);
toast.error(error.response?.data?.message || error.message || "화면 제거에 실패했습니다.");
}
};
// 화면 삭제 다이얼로그 열기
const handleDeleteScreen = (screen: ScreenDefinition) => {
setDeletingScreen(screen);
setIsScreenDeleteDialogOpen(true);
};
// 화면 삭제 확인
const confirmDeleteScreen = async () => {
if (!deletingScreen) return;
try {
// 화면 삭제 API 호출 (휴지통으로 이동)
await apiClient.delete(`/screen-management/screens/${deletingScreen.screenId}`);
toast.success(`"${deletingScreen.screenName}" 화면이 휴지통으로 이동되었습니다.`);
// 화면 목록 새로고침 (부모 컴포넌트에서 처리해야 함)
loadGroups();
// 삭제된 화면이 선택된 상태였다면 선택 해제
if (selectedScreen?.screenId === deletingScreen.screenId) {
onScreenSelect(null as any); // 선택 해제
}
} catch (error: any) {
console.error("화면 삭제 실패:", error);
toast.error(error.response?.data?.message || error.message || "화면 삭제에 실패했습니다.");
} finally {
setIsScreenDeleteDialogOpen(false);
setDeletingScreen(null);
}
};
// 그룹 순서 위로 이동
const handleMoveGroupUp = async (targetGroup: PopScreenGroup) => {
try {
// 같은 부모의 형제 그룹들 찾기
const parentId = targetGroup.parent_id;
const siblingGroups = groups
.filter((g) => g.parent_id === parentId)
.sort((a, b) => (a.display_order || 0) - (b.display_order || 0));
const currentIndex = siblingGroups.findIndex((g) => g.id === targetGroup.id);
if (currentIndex <= 0) return;
const prevGroup = siblingGroups[currentIndex - 1];
// 두 그룹의 display_order 교환
await Promise.all([
apiClient.put(`/screen-groups/groups/${targetGroup.id}`, {
display_order: prevGroup.display_order || currentIndex - 1
}),
apiClient.put(`/screen-groups/groups/${prevGroup.id}`, {
display_order: targetGroup.display_order || currentIndex
}),
]);
loadGroups();
} catch (error: any) {
console.error("그룹 순서 변경 실패:", error);
toast.error("순서 변경에 실패했습니다.");
}
};
// 그룹 순서 아래로 이동
const handleMoveGroupDown = async (targetGroup: PopScreenGroup) => {
try {
// 같은 부모의 형제 그룹들 찾기
const parentId = targetGroup.parent_id;
const siblingGroups = groups
.filter((g) => g.parent_id === parentId)
.sort((a, b) => (a.display_order || 0) - (b.display_order || 0));
const currentIndex = siblingGroups.findIndex((g) => g.id === targetGroup.id);
if (currentIndex >= siblingGroups.length - 1) return;
const nextGroup = siblingGroups[currentIndex + 1];
// 두 그룹의 display_order 교환
await Promise.all([
apiClient.put(`/screen-groups/groups/${targetGroup.id}`, {
display_order: nextGroup.display_order || currentIndex + 1
}),
apiClient.put(`/screen-groups/groups/${nextGroup.id}`, {
display_order: targetGroup.display_order || currentIndex
}),
]);
loadGroups();
} catch (error: any) {
console.error("그룹 순서 변경 실패:", error);
toast.error("순서 변경에 실패했습니다.");
}
};
// 화면 순서 위로 이동
const handleMoveScreenUp = async (screen: ScreenDefinition, groupId: number) => {
try {
const targetGroup = groups.find((g) => g.id === groupId);
if (!targetGroup?.screens) return;
const sortedScreens = [...targetGroup.screens].sort(
(a, b) => (a.display_order || 0) - (b.display_order || 0)
);
const currentIndex = sortedScreens.findIndex((s) => s.screen_id === screen.screenId);
if (currentIndex <= 0) return;
const currentLink = sortedScreens[currentIndex];
const prevLink = sortedScreens[currentIndex - 1];
// 두 화면의 display_order 교환
await Promise.all([
apiClient.put(`/screen-groups/group-screens/${currentLink.id}`, {
display_order: prevLink.display_order || currentIndex - 1
}),
apiClient.put(`/screen-groups/group-screens/${prevLink.id}`, {
display_order: currentLink.display_order || currentIndex
}),
]);
loadGroups();
} catch (error: any) {
console.error("화면 순서 변경 실패:", error);
toast.error("순서 변경에 실패했습니다.");
}
};
// 화면 순서 아래로 이동
const handleMoveScreenDown = async (screen: ScreenDefinition, groupId: number) => {
try {
const targetGroup = groups.find((g) => g.id === groupId);
if (!targetGroup?.screens) return;
const sortedScreens = [...targetGroup.screens].sort(
(a, b) => (a.display_order || 0) - (b.display_order || 0)
);
const currentIndex = sortedScreens.findIndex((s) => s.screen_id === screen.screenId);
if (currentIndex >= sortedScreens.length - 1) return;
const currentLink = sortedScreens[currentIndex];
const nextLink = sortedScreens[currentIndex + 1];
// 두 화면의 display_order 교환
await Promise.all([
apiClient.put(`/screen-groups/group-screens/${currentLink.id}`, {
display_order: nextLink.display_order || currentIndex + 1
}),
apiClient.put(`/screen-groups/group-screens/${nextLink.id}`, {
display_order: currentLink.display_order || currentIndex
}),
]);
loadGroups();
} catch (error: any) {
console.error("화면 순서 변경 실패:", error);
toast.error("순서 변경에 실패했습니다.");
}
};
// 미분류 화면 (그룹에 연결되지 않은 화면)
const ungroupedScreens = useMemo(() => {
const groupedScreenIds = new Set<number>();
groups.forEach((g) => {
g.screens?.forEach((gs) => groupedScreenIds.add(gs.screen_id));
});
return screens.filter((s) => !groupedScreenIds.has(s.screenId));
}, [groups, screens]);
// 전체 그룹 평탄화 (이동 드롭다운용)
const flattenedGroups = useMemo(() => {
const result: PopScreenGroup[] = [];
const flatten = (groups: PopScreenGroup[], parentName?: string) => {
groups.forEach((g) => {
// 표시 이름에 부모 경로 추가
const displayGroup = {
...g,
_displayName: parentName ? `${parentName} > ${g.group_name}` : g.group_name
};
result.push(displayGroup);
if (g.children && g.children.length > 0) {
flatten(g.children, displayGroup._displayName);
}
});
};
flatten(treeData);
return result;
}, [treeData]);
// 이동 모달 열기
const openMoveModal = (screen: ScreenDefinition, fromGroupId: number | null) => {
setMovingScreen(screen);
setMovingFromGroupId(fromGroupId);
setMoveSearchTerm("");
setIsMoveModalOpen(true);
};
// 이동 모달에서 그룹 선택 처리
const handleMoveToSelectedGroup = async (targetGroup: PopScreenGroup) => {
if (!movingScreen) return;
await handleMoveScreenToGroup(movingScreen, targetGroup);
setIsMoveModalOpen(false);
setMovingScreen(null);
setMovingFromGroupId(null);
};
// 이동 모달용 필터링된 그룹 목록
const filteredMoveGroups = useMemo(() => {
if (!moveSearchTerm) return flattenedGroups;
const searchLower = moveSearchTerm.toLowerCase();
return flattenedGroups.filter((g: any) =>
(g._displayName || g.group_name).toLowerCase().includes(searchLower)
);
}, [flattenedGroups, moveSearchTerm]);
if (loading) {
return (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* 헤더 */}
<div className="shrink-0 p-3 border-b flex items-center justify-between">
<h3 className="text-sm font-medium">POP </h3>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={loadGroups}>
<RefreshCw className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => openGroupModal()}>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* 트리 영역 */}
<ScrollArea className="flex-1">
<div className="p-2">
{treeData.length === 0 && ungroupedScreens.length === 0 ? (
<div className="text-center text-sm text-muted-foreground py-8">
.
<br />
<Button variant="link" className="text-xs" onClick={() => openGroupModal()}>
</Button>
</div>
) : (
<>
{/* 트리 렌더링 */}
{treeData.map((group) => (
<TreeNode
key={`group-${group.id}`}
group={group}
level={0}
expandedGroups={expandedGroups}
onToggle={handleToggle}
selectedGroupId={selectedGroupId}
selectedScreenId={selectedScreen?.screenId || null}
onGroupSelect={handleGroupSelect}
onScreenSelect={onScreenSelect}
onScreenDesign={onScreenDesign}
onEditGroup={(g) => openGroupModal(undefined, g)}
onDeleteGroup={(g) => {
setDeletingGroup(g);
setIsDeleteDialogOpen(true);
}}
onAddSubGroup={(g) => openGroupModal(g)}
screensMap={screensMap}
onOpenMoveModal={openMoveModal}
onRemoveScreenFromGroup={handleRemoveScreenFromGroup}
siblingGroups={treeData}
onMoveGroupUp={handleMoveGroupUp}
onMoveGroupDown={handleMoveGroupDown}
onMoveScreenUp={handleMoveScreenUp}
onMoveScreenDown={handleMoveScreenDown}
onDeleteScreen={handleDeleteScreen}
/>
))}
{/* 미분류 화면 */}
{ungroupedScreens.length > 0 && (
<div className="mt-4 pt-4 border-t">
<div className="text-xs text-muted-foreground px-2 mb-2">
({ungroupedScreens.length})
</div>
{ungroupedScreens.map((screen) => (
<div
key={`ungrouped-${screen.screenId}`}
className={cn(
"flex items-center gap-2 py-1.5 px-2 rounded-md cursor-pointer transition-colors",
selectedScreen?.screenId === screen.screenId
? "bg-primary/10 text-primary"
: "hover:bg-muted",
"group"
)}
onClick={() => onScreenSelect(screen)}
onDoubleClick={() => onScreenDesign(screen)}
>
<Monitor className="h-4 w-4 text-gray-400 shrink-0" />
<span className="flex-1 text-sm truncate">{screen.screenName}</span>
<span className="text-[10px] text-muted-foreground shrink-0">#{screen.screenId}</span>
{/* 더보기 메뉴 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 shrink-0"
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={() => onScreenDesign(screen)}>
<Edit className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => openMoveModal(screen, null)}>
<MoveRight className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => handleDeleteScreen(screen)}
>
<Trash2 className="h-4 w-4 mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
))}
</div>
)}
</>
)}
</div>
</ScrollArea>
{/* 그룹 생성/수정 모달 */}
<Dialog open={isGroupModalOpen} onOpenChange={setIsGroupModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[400px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{editingGroup ? "카테고리 수정" : "새 카테고리"}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{editingGroup ? "카테고리 정보를 수정합니다." : "POP 화면을 분류할 카테고리를 추가합니다."}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
<div>
<Label htmlFor="group_name" className="text-xs sm:text-sm">
*
</Label>
<Input
id="group_name"
value={groupFormData.group_name}
onChange={(e) => setGroupFormData((prev) => ({ ...prev, group_name: e.target.value }))}
placeholder="예: 생산관리"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
{!editingGroup && (
<div>
<Label htmlFor="group_code" className="text-xs sm:text-sm">
*
</Label>
<Input
id="group_code"
value={groupFormData.group_code}
onChange={(e) => setGroupFormData((prev) => ({ ...prev, group_code: e.target.value.toUpperCase() }))}
placeholder="예: PRODUCTION"
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="description" className="text-xs sm:text-sm">
</Label>
<Input
id="description"
value={groupFormData.description}
onChange={(e) => setGroupFormData((prev) => ({ ...prev, description: e.target.value }))}
placeholder="카테고리에 대한 설명"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setIsGroupModalOpen(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleSaveGroup}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{editingGroup ? "수정" : "생성"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
"{deletingGroup?.group_name}" ?
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleDeleteGroup}
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 화면 이동 모달 */}
<Dialog open={isMoveModalOpen} onOpenChange={setIsMoveModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[450px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
"{movingScreen?.screenName}" .
</DialogDescription>
</DialogHeader>
{/* 검색 입력 */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="카테고리 검색..."
value={moveSearchTerm}
onChange={(e) => setMoveSearchTerm(e.target.value)}
className="pl-9 h-9 text-sm"
/>
</div>
{/* 카테고리 트리 목록 */}
<ScrollArea className="h-64 border rounded-md">
<div className="p-2 space-y-0.5">
{filteredMoveGroups.length === 0 ? (
<div className="text-center text-sm text-muted-foreground py-8">
.
</div>
) : (
filteredMoveGroups.map((group: any) => {
const isCurrentGroup = group.id === movingFromGroupId;
const displayName = group._displayName || group.group_name;
const depth = (displayName.match(/>/g) || []).length;
return (
<button
key={group.id}
disabled={isCurrentGroup}
onClick={() => handleMoveToSelectedGroup(group)}
className={cn(
"w-full flex items-center gap-2 py-2 px-3 rounded-md text-left transition-colors",
isCurrentGroup
? "opacity-50 cursor-not-allowed bg-muted"
: "hover:bg-muted cursor-pointer"
)}
style={{ paddingLeft: `${depth * 16 + 12}px` }}
>
<Folder className={cn(
"h-4 w-4 shrink-0",
depth === 0 ? "text-orange-500" : "text-amber-500"
)} />
<span className="text-sm truncate flex-1">
{group.group_name}
</span>
{isCurrentGroup && (
<span className="text-xs text-muted-foreground">()</span>
)}
</button>
);
})
)}
</div>
</ScrollArea>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setIsMoveModalOpen(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 화면 삭제 확인 다이얼로그 */}
<AlertDialog open={isScreenDeleteDialogOpen} onOpenChange={setIsScreenDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
"{deletingScreen?.screenName}" ?
<br />
<span className="text-muted-foreground text-xs">
, .
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={confirmDeleteScreen}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}