1176 lines
43 KiB
TypeScript
1176 lines
43 KiB
TypeScript
"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>
|
|
);
|
|
}
|