ERP-node/frontend/app/(main)/admin/layouts/page.tsx

420 lines
14 KiB
TypeScript
Raw Normal View History

2025-09-10 18:36:28 +09:00
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { LayoutFormModal } from "@/components/admin/LayoutFormModal";
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 {
Plus,
Search,
MoreHorizontal,
Edit,
Copy,
Trash2,
Eye,
Grid,
Layout,
LayoutDashboard,
Table,
Navigation,
FileText,
Building,
} from "lucide-react";
import { LayoutStandard, LAYOUT_CATEGORIES, LayoutCategory } from "@/types/layout";
import { layoutApi } from "@/lib/api/layout";
import { toast } from "sonner";
// 코드 레벨 레이아웃 타입
interface CodeLayout {
id: string;
name: string;
nameEng?: string;
description?: string;
category: string;
type: "code";
isActive: boolean;
tags: string[];
metadata?: any;
zones: number;
}
// 카테고리 아이콘 매핑
const CATEGORY_ICONS = {
basic: Grid,
form: FileText,
table: Table,
dashboard: LayoutDashboard,
navigation: Navigation,
content: Layout,
business: Building,
};
// 카테고리 이름 매핑
const CATEGORY_NAMES = {
basic: "기본",
form: "폼",
table: "테이블",
dashboard: "대시보드",
navigation: "네비게이션",
content: "컨텐츠",
business: "업무용",
};
export default function LayoutManagementPage() {
const [layouts, setLayouts] = useState<LayoutStandard[]>([]);
const [codeLayouts, setCodeLayouts] = useState<CodeLayout[]>([]);
const [loading, setLoading] = useState(true);
const [codeLoading, setCodeLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [selectedCategory, setSelectedCategory] = useState<string>("all");
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [total, setTotal] = useState(0);
const [activeTab, setActiveTab] = useState("db");
// 모달 상태
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [layoutToDelete, setLayoutToDelete] = useState<LayoutStandard | null>(null);
const [createModalOpen, setCreateModalOpen] = useState(false);
// 카테고리별 개수
const [categoryCounts, setCategoryCounts] = useState<Record<string, number>>({});
// 레이아웃 목록 로드
const loadLayouts = async () => {
try {
setLoading(true);
const params = {
page: currentPage,
size: 20,
searchTerm: searchTerm || undefined,
category: selectedCategory !== "all" ? (selectedCategory as LayoutCategory) : undefined,
};
const response = await layoutApi.getLayouts(params);
setLayouts(response.data);
setTotalPages(response.totalPages);
setTotal(response.total);
} catch (error) {
console.error("레이아웃 목록 조회 실패:", error);
toast.error("레이아웃 목록을 불러오는데 실패했습니다.");
setLayouts([]);
} finally {
setLoading(false);
}
};
// 카테고리별 개수 로드
const loadCategoryCounts = async () => {
try {
const counts = await layoutApi.getLayoutCountsByCategory();
setCategoryCounts(counts);
} catch (error) {
console.error("카테고리 개수 조회 실패:", error);
}
};
// 코드 레벨 레이아웃 로드
const loadCodeLayouts = async () => {
try {
setCodeLoading(true);
const response = await fetch("/api/admin/layouts/list");
const result = await response.json();
if (result.success) {
setCodeLayouts(result.data.codeLayouts);
} else {
toast.error("코드 레이아웃 목록을 불러오는데 실패했습니다.");
setCodeLayouts([]);
}
} catch (error) {
console.error("코드 레이아웃 조회 실패:", error);
toast.error("코드 레이아웃 목록을 불러오는데 실패했습니다.");
setCodeLayouts([]);
} finally {
setCodeLoading(false);
}
};
useEffect(() => {
loadLayouts();
}, [currentPage, selectedCategory]);
useEffect(() => {
loadCategoryCounts();
loadCodeLayouts();
}, []);
// 검색
const handleSearch = () => {
setCurrentPage(1);
loadLayouts();
};
// 엔터키 검색
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handleSearch();
}
};
// 레이아웃 삭제
const handleDelete = async (layout: LayoutStandard) => {
setLayoutToDelete(layout);
setDeleteDialogOpen(true);
};
const confirmDelete = async () => {
if (!layoutToDelete) return;
try {
await layoutApi.deleteLayout(layoutToDelete.layoutCode);
toast.success("레이아웃이 삭제되었습니다.");
loadLayouts();
loadCategoryCounts();
} catch (error) {
console.error("레이아웃 삭제 실패:", error);
toast.error("레이아웃 삭제에 실패했습니다.");
} finally {
setDeleteDialogOpen(false);
setLayoutToDelete(null);
}
};
// 레이아웃 복제
const handleDuplicate = async (layout: LayoutStandard) => {
try {
const newName = `${layout.layoutName} (복사)`;
await layoutApi.duplicateLayout(layout.layoutCode, { newName });
toast.success("레이아웃이 복제되었습니다.");
loadLayouts();
loadCategoryCounts();
} catch (error) {
console.error("레이아웃 복제 실패:", error);
toast.error("레이아웃 복제에 실패했습니다.");
}
};
// 페이지네이션
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
return (
<div className="min-h-screen bg-gray-50">
<div className="container mx-auto p-6 space-y-6">
{/* 페이지 제목 */}
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
<div>
<h1 className="text-3xl font-bold text-gray-900"> </h1>
<p className="mt-2 text-gray-600"> </p>
</div>
<Button className="flex items-center gap-2 shadow-sm" onClick={() => setCreateModalOpen(true)}>
<Plus className="h-4 w-4" />
</Button>
</div>
2025-09-10 18:36:28 +09:00
{/* 검색 및 필터 */}
<Card className="mb-6 shadow-sm">
2025-09-10 18:36:28 +09:00
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="flex-1">
<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)}
onKeyPress={handleKeyPress}
className="pl-10"
/>
</div>
</div>
<Button onClick={handleSearch}></Button>
</div>
</CardContent>
</Card>
{/* 카테고리 탭 */}
<Tabs value={selectedCategory} onValueChange={setSelectedCategory} className="mb-6">
<TabsList className="grid w-full grid-cols-8">
<TabsTrigger value="all" className="flex items-center gap-2">
({total})
</TabsTrigger>
{Object.entries(LAYOUT_CATEGORIES).map(([key, value]) => {
const Icon = CATEGORY_ICONS[value as keyof typeof CATEGORY_ICONS];
const count = categoryCounts[value] || 0;
return (
<TabsTrigger key={key} value={value} className="flex items-center gap-2">
<Icon className="h-4 w-4" />
{CATEGORY_NAMES[value as keyof typeof CATEGORY_NAMES]} ({count})
</TabsTrigger>
);
})}
</TabsList>
<div className="mt-6">
{loading ? (
<div className="py-8 text-center"> ...</div>
) : layouts.length === 0 ? (
<div className="py-8 text-center text-gray-500"> .</div>
) : (
<>
{/* 레이아웃 그리드 */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{layouts.map((layout) => {
const CategoryIcon = CATEGORY_ICONS[layout.category as keyof typeof CATEGORY_ICONS];
return (
<Card key={layout.layoutCode} className="shadow-sm transition-shadow hover:shadow-lg">
2025-09-10 18:36:28 +09:00
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<CategoryIcon className="h-5 w-5 text-gray-600" />
<Badge variant="secondary">
{CATEGORY_NAMES[layout.category as keyof typeof CATEGORY_NAMES]}
</Badge>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<Eye className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem>
<Edit className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDuplicate(layout)}>
<Copy className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDelete(layout)} className="text-red-600">
<Trash2 className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<CardTitle className="text-lg">{layout.layoutName}</CardTitle>
{layout.description && (
<p className="line-clamp-2 text-sm text-gray-600">{layout.description}</p>
)}
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-500">:</span>
<Badge variant="outline">{layout.layoutType}</Badge>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-gray-500"> :</span>
<span>{layout.zonesConfig.length}</span>
</div>
{layout.isPublic === "Y" && (
<Badge variant="default" className="w-full justify-center">
</Badge>
)}
</div>
</CardContent>
</Card>
);
})}
</div>
{/* 페이지네이션 */}
{totalPages > 1 && (
<div className="mt-8 flex justify-center">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
>
</Button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<Button
key={page}
variant={currentPage === page ? "default" : "outline"}
size="sm"
onClick={() => handlePageChange(page)}
>
{page}
</Button>
))}
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
>
</Button>
</div>
</div>
)}
</>
)}
</div>
</Tabs>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
"{layoutToDelete?.layoutName}" ? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete} className="bg-red-600 hover:bg-red-700">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 새 레이아웃 생성 모달 */}
<LayoutFormModal
open={createModalOpen}
onOpenChange={setCreateModalOpen}
onSuccess={() => {
loadLayouts();
loadCategoryCounts();
}}
/>
</div>
2025-09-10 18:36:28 +09:00
</div>
);
}