ERP-node/frontend/components/screen/ScreenList.tsx

1377 lines
59 KiB
TypeScript
Raw Normal View History

2025-09-01 11:48:12 +09:00
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
2025-09-08 13:10:09 +09:00
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Checkbox } from "@/components/ui/checkbox";
2025-09-01 11:48:12 +09:00
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
2025-09-08 13:10:09 +09:00
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
2025-11-05 18:08:51 +09:00
AlertDialogContent,
AlertDialogDescription,
2025-09-08 13:10:09 +09:00
AlertDialogFooter,
2025-11-05 18:08:51 +09:00
AlertDialogHeader,
2025-09-08 13:10:09 +09:00
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
2025-11-05 18:08:51 +09:00
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
ResizableDialog,
ResizableDialogContent,
ResizableDialogHeader
} from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
2025-09-08 13:10:09 +09:00
import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette, RotateCcw, Trash } from "lucide-react";
2025-09-01 11:48:12 +09:00
import { ScreenDefinition } from "@/types/screen";
2025-09-01 17:57:52 +09:00
import { screenApi } from "@/lib/api/screen";
import CreateScreenModal from "./CreateScreenModal";
2025-09-03 18:23:47 +09:00
import CopyScreenModal from "./CopyScreenModal";
import dynamic from "next/dynamic";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import { DynamicWebTypeRenderer } from "@/lib/registry";
import { isFileComponent, getComponentWebType } from "@/lib/utils/componentTypeUtils";
// InteractiveScreenViewer를 동적으로 import (SSR 비활성화)
const InteractiveScreenViewer = dynamic(
() => import("./InteractiveScreenViewer").then((mod) => mod.InteractiveScreenViewer),
{
ssr: false,
loading: () => <div className="flex items-center justify-center p-8"> ...</div>,
},
);
2025-09-01 11:48:12 +09:00
interface ScreenListProps {
onScreenSelect: (screen: ScreenDefinition) => void;
selectedScreen: ScreenDefinition | null;
onDesignScreen: (screen: ScreenDefinition) => void;
2025-09-01 11:48:12 +09:00
}
2025-09-08 13:10:09 +09:00
type DeletedScreenDefinition = ScreenDefinition & {
deletedDate?: Date;
deletedBy?: string;
deleteReason?: string;
};
export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScreen }: ScreenListProps) {
2025-09-08 13:10:09 +09:00
const [activeTab, setActiveTab] = useState("active");
2025-09-01 11:48:12 +09:00
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
2025-09-08 13:10:09 +09:00
const [deletedScreens, setDeletedScreens] = useState<DeletedScreenDefinition[]>([]);
2025-09-01 11:48:12 +09:00
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
2025-09-01 17:57:52 +09:00
const [isCreateOpen, setIsCreateOpen] = useState(false);
2025-09-03 18:23:47 +09:00
const [isCopyOpen, setIsCopyOpen] = useState(false);
const [screenToCopy, setScreenToCopy] = useState<ScreenDefinition | null>(null);
2025-09-01 11:48:12 +09:00
2025-09-08 13:10:09 +09:00
// 삭제 관련 상태
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [screenToDelete, setScreenToDelete] = useState<ScreenDefinition | null>(null);
const [deleteReason, setDeleteReason] = useState("");
const [dependencies, setDependencies] = useState<
Array<{
screenId: number;
screenName: string;
screenCode: string;
componentId: string;
componentType: string;
referenceType: string;
}>
>([]);
const [showDependencyWarning, setShowDependencyWarning] = useState(false);
const [checkingDependencies, setCheckingDependencies] = useState(false);
// 영구 삭제 관련 상태
const [permanentDeleteDialogOpen, setPermanentDeleteDialogOpen] = useState(false);
const [screenToPermanentDelete, setScreenToPermanentDelete] = useState<DeletedScreenDefinition | null>(null);
// 일괄삭제 관련 상태
const [selectedScreenIds, setSelectedScreenIds] = useState<number[]>([]);
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
const [bulkDeleting, setBulkDeleting] = useState(false);
// 편집 관련 상태
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [screenToEdit, setScreenToEdit] = useState<ScreenDefinition | null>(null);
const [editFormData, setEditFormData] = useState({
screenName: "",
description: "",
isActive: "Y",
});
// 미리보기 관련 상태
const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
const [screenToPreview, setScreenToPreview] = useState<ScreenDefinition | null>(null);
const [previewLayout, setPreviewLayout] = useState<any>(null);
const [isLoadingPreview, setIsLoadingPreview] = useState(false);
const [previewFormData, setPreviewFormData] = useState<Record<string, any>>({});
2025-09-01 17:57:52 +09:00
// 화면 목록 로드 (실제 API)
2025-09-01 11:48:12 +09:00
useEffect(() => {
2025-09-01 17:57:52 +09:00
let abort = false;
const load = async () => {
try {
setLoading(true);
2025-09-08 13:10:09 +09:00
if (activeTab === "active") {
const resp = await screenApi.getScreens({ page: currentPage, size: 20, searchTerm });
if (abort) return;
setScreens(resp.data || []);
setTotalPages(Math.max(1, Math.ceil((resp.total || 0) / 20)));
} else if (activeTab === "trash") {
const resp = await screenApi.getDeletedScreens({ page: currentPage, size: 20 });
if (abort) return;
setDeletedScreens(resp.data || []);
setTotalPages(Math.max(1, Math.ceil((resp.total || 0) / 20)));
}
2025-09-01 17:57:52 +09:00
} catch (e) {
// console.error("화면 목록 조회 실패", e);
2025-09-08 13:10:09 +09:00
if (activeTab === "active") {
setScreens([]);
} else {
setDeletedScreens([]);
}
2025-09-01 17:57:52 +09:00
setTotalPages(1);
} finally {
if (!abort) setLoading(false);
}
};
load();
return () => {
abort = true;
};
2025-09-08 13:10:09 +09:00
}, [currentPage, searchTerm, activeTab]);
2025-09-01 17:57:52 +09:00
const filteredScreens = screens; // 서버 필터 기준 사용
2025-09-01 11:48:12 +09:00
2025-09-03 18:23:47 +09:00
// 화면 목록 다시 로드
const reloadScreens = async () => {
try {
setLoading(true);
const resp = await screenApi.getScreens({ page: currentPage, size: 20, searchTerm });
setScreens(resp.data || []);
setTotalPages(Math.max(1, Math.ceil((resp.total || 0) / 20)));
} catch (e) {
// console.error("화면 목록 조회 실패", e);
2025-09-03 18:23:47 +09:00
} finally {
setLoading(false);
}
};
2025-09-01 11:48:12 +09:00
const handleScreenSelect = (screen: ScreenDefinition) => {
onScreenSelect(screen);
};
const handleEdit = (screen: ScreenDefinition) => {
setScreenToEdit(screen);
setEditFormData({
screenName: screen.screenName,
description: screen.description || "",
isActive: screen.isActive,
});
setEditDialogOpen(true);
};
const handleEditSave = async () => {
if (!screenToEdit) return;
try {
// 화면 정보 업데이트 API 호출
await screenApi.updateScreenInfo(screenToEdit.screenId, editFormData);
// 목록에서 해당 화면 정보 업데이트
setScreens((prev) =>
prev.map((s) =>
s.screenId === screenToEdit.screenId
? {
...s,
screenName: editFormData.screenName,
description: editFormData.description,
isActive: editFormData.isActive,
}
: s,
),
);
setEditDialogOpen(false);
setScreenToEdit(null);
} catch (error) {
console.error("화면 정보 업데이트 실패:", error);
alert("화면 정보 업데이트에 실패했습니다.");
}
2025-09-01 11:48:12 +09:00
};
2025-09-08 13:10:09 +09:00
const handleDelete = async (screen: ScreenDefinition) => {
setScreenToDelete(screen);
setCheckingDependencies(true);
try {
// 의존성 체크
const dependencyResult = await screenApi.checkScreenDependencies(screen.screenId);
if (dependencyResult.hasDependencies) {
setDependencies(dependencyResult.dependencies);
setShowDependencyWarning(true);
} else {
setDeleteDialogOpen(true);
}
} catch (error) {
// console.error("의존성 체크 실패:", error);
2025-09-08 13:10:09 +09:00
// 의존성 체크 실패 시에도 삭제 다이얼로그는 열어줌
setDeleteDialogOpen(true);
} finally {
setCheckingDependencies(false);
}
};
const confirmDelete = async (force: boolean = false) => {
if (!screenToDelete) return;
try {
await screenApi.deleteScreen(screenToDelete.screenId, deleteReason, force);
setScreens((prev) => prev.filter((s) => s.screenId !== screenToDelete.screenId));
setDeleteDialogOpen(false);
setShowDependencyWarning(false);
setScreenToDelete(null);
setDeleteReason("");
setDependencies([]);
} catch (error: any) {
// console.error("화면 삭제 실패:", error);
2025-09-08 13:10:09 +09:00
// 의존성 오류인 경우 경고창 표시
if (error.response?.status === 409 && error.response?.data?.code === "SCREEN_HAS_DEPENDENCIES") {
setDependencies(error.response.data.dependencies || []);
setShowDependencyWarning(true);
setDeleteDialogOpen(false);
} else {
alert("화면 삭제에 실패했습니다.");
}
}
};
const handleCancelDelete = () => {
setDeleteDialogOpen(false);
setShowDependencyWarning(false);
setScreenToDelete(null);
setDeleteReason("");
setDependencies([]);
};
const handleRestore = async (screen: DeletedScreenDefinition) => {
if (!confirm(`"${screen.screenName}" 화면을 복원하시겠습니까?`)) return;
try {
await screenApi.restoreScreen(screen.screenId);
setDeletedScreens((prev) => prev.filter((s) => s.screenId !== screen.screenId));
// 활성 탭으로 이동하여 복원된 화면 확인
setActiveTab("active");
reloadScreens();
} catch (error) {
// console.error("화면 복원 실패:", error);
2025-09-08 13:10:09 +09:00
alert("화면 복원에 실패했습니다.");
}
};
const handlePermanentDelete = (screen: DeletedScreenDefinition) => {
setScreenToPermanentDelete(screen);
setPermanentDeleteDialogOpen(true);
};
const confirmPermanentDelete = async () => {
if (!screenToPermanentDelete) return;
try {
await screenApi.permanentDeleteScreen(screenToPermanentDelete.screenId);
setDeletedScreens((prev) => prev.filter((s) => s.screenId !== screenToPermanentDelete.screenId));
setPermanentDeleteDialogOpen(false);
setScreenToPermanentDelete(null);
} catch (error) {
// console.error("화면 영구 삭제 실패:", error);
2025-09-08 13:10:09 +09:00
alert("화면 영구 삭제에 실패했습니다.");
}
};
// 체크박스 선택 처리
const handleScreenCheck = (screenId: number, checked: boolean) => {
if (checked) {
setSelectedScreenIds((prev) => [...prev, screenId]);
} else {
setSelectedScreenIds((prev) => prev.filter((id) => id !== screenId));
}
};
// 전체 선택/해제
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedScreenIds(deletedScreens.map((screen) => screen.screenId));
} else {
setSelectedScreenIds([]);
}
};
// 일괄삭제 실행
const handleBulkDelete = () => {
if (selectedScreenIds.length === 0) {
alert("삭제할 화면을 선택해주세요.");
return;
}
setBulkDeleteDialogOpen(true);
};
const confirmBulkDelete = async () => {
if (selectedScreenIds.length === 0) return;
try {
setBulkDeleting(true);
const result = await screenApi.bulkPermanentDeleteScreens(selectedScreenIds);
// 삭제된 화면들을 목록에서 제거
setDeletedScreens((prev) => prev.filter((screen) => !selectedScreenIds.includes(screen.screenId)));
setSelectedScreenIds([]);
setBulkDeleteDialogOpen(false);
// 결과 메시지 표시
let message = `${result.deletedCount}개 화면이 영구 삭제되었습니다.`;
if (result.skippedCount > 0) {
message += `\n${result.skippedCount}개 화면은 삭제되지 않았습니다.`;
}
if (result.errors.length > 0) {
message += `\n오류 발생: ${result.errors.map((e) => `화면 ${e.screenId}: ${e.error}`).join(", ")}`;
}
alert(message);
} catch (error) {
// console.error("일괄 삭제 실패:", error);
2025-09-08 13:10:09 +09:00
alert("일괄 삭제에 실패했습니다.");
} finally {
setBulkDeleting(false);
2025-09-01 11:48:12 +09:00
}
};
const handleCopy = (screen: ScreenDefinition) => {
2025-09-03 18:23:47 +09:00
setScreenToCopy(screen);
setIsCopyOpen(true);
2025-09-01 11:48:12 +09:00
};
const handleView = async (screen: ScreenDefinition) => {
setScreenToPreview(screen);
setPreviewLayout(null); // 이전 레이아웃 초기화
setIsLoadingPreview(true);
setPreviewDialogOpen(true); // 모달 먼저 열기
// 모달이 열린 후에 레이아웃 로드
setTimeout(async () => {
try {
// 화면 레이아웃 로드
const layoutData = await screenApi.getLayout(screen.screenId);
console.log("📊 미리보기 레이아웃 로드:", layoutData);
setPreviewLayout(layoutData);
} catch (error) {
console.error("❌ 레이아웃 로드 실패:", error);
toast.error("화면 레이아웃을 불러오는데 실패했습니다.");
} finally {
setIsLoadingPreview(false);
}
}, 100); // 100ms 딜레이로 모달 애니메이션이 먼저 시작되도록
2025-09-01 11:48:12 +09:00
};
2025-09-03 18:23:47 +09:00
const handleCopySuccess = () => {
// 복사 성공 후 화면 목록 다시 로드
reloadScreens();
};
2025-09-01 11:48:12 +09:00
if (loading) {
return (
<div className="flex items-center justify-center py-8">
2025-10-22 14:52:13 +09:00
<div className="text-muted-foreground text-sm"> ...</div>
2025-09-01 11:48:12 +09:00
</div>
);
}
return (
<div className="space-y-4">
{/* 검색 및 필터 */}
2025-10-22 14:52:13 +09:00
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="w-full sm:w-[400px]">
2025-09-01 11:48:12 +09:00
<div className="relative">
2025-10-22 14:52:13 +09:00
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
2025-09-01 11:48:12 +09:00
<Input
placeholder="화면명, 코드, 테이블명으로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
2025-10-22 14:52:13 +09:00
className="h-10 pl-10 text-sm"
2025-09-08 13:10:09 +09:00
disabled={activeTab === "trash"}
2025-09-01 11:48:12 +09:00
/>
</div>
</div>
2025-10-22 14:52:13 +09:00
<Button
onClick={() => setIsCreateOpen(true)}
disabled={activeTab === "trash"}
className="h-10 gap-2 text-sm font-medium"
>
<Plus className="h-4 w-4" />
2025-09-01 11:48:12 +09:00
</Button>
</div>
2025-09-08 13:10:09 +09:00
{/* 탭 구조 */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="active"> </TabsTrigger>
<TabsTrigger value="trash"></TabsTrigger>
</TabsList>
{/* 활성 화면 탭 */}
<TabsContent value="active">
2025-10-22 14:52:13 +09:00
{/* 데스크톱 테이블 뷰 (lg 이상) */}
<div className="bg-card hidden shadow-sm lg:block">
2025-10-22 14:52:13 +09:00
<Table>
<TableHeader>
<TableRow>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"></TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"> </TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"></TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"></TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"></TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"></TableHead>
2025-10-22 14:52:13 +09:00
</TableRow>
</TableHeader>
<TableBody>
{screens.map((screen) => (
<TableRow
key={screen.screenId}
className={`bg-background hover:bg-muted/50 cursor-pointer border-b transition-colors ${
2025-10-22 14:52:13 +09:00
selectedScreen?.screenId === screen.screenId ? "border-primary/20 bg-accent" : ""
}`}
2025-10-28 15:39:22 +09:00
onClick={() => onDesignScreen(screen)}
2025-10-22 14:52:13 +09:00
>
<TableCell className="h-16 px-6 py-3 cursor-pointer">
2025-10-22 14:52:13 +09:00
<div>
<div className="font-medium">{screen.screenName}</div>
{screen.description && (
<div className="text-muted-foreground mt-1 text-sm">{screen.description}</div>
)}
</div>
</TableCell>
<TableCell className="h-16 px-6 py-3">
2025-10-22 14:52:13 +09:00
<Badge variant="outline" className="font-mono">
{screen.screenCode}
</Badge>
</TableCell>
<TableCell className="h-16 px-6 py-3">
2025-10-22 14:52:13 +09:00
<span className="text-muted-foreground font-mono text-sm">
{screen.tableLabel || screen.tableName}
</span>
</TableCell>
<TableCell className="h-16 px-6 py-3">
2025-10-22 14:52:13 +09:00
<Badge variant={screen.isActive === "Y" ? "default" : "secondary"}>
{screen.isActive === "Y" ? "활성" : "비활성"}
</Badge>
</TableCell>
<TableCell className="h-16 px-6 py-3">
2025-10-22 14:52:13 +09:00
<div className="text-muted-foreground text-sm">{screen.createdDate.toLocaleDateString()}</div>
<div className="text-muted-foreground text-xs">{screen.createdBy}</div>
</TableCell>
<TableCell className="h-16 px-6 py-3">
2025-10-22 14:52:13 +09:00
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDesignScreen(screen);
}}
>
<Palette className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleView(screen);
}}
>
<Eye className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleEdit(screen);
}}
>
<Edit className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleCopy(screen);
}}
>
<Copy className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleDelete(screen);
}}
className="text-destructive"
disabled={checkingDependencies && screenToDelete?.screenId === screen.screenId}
>
<Trash2 className="mr-2 h-4 w-4" />
{checkingDependencies && screenToDelete?.screenId === screen.screenId
? "확인 중..."
: "삭제"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
2025-09-08 13:10:09 +09:00
</TableRow>
2025-10-22 14:52:13 +09:00
))}
</TableBody>
</Table>
{filteredScreens.length === 0 && (
<div className="flex h-64 flex-col items-center justify-center">
<p className="text-muted-foreground text-sm"> .</p>
</div>
)}
</div>
{/* 모바일/태블릿 카드 뷰 (lg 미만) */}
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
{screens.map((screen) => (
<div
key={screen.screenId}
className={`bg-card hover:bg-muted/50 cursor-pointer rounded-lg border p-4 shadow-sm transition-colors ${
selectedScreen?.screenId === screen.screenId ? "border-primary bg-accent" : ""
}`}
onClick={() => handleScreenSelect(screen)}
>
{/* 헤더 */}
<div className="mb-4 flex items-start justify-between">
<div className="flex-1">
<h3 className="text-base font-semibold">{screen.screenName}</h3>
<p className="text-muted-foreground mt-1 font-mono text-sm">{screen.screenCode}</p>
</div>
<Badge variant={screen.isActive === "Y" ? "default" : "secondary"}>
{screen.isActive === "Y" ? "활성" : "비활성"}
</Badge>
</div>
{/* 설명 */}
{screen.description && <p className="text-muted-foreground mb-4 text-sm">{screen.description}</p>}
{/* 정보 */}
<div className="space-y-2 border-t pt-4">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-mono font-medium">{screen.tableLabel || screen.tableName}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium">{screen.createdDate.toLocaleDateString()}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium">{screen.createdBy}</span>
</div>
</div>
{/* 액션 */}
<div className="mt-4 flex gap-2 border-t pt-4">
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
onDesignScreen(screen);
}}
className="h-9 flex-1 gap-2 text-sm"
>
<Palette className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleView(screen);
}}
className="h-9 flex-1 gap-2 text-sm"
>
<Eye className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button variant="outline" size="sm" className="h-9 px-3">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleEdit(screen);
}}
>
<Edit className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleCopy(screen);
}}
>
<Copy className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleDelete(screen);
}}
className="text-destructive"
disabled={checkingDependencies && screenToDelete?.screenId === screen.screenId}
>
<Trash2 className="mr-2 h-4 w-4" />
{checkingDependencies && screenToDelete?.screenId === screen.screenId ? "확인 중..." : "삭제"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))}
{filteredScreens.length === 0 && (
<div className="bg-card col-span-2 flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
<p className="text-muted-foreground text-sm"> .</p>
</div>
)}
</div>
2025-09-08 13:10:09 +09:00
</TabsContent>
{/* 휴지통 탭 */}
<TabsContent value="trash">
2025-10-22 14:52:13 +09:00
{/* 데스크톱 테이블 뷰 (lg 이상) */}
<div className="bg-card hidden shadow-sm lg:block">
2025-10-22 14:52:13 +09:00
<Table>
<TableHeader>
<TableRow>
<TableHead className="h-12 w-12 px-6 py-3">
2025-10-22 14:52:13 +09:00
<Checkbox
checked={deletedScreens.length > 0 && selectedScreenIds.length === deletedScreens.length}
onCheckedChange={handleSelectAll}
aria-label="전체 선택"
/>
</TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"></TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"> </TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"></TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"></TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"></TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"> </TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"></TableHead>
2025-10-22 14:52:13 +09:00
</TableRow>
</TableHeader>
<TableBody>
{deletedScreens.map((screen) => (
<TableRow key={screen.screenId} className="bg-background hover:bg-muted/50 border-b transition-colors">
<TableCell className="h-16 px-6 py-3">
2025-09-08 13:10:09 +09:00
<Checkbox
2025-10-22 14:52:13 +09:00
checked={selectedScreenIds.includes(screen.screenId)}
onCheckedChange={(checked) => handleScreenCheck(screen.screenId, checked as boolean)}
aria-label={`${screen.screenName} 선택`}
2025-09-08 13:10:09 +09:00
/>
2025-10-22 14:52:13 +09:00
</TableCell>
<TableCell className="h-16 px-6 py-3">
2025-10-22 14:52:13 +09:00
<div>
<div className="font-medium">{screen.screenName}</div>
{screen.description && (
<div className="text-muted-foreground mt-1 text-sm">{screen.description}</div>
)}
</div>
</TableCell>
<TableCell className="h-16 px-6 py-3">
2025-10-22 14:52:13 +09:00
<Badge variant="outline" className="font-mono">
{screen.screenCode}
</Badge>
</TableCell>
<TableCell className="h-16 px-6 py-3">
2025-10-22 14:52:13 +09:00
<span className="text-muted-foreground font-mono text-sm">
{screen.tableLabel || screen.tableName}
</span>
</TableCell>
<TableCell className="h-16 px-6 py-3">
2025-10-22 14:52:13 +09:00
<div className="text-muted-foreground text-sm">{screen.deletedDate?.toLocaleDateString()}</div>
</TableCell>
<TableCell className="h-16 px-6 py-3">
2025-10-22 14:52:13 +09:00
<div className="text-muted-foreground text-sm">{screen.deletedBy}</div>
</TableCell>
<TableCell className="h-16 px-6 py-3">
2025-10-22 14:52:13 +09:00
<div className="text-muted-foreground max-w-32 truncate text-sm" title={screen.deleteReason}>
{screen.deleteReason || "-"}
</div>
</TableCell>
<TableCell className="h-16 px-6 py-3">
2025-10-22 14:52:13 +09:00
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleRestore(screen)}
className="text-primary hover:text-primary/80 h-9 gap-2 text-sm"
>
<RotateCcw className="h-4 w-4" />
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handlePermanentDelete(screen)}
className="h-9 gap-2 text-sm"
>
<Trash className="h-4 w-4" />
</Button>
</div>
</TableCell>
2025-09-08 13:10:09 +09:00
</TableRow>
2025-10-22 14:52:13 +09:00
))}
</TableBody>
</Table>
{deletedScreens.length === 0 && (
<div className="flex h-64 flex-col items-center justify-center">
<p className="text-muted-foreground text-sm"> .</p>
</div>
)}
</div>
{/* 모바일/태블릿 카드 뷰 (lg 미만) */}
<div className="space-y-4 lg:hidden">
{/* 헤더 */}
<div className="bg-card flex items-center justify-between rounded-lg border p-4 shadow-sm">
<div className="flex items-center gap-3">
<Checkbox
checked={deletedScreens.length > 0 && selectedScreenIds.length === deletedScreens.length}
onCheckedChange={handleSelectAll}
aria-label="전체 선택"
/>
</div>
{selectedScreenIds.length > 0 && (
<Button
variant="destructive"
size="sm"
onClick={handleBulkDelete}
disabled={bulkDeleting}
className="h-9 gap-2 text-sm"
>
<Trash className="h-4 w-4" />
{bulkDeleting ? "삭제 중..." : `${selectedScreenIds.length}`}
</Button>
2025-09-08 13:10:09 +09:00
)}
2025-10-22 14:52:13 +09:00
</div>
{/* 카드 목록 */}
<div className="grid gap-4 sm:grid-cols-2">
{deletedScreens.map((screen) => (
<div key={screen.screenId} className="bg-card rounded-lg border p-4 shadow-sm">
{/* 헤더 */}
<div className="mb-4 flex items-start gap-3">
<Checkbox
checked={selectedScreenIds.includes(screen.screenId)}
onCheckedChange={(checked) => handleScreenCheck(screen.screenId, checked as boolean)}
className="mt-1"
aria-label={`${screen.screenName} 선택`}
/>
<div className="flex-1">
<h3 className="text-base font-semibold">{screen.screenName}</h3>
<p className="text-muted-foreground mt-1 font-mono text-sm">{screen.screenCode}</p>
</div>
</div>
{/* 설명 */}
{screen.description && <p className="text-muted-foreground mb-4 text-sm">{screen.description}</p>}
{/* 정보 */}
<div className="space-y-2 border-t pt-4">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-mono font-medium">{screen.tableLabel || screen.tableName}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium">{screen.deletedDate?.toLocaleDateString()}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium">{screen.deletedBy}</span>
</div>
{screen.deleteReason && (
<div className="flex flex-col gap-1 text-sm">
<span className="text-muted-foreground"> </span>
<span className="font-medium">{screen.deleteReason}</span>
</div>
)}
</div>
{/* 액션 */}
<div className="mt-4 flex gap-2 border-t pt-4">
<Button
variant="outline"
size="sm"
onClick={() => handleRestore(screen)}
className="text-primary hover:text-primary/80 h-9 flex-1 gap-2 text-sm"
>
<RotateCcw className="h-4 w-4" />
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handlePermanentDelete(screen)}
className="h-9 flex-1 gap-2 text-sm"
>
<Trash className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
{deletedScreens.length === 0 && (
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
<p className="text-muted-foreground text-sm"> .</p>
</div>
)}
</div>
2025-09-08 13:10:09 +09:00
</TabsContent>
</Tabs>
2025-09-01 11:48:12 +09:00
{/* 페이지네이션 */}
{totalPages > 1 && (
<div className="flex items-center justify-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
>
</Button>
<span className="text-muted-foreground text-sm">
2025-09-01 11:48:12 +09:00
{currentPage} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
>
</Button>
</div>
)}
2025-09-01 17:57:52 +09:00
{/* 새 화면 생성 모달 */}
<CreateScreenModal
open={isCreateOpen}
onOpenChange={setIsCreateOpen}
onCreated={(created) => {
// 목록에 즉시 반영 (첫 페이지 기준 상단 추가)
setScreens((prev) => [created, ...prev]);
}}
/>
2025-09-03 18:23:47 +09:00
{/* 화면 복사 모달 */}
<CopyScreenModal
isOpen={isCopyOpen}
onClose={() => setIsCopyOpen(false)}
sourceScreen={screenToCopy}
onCopySuccess={handleCopySuccess}
/>
2025-09-08 13:10:09 +09:00
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
"{screenToDelete?.screenName}" ?
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-2">
<Label htmlFor="deleteReason"> ()</Label>
<Textarea
id="deleteReason"
placeholder="삭제 사유를 입력하세요..."
value={deleteReason}
onChange={(e) => setDeleteReason(e.target.value)}
rows={3}
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancelDelete}></AlertDialogCancel>
<AlertDialogAction onClick={() => confirmDelete(false)} variant="destructive">
2025-09-08 13:10:09 +09:00
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 의존성 경고 다이얼로그 */}
<AlertDialog open={showDependencyWarning} onOpenChange={setShowDependencyWarning}>
<AlertDialogContent className="max-w-2xl">
<AlertDialogHeader>
<AlertDialogTitle className="text-orange-600"> </AlertDialogTitle>
<AlertDialogDescription>
"{screenToDelete?.screenName}" .
<br /> .
</AlertDialogDescription>
</AlertDialogHeader>
<div className="max-h-60 overflow-y-auto">
<div className="space-y-3">
2025-10-22 14:52:13 +09:00
<h4 className="font-medium"> :</h4>
2025-09-08 13:10:09 +09:00
{dependencies.map((dep, index) => (
<div key={index} className="rounded-lg border border-orange-200 bg-orange-50 p-3">
<div className="flex items-center justify-between">
<div>
2025-10-22 14:52:13 +09:00
<div className="font-medium">{dep.screenName}</div>
<div className="text-muted-foreground text-sm"> : {dep.screenCode}</div>
2025-09-08 13:10:09 +09:00
</div>
<div className="text-right">
<div className="text-sm font-medium text-orange-600">
{dep.referenceType === "popup" && "팝업 버튼"}
{dep.referenceType === "navigate" && "이동 버튼"}
{dep.referenceType === "url" && "URL 링크"}
{dep.referenceType === "menu_assignment" && "메뉴 할당"}
</div>
2025-10-22 14:52:13 +09:00
<div className="text-muted-foreground text-xs">
2025-09-08 13:10:09 +09:00
{dep.referenceType === "menu_assignment" ? "메뉴" : "컴포넌트"}: {dep.componentId}
</div>
</div>
</div>
</div>
))}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="forceDeleteReason"> ()</Label>
<Textarea
id="forceDeleteReason"
placeholder="강제 삭제 사유를 입력하세요..."
value={deleteReason}
onChange={(e) => setDeleteReason(e.target.value)}
rows={3}
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancelDelete}></AlertDialogCancel>
<AlertDialogAction
onClick={() => confirmDelete(true)}
variant="destructive"
2025-09-08 13:10:09 +09:00
disabled={!deleteReason.trim()}
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 영구 삭제 확인 다이얼로그 */}
<AlertDialog open={permanentDeleteDialogOpen} onOpenChange={setPermanentDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription className="text-destructive">
2025-09-08 13:10:09 +09:00
"{screenToPermanentDelete?.screenName}" ?
<br />
<strong> !</strong>
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => {
setPermanentDeleteDialogOpen(false);
setScreenToPermanentDelete(null);
}}
>
</AlertDialogCancel>
<AlertDialogAction onClick={confirmPermanentDelete} variant="destructive">
2025-09-08 13:10:09 +09:00
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 일괄삭제 확인 다이얼로그 */}
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription className="text-destructive">
2025-09-08 13:10:09 +09:00
{selectedScreenIds.length} ?
<br />
<strong> !</strong>
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => {
setBulkDeleteDialogOpen(false);
}}
disabled={bulkDeleting}
>
</AlertDialogCancel>
<AlertDialogAction onClick={confirmBulkDelete} variant="destructive" disabled={bulkDeleting}>
2025-09-08 13:10:09 +09:00
{bulkDeleting ? "삭제 중..." : `${selectedScreenIds.length}개 영구 삭제`}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 화면 편집 다이얼로그 */}
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="edit-screenName"> *</Label>
<Input
id="edit-screenName"
value={editFormData.screenName}
onChange={(e) => setEditFormData({ ...editFormData, screenName: e.target.value })}
placeholder="화면명을 입력하세요"
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-description"></Label>
<Textarea
id="edit-description"
value={editFormData.description}
onChange={(e) => setEditFormData({ ...editFormData, description: e.target.value })}
placeholder="화면 설명을 입력하세요"
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-isActive"></Label>
<Select
value={editFormData.isActive}
onValueChange={(value) => setEditFormData({ ...editFormData, isActive: value })}
>
<SelectTrigger id="edit-isActive">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Y"></SelectItem>
<SelectItem value="N"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>
</Button>
<Button onClick={handleEditSave} disabled={!editFormData.screenName.trim()}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 화면 미리보기 다이얼로그 */}
<Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
<DialogContent className="h-[95vh] max-w-[95vw]">
<DialogHeader>
<DialogTitle> - {screenToPreview?.screenName}</DialogTitle>
</DialogHeader>
<div className="flex flex-1 items-center justify-center overflow-hidden bg-gradient-to-br from-gray-50 to-slate-100 p-6">
{isLoadingPreview ? (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<div className="mb-2 text-lg font-medium"> ...</div>
2025-10-22 14:52:13 +09:00
<div className="text-muted-foreground text-sm"> .</div>
</div>
</div>
) : previewLayout && previewLayout.components ? (
(() => {
const screenWidth = previewLayout.screenResolution?.width || 1200;
const screenHeight = previewLayout.screenResolution?.height || 800;
// 모달 내부 가용 공간 계산 (헤더, 푸터, 패딩 제외)
const availableWidth = typeof window !== "undefined" ? window.innerWidth * 0.95 - 100 : 1800; // 95vw - 패딩
// 가로폭 기준으로 스케일 계산 (가로폭에 맞춤)
const scale = availableWidth / screenWidth;
return (
<div
2025-10-22 14:52:13 +09:00
className="bg-card relative mx-auto rounded-xl border shadow-lg"
style={{
width: `${screenWidth}px`,
height: `${screenHeight}px`,
transform: `scale(${scale})`,
transformOrigin: "center center",
}}
>
{/* 실제 화면과 동일한 렌더링 */}
{previewLayout.components
.filter((comp: any) => !comp.parentId) // 최상위 컴포넌트만 렌더링
.map((component: any) => {
if (!component || !component.id) return null;
// 그룹 컴포넌트인 경우 특별 처리
if (component.type === "group") {
const groupChildren = previewLayout.components.filter(
(child: any) => child.parentId === component.id,
);
return (
<div
key={component.id}
style={{
position: "absolute",
left: `${component.position?.x || 0}px`,
top: `${component.position?.y || 0}px`,
width: component.style?.width || `${component.size?.width || 200}px`,
height: component.style?.height || `${component.size?.height || 40}px`,
zIndex: component.position?.z || 1,
backgroundColor: component.backgroundColor || "rgba(59, 130, 246, 0.05)",
border: component.border || "1px solid rgba(59, 130, 246, 0.2)",
borderRadius: component.borderRadius || "12px",
padding: "20px",
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)",
}}
>
{/* 그룹 제목 */}
{component.title && (
<div className="mb-3 inline-block rounded-lg bg-blue-50 px-3 py-1 text-sm font-semibold text-blue-700">
{component.title}
</div>
)}
{/* 그룹 내 자식 컴포넌트들 렌더링 */}
{groupChildren.map((child: any) => (
<div
key={child.id}
style={{
position: "absolute",
left: `${child.position.x}px`,
top: `${child.position.y}px`,
width: child.style?.width || `${child.size.width}px`,
height: child.style?.height || `${child.size.height}px`,
zIndex: child.position.z || 1,
}}
>
<InteractiveScreenViewer
component={child}
allComponents={previewLayout.components}
formData={previewFormData}
onFormDataChange={(fieldName, value) => {
setPreviewFormData((prev) => ({
...prev,
[fieldName]: value,
}));
}}
screenInfo={{
id: screenToPreview!.screenId,
tableName: screenToPreview?.tableName,
}}
/>
</div>
))}
</div>
);
}
// 라벨 표시 여부 계산
const templateTypes = ["datatable"];
const shouldShowLabel =
component.style?.labelDisplay !== false &&
(component.label || component.style?.labelText) &&
!templateTypes.includes(component.type);
const labelText = component.style?.labelText || component.label || "";
const labelStyle = {
fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#212121",
fontWeight: component.style?.labelFontWeight || "500",
backgroundColor: component.style?.labelBackgroundColor || "transparent",
};
const labelMarginBottom = component.style?.labelMarginBottom || "4px";
// 일반 컴포넌트 렌더링
return (
<div key={component.id}>
{/* 라벨을 외부에 별도로 렌더링 */}
{shouldShowLabel && (
<div
style={{
position: "absolute",
left: `${component.position.x}px`,
top: `${component.position.y - 25}px`, // 컴포넌트 위쪽에 라벨 배치
zIndex: (component.position.z || 1) + 1,
...labelStyle,
}}
>
{labelText}
{component.required && <span style={{ color: "#f97316", marginLeft: "2px" }}>*</span>}
</div>
)}
{/* 실제 컴포넌트 */}
<div
2025-11-04 11:41:20 +09:00
style={(() => {
const style = {
position: "absolute" as const,
left: `${component.position.x}px`,
top: `${component.position.y}px`,
width: component.style?.width || `${component.size.width}px`,
height: component.style?.height || `${component.size.height}px`,
zIndex: component.position.z || 1,
};
// 버튼 타입일 때 디버깅 (widget 타입 또는 component 타입 모두 체크)
if (
(component.type === "widget" && (component as any).widgetType === "button") ||
(component.type === "component" && (component as any).componentType?.includes("button"))
) {
console.log("🔘 ScreenList 버튼 외부 div 스타일:", {
id: component.id,
label: component.label,
position: component.position,
size: component.size,
componentStyle: component.style,
appliedStyle: style,
});
}
return style;
})()}
>
{/* 위젯 컴포넌트가 아닌 경우 DynamicComponentRenderer 사용 */}
{component.type !== "widget" ? (
<DynamicComponentRenderer
component={{
...component,
style: {
...component.style,
labelDisplay: shouldShowLabel ? false : (component.style?.labelDisplay ?? true), // 상위에서 라벨을 표시했으면 컴포넌트 내부에서는 숨김
},
}}
isInteractive={true}
formData={previewFormData}
onFormDataChange={(fieldName, value) => {
setPreviewFormData((prev) => ({
...prev,
[fieldName]: value,
}));
}}
screenId={screenToPreview!.screenId}
tableName={screenToPreview?.tableName}
/>
) : (
<DynamicWebTypeRenderer
webType={(() => {
// 유틸리티 함수로 파일 컴포넌트 감지
if (isFileComponent(component)) {
return "file";
}
// 다른 컴포넌트는 유틸리티 함수로 webType 결정
return getComponentWebType(component) || "text";
})()}
config={component.webTypeConfig}
props={{
component: component,
value: previewFormData[component.columnName || component.id] || "",
onChange: (value: any) => {
const fieldName = component.columnName || component.id;
setPreviewFormData((prev) => ({
...prev,
[fieldName]: value,
}));
},
onFormDataChange: (fieldName, value) => {
setPreviewFormData((prev) => ({
...prev,
[fieldName]: value,
}));
},
isInteractive: true,
formData: previewFormData,
readonly: component.readonly,
required: component.required,
placeholder: component.placeholder,
className: "w-full h-full",
}}
/>
)}
</div>
</div>
);
})}
</div>
);
})()
) : (
<div className="flex h-full items-center justify-center">
<div className="text-center">
2025-10-22 14:52:13 +09:00
<div className="text-muted-foreground mb-2 text-lg font-medium"> </div>
<div className="text-muted-foreground text-sm"> .</div>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setPreviewDialogOpen(false)}>
</Button>
<Button onClick={() => onDesignScreen(screenToPreview!)}>
<Palette className="mr-2 h-4 w-4" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
2025-09-01 11:48:12 +09:00
</div>
);
}