2025-09-03 15:23:12 +09:00
|
|
|
|
"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";
|
2025-09-09 17:42:23 +09:00
|
|
|
|
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
2025-09-04 11:33:52 +09:00
|
|
|
|
import {
|
|
|
|
|
|
Table,
|
|
|
|
|
|
Search,
|
|
|
|
|
|
FileText,
|
|
|
|
|
|
Grid3x3,
|
|
|
|
|
|
Info,
|
|
|
|
|
|
FormInput,
|
|
|
|
|
|
Save,
|
|
|
|
|
|
X,
|
|
|
|
|
|
Trash2,
|
|
|
|
|
|
Edit,
|
|
|
|
|
|
Plus,
|
|
|
|
|
|
RotateCcw,
|
|
|
|
|
|
Send,
|
|
|
|
|
|
ExternalLink,
|
|
|
|
|
|
MousePointer,
|
|
|
|
|
|
Settings,
|
2025-09-05 21:52:19 +09:00
|
|
|
|
Upload,
|
2025-09-08 13:10:09 +09:00
|
|
|
|
Square,
|
|
|
|
|
|
CreditCard,
|
|
|
|
|
|
Layout,
|
|
|
|
|
|
Columns,
|
|
|
|
|
|
Rows,
|
|
|
|
|
|
SidebarOpen,
|
|
|
|
|
|
Folder,
|
|
|
|
|
|
ChevronDown,
|
2025-09-09 17:42:23 +09:00
|
|
|
|
RefreshCw,
|
2025-09-04 11:33:52 +09:00
|
|
|
|
} from "lucide-react";
|
2025-09-09 17:42:23 +09:00
|
|
|
|
import { useTemplates, TemplateStandard } from "@/hooks/admin/useTemplates";
|
|
|
|
|
|
import { toast } from "sonner";
|
2025-09-03 15:23:12 +09:00
|
|
|
|
|
|
|
|
|
|
// 템플릿 컴포넌트 타입 정의
|
|
|
|
|
|
export interface TemplateComponent {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
description: string;
|
2025-09-08 13:10:09 +09:00
|
|
|
|
category: "table" | "button" | "form" | "layout" | "chart" | "status" | "file" | "area";
|
2025-09-03 15:23:12 +09:00
|
|
|
|
icon: React.ReactNode;
|
|
|
|
|
|
defaultSize: { width: number; height: number };
|
|
|
|
|
|
components: Array<{
|
2025-09-08 13:10:09 +09:00
|
|
|
|
type: "widget" | "container" | "datatable" | "file" | "area";
|
2025-09-03 15:23:12 +09:00
|
|
|
|
widgetType?: string;
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
placeholder?: string;
|
|
|
|
|
|
position: { x: number; y: number };
|
|
|
|
|
|
size: { width: number; height: number };
|
|
|
|
|
|
style?: any;
|
|
|
|
|
|
required?: boolean;
|
|
|
|
|
|
readonly?: boolean;
|
2025-09-08 13:10:09 +09:00
|
|
|
|
parentId?: string;
|
|
|
|
|
|
title?: string;
|
|
|
|
|
|
// 영역 컴포넌트 전용 속성
|
|
|
|
|
|
layoutType?: string;
|
|
|
|
|
|
description?: string;
|
|
|
|
|
|
layoutConfig?: any;
|
|
|
|
|
|
areaStyle?: any;
|
2025-09-03 15:23:12 +09:00
|
|
|
|
}>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-09 17:42:23 +09:00
|
|
|
|
// 아이콘 매핑 함수
|
|
|
|
|
|
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[] = [
|
2025-09-03 15:23:12 +09:00
|
|
|
|
// 고급 데이터 테이블 템플릿
|
|
|
|
|
|
{
|
|
|
|
|
|
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",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
2025-09-04 11:33:52 +09:00
|
|
|
|
|
2025-09-08 13:10:09 +09:00
|
|
|
|
// === 영역 템플릿들 ===
|
|
|
|
|
|
|
|
|
|
|
|
// 기본 박스 영역
|
|
|
|
|
|
{
|
|
|
|
|
|
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",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-01-05 14:36:56 +09:00
|
|
|
|
// 아코디언 영역 - 숨김 처리
|
|
|
|
|
|
// {
|
|
|
|
|
|
// 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",
|
|
|
|
|
|
// },
|
|
|
|
|
|
// },
|
|
|
|
|
|
// ],
|
|
|
|
|
|
// },
|
2025-09-03 15:23:12 +09:00
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
|
|
2025-09-09 17:42:23 +09:00
|
|
|
|
// 동적 템플릿 데이터 조회
|
|
|
|
|
|
const {
|
|
|
|
|
|
templates: dbTemplates,
|
|
|
|
|
|
categories: dbCategories,
|
|
|
|
|
|
isLoading,
|
|
|
|
|
|
error,
|
|
|
|
|
|
refetch,
|
|
|
|
|
|
} = useTemplates({
|
|
|
|
|
|
active: "Y", // 활성화된 템플릿만 조회
|
|
|
|
|
|
is_public: "Y", // 공개 템플릿만 조회 (회사별 템플릿도 포함됨)
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 데이터베이스 템플릿을 TemplateComponent 형태로 변환
|
|
|
|
|
|
const dynamicTemplates = React.useMemo(() => {
|
|
|
|
|
|
if (error || !dbTemplates) {
|
|
|
|
|
|
// 오류 발생 시 폴백 템플릿 사용
|
2025-10-01 18:17:30 +09:00
|
|
|
|
// console.warn("템플릿 로딩 실패, 폴백 템플릿 사용:", error);
|
2025-09-09 17:42:23 +09:00
|
|
|
|
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]);
|
2025-09-03 15:23:12 +09:00
|
|
|
|
|
2025-09-09 17:42:23 +09:00
|
|
|
|
const filteredTemplates = dynamicTemplates.filter((template) => {
|
2025-09-03 15:23:12 +09:00
|
|
|
|
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 (
|
2025-10-02 14:34:15 +09:00
|
|
|
|
<div className="flex h-full flex-col bg-slate-50 p-6 border-r border-gray-200/60 shadow-sm">
|
2025-09-29 17:21:47 +09:00
|
|
|
|
{/* 헤더 */}
|
|
|
|
|
|
<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>
|
|
|
|
|
|
|
2025-09-03 15:23:12 +09:00
|
|
|
|
{/* 검색 */}
|
2025-09-29 17:21:47 +09:00
|
|
|
|
<div className="space-y-4">
|
2025-09-03 15:23:12 +09:00
|
|
|
|
<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)}
|
2025-09-29 17:21:47 +09:00
|
|
|
|
className="pl-10 border-0 bg-white/80 backdrop-blur-sm shadow-sm focus:bg-white transition-colors"
|
2025-09-03 15:23:12 +09:00
|
|
|
|
/>
|
|
|
|
|
|
</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)}
|
2025-10-02 14:34:15 +09:00
|
|
|
|
className="flex items-center space-x-1.5 h-8 px-3 text-xs font-medium rounded-full transition-all"
|
2025-09-03 15:23:12 +09:00
|
|
|
|
>
|
|
|
|
|
|
{category.icon}
|
|
|
|
|
|
<span>{category.name}</span>
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-09-09 17:42:23 +09:00
|
|
|
|
{/* 새로고침 버튼 */}
|
|
|
|
|
|
{error && (
|
2025-09-29 17:21:47 +09:00
|
|
|
|
<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">
|
2025-09-09 17:42:23 +09:00
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
|
|
<Info className="h-4 w-4" />
|
2025-11-25 13:04:58 +09:00
|
|
|
|
<span className="text-xs">템플릿 로딩 실패, 기본 템플릿 사용 중</span>
|
2025-09-09 17:42:23 +09:00
|
|
|
|
</div>
|
2025-09-29 17:21:47 +09:00
|
|
|
|
<Button size="sm" variant="outline" onClick={() => refetch()} className="border-amber-300 text-amber-700 hover:bg-amber-100">
|
2025-09-09 17:42:23 +09:00
|
|
|
|
<RefreshCw className="h-4 w-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2025-09-03 15:23:12 +09:00
|
|
|
|
{/* 템플릿 목록 */}
|
2025-09-29 17:21:47 +09:00
|
|
|
|
<div className="flex-1 space-y-3 overflow-y-auto mt-6">
|
2025-09-09 17:42:23 +09:00
|
|
|
|
{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 ? (
|
2025-09-03 15:23:12 +09:00
|
|
|
|
<div className="flex h-32 items-center justify-center text-center text-gray-500">
|
2025-09-29 17:21:47 +09:00
|
|
|
|
<div className="p-8">
|
|
|
|
|
|
<FileText className="mx-auto mb-3 h-12 w-12 text-gray-300" />
|
2025-10-02 14:34:15 +09:00
|
|
|
|
<p className="text-sm font-medium text-muted-foreground">템플릿을 찾을 수 없습니다</p>
|
2025-09-29 17:21:47 +09:00
|
|
|
|
<p className="text-xs text-gray-400 mt-1">검색어나 필터를 조정해보세요</p>
|
2025-09-03 15:23:12 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
filteredTemplates.map((template) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={template.id}
|
|
|
|
|
|
draggable
|
2025-09-29 17:21:47 +09:00
|
|
|
|
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';
|
|
|
|
|
|
}}
|
2025-09-30 18:42:33 +09:00
|
|
|
|
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"
|
2025-09-03 15:23:12 +09:00
|
|
|
|
>
|
2025-09-29 17:21:47 +09:00
|
|
|
|
<div className="flex items-start space-x-4">
|
2025-10-02 14:34:15 +09:00
|
|
|
|
<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">
|
2025-09-03 15:23:12 +09:00
|
|
|
|
{template.icon}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="min-w-0 flex-1">
|
2025-09-29 17:21:47 +09:00
|
|
|
|
<div className="flex items-start justify-between mb-2">
|
|
|
|
|
|
<h4 className="font-semibold text-gray-900 text-sm leading-tight">{template.name}</h4>
|
2025-10-02 14:34:15 +09:00
|
|
|
|
<Badge variant="secondary" className="text-xs bg-blue-50 text-blue-600 border-0 ml-2 px-2 py-1 rounded-full font-medium">
|
2025-09-29 17:21:47 +09:00
|
|
|
|
{template.components.length}
|
2025-09-03 15:23:12 +09:00
|
|
|
|
</Badge>
|
|
|
|
|
|
</div>
|
2025-09-29 17:21:47 +09:00
|
|
|
|
<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">
|
2025-10-02 14:34:15 +09:00
|
|
|
|
<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">
|
2025-09-29 17:21:47 +09:00
|
|
|
|
{template.defaultSize.width}×{template.defaultSize.height}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2025-10-02 14:34:15 +09:00
|
|
|
|
<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">
|
2025-09-29 17:21:47 +09:00
|
|
|
|
{template.category}
|
2025-09-03 15:23:12 +09:00
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 도움말 */}
|
2025-09-29 17:21:47 +09:00
|
|
|
|
<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">
|
2025-10-02 14:34:15 +09:00
|
|
|
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/20 text-primary">
|
2025-09-29 17:21:47 +09:00
|
|
|
|
<Info className="h-4 w-4" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="text-xs text-blue-800">
|
|
|
|
|
|
<p className="font-semibold mb-1">사용 방법</p>
|
2025-10-02 14:34:15 +09:00
|
|
|
|
<p className="text-primary leading-relaxed">템플릿을 캔버스로 드래그하여 빠르게 화면을 구성하세요.</p>
|
2025-09-03 15:23:12 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export default TemplatesPanel;
|