테이블 템플릿 제작

This commit is contained in:
kjs
2025-09-03 15:23:12 +09:00
parent 55a7e1dc89
commit 4a0c42d80c
14 changed files with 3757 additions and 42 deletions

View File

@@ -3,7 +3,20 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Menu, Database, Settings, Palette, Grid3X3, Save, Undo, Redo, Play, ArrowLeft, Cog } from "lucide-react";
import {
Menu,
Database,
Settings,
Palette,
Grid3X3,
Save,
Undo,
Redo,
Play,
ArrowLeft,
Cog,
Layout,
} from "lucide-react";
import { cn } from "@/lib/utils";
interface DesignerToolbarProps {
@@ -75,6 +88,19 @@ export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
</Badge>
</Button>
<Button
variant={panelStates.templates?.isOpen ? "default" : "outline"}
size="sm"
onClick={() => onTogglePanel("templates")}
className={cn("flex items-center space-x-2", panelStates.templates?.isOpen && "bg-blue-600 text-white")}
>
<Layout className="h-4 w-4" />
<span>릿</span>
<Badge variant="secondary" className="ml-1 text-xs">
M
</Badge>
</Button>
<Button
variant={panelStates.properties?.isOpen ? "default" : "outline"}
size="sm"

View File

@@ -0,0 +1,438 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Separator } from "@/components/ui/separator";
import { Search, ChevronLeft, ChevronRight, RotateCcw, Database, Loader2 } from "lucide-react";
import { tableTypeApi } from "@/lib/api/screen";
import { DataTableComponent, DataTableColumn, DataTableFilter } from "@/types/screen";
import { cn } from "@/lib/utils";
interface InteractiveDataTableProps {
component: DataTableComponent;
className?: string;
style?: React.CSSProperties;
}
export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
component,
className = "",
style = {},
}) => {
const [data, setData] = useState<Record<string, any>[]>([]);
const [loading, setLoading] = useState(false);
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [total, setTotal] = useState(0);
// 검색 가능한 컬럼만 필터링
const visibleColumns = component.columns?.filter((col: DataTableColumn) => col.visible) || [];
const searchFilters = component.filters || [];
// 그리드 컬럼 계산
const totalGridColumns = visibleColumns.reduce((sum, col) => sum + (col.gridColumns || 2), 0);
// 페이지 크기 설정
const pageSize = component.pagination?.pageSize || 10;
// 데이터 로드 함수
const loadData = useCallback(
async (page: number = 1, searchParams: Record<string, any> = {}) => {
if (!component.tableName) return;
setLoading(true);
try {
console.log("🔍 테이블 데이터 조회:", {
tableName: component.tableName,
page,
pageSize,
searchParams,
});
const result = await tableTypeApi.getTableData(component.tableName, {
page,
size: pageSize,
search: searchParams,
});
console.log("✅ 테이블 데이터 조회 결과:", result);
setData(result.data);
setTotal(result.total);
setTotalPages(result.totalPages);
setCurrentPage(result.page);
} catch (error) {
console.error("❌ 테이블 데이터 조회 실패:", error);
setData([]);
setTotal(0);
setTotalPages(1);
} finally {
setLoading(false);
}
},
[component.tableName, pageSize],
);
// 초기 데이터 로드
useEffect(() => {
loadData(1, searchValues);
}, [loadData]);
// 검색 실행
const handleSearch = useCallback(() => {
console.log("🔍 검색 실행:", searchValues);
loadData(1, searchValues);
}, [searchValues, loadData]);
// 검색값 변경
const handleSearchValueChange = useCallback((columnName: string, value: any) => {
setSearchValues((prev) => ({
...prev,
[columnName]: value,
}));
}, []);
// 페이지 변경
const handlePageChange = useCallback(
(page: number) => {
loadData(page, searchValues);
},
[loadData, searchValues],
);
// 검색 필터 렌더링
const renderSearchFilter = (filter: DataTableFilter) => {
const value = searchValues[filter.columnName] || "";
switch (filter.widgetType) {
case "text":
case "email":
case "tel":
return (
<Input
key={filter.columnName}
placeholder={`${filter.label} 검색...`}
value={value}
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
onKeyPress={(e) => {
if (e.key === "Enter") {
handleSearch();
}
}}
/>
);
case "number":
case "decimal":
return (
<Input
key={filter.columnName}
type="number"
placeholder={`${filter.label} 입력...`}
value={value}
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
onKeyPress={(e) => {
if (e.key === "Enter") {
handleSearch();
}
}}
/>
);
case "date":
return (
<Input
key={filter.columnName}
type="date"
value={value}
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
/>
);
case "datetime":
return (
<Input
key={filter.columnName}
type="datetime-local"
value={value}
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
/>
);
case "select":
// TODO: 선택 옵션은 추후 구현
return (
<Select
key={filter.columnName}
value={value}
onValueChange={(newValue) => handleSearchValueChange(filter.columnName, newValue)}
>
<SelectTrigger>
<SelectValue placeholder={`${filter.label} 선택...`} />
</SelectTrigger>
<SelectContent>
<SelectItem value=""></SelectItem>
{/* TODO: 동적 옵션 로드 */}
</SelectContent>
</Select>
);
default:
return (
<Input
key={filter.columnName}
placeholder={`${filter.label} 검색...`}
value={value}
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
onKeyPress={(e) => {
if (e.key === "Enter") {
handleSearch();
}
}}
/>
);
}
};
// 셀 값 포맷팅
const formatCellValue = (value: any, column: DataTableColumn) => {
if (value === null || value === undefined) return "";
switch (column.widgetType) {
case "date":
if (value) {
try {
const date = new Date(value);
return date.toLocaleDateString("ko-KR");
} catch {
return value;
}
}
break;
case "datetime":
if (value) {
try {
const date = new Date(value);
return date.toLocaleString("ko-KR");
} catch {
return value;
}
}
break;
case "number":
case "decimal":
if (typeof value === "number") {
return value.toLocaleString();
}
break;
default:
return String(value);
}
return String(value);
};
return (
<Card className={cn("flex h-full flex-col", className)} style={{ ...style, minHeight: "680px" }}>
{/* 헤더 */}
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Database className="text-muted-foreground h-4 w-4" />
<CardTitle className="text-lg">{component.title || component.label}</CardTitle>
{loading && (
<Badge variant="secondary" className="flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
...
</Badge>
)}
</div>
<div className="flex items-center space-x-2">
{searchFilters.length > 0 && (
<Badge variant="outline" className="text-xs">
<Search className="mr-1 h-3 w-3" />
{searchFilters.length}
</Badge>
)}
{component.showSearchButton && (
<Button size="sm" onClick={handleSearch} disabled={loading} className="gap-2">
<Search className="h-3 w-3" />
{component.searchButtonText || "검색"}
</Button>
)}
<Button size="sm" variant="outline" onClick={() => loadData(1, {})} disabled={loading} className="gap-2">
<RotateCcw className="h-3 w-3" />
</Button>
</div>
</div>
{/* 검색 필터 */}
{searchFilters.length > 0 && (
<>
<Separator className="my-2" />
<div className="space-y-3">
<CardDescription className="flex items-center gap-2">
<Search className="h-3 w-3" />
</CardDescription>
<div
className="grid gap-3"
style={{
gridTemplateColumns: searchFilters
.map((filter: DataTableFilter) => `${filter.gridColumns || 3}fr`)
.join(" "),
}}
>
{searchFilters.map((filter: DataTableFilter) => (
<div key={filter.columnName} className="space-y-1">
<label className="text-muted-foreground text-xs font-medium">{filter.label}</label>
{renderSearchFilter(filter)}
</div>
))}
</div>
</div>
</>
)}
</CardHeader>
{/* 테이블 내용 */}
<CardContent className="flex-1 p-0">
<div className="flex h-full flex-col">
{visibleColumns.length > 0 ? (
<>
<Table>
<TableHeader>
<TableRow>
{visibleColumns.map((column: DataTableColumn) => (
<TableHead
key={column.id}
className="px-4 font-semibold"
style={{ width: `${((column.gridColumns || 2) / totalGridColumns) * 100}%` }}
>
{column.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={visibleColumns.length} className="h-32 text-center">
<div className="text-muted-foreground flex items-center justify-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
...
</div>
</TableCell>
</TableRow>
) : data.length > 0 ? (
data.map((row, rowIndex) => (
<TableRow key={rowIndex} className="hover:bg-muted/50">
{visibleColumns.map((column: DataTableColumn) => (
<TableCell key={column.id} className="px-4 font-mono text-sm">
{formatCellValue(row[column.columnName], column)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={visibleColumns.length} className="h-32 text-center">
<div className="text-muted-foreground flex flex-col items-center gap-2">
<Database className="h-8 w-8" />
<p> </p>
<p className="text-xs"> </p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{/* 페이지네이션 */}
{component.pagination?.enabled && totalPages > 1 && (
<div className="bg-muted/20 mt-auto border-t">
<div className="flex items-center justify-between px-6 py-3">
{component.pagination.showPageInfo && (
<div className="text-muted-foreground text-sm">
<span className="font-medium">{total.toLocaleString()}</span> {" "}
<span className="font-medium">{((currentPage - 1) * pageSize + 1).toLocaleString()}</span>-
<span className="font-medium">{Math.min(currentPage * pageSize, total).toLocaleString()}</span>
</div>
)}
<div className="flex items-center space-x-2">
{component.pagination.showFirstLast && (
<Button
size="sm"
variant="outline"
onClick={() => handlePageChange(1)}
disabled={currentPage === 1 || loading}
className="gap-1"
>
</Button>
)}
<Button
size="sm"
variant="outline"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1 || loading}
className="gap-1"
>
<ChevronLeft className="h-3 w-3" />
</Button>
<div className="flex items-center gap-1 text-sm font-medium">
<span>{currentPage}</span>
<span className="text-muted-foreground">/</span>
<span>{totalPages}</span>
</div>
<Button
size="sm"
variant="outline"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages || loading}
className="gap-1"
>
<ChevronRight className="h-3 w-3" />
</Button>
{component.pagination.showFirstLast && (
<Button
size="sm"
variant="outline"
onClick={() => handlePageChange(totalPages)}
disabled={currentPage === totalPages || loading}
className="gap-1"
>
</Button>
)}
</div>
</div>
</div>
)}
</>
) : (
<div className="flex flex-1 items-center justify-center">
<div className="text-muted-foreground flex flex-col items-center gap-2">
<Database className="h-8 w-8" />
<p className="text-sm"> </p>
<p className="text-xs"> </p>
</div>
</div>
)}
</div>
</CardContent>
</Card>
);
};

View File

@@ -14,6 +14,7 @@ import { ko } from "date-fns/locale";
import {
ComponentData,
WidgetComponent,
DataTableComponent,
TextTypeConfig,
NumberTypeConfig,
DateTypeConfig,
@@ -25,6 +26,7 @@ import {
CodeTypeConfig,
EntityTypeConfig,
} from "@/types/screen";
import { InteractiveDataTable } from "./InteractiveDataTable";
interface InteractiveScreenViewerProps {
component: ComponentData;
@@ -70,6 +72,20 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
// 실제 사용 가능한 위젯 렌더링
const renderInteractiveWidget = (comp: ComponentData) => {
// 데이터 테이블 컴포넌트 처리
if (comp.type === "datatable") {
return (
<InteractiveDataTable
component={comp as DataTableComponent}
className="h-full w-full"
style={{
width: "100%",
height: "100%",
}}
/>
);
}
const { widgetType, label, placeholder, required, readonly, columnName } = comp;
const fieldName = columnName || comp.id;
const currentValue = formData[fieldName] || "";
@@ -686,18 +702,40 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
}
// 일반 위젯 컴포넌트
// 템플릿 컴포넌트 목록 (자체적으로 제목을 가지므로 라벨 불필요)
const templateTypes = ["datatable"];
// 라벨 표시 여부 계산
const shouldShowLabel =
component.style?.labelDisplay !== false &&
(component.label || component.style?.labelText) &&
!templateTypes.includes(component.type); // 템플릿 컴포넌트는 라벨 표시 안함
const labelText = component.style?.labelText || component.label || "";
// 라벨 스타일 적용
const labelStyle = {
fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#374151",
fontWeight: component.style?.labelFontWeight || "500",
backgroundColor: component.style?.labelBackgroundColor || "transparent",
padding: component.style?.labelPadding || "0",
borderRadius: component.style?.labelBorderRadius || "0",
marginBottom: component.style?.labelMarginBottom || "4px",
};
return (
<div className="h-full w-full">
{/* 라벨이 있는 경우 표시 */}
{component.label && (
<label className="mb-1 block text-sm font-medium text-gray-700">
{component.label}
{component.required && <span className="ml-1 text-red-500">*</span>}
</label>
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
{shouldShowLabel && (
<div className="block" style={labelStyle}>
{labelText}
{component.required && <span style={{ color: "#f97316", marginLeft: "2px" }}>*</span>}
</div>
)}
{/* 실제 위젯 */}
<div className={component.label ? "flex-1" : "h-full w-full"}>{renderInteractiveWidget(component)}</div>
<div className={shouldShowLabel ? "flex-1" : "h-full w-full"}>{renderInteractiveWidget(component)}</div>
</div>
);
};

View File

@@ -19,6 +19,11 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
// import { Checkbox } from "@/components/ui/checkbox";
// import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
@@ -36,6 +41,8 @@ import {
Group,
ChevronDown,
ChevronRight,
Search,
RotateCcw,
} from "lucide-react";
interface RealtimePreviewProps {
@@ -665,16 +672,17 @@ export const RealtimePreview: React.FC<RealtimePreviewProps> = ({
// 사용자가 테두리를 설정했는지 확인
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
// 기본 선택 테두리는 사용자 테두리가 없을 때만 적용
const defaultRingClass = hasCustomBorder
? ""
: isSelected
? "ring-opacity-50 ring-2 ring-blue-500"
: "hover:ring-opacity-50 hover:ring-1 hover:ring-gray-300";
// 기본 선택 테두리는 사용자 테두리가 없을 때만 적용 (데이터 테이블 제외)
const defaultRingClass =
hasCustomBorder || type === "datatable"
? ""
: isSelected
? "ring-opacity-50 ring-2 ring-blue-500"
: "hover:ring-opacity-50 hover:ring-1 hover:ring-gray-300";
// 사용자 테두리가 있을 때 선택 상태 표시를 위한 스타일
// 사용자 테두리가 있을 때 또는 데이터 테이블일 때 선택 상태 표시를 위한 스타일
const selectionStyle =
hasCustomBorder && isSelected
(hasCustomBorder || type === "datatable") && isSelected
? {
boxShadow: "0 0 0 2px rgba(59, 130, 246, 0.5)", // 외부 그림자로 선택 표시
...style,
@@ -699,6 +707,211 @@ export const RealtimePreview: React.FC<RealtimePreviewProps> = ({
borderRadius: component.style?.labelBorderRadius || "0",
};
// 데이터 테이블은 특별한 구조로 렌더링
if (type === "datatable") {
const dataTableComponent = component as any; // DataTableComponent 타입
// 메모이제이션을 위한 계산 최적화
const visibleColumns = React.useMemo(
() => dataTableComponent.columns?.filter((col: any) => col.visible) || [],
[dataTableComponent.columns],
);
const filters = React.useMemo(() => dataTableComponent.filters || [], [dataTableComponent.filters]);
return (
<div
className="absolute cursor-move"
style={{
left: `${component.position.x}px`,
top: `${component.position.y}px`,
width: `${size.width}px`,
height: shouldShowLabel ? `${size.height + 20 + labelMarginBottomValue}px` : `${size.height}px`,
zIndex: component.position.z || 1,
...(isSelected ? { boxShadow: "0 0 0 2px rgba(59, 130, 246, 0.5)" } : {}),
}}
onClick={(e) => {
e.stopPropagation();
onClick?.(e);
}}
draggable
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onMouseDown={(e) => {
e.stopPropagation();
}}
>
{/* 라벨 표시 */}
{shouldShowLabel && (
<div
className="pointer-events-none absolute left-0 w-full truncate"
style={{
...labelStyle,
top: `${-20 - labelMarginBottomValue}px`,
}}
>
{labelText}
{component.required && <span style={{ color: "#f97316", marginLeft: "2px" }}>*</span>}
</div>
)}
{/* Shadcn UI 기반 데이터 테이블 */}
<Card
className="flex h-full w-full flex-col overflow-hidden"
style={{
width: `${size.width}px`,
height: `${size.height}px`,
}}
>
{/* 카드 헤더 */}
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Database className="text-muted-foreground h-4 w-4" />
<CardTitle className="text-sm">{dataTableComponent.title || label}</CardTitle>
</div>
<div className="flex items-center space-x-2">
{filters.length > 0 && (
<Badge variant="outline" className="text-xs">
<Search className="mr-1 h-3 w-3" />
{filters.length}
</Badge>
)}
{dataTableComponent.showSearchButton && (
<Button size="sm" className="gap-1 text-xs">
<Search className="h-3 w-3" />
{dataTableComponent.searchButtonText || "검색"}
</Button>
)}
<Button size="sm" variant="outline" className="gap-1 text-xs">
<RotateCcw className="h-3 w-3" />
</Button>
</div>
</div>
{/* 필터 영역 미리보기 */}
{filters.length > 0 && (
<>
<Separator className="my-2" />
<div className="space-y-3">
<div className="text-muted-foreground flex items-center gap-2 text-xs">
<Search className="h-3 w-3" />
</div>
<div
className="grid gap-3"
style={{
gridTemplateColumns: filters.map((filter: any) => `${filter.gridColumns || 3}fr`).join(" "),
}}
>
{filters.map((filter: any, index: number) => (
<div key={`filter-${index}`} className="space-y-1">
<label className="text-muted-foreground text-xs font-medium">{filter.label}</label>
<div className="bg-background text-muted-foreground rounded border px-2 py-1 text-xs">
...
</div>
</div>
))}
</div>
</div>
</>
)}
</CardHeader>
{/* 테이블 내용 */}
<CardContent className="flex-1 p-0">
<div className="flex h-full flex-col">
{visibleColumns.length > 0 ? (
<>
<Table>
<TableHeader>
<TableRow>
{visibleColumns.map((column: any) => (
<TableHead key={column.id} className="px-4 text-xs font-semibold">
{column.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{/* 샘플 데이터 3행 */}
<TableRow className="hover:bg-muted/50">
{visibleColumns.map((column: any, colIndex: number) => (
<TableCell key={`sample1-${colIndex}`} className="px-4 font-mono text-xs">
1-{colIndex + 1}
</TableCell>
))}
</TableRow>
<TableRow className="hover:bg-muted/50">
{visibleColumns.map((column: any, colIndex: number) => (
<TableCell key={`sample2-${colIndex}`} className="px-4 font-mono text-xs">
2-{colIndex + 1}
</TableCell>
))}
</TableRow>
<TableRow className="hover:bg-muted/50">
{visibleColumns.map((column: any, colIndex: number) => (
<TableCell key={`sample3-${colIndex}`} className="px-4 font-mono text-xs">
3-{colIndex + 1}
</TableCell>
))}
</TableRow>
</TableBody>
</Table>
{/* 페이지네이션 미리보기 */}
{dataTableComponent.pagination?.enabled && (
<div className="bg-muted/20 mt-auto border-t">
<div className="flex items-center justify-between px-4 py-2">
{dataTableComponent.pagination.showPageInfo && (
<div className="text-muted-foreground text-xs">
<span className="font-medium">100</span> <span className="font-medium">1</span>-
<span className="font-medium">10</span>
</div>
)}
<div className="flex items-center space-x-2">
{dataTableComponent.pagination.showFirstLast && (
<Button size="sm" variant="outline" className="gap-1 text-xs">
</Button>
)}
<Button size="sm" variant="outline" className="gap-1 text-xs">
</Button>
<div className="flex items-center gap-1 text-xs font-medium">
<span>1</span>
<span className="text-muted-foreground">/</span>
<span>10</span>
</div>
<Button size="sm" variant="outline" className="gap-1 text-xs">
</Button>
{dataTableComponent.pagination.showFirstLast && (
<Button size="sm" variant="outline" className="gap-1 text-xs">
</Button>
)}
</div>
</div>
</div>
)}
</>
) : (
<div className="flex flex-1 items-center justify-center">
<div className="text-muted-foreground flex flex-col items-center gap-2">
<Database className="h-6 w-6" />
<p className="text-xs"> </p>
</div>
</div>
)}
</div>
</CardContent>
</Card>
</div>
);
}
// 다른 컴포넌트들은 기존 구조 사용
return (
<div
className={`absolute cursor-move transition-all ${defaultRingClass}`}
@@ -755,6 +968,168 @@ export const RealtimePreview: React.FC<RealtimePreviewProps> = ({
</div>
)}
{false &&
(() => {
const dataTableComponent = component as any; // DataTableComponent 타입
const visibleColumns = dataTableComponent.columns?.filter((col: any) => col.visible) || [];
const filters = dataTableComponent.filters || [];
return (
<>
{/* 데이터 테이블 헤더 */}
<div className="border-b bg-gray-50 px-4 py-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-900">{dataTableComponent.title || label}</h3>
<div className="flex items-center space-x-2">
{filters.length > 0 && <div className="text-xs text-gray-500"> {filters.length}</div>}
{dataTableComponent.showSearchButton && (
<button className="rounded bg-blue-600 px-3 py-1 text-xs text-white">
{dataTableComponent.searchButtonText || "검색"}
</button>
)}
</div>
</div>
{/* 필터 영역 미리보기 */}
{filters.length > 0 && (
<div className="mt-3 space-y-2">
<div className="text-xs font-medium text-gray-700"> </div>
<div
className="grid gap-2"
style={{
gridTemplateColumns: filters.map((filter: any) => `${filter.gridColumns || 3}fr`).join(" "),
}}
>
{filters.map((filter: any, index: number) => {
const getFilterIcon = (webType: string) => {
switch (webType) {
case "text":
case "email":
case "tel":
return "📝";
case "number":
case "decimal":
return "🔢";
case "date":
case "datetime":
return "📅";
case "select":
return "📋";
default:
return "🔍";
}
};
const getFilterPlaceholder = (webType: string) => {
switch (webType) {
case "text":
return "텍스트 검색...";
case "email":
return "이메일 검색...";
case "tel":
return "전화번호 검색...";
case "number":
return "숫자 입력...";
case "decimal":
return "소수 입력...";
case "date":
return "날짜 선택...";
case "datetime":
return "날짜시간 선택...";
case "select":
return "옵션 선택...";
default:
return "검색...";
}
};
return (
<div key={index} className="text-xs">
<div className="mb-1 flex items-center space-x-1 text-gray-600">
<span>{getFilterIcon(filter.widgetType)}</span>
<span>{filter.label}</span>
</div>
<div className="rounded border bg-white px-2 py-1 text-gray-400">
{getFilterPlaceholder(filter.widgetType)}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
{/* 테이블 내용 미리보기 */}
<div className="flex-1 p-4">
<div className="space-y-2">
{/* 테이블 헤더 행 */}
{visibleColumns.length > 0 ? (
<div
className="gap-2 border-b pb-2 text-xs font-medium text-gray-700"
style={{
display: "grid",
gridTemplateColumns: visibleColumns.map((col: any) => `${col.gridColumns || 2}fr`).join(" "),
}}
>
{visibleColumns.map((column: any) => (
<div key={column.id} className="truncate">
{column.label}
</div>
))}
</div>
) : (
<div className="py-4 text-center text-xs text-gray-500"> </div>
)}
{/* 샘플 데이터 행들 */}
{visibleColumns.length > 0 &&
[1, 2, 3].map((row) => (
<div
key={row}
className="gap-2 py-1 text-xs text-gray-600"
style={{
display: "grid",
gridTemplateColumns: visibleColumns
.map((col: any) => `${col.gridColumns || 2}fr`)
.join(" "),
}}
>
{visibleColumns.map((column: any, colIndex: number) => (
<div key={colIndex} className="truncate">
{row}-{colIndex + 1}
</div>
))}
</div>
))}
</div>
</div>
{/* 페이지네이션 미리보기 */}
{dataTableComponent.pagination?.enabled && (
<div className="border-t bg-gray-50 px-4 py-2">
<div className="flex items-center justify-between text-xs text-gray-600">
{dataTableComponent.pagination.showPageInfo && (
<div> 100 1-{dataTableComponent.pagination.pageSize || 10}</div>
)}
<div className="flex items-center space-x-1">
{dataTableComponent.pagination.showFirstLast && (
<button className="rounded border px-2 py-1"></button>
)}
<button className="rounded border px-2 py-1"></button>
<span className="px-2">1</span>
<button className="rounded border px-2 py-1"></button>
{dataTableComponent.pagination.showFirstLast && (
<button className="rounded border px-2 py-1"></button>
)}
</div>
</div>
</div>
)}
</>
);
})()}
{type === "group" && (
<div className="relative h-full w-full">
{/* 그룹 내용 */}

View File

@@ -29,6 +29,7 @@ import {
alignGroupChildrenToGrid,
calculateOptimalGroupSize,
normalizeGroupChildPositions,
calculateWidthFromColumns,
GridSettings as GridUtilSettings,
} from "@/lib/utils/gridUtils";
import { GroupingToolbar } from "./GroupingToolbar";
@@ -40,6 +41,7 @@ import { RealtimePreview } from "./RealtimePreview";
import FloatingPanel from "./FloatingPanel";
import DesignerToolbar from "./DesignerToolbar";
import TablesPanel from "./panels/TablesPanel";
import { TemplatesPanel, TemplateComponent } from "./panels/TemplatesPanel";
import PropertiesPanel from "./panels/PropertiesPanel";
import DetailSettingsPanel from "./panels/DetailSettingsPanel";
import GridPanel from "./panels/GridPanel";
@@ -60,6 +62,14 @@ const panelConfigs: PanelConfig[] = [
defaultHeight: 700, // 테이블 목록은 그대로 유지
shortcutKey: "t",
},
{
id: "templates",
title: "템플릿",
defaultPosition: "left",
defaultWidth: 380,
defaultHeight: 700,
shortcutKey: "m", // template의 m
},
{
id: "properties",
title: "속성 편집",
@@ -298,6 +308,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}
// gridColumns 변경 시 크기 자동 업데이트
console.log("🔍 gridColumns 변경 감지:", {
path,
value,
componentType: newComp.type,
hasGridInfo: !!gridInfo,
hasGridSettings: !!layout.gridSettings,
currentGridColumns: (newComp as any).gridColumns,
});
if (path === "gridColumns" && gridInfo) {
const updatedSize = updateSizeFromGridColumns(newComp, gridInfo, layout.gridSettings as GridUtilSettings);
newComp.size = updatedSize;
@@ -306,6 +325,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
oldSize: comp.size,
newSize: updatedSize,
});
} else if (path === "gridColumns") {
console.log("❌ gridColumns 변경 실패:", {
hasGridInfo: !!gridInfo,
hasGridSettings: !!layout.gridSettings,
gridInfo,
gridSettings: layout.gridSettings,
});
}
// 크기 변경 시 격자 스냅 적용 (그룹 컴포넌트 제외)
@@ -414,6 +440,27 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
setLayout(newLayout);
saveToHistory(newLayout);
// selectedComponent가 업데이트된 컴포넌트와 같다면 selectedComponent도 업데이트
if (selectedComponent && selectedComponent.id === componentId) {
const updatedSelectedComponent = updatedComponents.find((c) => c.id === componentId);
if (updatedSelectedComponent) {
console.log("🔄 selectedComponent 동기화:", {
componentId,
path,
oldColumnsCount:
selectedComponent.type === "datatable" ? (selectedComponent as any).columns?.length : "N/A",
newColumnsCount:
updatedSelectedComponent.type === "datatable" ? (updatedSelectedComponent as any).columns?.length : "N/A",
oldFiltersCount:
selectedComponent.type === "datatable" ? (selectedComponent as any).filters?.length : "N/A",
newFiltersCount:
updatedSelectedComponent.type === "datatable" ? (updatedSelectedComponent as any).filters?.length : "N/A",
timestamp: new Date().toISOString(),
});
setSelectedComponent(updatedSelectedComponent);
}
}
// webTypeConfig 업데이트 후 레이아웃 상태 확인
if (path === "webTypeConfig") {
const updatedComponent = newLayout.components.find((c) => c.id === componentId);
@@ -574,6 +621,221 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}
}, [selectedScreen?.screenId, layout]);
// 템플릿 드래그 처리
const handleTemplateDrop = useCallback(
(e: React.DragEvent, template: TemplateComponent) => {
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const dropX = e.clientX - rect.left;
const dropY = e.clientY - rect.top;
// 격자 스냅 적용
const snappedPosition =
layout.gridSettings?.snapToGrid && gridInfo
? snapToGrid({ x: dropX, y: dropY, z: 1 }, gridInfo, layout.gridSettings)
: { x: dropX, y: dropY, z: 1 };
console.log("🎨 템플릿 드롭:", {
templateName: template.name,
componentsCount: template.components.length,
dropPosition: { x: dropX, y: dropY },
snappedPosition,
});
// 템플릿의 모든 컴포넌트들을 생성
const newComponents: ComponentData[] = template.components.map((templateComp, index) => {
const componentId = generateComponentId();
// 템플릿 컴포넌트의 상대 위치를 드롭 위치 기준으로 조정
const absoluteX = snappedPosition.x + templateComp.position.x;
const absoluteY = snappedPosition.y + templateComp.position.y;
// 격자 스냅 적용
const finalPosition =
layout.gridSettings?.snapToGrid && gridInfo
? snapToGrid({ x: absoluteX, y: absoluteY, z: 1 }, gridInfo, layout.gridSettings)
: { x: absoluteX, y: absoluteY, z: 1 };
if (templateComp.type === "container") {
return {
id: componentId,
type: "container",
label: templateComp.label,
tableName: selectedScreen?.tableName || "",
position: finalPosition,
size: templateComp.size,
style: {
labelDisplay: true,
labelFontSize: "14px",
labelColor: "#374151",
labelFontWeight: "600",
labelMarginBottom: "8px",
...templateComp.style,
},
};
} else if (templateComp.type === "datatable") {
// 데이터 테이블 컴포넌트 생성
const gridColumns = 6; // 기본값: 6컬럼 (50% 너비)
// gridColumns에 맞는 크기 계산
const calculatedSize =
gridInfo && layout.gridSettings?.snapToGrid
? (() => {
const newWidth = calculateWidthFromColumns(
gridColumns,
gridInfo,
layout.gridSettings as GridUtilSettings,
);
return {
width: newWidth,
height: templateComp.size.height, // 높이는 템플릿 값 유지
};
})()
: templateComp.size;
console.log("📊 데이터 테이블 생성 시 크기 계산:", {
gridColumns,
templateSize: templateComp.size,
calculatedSize,
hasGridInfo: !!gridInfo,
hasGridSettings: !!layout.gridSettings?.snapToGrid,
});
return {
id: componentId,
type: "datatable",
label: templateComp.label,
tableName: selectedScreen?.tableName || "",
position: finalPosition,
size: calculatedSize,
title: templateComp.label,
columns: [], // 초기에는 빈 배열, 나중에 설정
filters: [], // 초기에는 빈 배열, 나중에 설정
pagination: {
enabled: true,
pageSize: 10,
pageSizeOptions: [5, 10, 20, 50],
showPageSizeSelector: true,
showPageInfo: true,
showFirstLast: true,
},
showSearchButton: true,
searchButtonText: "검색",
enableExport: true,
enableRefresh: true,
gridColumns,
style: {
labelDisplay: true,
labelFontSize: "14px",
labelColor: "#374151",
labelFontWeight: "600",
labelMarginBottom: "8px",
...templateComp.style,
},
} as ComponentData;
} else {
// 위젯 컴포넌트
const widgetType = templateComp.widgetType || "text";
// 웹타입별 기본 설정 생성
const getDefaultWebTypeConfig = (wType: string) => {
switch (wType) {
case "date":
return {
format: "YYYY-MM-DD" as const,
showTime: false,
placeholder: templateComp.placeholder || "날짜를 선택하세요",
};
case "select":
case "dropdown":
return {
options: [
{ label: "옵션 1", value: "option1" },
{ label: "옵션 2", value: "option2" },
{ label: "옵션 3", value: "option3" },
],
multiple: false,
searchable: false,
placeholder: templateComp.placeholder || "옵션을 선택하세요",
};
case "text":
return {
format: "none" as const,
placeholder: templateComp.placeholder || "텍스트를 입력하세요",
multiline: false,
};
case "email":
return {
format: "email" as const,
placeholder: templateComp.placeholder || "이메일을 입력하세요",
multiline: false,
};
case "tel":
return {
format: "phone" as const,
placeholder: templateComp.placeholder || "전화번호를 입력하세요",
multiline: false,
};
case "textarea":
return {
rows: 3,
placeholder: templateComp.placeholder || "텍스트를 입력하세요",
resizable: true,
wordWrap: true,
};
default:
return {
placeholder: templateComp.placeholder || "입력하세요",
};
}
};
return {
id: componentId,
type: "widget",
widgetType: widgetType as any,
label: templateComp.label,
placeholder: templateComp.placeholder,
columnName: `field_${index + 1}`,
position: finalPosition,
size: templateComp.size,
required: templateComp.required || false,
readonly: templateComp.readonly || false,
gridColumns: 1,
webTypeConfig: getDefaultWebTypeConfig(widgetType),
style: {
labelDisplay: true,
labelFontSize: "14px",
labelColor: "#374151",
labelFontWeight: "600",
labelMarginBottom: "8px",
...templateComp.style,
},
} as ComponentData;
}
});
// 레이아웃에 새 컴포넌트들 추가
const newLayout = {
...layout,
components: [...layout.components, ...newComponents],
};
setLayout(newLayout);
saveToHistory(newLayout);
// 첫 번째 컴포넌트 선택
if (newComponents.length > 0) {
setSelectedComponent(newComponents[0]);
openPanel("properties");
}
toast.success(`${template.name} 템플릿이 추가되었습니다.`);
},
[layout, gridInfo, selectedScreen, snapToGrid, saveToHistory, openPanel],
);
// 드래그 앤 드롭 처리
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
@@ -587,7 +849,16 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
if (!dragData) return;
try {
const { type, table, column } = JSON.parse(dragData);
const parsedData = JSON.parse(dragData);
// 템플릿 드래그인 경우
if (parsedData.type === "template") {
handleTemplateDrop(e, parsedData.template);
return;
}
// 기존 테이블/컬럼 드래그 처리
const { type, table, column } = parsedData;
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
@@ -2081,8 +2352,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
<div className="text-center text-gray-400">
<Database className="mx-auto mb-4 h-16 w-16" />
<h3 className="mb-2 text-xl font-medium"> </h3>
<p className="text-sm"> </p>
<p className="mt-2 text-xs">단축키: T(), P(), S(), R(), D()</p>
<p className="text-sm"> / 릿 </p>
<p className="mt-2 text-xs">단축키: T(), M(릿), P(), S(), R(), D()</p>
<p className="mt-1 text-xs">
편집: Ctrl+C(), Ctrl+V(), Ctrl+S(), Ctrl+Z(), Delete()
</p>
@@ -2121,6 +2392,27 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
/>
</FloatingPanel>
<FloatingPanel
id="templates"
title="템플릿"
isOpen={panelStates.templates?.isOpen || false}
onClose={() => closePanel("templates")}
position="left"
width={380}
height={700}
autoHeight={false}
>
<TemplatesPanel
onDragStart={(e, template) => {
const dragData = {
type: "template",
template,
};
e.dataTransfer.setData("application/json", JSON.stringify(dragData));
}}
/>
</FloatingPanel>
<FloatingPanel
id="properties"
title="속성 편집"
@@ -2133,7 +2425,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
>
<PropertiesPanel
selectedComponent={selectedComponent || undefined}
tables={tables}
onUpdateProperty={(path: string, value: any) => {
console.log("🔧 속성 업데이트 요청:", {
componentId: selectedComponent?.id,
componentType: selectedComponent?.type,
path,
value: typeof value === "object" ? JSON.stringify(value).substring(0, 100) + "..." : value,
});
if (selectedComponent) {
updateComponentProperty(selectedComponent.id, path, value);
}

File diff suppressed because it is too large Load Diff

View File

@@ -9,10 +9,12 @@ import { Separator } from "@/components/ui/separator";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { Settings, Move, Type, Trash2, Copy, Group, Ungroup } from "lucide-react";
import { ComponentData, WebType, WidgetComponent, GroupComponent } from "@/types/screen";
import { ComponentData, WebType, WidgetComponent, GroupComponent, DataTableComponent, TableInfo } from "@/types/screen";
import DataTableConfigPanel from "./DataTableConfigPanel";
interface PropertiesPanelProps {
selectedComponent?: ComponentData;
tables?: TableInfo[];
onUpdateProperty: (path: string, value: unknown) => void;
onDeleteComponent: () => void;
onCopyComponent: () => void;
@@ -43,6 +45,7 @@ const webTypeOptions: { value: WebType; label: string }[] = [
export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
selectedComponent,
tables = [],
onUpdateProperty,
onDeleteComponent,
onCopyComponent,
@@ -71,6 +74,7 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
labelMarginBottom: selectedComponent?.style?.labelMarginBottom || "4px",
required: (selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).required : false) || false,
readonly: (selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).readonly : false) || false,
labelDisplay: selectedComponent?.style?.labelDisplay !== false,
});
useEffect(() => {
@@ -83,6 +87,18 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
if (selectedComponent) {
const widget = selectedComponent.type === "widget" ? (selectedComponent as WidgetComponent) : null;
const group = selectedComponent.type === "group" ? (selectedComponent as GroupComponent) : null;
console.log("🔄 PropertiesPanel: 컴포넌트 변경 감지", {
componentId: selectedComponent.id,
componentType: selectedComponent.type,
currentValues: {
placeholder: widget?.placeholder,
title: group?.title,
positionX: selectedComponent.position.x,
labelText: selectedComponent.style?.labelText || selectedComponent.label,
},
});
setLocalInputs({
placeholder: widget?.placeholder || "",
title: group?.title || "",
@@ -98,9 +114,16 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
labelMarginBottom: selectedComponent.style?.labelMarginBottom || "4px",
required: widget?.required || false,
readonly: widget?.readonly || false,
labelDisplay: selectedComponent.style?.labelDisplay !== false,
});
}
}, [selectedComponent]);
}, [
selectedComponent,
selectedComponent?.position,
selectedComponent?.size,
selectedComponent?.style,
selectedComponent?.label,
]);
if (!selectedComponent) {
return (
@@ -112,6 +135,65 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
);
}
// 데이터 테이블 컴포넌트인 경우 전용 패널 사용
if (selectedComponent.type === "datatable") {
return (
<div className="flex h-full flex-col">
{/* 헤더 */}
<div className="border-b border-gray-200 p-4">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center space-x-2">
<Settings className="h-5 w-5 text-gray-600" />
<span className="text-lg font-semibold"> </span>
</div>
<Badge variant="secondary" className="text-xs">
{selectedComponent.type}
</Badge>
</div>
{/* 액션 버튼들 */}
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={onCopyComponent}>
<Copy className="mr-1 h-4 w-4" />
</Button>
<Button variant="destructive" size="sm" onClick={onDeleteComponent}>
<Trash2 className="mr-1 h-4 w-4" />
</Button>
</div>
</div>
{/* 데이터 테이블 설정 패널 */}
<div className="flex-1 overflow-y-auto">
<DataTableConfigPanel
key={`datatable-${selectedComponent.id}-${selectedComponent.columns.length}-${selectedComponent.filters.length}-${JSON.stringify(selectedComponent.columns.map((c) => c.id))}-${JSON.stringify(selectedComponent.filters.map((f) => f.columnName))}`}
component={selectedComponent as DataTableComponent}
tables={tables}
onUpdateComponent={(updates) => {
console.log("🔄 DataTable 컴포넌트 업데이트:", updates);
console.log("🔄 업데이트 항목들:", Object.keys(updates));
// 각 속성을 개별적으로 업데이트
Object.entries(updates).forEach(([key, value]) => {
console.log(` - ${key}:`, value);
if (key === "columns") {
console.log(` 컬럼 개수: ${Array.isArray(value) ? value.length : 0}`);
}
if (key === "filters") {
console.log(` 필터 개수: ${Array.isArray(value) ? value.length : 0}`);
}
onUpdateProperty(key, value);
});
console.log("✅ DataTable 컴포넌트 업데이트 완료");
}}
/>
</div>
</div>
);
}
return (
<div className="flex h-full flex-col">
{/* 헤더 */}
@@ -210,6 +292,7 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
value={localInputs.placeholder}
onChange={(e) => {
const newValue = e.target.value;
console.log("🔄 placeholder 변경:", newValue);
setLocalInputs((prev) => ({ ...prev, placeholder: newValue }));
onUpdateProperty("placeholder", newValue);
}}
@@ -394,8 +477,12 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
</Label>
<Checkbox
id="labelDisplay"
checked={selectedComponent.style?.labelDisplay !== false}
onCheckedChange={(checked) => onUpdateProperty("style.labelDisplay", checked)}
checked={localInputs.labelDisplay}
onCheckedChange={(checked) => {
console.log("🔄 라벨 표시 변경:", checked);
setLocalInputs((prev) => ({ ...prev, labelDisplay: checked as boolean }));
onUpdateProperty("style.labelDisplay", checked);
}}
/>
</div>
@@ -409,6 +496,7 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
value={localInputs.labelText}
onChange={(e) => {
const newValue = e.target.value;
console.log("🔄 라벨 텍스트 변경:", newValue);
setLocalInputs((prev) => ({ ...prev, labelText: newValue }));
// 기본 라벨과 스타일 라벨을 모두 업데이트
onUpdateProperty("label", newValue);

View File

@@ -0,0 +1,170 @@
"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 { Table, Search, FileText, Grid3x3, Info } from "lucide-react";
// 템플릿 컴포넌트 타입 정의
export interface TemplateComponent {
id: string;
name: string;
description: string;
category: "table" | "button" | "form" | "layout" | "chart" | "status";
icon: React.ReactNode;
defaultSize: { width: number; height: number };
components: Array<{
type: "widget" | "container";
widgetType?: string;
label: string;
placeholder?: string;
position: { x: number; y: number };
size: { width: number; height: number };
style?: any;
required?: boolean;
readonly?: boolean;
}>;
}
// 미리 정의된 템플릿 컴포넌트들
const templateComponents: 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",
},
},
],
},
];
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 categories = [
{ id: "all", name: "전체", icon: <Grid3x3 className="h-4 w-4" /> },
{ id: "table", name: "테이블", icon: <Table className="h-4 w-4" /> },
];
const filteredTemplates = templateComponents.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 space-y-4 p-4">
{/* 검색 */}
<div className="space-y-3">
<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 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"
>
{category.icon}
<span>{category.name}</span>
</Button>
))}
</div>
</div>
<Separator />
{/* 템플릿 목록 */}
<div className="flex-1 space-y-2 overflow-y-auto">
{filteredTemplates.length === 0 ? (
<div className="flex h-32 items-center justify-center text-center text-gray-500">
<div>
<FileText className="mx-auto mb-2 h-8 w-8" />
<p className="text-sm"> </p>
</div>
</div>
) : (
filteredTemplates.map((template) => (
<div
key={template.id}
draggable
onDragStart={(e) => onDragStart(e, template)}
className="group cursor-move rounded-lg border border-gray-200 bg-white p-3 shadow-sm transition-all hover:border-blue-300 hover:shadow-md"
>
<div className="flex items-start space-x-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-50 text-blue-600 group-hover:bg-blue-100">
{template.icon}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center space-x-2">
<h4 className="truncate font-medium text-gray-900">{template.name}</h4>
<Badge variant="secondary" className="text-xs">
{template.components.length}
</Badge>
</div>
<p className="mt-1 line-clamp-2 text-xs text-gray-500">{template.description}</p>
<div className="mt-2 flex items-center space-x-2 text-xs text-gray-400">
<span>
{template.defaultSize.width}×{template.defaultSize.height}
</span>
<span></span>
<span className="capitalize">{template.category}</span>
</div>
</div>
</div>
</div>
))
)}
</div>
{/* 도움말 */}
<div className="rounded-lg bg-blue-50 p-3">
<div className="flex items-start space-x-2">
<Info className="mt-0.5 h-4 w-4 flex-shrink-0 text-blue-600" />
<div className="text-xs text-blue-700">
<p className="mb-1 font-medium"> </p>
<p>릿 .</p>
</div>
</div>
</div>
</div>
);
};
export default TemplatesPanel;