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

1123 lines
48 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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
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-gray-500"> ...</div>
</div>
);
}
return (
<div className="space-y-4">
{/* 검색 및 필터 */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className="relative">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
<Input
placeholder="화면명, 코드, 테이블명으로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-80 pl-10"
disabled={activeTab === "trash"}
/>
</div>
</div>
<Button variant="default" onClick={() => setIsCreateOpen(true)} disabled={activeTab === "trash"}>
<Plus className="mr-2 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">
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span> ({screens.length})</span>
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead> </TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{screens.map((screen) => (
<TableRow
key={screen.screenId}
className={`cursor-pointer hover:bg-gray-50 ${
selectedScreen?.screenId === screen.screenId ? "border-primary/20 bg-accent" : ""
}`}
onClick={() => handleScreenSelect(screen)}
>
<TableCell>
<div>
<div className="font-medium text-gray-900">{screen.screenName}</div>
{screen.description && <div className="mt-1 text-sm text-gray-500">{screen.description}</div>}
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="font-mono">
{screen.screenCode}
</Badge>
</TableCell>
<TableCell>
<span className="text-muted-foreground font-mono text-sm">
{screen.tableLabel || screen.tableName}
</span>
</TableCell>
<TableCell>
<Badge
variant={screen.isActive === "Y" ? "default" : "secondary"}
className={
screen.isActive === "Y" ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"
}
>
{screen.isActive === "Y" ? "활성" : "비활성"}
</Badge>
</TableCell>
<TableCell>
<div className="text-muted-foreground text-sm">{screen.createdDate.toLocaleDateString()}</div>
<div className="text-xs text-gray-400">{screen.createdBy}</div>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onDesignScreen(screen)}>
<Palette className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleView(screen)}>
<Eye className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleEdit(screen)}>
<Edit className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCopy(screen)}>
<Copy className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => 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="py-8 text-center text-gray-500"> .</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* 휴지통 탭 */}
<TabsContent value="trash">
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span> ({deletedScreens.length})</span>
{selectedScreenIds.length > 0 && (
<Button variant="destructive" size="sm" onClick={handleBulkDelete} disabled={bulkDeleting}>
{bulkDeleting ? "삭제 중..." : `선택된 ${selectedScreenIds.length}개 영구삭제`}
</Button>
)}
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<Checkbox
checked={deletedScreens.length > 0 && selectedScreenIds.length === deletedScreens.length}
onCheckedChange={handleSelectAll}
/>
</TableHead>
<TableHead></TableHead>
<TableHead> </TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead> </TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{deletedScreens.map((screen) => (
<TableRow key={screen.screenId} className="hover:bg-gray-50">
<TableCell>
<Checkbox
checked={selectedScreenIds.includes(screen.screenId)}
onCheckedChange={(checked) => handleScreenCheck(screen.screenId, checked as boolean)}
/>
</TableCell>
<TableCell>
<div>
<div className="font-medium text-gray-900">{screen.screenName}</div>
{screen.description && <div className="mt-1 text-sm text-gray-500">{screen.description}</div>}
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="font-mono">
{screen.screenCode}
</Badge>
</TableCell>
<TableCell>
<span className="text-muted-foreground font-mono text-sm">
{screen.tableLabel || screen.tableName}
</span>
</TableCell>
<TableCell>
<div className="text-muted-foreground text-sm">{screen.deletedDate?.toLocaleDateString()}</div>
</TableCell>
<TableCell>
<div className="text-muted-foreground text-sm">{screen.deletedBy}</div>
</TableCell>
<TableCell>
<div className="text-muted-foreground max-w-32 truncate text-sm" title={screen.deleteReason}>
{screen.deleteReason || "-"}
</div>
</TableCell>
<TableCell>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => handleRestore(screen)}
className="text-green-600 hover:text-green-700"
>
<RotateCcw className="mr-1 h-3 w-3" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handlePermanentDelete(screen)}
className="text-destructive hover:text-red-700"
>
<Trash className="mr-1 h-3 w-3" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{deletedScreens.length === 0 && (
<div className="py-8 text-center text-gray-500"> .</div>
)}
</CardContent>
</Card>
</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 text-gray-900"> :</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 text-gray-900">{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-xs text-gray-500">
{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-sm text-gray-500"> .</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 availableHeight = typeof window !== "undefined" ? window.innerHeight * 0.95 - 200 : 800; // 95vh - 헤더/푸터
// 축소 비율 계산 (가로, 세로 중 더 작은 비율 사용)
const scaleX = availableWidth / screenWidth;
const scaleY = availableHeight / screenHeight;
const scale = Math.min(scaleX, scaleY, 1); // 최대 1 (확대하지 않음)
return (
<div
className="relative mx-auto rounded-xl border border-gray-200/60 bg-white shadow-lg shadow-gray-900/5"
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="mb-2 text-lg font-medium text-gray-600"> </div>
<div className="text-sm text-gray-500"> .</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>
);
}