템플릿관리, 컴포넌트 관리
This commit is contained in:
318
frontend/app/(main)/admin/components/page.tsx
Normal file
318
frontend/app/(main)/admin/components/page.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { Search, Plus, Edit, Trash2, RefreshCw, Package, Filter, Download, Upload } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { AlertModal } from "@/components/common/AlertModal";
|
||||
import {
|
||||
useComponents,
|
||||
useComponentCategories,
|
||||
useComponentStatistics,
|
||||
useDeleteComponent,
|
||||
} from "@/hooks/admin/useComponents";
|
||||
|
||||
// 컴포넌트 카테고리 정의
|
||||
const COMPONENT_CATEGORIES = [
|
||||
{ id: "input", name: "입력", color: "blue" },
|
||||
{ id: "action", name: "액션", color: "green" },
|
||||
{ id: "display", name: "표시", color: "purple" },
|
||||
{ id: "layout", name: "레이아웃", color: "orange" },
|
||||
{ id: "other", name: "기타", color: "gray" },
|
||||
];
|
||||
|
||||
export default function ComponentManagementPage() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>("all");
|
||||
const [sortBy, setSortBy] = useState<string>("sort_order");
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
||||
const [selectedComponent, setSelectedComponent] = useState<any>(null);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
|
||||
// 컴포넌트 데이터 가져오기
|
||||
const {
|
||||
data: componentsData,
|
||||
isLoading: loading,
|
||||
error,
|
||||
refetch,
|
||||
} = useComponents({
|
||||
category: selectedCategory === "all" ? undefined : selectedCategory,
|
||||
active: "Y",
|
||||
search: searchTerm,
|
||||
sort: sortBy,
|
||||
order: sortOrder,
|
||||
});
|
||||
|
||||
// 카테고리와 통계 데이터
|
||||
const { data: categories } = useComponentCategories();
|
||||
const { data: statistics } = useComponentStatistics();
|
||||
|
||||
// 삭제 뮤테이션
|
||||
const deleteComponentMutation = useDeleteComponent();
|
||||
|
||||
// 컴포넌트 목록 (이미 필터링과 정렬이 적용된 상태)
|
||||
const components = componentsData?.components || [];
|
||||
|
||||
// 카테고리별 통계 (백엔드에서 가져온 데이터 사용)
|
||||
const categoryStats = useMemo(() => {
|
||||
if (!statistics?.byCategory) return {};
|
||||
|
||||
const stats: Record<string, number> = {};
|
||||
statistics.byCategory.forEach(({ category, count }) => {
|
||||
stats[category] = count;
|
||||
});
|
||||
|
||||
return stats;
|
||||
}, [statistics]);
|
||||
|
||||
// 카테고리 이름 및 색상 가져오기
|
||||
const getCategoryInfo = (categoryId: string) => {
|
||||
const category = COMPONENT_CATEGORIES.find((cat) => cat.id === categoryId);
|
||||
return category || { id: "other", name: "기타", color: "gray" };
|
||||
};
|
||||
|
||||
// 삭제 처리
|
||||
const handleDelete = async () => {
|
||||
if (!selectedComponent) return;
|
||||
|
||||
try {
|
||||
await deleteComponentMutation.mutateAsync(selectedComponent.component_code);
|
||||
setShowDeleteModal(false);
|
||||
setSelectedComponent(null);
|
||||
} catch (error) {
|
||||
console.error("컴포넌트 삭제 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<RefreshCw className="mx-auto h-8 w-8 animate-spin text-gray-400" />
|
||||
<p className="mt-2 text-sm text-gray-500">컴포넌트 목록을 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Package className="mx-auto h-8 w-8 text-red-400" />
|
||||
<p className="mt-2 text-sm text-red-600">컴포넌트 목록을 불러오는데 실패했습니다.</p>
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()} className="mt-4">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
다시 시도
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col p-6">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">컴포넌트 관리</h1>
|
||||
<p className="text-sm text-gray-500">화면 설계에 사용되는 컴포넌트들을 관리합니다</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
가져오기
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
내보내기
|
||||
</Button>
|
||||
<Button size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />새 컴포넌트
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 통계 */}
|
||||
<div className="mb-6 grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-6">
|
||||
{COMPONENT_CATEGORIES.map((category) => {
|
||||
const count = categoryStats[category.id] || 0;
|
||||
return (
|
||||
<Card
|
||||
key={category.id}
|
||||
className="cursor-pointer hover:shadow-md"
|
||||
onClick={() => setSelectedCategory(category.id)}
|
||||
>
|
||||
<CardContent className="p-4 text-center">
|
||||
<div className={`mb-2 text-2xl font-bold text-${category.color}-600`}>{count}</div>
|
||||
<div className="text-sm text-gray-600">{category.name}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 검색 및 필터 */}
|
||||
<Card className="mb-6">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-col space-y-4 md:flex-row md:items-center md:space-y-0 md:space-x-4">
|
||||
{/* 검색 */}
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Input
|
||||
placeholder="컴포넌트 이름, 타입, 설명으로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 필터 */}
|
||||
<Select value={selectedCategory} onValueChange={setSelectedCategory}>
|
||||
<SelectTrigger className="w-40">
|
||||
<Filter className="mr-2 h-4 w-4" />
|
||||
<SelectValue placeholder="카테고리" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체 카테고리</SelectItem>
|
||||
{COMPONENT_CATEGORIES.map((category) => (
|
||||
<SelectItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 정렬 */}
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue placeholder="정렬" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sort_order">순서</SelectItem>
|
||||
<SelectItem value="type_name">이름</SelectItem>
|
||||
<SelectItem value="web_type">타입</SelectItem>
|
||||
<SelectItem value="category">카테고리</SelectItem>
|
||||
<SelectItem value="updated_date">수정일</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={() => setSortOrder(sortOrder === "asc" ? "desc" : "asc")}>
|
||||
{sortOrder === "asc" ? "↑" : "↓"}
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 컴포넌트 목록 테이블 */}
|
||||
<Card className="flex-1">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>컴포넌트 목록 ({components.length}개)</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>컴포넌트 이름</TableHead>
|
||||
<TableHead>컴포넌트 코드</TableHead>
|
||||
<TableHead>카테고리</TableHead>
|
||||
<TableHead>타입</TableHead>
|
||||
<TableHead>상태</TableHead>
|
||||
<TableHead>수정일</TableHead>
|
||||
<TableHead className="w-24">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{components.map((component) => {
|
||||
const categoryInfo = getCategoryInfo(component.category || "other");
|
||||
|
||||
return (
|
||||
<TableRow key={component.component_code}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{component.component_name}</div>
|
||||
{component.component_name_eng && (
|
||||
<div className="text-xs text-gray-500">{component.component_name_eng}</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="rounded bg-gray-100 px-2 py-1 text-xs">{component.component_code}</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-${categoryInfo.color}-600 border-${categoryInfo.color}-200`}
|
||||
>
|
||||
{categoryInfo.name}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{component.component_config ? (
|
||||
<code className="text-xs text-blue-600">
|
||||
{component.component_config.type || component.component_code}
|
||||
</code>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">없음</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={component.is_active === "Y" ? "default" : "secondary"}>
|
||||
{component.is_active === "Y" ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-gray-500">
|
||||
{component.updated_date ? new Date(component.updated_date).toLocaleDateString() : "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Button variant="ghost" size="sm">
|
||||
<Edit className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedComponent(component);
|
||||
setShowDeleteModal(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 삭제 확인 모달 */}
|
||||
<AlertModal
|
||||
isOpen={showDeleteModal}
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
onConfirm={handleDelete}
|
||||
type="warning"
|
||||
title="컴포넌트 삭제"
|
||||
message={`정말로 "${selectedComponent?.component_name}" 컴포넌트를 삭제하시겠습니까?`}
|
||||
confirmText="삭제"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Users, Shield, Settings, BarChart3, Palette } from "lucide-react";
|
||||
import { Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
/**
|
||||
* 관리자 메인 페이지
|
||||
@@ -7,7 +7,7 @@ export default function AdminPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 관리자 기능 카드들 */}
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-5">
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Link href="/admin/userMng" className="block">
|
||||
<div className="rounded-lg border bg-white p-6 shadow-sm transition-colors hover:bg-gray-50">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -73,6 +73,68 @@ export default function AdminPage() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 표준 관리 섹션 */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900">표준 관리</h2>
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Link href="/admin/standards" className="block">
|
||||
<div className="rounded-lg border bg-white p-6 shadow-sm transition-colors hover:bg-gray-50">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-teal-50">
|
||||
<Database className="h-6 w-6 text-teal-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">웹타입 관리</h3>
|
||||
<p className="text-sm text-gray-600">입력 컴포넌트 웹타입 표준 관리</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link href="/admin/templates" className="block">
|
||||
<div className="rounded-lg border bg-white p-6 shadow-sm transition-colors hover:bg-gray-50">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-emerald-50">
|
||||
<Layout className="h-6 w-6 text-emerald-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">템플릿 관리</h3>
|
||||
<p className="text-sm text-gray-600">화면 디자이너 템플릿 표준 관리</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link href="/admin/tableMng" className="block">
|
||||
<div className="rounded-lg border bg-white p-6 shadow-sm transition-colors hover:bg-gray-50">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-cyan-50">
|
||||
<Database className="h-6 w-6 text-cyan-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">테이블 관리</h3>
|
||||
<p className="text-sm text-gray-600">데이터베이스 테이블 및 웹타입 매핑</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link href="/admin/components" className="block">
|
||||
<div className="rounded-lg border bg-white p-6 shadow-sm transition-colors hover:bg-gray-50">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-violet-50">
|
||||
<Package className="h-6 w-6 text-violet-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">컴포넌트 관리</h3>
|
||||
<p className="text-sm text-gray-600">화면 디자이너 컴포넌트 표준 관리</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 최근 활동 */}
|
||||
<div className="rounded-lg border bg-white p-6 shadow-sm">
|
||||
<h3 className="mb-4 text-lg font-semibold">최근 관리자 활동</h3>
|
||||
|
||||
395
frontend/app/(main)/admin/templates/page.tsx
Normal file
395
frontend/app/(main)/admin/templates/page.tsx
Normal file
@@ -0,0 +1,395 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Search, Plus, Edit2, Trash2, Eye, Copy, Download, Upload, ArrowUpDown, Filter, RefreshCw } from "lucide-react";
|
||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||
import { toast } from "sonner";
|
||||
import { useTemplates, TemplateStandard } from "@/hooks/admin/useTemplates";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function TemplatesManagePage() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>("all");
|
||||
const [activeFilter, setActiveFilter] = useState<string>("Y");
|
||||
const [sortField, setSortField] = useState<string>("sort_order");
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||
|
||||
// 템플릿 데이터 조회
|
||||
const { templates, categories, isLoading, error, deleteTemplate, isDeleting, deleteError, refetch, exportTemplate } =
|
||||
useTemplates({
|
||||
active: activeFilter === "all" ? undefined : activeFilter,
|
||||
search: searchTerm || undefined,
|
||||
category: categoryFilter === "all" ? undefined : categoryFilter,
|
||||
});
|
||||
|
||||
// 필터링 및 정렬된 데이터
|
||||
const filteredAndSortedTemplates = useMemo(() => {
|
||||
let filtered = [...templates];
|
||||
|
||||
// 정렬
|
||||
filtered.sort((a, b) => {
|
||||
let aValue: any = a[sortField as keyof typeof a];
|
||||
let bValue: any = b[sortField as keyof typeof b];
|
||||
|
||||
// 숫자 필드 처리
|
||||
if (sortField === "sort_order") {
|
||||
aValue = aValue || 0;
|
||||
bValue = bValue || 0;
|
||||
}
|
||||
|
||||
// 문자열 필드 처리
|
||||
if (typeof aValue === "string") {
|
||||
aValue = aValue.toLowerCase();
|
||||
}
|
||||
if (typeof bValue === "string") {
|
||||
bValue = bValue.toLowerCase();
|
||||
}
|
||||
|
||||
if (aValue < bValue) return sortDirection === "asc" ? -1 : 1;
|
||||
if (aValue > bValue) return sortDirection === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [templates, sortField, sortDirection]);
|
||||
|
||||
// 정렬 변경 핸들러
|
||||
const handleSort = (field: string) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection("asc");
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제 핸들러
|
||||
const handleDelete = async (templateCode: string, templateName: string) => {
|
||||
try {
|
||||
await deleteTemplate(templateCode);
|
||||
toast.success(`템플릿 '${templateName}'이 삭제되었습니다.`);
|
||||
} catch (error) {
|
||||
toast.error(`템플릿 삭제 중 오류가 발생했습니다: ${deleteError?.message || error}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 내보내기 핸들러
|
||||
const handleExport = async (templateCode: string, templateName: string) => {
|
||||
try {
|
||||
const templateData = await exportTemplate(templateCode);
|
||||
|
||||
// JSON 파일로 다운로드
|
||||
const blob = new Blob([JSON.stringify(templateData, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `template-${templateCode}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success(`템플릿 '${templateName}'이 내보내기되었습니다.`);
|
||||
} catch (error: any) {
|
||||
toast.error(`템플릿 내보내기 중 오류가 발생했습니다: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 아이콘 렌더링 함수
|
||||
const renderIcon = (iconName?: string) => {
|
||||
if (!iconName) return null;
|
||||
|
||||
// 간단한 아이콘 매핑 (실제로는 더 복잡한 시스템 필요)
|
||||
const iconMap: Record<string, JSX.Element> = {
|
||||
table: <div className="h-4 w-4 border border-gray-400" />,
|
||||
"mouse-pointer": <div className="h-4 w-4 rounded bg-blue-500" />,
|
||||
upload: <div className="h-4 w-4 border-2 border-dashed border-gray-400" />,
|
||||
};
|
||||
|
||||
return iconMap[iconName] || <div className="h-4 w-4 rounded bg-gray-300" />;
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-8">
|
||||
<p className="mb-4 text-red-600">템플릿 목록을 불러오는 중 오류가 발생했습니다.</p>
|
||||
<Button onClick={() => refetch()} variant="outline">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
다시 시도
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto space-y-6 p-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">템플릿 관리</h1>
|
||||
<p className="text-muted-foreground">화면 디자이너에서 사용할 템플릿을 관리합니다.</p>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button asChild>
|
||||
<Link href="/admin/templates/new">
|
||||
<Plus className="mr-2 h-4 w-4" />새 템플릿
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필터 및 검색 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Filter className="mr-2 h-5 w-5" />
|
||||
필터 및 검색
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{/* 검색 */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">검색</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Input
|
||||
placeholder="템플릿명, 설명 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 필터 */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">카테고리</label>
|
||||
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category} value={category}>
|
||||
{category}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 활성화 상태 필터 */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">활성화 상태</label>
|
||||
<Select value={activeFilter} onValueChange={setActiveFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
<SelectItem value="Y">활성화</SelectItem>
|
||||
<SelectItem value="N">비활성화</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 새로고침 버튼 */}
|
||||
<div className="flex items-end">
|
||||
<Button onClick={() => refetch()} variant="outline" className="w-full">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 템플릿 목록 테이블 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>템플릿 목록 ({filteredAndSortedTemplates.length}개)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[60px]">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleSort("sort_order")}
|
||||
className="h-8 p-0 font-medium"
|
||||
>
|
||||
순서
|
||||
<ArrowUpDown className="ml-1 h-3 w-3" />
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleSort("template_code")}
|
||||
className="h-8 p-0 font-medium"
|
||||
>
|
||||
템플릿 코드
|
||||
<ArrowUpDown className="ml-1 h-3 w-3" />
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleSort("template_name")}
|
||||
className="h-8 p-0 font-medium"
|
||||
>
|
||||
템플릿명
|
||||
<ArrowUpDown className="ml-1 h-3 w-3" />
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>카테고리</TableHead>
|
||||
<TableHead>설명</TableHead>
|
||||
<TableHead>아이콘</TableHead>
|
||||
<TableHead>기본 크기</TableHead>
|
||||
<TableHead>공개 여부</TableHead>
|
||||
<TableHead>활성화</TableHead>
|
||||
<TableHead>수정일</TableHead>
|
||||
<TableHead className="w-[200px]">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="py-8 text-center">
|
||||
<LoadingSpinner />
|
||||
<span className="ml-2">템플릿 목록을 불러오는 중...</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredAndSortedTemplates.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="py-8 text-center text-gray-500">
|
||||
검색 조건에 맞는 템플릿이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredAndSortedTemplates.map((template) => (
|
||||
<TableRow key={template.template_code}>
|
||||
<TableCell className="font-mono">{template.sort_order || 0}</TableCell>
|
||||
<TableCell className="font-mono">{template.template_code}</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{template.template_name}
|
||||
{template.template_name_eng && (
|
||||
<div className="text-muted-foreground text-xs">{template.template_name_eng}</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{template.category}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs truncate">{template.description || "-"}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-center">{renderIcon(template.icon_name)}</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{template.default_size ? `${template.default_size.width}×${template.default_size.height}` : "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={template.is_public === "Y" ? "default" : "secondary"}>
|
||||
{template.is_public === "Y" ? "공개" : "비공개"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={template.is_active === "Y" ? "default" : "secondary"}>
|
||||
{template.is_active === "Y" ? "활성화" : "비활성화"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{template.updated_date ? new Date(template.updated_date).toLocaleDateString("ko-KR") : "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex space-x-1">
|
||||
<Button asChild size="sm" variant="ghost">
|
||||
<Link href={`/admin/templates/${template.template_code}`}>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm" variant="ghost">
|
||||
<Link href={`/admin/templates/${template.template_code}/edit`}>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleExport(template.template_code, template.template_name)}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button asChild size="sm" variant="ghost">
|
||||
<Link href={`/admin/templates/${template.template_code}/duplicate`}>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="ghost" className="text-red-600 hover:text-red-700">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>템플릿 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
템플릿 '{template.template_name}'을 정말 삭제하시겠습니까?
|
||||
<br />이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleDelete(template.template_code, template.template_name)}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user