ERP-node/frontend/components/screen/panels/TemplatesPanel.tsx

618 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import {
Table,
Search,
FileText,
Grid3x3,
Info,
FormInput,
Save,
X,
Trash2,
Edit,
Plus,
RotateCcw,
Send,
ExternalLink,
MousePointer,
Settings,
Upload,
Square,
CreditCard,
Layout,
Columns,
Rows,
SidebarOpen,
Folder,
ChevronDown,
RefreshCw,
} from "lucide-react";
import { useTemplates, TemplateStandard } from "@/hooks/admin/useTemplates";
import { toast } from "sonner";
// 템플릿 컴포넌트 타입 정의
export interface TemplateComponent {
id: string;
name: string;
description: string;
category: "table" | "button" | "form" | "layout" | "chart" | "status" | "file" | "area";
icon: React.ReactNode;
defaultSize: { width: number; height: number };
components: Array<{
type: "widget" | "container" | "datatable" | "file" | "area";
widgetType?: string;
label: string;
placeholder?: string;
position: { x: number; y: number };
size: { width: number; height: number };
style?: any;
required?: boolean;
readonly?: boolean;
parentId?: string;
title?: string;
// 영역 컴포넌트 전용 속성
layoutType?: string;
description?: string;
layoutConfig?: any;
areaStyle?: any;
}>;
}
// 아이콘 매핑 함수
const getIconByName = (iconName?: string): React.ReactNode => {
const iconMap: Record<string, React.ReactNode> = {
table: <Table className="h-4 w-4" />,
"mouse-pointer": <MousePointer className="h-4 w-4" />,
upload: <Upload className="h-4 w-4" />,
layout: <Layout className="h-4 w-4" />,
form: <FormInput className="h-4 w-4" />,
grid: <Grid3x3 className="h-4 w-4" />,
folder: <Folder className="h-4 w-4" />,
square: <Square className="h-4 w-4" />,
columns: <Columns className="h-4 w-4" />,
rows: <Rows className="h-4 w-4" />,
card: <CreditCard className="h-4 w-4" />,
sidebar: <SidebarOpen className="h-4 w-4" />,
};
return iconMap[iconName || ""] || <Grid3x3 className="h-4 w-4" />;
};
// TemplateStandard를 TemplateComponent로 변환하는 함수
const convertTemplateStandardToComponent = (template: TemplateStandard): TemplateComponent => {
return {
id: template.template_code,
name: template.template_name,
description: template.description || "",
category: template.category as TemplateComponent["category"],
icon: getIconByName(template.icon_name),
defaultSize: template.default_size || { width: 300, height: 200 },
components: template.layout_config?.components || [],
};
};
// 폴백 템플릿 (데이터베이스 연결 실패 시)
const fallbackTemplates: TemplateComponent[] = [
// 고급 데이터 테이블 템플릿
{
id: "advanced-data-table",
name: "고급 데이터 테이블",
description: "컬럼 설정, 필터링, 페이지네이션이 포함된 완전한 데이터 테이블",
category: "table",
icon: <Table className="h-4 w-4" />,
defaultSize: { width: 1000, height: 680 },
components: [
{
type: "datatable",
label: "데이터 테이블",
position: { x: 0, y: 0 },
size: { width: 1000, height: 680 },
style: {
border: "1px solid #e5e7eb",
borderRadius: "8px",
backgroundColor: "#ffffff",
padding: "16px",
},
},
],
},
// === 영역 템플릿들 ===
// 기본 박스 영역
{
id: "area-box",
name: "기본 박스 영역",
description: "컴포넌트들을 그룹화할 수 있는 기본 박스 형태의 영역",
category: "area",
icon: <Square className="h-4 w-4" />,
defaultSize: { width: 400, height: 300 },
components: [
{
type: "area",
label: "박스 영역",
position: { x: 0, y: 0 },
size: { width: 400, height: 300 },
layoutType: "box",
title: "박스 영역",
description: "컴포넌트들을 그룹화할 수 있는 기본 박스",
layoutConfig: {},
areaStyle: {
backgroundColor: "#f9fafb",
borderWidth: 1,
borderStyle: "solid",
borderColor: "#d1d5db",
borderRadius: 8,
padding: 16,
margin: 0,
shadow: "none",
},
style: {
border: "1px solid #d1d5db",
borderRadius: "8px",
backgroundColor: "#f9fafb",
padding: "16px",
},
},
],
},
// 카드 영역
{
id: "area-card",
name: "카드 영역",
description: "그림자와 둥근 모서리가 있는 카드 형태의 영역",
category: "area",
icon: <CreditCard className="h-4 w-4" />,
defaultSize: { width: 400, height: 300 },
components: [
{
type: "area",
label: "카드 영역",
position: { x: 0, y: 0 },
size: { width: 400, height: 300 },
layoutType: "card",
title: "카드 영역",
description: "그림자와 둥근 모서리가 있는 카드 형태",
layoutConfig: {},
areaStyle: {
backgroundColor: "#ffffff",
borderWidth: 0,
borderStyle: "none",
borderColor: "#e5e7eb",
borderRadius: 12,
padding: 20,
margin: 0,
shadow: "md",
},
style: {
backgroundColor: "#ffffff",
borderRadius: "12px",
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1)",
padding: "20px",
},
},
],
},
// 패널 영역 (헤더 포함)
{
id: "area-panel",
name: "패널 영역",
description: "제목 헤더가 포함된 패널 형태의 영역",
category: "area",
icon: <Layout className="h-4 w-4" />,
defaultSize: { width: 500, height: 400 },
components: [
{
type: "area",
label: "패널 영역",
position: { x: 0, y: 0 },
size: { width: 500, height: 400 },
layoutType: "panel",
title: "패널 제목",
style: {
backgroundColor: "#ffffff",
border: "1px solid #e5e7eb",
borderRadius: "8px",
headerBackgroundColor: "#f3f4f6",
headerHeight: 48,
headerPadding: 16,
},
},
],
},
// 그리드 영역
{
id: "area-grid",
name: "그리드 영역",
description: "내부 컴포넌트들을 격자 형태로 배치하는 영역",
category: "area",
icon: <Grid3x3 className="h-4 w-4" />,
defaultSize: { width: 600, height: 400 },
components: [
{
type: "area",
label: "그리드 영역",
position: { x: 0, y: 0 },
size: { width: 600, height: 400 },
layoutType: "grid",
title: "그리드 영역",
description: "격자 형태로 컴포넌트 배치",
layoutConfig: {
gridColumns: 3,
gridRows: 2,
gridGap: 16,
},
areaStyle: {
backgroundColor: "#ffffff",
borderWidth: 1,
borderStyle: "solid",
borderColor: "#d1d5db",
borderRadius: 8,
padding: 16,
margin: 0,
shadow: "none",
showGridLines: true,
gridLineColor: "#e5e7eb",
},
style: {
backgroundColor: "#ffffff",
border: "1px solid #d1d5db",
borderRadius: "8px",
padding: "16px",
},
},
],
},
// 가로 플렉스 영역
{
id: "area-flex-row",
name: "가로 배치 영역",
description: "내부 컴포넌트들을 가로로 나란히 배치하는 영역",
category: "area",
icon: <Columns className="h-4 w-4" />,
defaultSize: { width: 600, height: 200 },
components: [
{
type: "area",
label: "가로 배치 영역",
position: { x: 0, y: 0 },
size: { width: 600, height: 200 },
layoutType: "flex-row",
layoutConfig: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
gap: 16,
},
style: {
backgroundColor: "#f8fafc",
border: "1px solid #cbd5e1",
borderRadius: "8px",
padding: "16px",
},
},
],
},
// 세로 플렉스 영역
{
id: "area-flex-column",
name: "세로 배치 영역",
description: "내부 컴포넌트들을 세로로 순차 배치하는 영역",
category: "area",
icon: <Rows className="h-4 w-4" />,
defaultSize: { width: 300, height: 500 },
components: [
{
type: "area",
label: "세로 배치 영역",
position: { x: 0, y: 0 },
size: { width: 300, height: 500 },
layoutType: "flex-column",
layoutConfig: {
flexDirection: "column",
justifyContent: "flex-start",
alignItems: "stretch",
gap: 12,
},
style: {
backgroundColor: "#f1f5f9",
border: "1px solid #94a3b8",
borderRadius: "8px",
padding: "16px",
},
},
],
},
// 사이드바 영역
{
id: "area-sidebar",
name: "사이드바 영역",
description: "사이드바와 메인 컨텐츠 영역으로 구분된 레이아웃",
category: "area",
icon: <SidebarOpen className="h-4 w-4" />,
defaultSize: { width: 700, height: 400 },
components: [
{
type: "area",
label: "사이드바 영역",
position: { x: 0, y: 0 },
size: { width: 700, height: 400 },
layoutType: "sidebar",
layoutConfig: {
sidebarPosition: "left",
sidebarWidth: 200,
collapsible: true,
},
style: {
backgroundColor: "#ffffff",
border: "1px solid #e2e8f0",
borderRadius: "8px",
},
},
],
},
// 탭 영역
{
id: "area-tabs",
name: "탭 영역",
description: "탭으로 구분된 여러 컨텐츠 영역을 제공하는 레이아웃",
category: "area",
icon: <Folder className="h-4 w-4" />,
defaultSize: { width: 600, height: 400 },
components: [
{
type: "area",
label: "탭 영역",
position: { x: 0, y: 0 },
size: { width: 600, height: 400 },
layoutType: "tabs",
layoutConfig: {
tabPosition: "top",
defaultActiveTab: "tab1",
},
style: {
backgroundColor: "#ffffff",
border: "1px solid #d1d5db",
borderRadius: "8px",
},
},
],
},
// 아코디언 영역 - 숨김 처리
// {
// id: "area-accordion",
// name: "아코디언 영역",
// description: "접고 펼칠 수 있는 섹션들로 구성된 영역",
// category: "area",
// icon: <ChevronDown className="h-4 w-4" />,
// defaultSize: { width: 500, height: 600 },
// components: [
// {
// type: "area",
// label: "아코디언 영역",
// position: { x: 0, y: 0 },
// size: { width: 500, height: 600 },
// layoutType: "accordion",
// layoutConfig: {
// allowMultiple: false,
// defaultExpanded: ["section1"],
// },
// style: {
// backgroundColor: "#ffffff",
// border: "1px solid #e5e7eb",
// borderRadius: "8px",
// },
// },
// ],
// },
];
interface TemplatesPanelProps {
onDragStart: (e: React.DragEvent, template: TemplateComponent) => void;
}
export const TemplatesPanel: React.FC<TemplatesPanelProps> = ({ onDragStart }) => {
const [searchTerm, setSearchTerm] = React.useState("");
const [selectedCategory, setSelectedCategory] = React.useState<string>("all");
// 동적 템플릿 데이터 조회
const {
templates: dbTemplates,
categories: dbCategories,
isLoading,
error,
refetch,
} = useTemplates({
active: "Y", // 활성화된 템플릿만 조회
is_public: "Y", // 공개 템플릿만 조회 (회사별 템플릿도 포함됨)
});
// 데이터베이스 템플릿을 TemplateComponent 형태로 변환
const dynamicTemplates = React.useMemo(() => {
if (error || !dbTemplates) {
// 오류 발생 시 폴백 템플릿 사용
// console.warn("템플릿 로딩 실패, 폴백 템플릿 사용:", error);
return fallbackTemplates;
}
return dbTemplates.map(convertTemplateStandardToComponent);
}, [dbTemplates, error]);
// 카테고리 목록 동적 생성
const categories = React.useMemo(() => {
const allCategories = [{ id: "all", name: "전체", icon: <Grid3x3 className="h-4 w-4" /> }];
if (dbCategories && dbCategories.length > 0) {
// 데이터베이스에서 가져온 카테고리 사용
dbCategories.forEach((category) => {
const icon = getIconByName(category);
allCategories.push({
id: category,
name: category,
icon,
});
});
} else {
// 폴백 카테고리 (실제 템플릿만)
allCategories.push(
{ id: "area", name: "영역", icon: <Layout className="h-4 w-4" /> },
{ id: "table", name: "테이블", icon: <Table className="h-4 w-4" /> },
);
}
return allCategories;
}, [dbCategories]);
const filteredTemplates = dynamicTemplates.filter((template) => {
const matchesSearch =
template.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
template.description.toLowerCase().includes(searchTerm.toLowerCase());
const matchesCategory = selectedCategory === "all" || template.category === selectedCategory;
return matchesSearch && matchesCategory;
});
return (
<div className="flex h-full flex-col bg-slate-50 p-6 border-r border-gray-200/60 shadow-sm">
{/* 헤더 */}
<div className="mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-1">릿</h2>
<p className="text-sm text-gray-500"> </p>
</div>
{/* 검색 */}
<div className="space-y-4">
<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 border-0 bg-white/80 backdrop-blur-sm shadow-sm focus:bg-white transition-colors"
/>
</div>
{/* 카테고리 필터 */}
<div className="flex flex-wrap gap-2">
{categories.map((category) => (
<Button
key={category.id}
variant={selectedCategory === category.id ? "default" : "outline"}
size="sm"
onClick={() => setSelectedCategory(category.id)}
className="flex items-center space-x-1.5 h-8 px-3 text-xs font-medium rounded-full transition-all"
>
{category.icon}
<span>{category.name}</span>
</Button>
))}
</div>
</div>
{/* 새로고침 버튼 */}
{error && (
<div className="flex items-center justify-between rounded-xl bg-amber-50/80 border border-amber-200/60 p-3 text-amber-800 mb-4">
<div className="flex items-center space-x-2">
<Info className="h-4 w-4" />
<span className="text-xs">릿 , 릿 </span>
</div>
<Button size="sm" variant="outline" onClick={() => refetch()} className="border-amber-300 text-amber-700 hover:bg-amber-100">
<RefreshCw className="h-4 w-4" />
</Button>
</div>
)}
{/* 템플릿 목록 */}
<div className="flex-1 space-y-3 overflow-y-auto mt-6">
{isLoading ? (
<div className="flex h-32 items-center justify-center">
<LoadingSpinner />
<span className="ml-2 text-sm text-gray-500">릿 ...</span>
</div>
) : filteredTemplates.length === 0 ? (
<div className="flex h-32 items-center justify-center text-center text-gray-500">
<div className="p-8">
<FileText className="mx-auto mb-3 h-12 w-12 text-gray-300" />
<p className="text-sm font-medium text-muted-foreground">릿 </p>
<p className="text-xs text-gray-400 mt-1"> </p>
</div>
</div>
) : (
filteredTemplates.map((template) => (
<div
key={template.id}
draggable
onDragStart={(e) => {
onDragStart(e, template);
// 드래그 시작 시 시각적 피드백
e.currentTarget.style.opacity = '0.6';
e.currentTarget.style.transform = 'rotate(2deg) scale(0.98)';
}}
onDragEnd={(e) => {
// 드래그 종료 시 원래 상태로 복원
e.currentTarget.style.opacity = '1';
e.currentTarget.style.transform = 'none';
}}
className="group cursor-grab rounded-lg border border-gray-200/40 bg-white/90 backdrop-blur-sm p-6 shadow-sm transition-all duration-300 hover:bg-white hover:shadow-lg hover:shadow-blue-500/15 hover:scale-[1.02] hover:border-blue-300/60 hover:-translate-y-1 active:cursor-grabbing active:scale-[0.98] active:translate-y-0"
>
<div className="flex items-start space-x-4">
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-gradient-to-br from-blue-100 to-indigo-100 text-blue-700 shadow-md group-hover:shadow-lg group-hover:scale-110 transition-all duration-300">
{template.icon}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between mb-2">
<h4 className="font-semibold text-gray-900 text-sm leading-tight">{template.name}</h4>
<Badge variant="secondary" className="text-xs bg-blue-50 text-blue-600 border-0 ml-2 px-2 py-1 rounded-full font-medium">
{template.components.length}
</Badge>
</div>
<p className="text-xs text-gray-500 line-clamp-2 leading-relaxed mb-3">{template.description}</p>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2 text-xs text-gray-400">
<span className="bg-gradient-to-r from-blue-100 to-indigo-100 px-3 py-1 rounded-full font-medium text-blue-700 shadow-sm">
{template.defaultSize.width}×{template.defaultSize.height}
</span>
</div>
<span className="text-xs font-medium text-primary capitalize bg-gradient-to-r from-blue-50 to-indigo-50 px-3 py-1 rounded-full border border-primary/20/50">
{template.category}
</span>
</div>
</div>
</div>
</div>
))
)}
</div>
{/* 도움말 */}
<div className="rounded-xl bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-100/60 p-4 mt-6">
<div className="flex items-start space-x-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/20 text-primary">
<Info className="h-4 w-4" />
</div>
<div className="text-xs text-blue-800">
<p className="font-semibold mb-1"> </p>
<p className="text-primary leading-relaxed">릿 .</p>
</div>
</div>
</div>
</div>
);
};
export default TemplatesPanel;