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

1368 lines
59 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Checkbox } from "@/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette, RotateCcw, Trash } from "lucide-react";
import { ScreenDefinition } from "@/types/screen";
import { screenApi } from "@/lib/api/screen";
import CreateScreenModal from "./CreateScreenModal";
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>,
},
);
interface ScreenListProps {
onScreenSelect: (screen: ScreenDefinition) => void;
selectedScreen: ScreenDefinition | null;
onDesignScreen: (screen: ScreenDefinition) => void;
}
type DeletedScreenDefinition = ScreenDefinition & {
deletedDate?: Date;
deletedBy?: string;
deleteReason?: string;
};
export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScreen }: ScreenListProps) {
const [activeTab, setActiveTab] = useState("active");
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
const [deletedScreens, setDeletedScreens] = useState<DeletedScreenDefinition[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [isCopyOpen, setIsCopyOpen] = useState(false);
const [screenToCopy, setScreenToCopy] = useState<ScreenDefinition | null>(null);
// 삭제 관련 상태
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>>({});
// 화면 목록 로드 (실제 API)
useEffect(() => {
let abort = false;
const load = async () => {
try {
setLoading(true);
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)));
}
} catch (e) {
// console.error("화면 목록 조회 실패", e);
if (activeTab === "active") {
setScreens([]);
} else {
setDeletedScreens([]);
}
setTotalPages(1);
} finally {
if (!abort) setLoading(false);
}
};
load();
return () => {
abort = true;
};
}, [currentPage, searchTerm, activeTab]);
const filteredScreens = screens; // 서버 필터 기준 사용
// 화면 목록 다시 로드
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);
} finally {
setLoading(false);
}
};
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("화면 정보 업데이트에 실패했습니다.");
}
};
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);
// 의존성 체크 실패 시에도 삭제 다이얼로그는 열어줌
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);
// 의존성 오류인 경우 경고창 표시
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);
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);
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);
alert("일괄 삭제에 실패했습니다.");
} finally {
setBulkDeleting(false);
}
};
const handleCopy = (screen: ScreenDefinition) => {
setScreenToCopy(screen);
setIsCopyOpen(true);
};
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 딜레이로 모달 애니메이션이 먼저 시작되도록
};
const handleCopySuccess = () => {
// 복사 성공 후 화면 목록 다시 로드
reloadScreens();
};
if (loading) {
return (
<div className="flex items-center justify-center py-8">
<div className="text-muted-foreground text-sm"> ...</div>
</div>
);
}
return (
<div className="space-y-4">
{/* 검색 및 필터 */}
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="w-full sm:w-[400px]">
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="화면명, 코드, 테이블명으로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-10 pl-10 text-sm"
disabled={activeTab === "trash"}
/>
</div>
</div>
<Button
onClick={() => setIsCreateOpen(true)}
disabled={activeTab === "trash"}
className="h-10 gap-2 text-sm font-medium"
>
<Plus className="h-4 w-4" />
</Button>
</div>
{/* 탭 구조 */}
<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">
{/* 데스크톱 테이블 뷰 (lg 이상) */}
<div className="bg-card hidden rounded-lg border shadow-sm lg:block">
<div className="border-b p-6">
<h3 className="text-lg font-semibold"> ({screens.length})</h3>
</div>
<Table>
<TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"> </TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{screens.map((screen) => (
<TableRow
key={screen.screenId}
className={`hover:bg-muted/50 border-b transition-colors ${
selectedScreen?.screenId === screen.screenId ? "border-primary/20 bg-accent" : ""
}`}
onClick={() => handleScreenSelect(screen)}
>
<TableCell className="h-16 cursor-pointer">
<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">
<Badge variant="outline" className="font-mono">
{screen.screenCode}
</Badge>
</TableCell>
<TableCell className="h-16">
<span className="text-muted-foreground font-mono text-sm">
{screen.tableLabel || screen.tableName}
</span>
</TableCell>
<TableCell className="h-16">
<Badge variant={screen.isActive === "Y" ? "default" : "secondary"}>
{screen.isActive === "Y" ? "활성" : "비활성"}
</Badge>
</TableCell>
<TableCell className="h-16">
<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">
<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>
</TableRow>
))}
</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>
</TabsContent>
{/* 휴지통 탭 */}
<TabsContent value="trash">
{/* 데스크톱 테이블 뷰 (lg 이상) */}
<div className="bg-card hidden rounded-lg border shadow-sm lg:block">
<div className="flex items-center justify-between border-b p-6">
<h3 className="text-lg font-semibold"> ({deletedScreens.length})</h3>
{selectedScreenIds.length > 0 && (
<Button
variant="destructive"
size="sm"
onClick={handleBulkDelete}
disabled={bulkDeleting}
className="h-9 gap-2 text-sm font-medium"
>
<Trash className="h-4 w-4" />
{bulkDeleting ? "삭제 중..." : `선택된 ${selectedScreenIds.length}개 영구삭제`}
</Button>
)}
</div>
<Table>
<TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
<TableHead className="h-12 w-12">
<Checkbox
checked={deletedScreens.length > 0 && selectedScreenIds.length === deletedScreens.length}
onCheckedChange={handleSelectAll}
aria-label="전체 선택"
/>
</TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"> </TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"> </TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{deletedScreens.map((screen) => (
<TableRow key={screen.screenId} className="hover:bg-muted/50 border-b transition-colors">
<TableCell className="h-16">
<Checkbox
checked={selectedScreenIds.includes(screen.screenId)}
onCheckedChange={(checked) => handleScreenCheck(screen.screenId, checked as boolean)}
aria-label={`${screen.screenName} 선택`}
/>
</TableCell>
<TableCell className="h-16">
<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">
<Badge variant="outline" className="font-mono">
{screen.screenCode}
</Badge>
</TableCell>
<TableCell className="h-16">
<span className="text-muted-foreground font-mono text-sm">
{screen.tableLabel || screen.tableName}
</span>
</TableCell>
<TableCell className="h-16">
<div className="text-muted-foreground text-sm">{screen.deletedDate?.toLocaleDateString()}</div>
</TableCell>
<TableCell className="h-16">
<div className="text-muted-foreground text-sm">{screen.deletedBy}</div>
</TableCell>
<TableCell className="h-16">
<div className="text-muted-foreground max-w-32 truncate text-sm" title={screen.deleteReason}>
{screen.deleteReason || "-"}
</div>
</TableCell>
<TableCell className="h-16">
<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>
</TableRow>
))}
</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="전체 선택"
/>
<h3 className="text-base font-semibold"> ({deletedScreens.length})</h3>
</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>
)}
</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>
</TabsContent>
</Tabs>
{/* 페이지네이션 */}
{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">
{currentPage} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
>
</Button>
</div>
)}
{/* 새 화면 생성 모달 */}
<CreateScreenModal
open={isCreateOpen}
onOpenChange={setIsCreateOpen}
onCreated={(created) => {
// 목록에 즉시 반영 (첫 페이지 기준 상단 추가)
setScreens((prev) => [created, ...prev]);
}}
/>
{/* 화면 복사 모달 */}
<CopyScreenModal
isOpen={isCopyOpen}
onClose={() => setIsCopyOpen(false)}
sourceScreen={screenToCopy}
onCopySuccess={handleCopySuccess}
/>
{/* 삭제 확인 다이얼로그 */}
<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">
</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">
<h4 className="font-medium"> :</h4>
{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>
<div className="font-medium">{dep.screenName}</div>
<div className="text-muted-foreground text-sm"> : {dep.screenCode}</div>
</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>
<div className="text-muted-foreground text-xs">
{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"
disabled={!deleteReason.trim()}
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 영구 삭제 확인 다이얼로그 */}
<AlertDialog open={permanentDeleteDialogOpen} onOpenChange={setPermanentDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription className="text-destructive">
"{screenToPermanentDelete?.screenName}" ?
<br />
<strong> !</strong>
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => {
setPermanentDeleteDialogOpen(false);
setScreenToPermanentDelete(null);
}}
>
</AlertDialogCancel>
<AlertDialogAction onClick={confirmPermanentDelete} variant="destructive">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 일괄삭제 확인 다이얼로그 */}
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription className="text-destructive">
{selectedScreenIds.length} ?
<br />
<strong> !</strong>
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => {
setBulkDeleteDialogOpen(false);
}}
disabled={bulkDeleting}
>
</AlertDialogCancel>
<AlertDialogAction onClick={confirmBulkDelete} variant="destructive" disabled={bulkDeleting}>
{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>
<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
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
style={{
position: "absolute",
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,
}}
>
{/* 위젯 컴포넌트가 아닌 경우 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">
<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>
</div>
);
}