398 lines
17 KiB
TypeScript
398 lines
17 KiB
TypeScript
"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="w-full max-w-none px-4 py-8">
|
||
<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="min-h-screen bg-gray-50">
|
||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||
{/* 페이지 제목 */}
|
||
<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>
|
||
<div className="flex space-x-2">
|
||
<Button asChild className="shadow-sm">
|
||
<Link href="/admin/templates/new">
|
||
<Plus className="mr-2 h-4 w-4" />새 템플릿
|
||
</Link>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 필터 및 검색 */}
|
||
<Card className="shadow-sm">
|
||
<CardHeader className="bg-gray-50/50">
|
||
<CardTitle className="flex items-center">
|
||
<Filter className="mr-2 h-5 w-5 text-gray-600" />
|
||
필터 및 검색
|
||
</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 className="shadow-sm">
|
||
<CardHeader className="bg-gray-50/50">
|
||
<CardTitle>템플릿 목록 ({filteredAndSortedTemplates.length}개)</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="rounded-md border">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead className="h-12 px-6 py-3 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 className="h-12 px-6 py-3 text-sm font-semibold">
|
||
<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 className="h-12 px-6 py-3 text-sm font-semibold">
|
||
<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 className="h-12 px-6 py-3 text-sm font-semibold">카테고리</TableHead>
|
||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">설명</TableHead>
|
||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">아이콘</TableHead>
|
||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">기본 크기</TableHead>
|
||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">공개 여부</TableHead>
|
||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">활성화</TableHead>
|
||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">수정일</TableHead>
|
||
<TableHead className="h-12 px-6 py-3 w-[200px] text-sm font-semibold">작업</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} className="bg-background transition-colors hover:bg-muted/50">
|
||
<TableCell className="h-16 px-6 py-3 font-mono text-sm">{template.sort_order || 0}</TableCell>
|
||
<TableCell className="h-16 px-6 py-3 font-mono text-sm">{template.template_code}</TableCell>
|
||
<TableCell className="h-16 px-6 py-3 font-medium text-sm">
|
||
{template.template_name}
|
||
{template.template_name_eng && (
|
||
<div className="text-muted-foreground text-xs">{template.template_name_eng}</div>
|
||
)}
|
||
</TableCell>
|
||
<TableCell className="h-16 px-6 py-3 text-sm">
|
||
<Badge variant="secondary">{template.category}</Badge>
|
||
</TableCell>
|
||
<TableCell className="h-16 px-6 py-3 max-w-xs truncate text-sm">{template.description || "-"}</TableCell>
|
||
<TableCell className="h-16 px-6 py-3 text-sm">
|
||
<div className="flex items-center justify-center">{renderIcon(template.icon_name)}</div>
|
||
</TableCell>
|
||
<TableCell className="h-16 px-6 py-3 font-mono text-xs">
|
||
{template.default_size ? `${template.default_size.width}×${template.default_size.height}` : "-"}
|
||
</TableCell>
|
||
<TableCell className="h-16 px-6 py-3 text-sm">
|
||
<Badge variant={template.is_public === "Y" ? "default" : "secondary"}>
|
||
{template.is_public === "Y" ? "공개" : "비공개"}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell className="h-16 px-6 py-3 text-sm">
|
||
<Badge variant={template.is_active === "Y" ? "default" : "secondary"}>
|
||
{template.is_active === "Y" ? "활성화" : "비활성화"}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell className="h-16 px-6 py-3 text-muted-foreground text-sm">
|
||
{template.updated_date ? new Date(template.updated_date).toLocaleDateString("ko-KR") : "-"}
|
||
</TableCell>
|
||
<TableCell className="h-16 px-6 py-3 text-sm">
|
||
<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>
|
||
</div>
|
||
);
|
||
}
|