387 lines
15 KiB
TypeScript
387 lines
15 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import { useState, useEffect } from "react";
|
||
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||
|
|
import { Button } from "@/components/ui/button";
|
||
|
|
import { Input } from "@/components/ui/input";
|
||
|
|
import { Badge } from "@/components/ui/badge";
|
||
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||
|
|
import { Separator } from "@/components/ui/separator";
|
||
|
|
import { Search, Plus, Download, Upload, Trash2, Eye, Edit } from "lucide-react";
|
||
|
|
import { ScreenTemplate, LayoutData } from "@/types/screen";
|
||
|
|
import { templateApi } from "@/lib/api/screen";
|
||
|
|
import { useAuth } from "@/hooks/useAuth";
|
||
|
|
|
||
|
|
interface TemplateManagerProps {
|
||
|
|
onTemplateSelect?: (template: ScreenTemplate) => void;
|
||
|
|
onTemplateApply?: (template: ScreenTemplate) => void;
|
||
|
|
className?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function TemplateManager({ onTemplateSelect, onTemplateApply, className }: TemplateManagerProps) {
|
||
|
|
const { user } = useAuth();
|
||
|
|
const [templates, setTemplates] = useState<ScreenTemplate[]>([]);
|
||
|
|
const [selectedTemplate, setSelectedTemplate] = useState<ScreenTemplate | null>(null);
|
||
|
|
const [searchTerm, setSearchTerm] = useState("");
|
||
|
|
const [isLoading, setIsLoading] = useState(false);
|
||
|
|
const [activeTab, setActiveTab] = useState("my");
|
||
|
|
|
||
|
|
// 템플릿 목록 조회
|
||
|
|
useEffect(() => {
|
||
|
|
const fetchTemplates = async () => {
|
||
|
|
try {
|
||
|
|
setIsLoading(true);
|
||
|
|
const templateList = await templateApi.getTemplates({
|
||
|
|
companyCode: user?.company_code || "*",
|
||
|
|
});
|
||
|
|
setTemplates(templateList);
|
||
|
|
} catch (error) {
|
||
|
|
console.error("템플릿 목록 조회 실패:", error);
|
||
|
|
// API 호출 실패 시 기본 템플릿 목록 사용
|
||
|
|
const fallbackTemplates: ScreenTemplate[] = [
|
||
|
|
{
|
||
|
|
templateId: 1,
|
||
|
|
templateName: "기본 CRUD 화면",
|
||
|
|
templateType: "CRUD",
|
||
|
|
companyCode: "*",
|
||
|
|
description: "기본적인 CRUD 기능을 제공하는 화면 템플릿",
|
||
|
|
layoutData: {
|
||
|
|
components: [
|
||
|
|
{
|
||
|
|
id: "search-container",
|
||
|
|
type: "container",
|
||
|
|
position: { x: 0, y: 0 },
|
||
|
|
size: { width: 12, height: 100 },
|
||
|
|
title: "검색 영역",
|
||
|
|
children: [],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "table-container",
|
||
|
|
type: "container",
|
||
|
|
position: { x: 0, y: 1 },
|
||
|
|
size: { width: 12, height: 300 },
|
||
|
|
title: "데이터 테이블",
|
||
|
|
children: [],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "form-container",
|
||
|
|
type: "container",
|
||
|
|
position: { x: 0, y: 2 },
|
||
|
|
size: { width: 12, height: 200 },
|
||
|
|
title: "입력 폼",
|
||
|
|
children: [],
|
||
|
|
},
|
||
|
|
],
|
||
|
|
gridSettings: { columns: 12, gap: 16, padding: 16 },
|
||
|
|
},
|
||
|
|
isPublic: true,
|
||
|
|
createdBy: "admin",
|
||
|
|
createdDate: new Date(),
|
||
|
|
},
|
||
|
|
{
|
||
|
|
templateId: 2,
|
||
|
|
templateName: "목록 화면",
|
||
|
|
templateType: "LIST",
|
||
|
|
companyCode: "*",
|
||
|
|
description: "데이터 목록을 표시하는 화면 템플릿",
|
||
|
|
layoutData: {
|
||
|
|
components: [
|
||
|
|
{
|
||
|
|
id: "filter-container",
|
||
|
|
type: "container",
|
||
|
|
position: { x: 0, y: 0 },
|
||
|
|
size: { width: 12, height: 80 },
|
||
|
|
title: "필터 영역",
|
||
|
|
children: [],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "list-container",
|
||
|
|
type: "container",
|
||
|
|
position: { x: 0, y: 1 },
|
||
|
|
size: { width: 12, height: 400 },
|
||
|
|
title: "목록 영역",
|
||
|
|
children: [],
|
||
|
|
},
|
||
|
|
],
|
||
|
|
gridSettings: { columns: 12, gap: 16, padding: 16 },
|
||
|
|
},
|
||
|
|
isPublic: true,
|
||
|
|
createdBy: "admin",
|
||
|
|
createdDate: new Date(),
|
||
|
|
},
|
||
|
|
{
|
||
|
|
templateId: 3,
|
||
|
|
templateName: "상세 화면",
|
||
|
|
templateType: "DETAIL",
|
||
|
|
companyCode: "*",
|
||
|
|
description: "데이터 상세 정보를 표시하는 화면 템플릿",
|
||
|
|
layoutData: {
|
||
|
|
components: [
|
||
|
|
{
|
||
|
|
id: "header-container",
|
||
|
|
type: "container",
|
||
|
|
position: { x: 0, y: 0 },
|
||
|
|
size: { width: 12, height: 60 },
|
||
|
|
title: "헤더 영역",
|
||
|
|
children: [],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "detail-container",
|
||
|
|
type: "container",
|
||
|
|
position: { x: 0, y: 1 },
|
||
|
|
size: { width: 12, height: 400 },
|
||
|
|
title: "상세 정보",
|
||
|
|
children: [],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "action-container",
|
||
|
|
type: "container",
|
||
|
|
position: { x: 0, y: 2 },
|
||
|
|
size: { width: 12, height: 80 },
|
||
|
|
title: "액션 버튼",
|
||
|
|
children: [],
|
||
|
|
},
|
||
|
|
],
|
||
|
|
gridSettings: { columns: 12, gap: 16, padding: 16 },
|
||
|
|
},
|
||
|
|
isPublic: true,
|
||
|
|
createdBy: "admin",
|
||
|
|
createdDate: new Date(),
|
||
|
|
},
|
||
|
|
];
|
||
|
|
setTemplates(fallbackTemplates);
|
||
|
|
} finally {
|
||
|
|
setIsLoading(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
fetchTemplates();
|
||
|
|
}, [user?.company_code]);
|
||
|
|
|
||
|
|
// 템플릿 검색
|
||
|
|
const filteredTemplates = templates.filter(
|
||
|
|
(template) =>
|
||
|
|
template.templateName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
|
|
(template.description || "").toLowerCase().includes(searchTerm.toLowerCase()),
|
||
|
|
);
|
||
|
|
|
||
|
|
// 템플릿 선택
|
||
|
|
const handleTemplateSelect = (template: ScreenTemplate) => {
|
||
|
|
setSelectedTemplate(template);
|
||
|
|
onTemplateSelect?.(template);
|
||
|
|
};
|
||
|
|
|
||
|
|
// 템플릿 적용
|
||
|
|
const handleTemplateApply = (template: ScreenTemplate) => {
|
||
|
|
onTemplateApply?.(template);
|
||
|
|
};
|
||
|
|
|
||
|
|
// 템플릿 삭제
|
||
|
|
const handleTemplateDelete = async (templateId: number) => {
|
||
|
|
if (!confirm("정말로 이 템플릿을 삭제하시겠습니까?")) return;
|
||
|
|
|
||
|
|
try {
|
||
|
|
await templateApi.deleteTemplate(templateId);
|
||
|
|
setTemplates((prev) => prev.filter((t) => t.templateId !== templateId));
|
||
|
|
if (selectedTemplate?.templateId === templateId) {
|
||
|
|
setSelectedTemplate(null);
|
||
|
|
}
|
||
|
|
alert("템플릿이 삭제되었습니다.");
|
||
|
|
} catch (error) {
|
||
|
|
console.error("템플릿 삭제 실패:", error);
|
||
|
|
alert("템플릿 삭제에 실패했습니다. 다시 시도해주세요.");
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// 새 템플릿 생성
|
||
|
|
const handleCreateTemplate = () => {
|
||
|
|
// TODO: 새 템플릿 생성 모달 또는 페이지로 이동
|
||
|
|
console.log("새 템플릿 생성");
|
||
|
|
};
|
||
|
|
|
||
|
|
// 템플릿 내보내기
|
||
|
|
const handleExportTemplate = (template: ScreenTemplate) => {
|
||
|
|
const dataStr = JSON.stringify(template, null, 2);
|
||
|
|
const dataBlob = new Blob([dataStr], { type: "application/json" });
|
||
|
|
const url = URL.createObjectURL(dataBlob);
|
||
|
|
const link = document.createElement("a");
|
||
|
|
link.href = url;
|
||
|
|
link.download = `${template.templateName}.json`;
|
||
|
|
link.click();
|
||
|
|
URL.revokeObjectURL(url);
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className={`space-y-4 ${className}`}>
|
||
|
|
{/* 헤더 */}
|
||
|
|
<Card>
|
||
|
|
<CardHeader className="pb-3">
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<CardTitle className="text-sm font-medium">화면 템플릿 관리</CardTitle>
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Button variant="outline" size="sm" onClick={handleCreateTemplate}>
|
||
|
|
<Plus className="mr-1 h-3 w-3" />새 템플릿
|
||
|
|
</Button>
|
||
|
|
<Button variant="outline" size="sm">
|
||
|
|
<Upload className="mr-1 h-3 w-3" />
|
||
|
|
가져오기
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
<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>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
{/* 템플릿 목록 */}
|
||
|
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||
|
|
{/* 템플릿 카드 목록 */}
|
||
|
|
<div className="space-y-3">
|
||
|
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||
|
|
<TabsList className="grid w-full grid-cols-2">
|
||
|
|
<TabsTrigger value="my">내 템플릿</TabsTrigger>
|
||
|
|
<TabsTrigger value="public">공개 템플릿</TabsTrigger>
|
||
|
|
</TabsList>
|
||
|
|
</Tabs>
|
||
|
|
|
||
|
|
<div className="max-h-96 space-y-3 overflow-y-auto">
|
||
|
|
{filteredTemplates
|
||
|
|
.filter((template) => (activeTab === "my" ? template.companyCode !== "*" : template.isPublic))
|
||
|
|
.map((template) => (
|
||
|
|
<Card
|
||
|
|
key={template.templateId}
|
||
|
|
className={`cursor-pointer transition-all hover:shadow-md ${
|
||
|
|
selectedTemplate?.templateId === template.templateId ? "ring-2 ring-blue-500" : ""
|
||
|
|
}`}
|
||
|
|
onClick={() => handleTemplateSelect(template)}
|
||
|
|
>
|
||
|
|
<CardContent className="p-4">
|
||
|
|
<div className="flex items-start justify-between">
|
||
|
|
<div className="flex-1">
|
||
|
|
<h3 className="mb-1 text-sm font-medium">{template.templateName}</h3>
|
||
|
|
<p className="mb-2 text-xs text-gray-600">{template.description || "설명 없음"}</p>
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Badge variant="outline" className="text-xs">
|
||
|
|
{template.templateType}
|
||
|
|
</Badge>
|
||
|
|
{template.isPublic && (
|
||
|
|
<Badge variant="secondary" className="text-xs">
|
||
|
|
공개
|
||
|
|
</Badge>
|
||
|
|
)}
|
||
|
|
<span className="text-xs text-gray-500">
|
||
|
|
{template.createdBy} • {template.createdDate.toLocaleDateString()}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center gap-1">
|
||
|
|
<Button
|
||
|
|
size="sm"
|
||
|
|
variant="ghost"
|
||
|
|
onClick={(e) => {
|
||
|
|
e.stopPropagation();
|
||
|
|
handleTemplateApply(template);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<Eye className="h-3 w-3" />
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
size="sm"
|
||
|
|
variant="ghost"
|
||
|
|
onClick={(e) => {
|
||
|
|
e.stopPropagation();
|
||
|
|
handleExportTemplate(template);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<Download className="h-3 w-3" />
|
||
|
|
</Button>
|
||
|
|
{template.companyCode !== "*" && (
|
||
|
|
<Button
|
||
|
|
size="sm"
|
||
|
|
variant="ghost"
|
||
|
|
onClick={(e) => {
|
||
|
|
e.stopPropagation();
|
||
|
|
handleTemplateDelete(template.templateId);
|
||
|
|
}}
|
||
|
|
className="text-red-600 hover:text-red-700"
|
||
|
|
>
|
||
|
|
<Trash2 className="h-3 w-3" />
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 선택된 템플릿 상세 정보 */}
|
||
|
|
<div>
|
||
|
|
{selectedTemplate ? (
|
||
|
|
<Card>
|
||
|
|
<CardHeader className="pb-3">
|
||
|
|
<CardTitle className="text-sm font-medium">템플릿 상세 정보</CardTitle>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent className="space-y-4">
|
||
|
|
<div>
|
||
|
|
<label className="text-sm font-medium">템플릿명</label>
|
||
|
|
<p className="text-sm text-gray-600">{selectedTemplate.templateName}</p>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<label className="text-sm font-medium">설명</label>
|
||
|
|
<p className="text-sm text-gray-600">{selectedTemplate.description}</p>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<label className="text-sm font-medium">타입</label>
|
||
|
|
<Badge variant="outline" className="text-xs">
|
||
|
|
{selectedTemplate.templateType}
|
||
|
|
</Badge>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<label className="text-sm font-medium">컴포넌트 수</label>
|
||
|
|
<p className="text-sm text-gray-600">{selectedTemplate.layoutData?.components?.length || 0}개</p>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<label className="text-sm font-medium">그리드 설정</label>
|
||
|
|
<p className="text-sm text-gray-600">
|
||
|
|
{selectedTemplate.layoutData?.gridSettings?.columns || 12} 컬럼, 간격:{" "}
|
||
|
|
{selectedTemplate.layoutData?.gridSettings?.gap || 16}px
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
<Separator />
|
||
|
|
<div className="flex gap-2">
|
||
|
|
<Button className="flex-1" onClick={() => handleTemplateApply(selectedTemplate)}>
|
||
|
|
<Plus className="mr-1 h-3 w-3" />
|
||
|
|
템플릿 적용
|
||
|
|
</Button>
|
||
|
|
<Button variant="outline" className="flex-1">
|
||
|
|
<Edit className="mr-1 h-3 w-3" />
|
||
|
|
편집
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
) : (
|
||
|
|
<Card>
|
||
|
|
<CardContent className="p-8 text-center text-gray-500">
|
||
|
|
<Eye className="mx-auto mb-4 h-12 w-12 text-gray-300" />
|
||
|
|
<p>템플릿을 선택하면 상세 정보를 볼 수 있습니다</p>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|