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

753 lines
29 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 { 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";
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);
// 화면 목록 로드 (실제 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) => {
// 편집 모달 열기
console.log("편집:", screen);
};
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 = (screen: ScreenDefinition) => {
// 미리보기 모달 열기
console.log("미리보기:", screen);
};
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
className="bg-blue-600 hover:bg-blue-700"
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-blue-200 bg-blue-50" : ""
}`}
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="font-mono text-sm text-gray-600">{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-sm text-gray-600">{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-red-600"
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="font-mono text-sm text-gray-600">{screen.tableName}</span>
</TableCell>
<TableCell>
<div className="text-sm text-gray-600">{screen.deletedDate?.toLocaleDateString()}</div>
</TableCell>
<TableCell>
<div className="text-sm text-gray-600">{screen.deletedBy}</div>
</TableCell>
<TableCell>
<div className="max-w-32 truncate text-sm text-gray-600" 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-red-600 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-sm text-gray-600">
{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)} className="bg-red-600 hover:bg-red-700">
</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-sm text-gray-600"> : {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)}
className="bg-orange-600 hover:bg-orange-700"
disabled={!deleteReason.trim()}
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 영구 삭제 확인 다이얼로그 */}
<AlertDialog open={permanentDeleteDialogOpen} onOpenChange={setPermanentDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription className="text-red-600">
"{screenToPermanentDelete?.screenName}" ?
<br />
<strong> !</strong>
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => {
setPermanentDeleteDialogOpen(false);
setScreenToPermanentDelete(null);
}}
>
</AlertDialogCancel>
<AlertDialogAction onClick={confirmPermanentDelete} className="bg-red-600 hover:bg-red-700">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 일괄삭제 확인 다이얼로그 */}
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription className="text-red-600">
{selectedScreenIds.length} ?
<br />
<strong> !</strong>
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => {
setBulkDeleteDialogOpen(false);
}}
disabled={bulkDeleting}
>
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmBulkDelete}
className="bg-red-600 hover:bg-red-700"
disabled={bulkDeleting}
>
{bulkDeleting ? "삭제 중..." : `${selectedScreenIds.length}개 영구 삭제`}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}