1119 lines
47 KiB
TypeScript
1119 lines
47 KiB
TypeScript
"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);
|
||
setPreviewDialogOpen(true);
|
||
setIsLoadingPreview(true);
|
||
|
||
try {
|
||
// 화면 레이아웃 로드
|
||
const layoutData = await screenApi.getLayout(screen.screenId);
|
||
console.log("📊 미리보기 레이아웃 로드:", layoutData);
|
||
setPreviewLayout(layoutData);
|
||
} catch (error) {
|
||
console.error("❌ 레이아웃 로드 실패:", error);
|
||
toast.error("화면 레이아웃을 불러오는데 실패했습니다.");
|
||
} finally {
|
||
setIsLoadingPreview(false);
|
||
}
|
||
};
|
||
|
||
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="data-[state=open]:animate-in data-[state=open]:fade-in data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:zoom-out-95 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>
|
||
);
|
||
}
|