ERP-node/frontend/components/screen/RealtimePreviewDynamic.tsx

431 lines
15 KiB
TypeScript
Raw Normal View History

2025-09-09 14:29:04 +09:00
"use client";
import React from "react";
import { ComponentData, WebType, WidgetComponent, FileComponent, AreaComponent, AreaLayoutType } from "@/types/screen";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
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 { FileUpload } from "./widgets/FileUpload";
import { useAuth } from "@/hooks/useAuth";
import { DynamicWebTypeRenderer, WebTypeRegistry } from "@/lib/registry";
import {
Database,
Type,
Hash,
List,
AlignLeft,
CheckSquare,
Radio,
Calendar,
Code,
Building,
File,
Group,
ChevronDown,
ChevronRight,
Search,
RotateCcw,
Plus,
Edit,
Trash2,
Upload,
Square,
CreditCard,
Layout,
Grid3x3,
Columns,
Rows,
SidebarOpen,
Folder,
ChevronUp,
} from "lucide-react";
interface RealtimePreviewProps {
component: ComponentData;
isSelected?: boolean;
onClick?: (e?: React.MouseEvent) => void;
onDragStart?: (e: React.DragEvent) => void;
onDragEnd?: () => void;
onGroupToggle?: (groupId: string) => void; // 그룹 접기/펼치기
children?: React.ReactNode; // 그룹 내 자식 컴포넌트들
}
// 영역 레이아웃에 따른 아이콘 반환
const getAreaIcon = (layoutType: AreaLayoutType) => {
switch (layoutType) {
case "flex":
return <Layout className="h-4 w-4 text-blue-600" />;
case "grid":
return <Grid3x3 className="h-4 w-4 text-green-600" />;
case "columns":
return <Columns className="h-4 w-4 text-purple-600" />;
case "rows":
return <Rows className="h-4 w-4 text-orange-600" />;
case "sidebar":
return <SidebarOpen className="h-4 w-4 text-indigo-600" />;
case "tabs":
return <Folder className="h-4 w-4 text-pink-600" />;
default:
return <Square className="h-4 w-4 text-gray-500" />;
}
};
// 영역 렌더링
const renderArea = (component: ComponentData, children?: React.ReactNode) => {
const area = component as AreaComponent;
const { areaType, label } = area;
const renderPlaceholder = () => (
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
<div className="text-center">
{getAreaIcon(areaType)}
<p className="mt-2 text-sm text-gray-600">{label || `${areaType} 영역`}</p>
<p className="text-xs text-gray-400"> </p>
</div>
</div>
);
return (
<div className="relative h-full w-full">
<div className="absolute inset-0 h-full w-full">
{children && React.Children.count(children) > 0 ? children : renderPlaceholder()}
</div>
</div>
);
};
// 동적 웹 타입 위젯 렌더링
const renderWidget = (component: ComponentData) => {
// 위젯 컴포넌트가 아닌 경우 빈 div 반환
if (component.type !== "widget") {
return <div className="text-xs text-gray-500"> </div>;
}
const widget = component as WidgetComponent;
const { widgetType, label, placeholder, required, readonly, columnName, style } = widget;
// 디버깅: 실제 widgetType 값 확인
console.log("RealtimePreviewDynamic - widgetType:", widgetType, "columnName:", columnName);
// 사용자가 테두리를 설정했는지 확인
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
// 기본 테두리 제거 여부 결정 - Shadcn UI 기본 border 클래스를 덮어쓰기
const borderClass = hasCustomBorder ? "!border-0" : "";
const commonProps = {
placeholder: placeholder || "입력하세요...",
disabled: readonly,
required: required,
className: `w-full h-full ${borderClass}`,
};
// 동적 웹타입 렌더링 사용
if (widgetType) {
try {
return (
<DynamicWebTypeRenderer
webType={widgetType}
props={{
...commonProps,
component: widget,
value: undefined, // 미리보기이므로 값은 없음
readonly: readonly,
}}
config={widget.webTypeConfig}
/>
);
} catch (error) {
console.error(`웹타입 "${widgetType}" 렌더링 실패:`, error);
// 오류 발생 시 폴백으로 기본 input 렌더링
return <Input type="text" {...commonProps} placeholder={`${widgetType} (렌더링 오류)`} />;
}
}
// 웹타입이 없는 경우 기본 input 렌더링 (하위 호환성)
return <Input type="text" {...commonProps} />;
};
// 동적 위젯 타입 아이콘 (레지스트리에서 조회)
const getWidgetIcon = (widgetType: WebType | undefined) => {
if (!widgetType) {
return <Type className="h-4 w-4 text-gray-500" />;
}
// 레지스트리에서 웹타입 정의 조회
const webTypeDefinition = WebTypeRegistry.getWebType(widgetType);
if (webTypeDefinition && webTypeDefinition.icon) {
const IconComponent = webTypeDefinition.icon;
return <IconComponent className="h-4 w-4" />;
}
// 기본 아이콘 매핑 (하위 호환성)
switch (widgetType) {
case "text":
case "email":
case "tel":
return <Type className="h-4 w-4 text-blue-600" />;
case "number":
case "decimal":
return <Hash className="h-4 w-4 text-green-600" />;
case "date":
case "datetime":
return <Calendar className="h-4 w-4 text-purple-600" />;
case "select":
case "dropdown":
return <List className="h-4 w-4 text-orange-600" />;
case "textarea":
case "text_area":
return <AlignLeft className="h-4 w-4 text-indigo-600" />;
case "boolean":
case "checkbox":
return <CheckSquare className="h-4 w-4 text-blue-600" />;
case "radio":
return <Radio className="h-4 w-4 text-blue-600" />;
case "code":
return <Code className="h-4 w-4 text-gray-600" />;
case "entity":
return <Building className="h-4 w-4 text-cyan-600" />;
case "file":
return <File className="h-4 w-4 text-yellow-600" />;
default:
return <Type className="h-4 w-4 text-gray-500" />;
}
};
export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
component,
isSelected = false,
onClick,
onDragStart,
onDragEnd,
onGroupToggle,
children,
}) => {
const { user } = useAuth();
const { type, id, position, size, style = {} } = component;
// 컴포넌트 스타일 계산
const componentStyle = {
position: "absolute" as const,
left: position?.x || 0,
top: position?.y || 0,
width: size?.width || 200,
height: size?.height || 40,
zIndex: position?.z || 1,
...style,
};
// 선택된 컴포넌트 스타일
const selectionStyle = isSelected
? {
outline: "2px solid #3b82f6",
outlineOffset: "2px",
}
: {};
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.(e);
};
const handleDragStart = (e: React.DragEvent) => {
e.stopPropagation();
onDragStart?.(e);
};
const handleDragEnd = () => {
onDragEnd?.();
};
return (
<div
id={`component-${id}`}
className="absolute cursor-pointer"
style={{ ...componentStyle, ...selectionStyle }}
onClick={handleClick}
draggable
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
{/* 컴포넌트 타입별 렌더링 */}
<div className="h-full w-full">
{/* 영역 타입 */}
{type === "area" && renderArea(component, children)}
{/* 데이터 테이블 타입 */}
{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="flex h-full w-full flex-col overflow-hidden rounded border bg-white">
{/* 테이블 제목 */}
{dataTableComponent.title && (
<div className="border-b bg-gray-50 px-4 py-2">
<h3 className="text-sm font-medium">{dataTableComponent.title}</h3>
</div>
)}
{/* 검색 및 필터 영역 */}
{(dataTableComponent.showSearchButton || filters.length > 0) && (
<div className="border-b bg-gray-50 px-4 py-2">
<div className="flex items-center space-x-2">
{dataTableComponent.showSearchButton && (
<div className="flex items-center space-x-2">
<Input placeholder="검색..." className="h-8 w-48" />
<Button size="sm" variant="outline">
{dataTableComponent.searchButtonText || "검색"}
</Button>
</div>
)}
{filters.length > 0 && (
<div className="flex items-center space-x-2">
<span className="text-xs text-gray-500">:</span>
{filters.slice(0, 2).map((filter: any, index: number) => (
<Badge key={index} variant="secondary" className="text-xs">
{filter.label || filter.columnName}
</Badge>
))}
{filters.length > 2 && (
<Badge variant="secondary" className="text-xs">
+{filters.length - 2}
</Badge>
)}
</div>
)}
</div>
</div>
)}
{/* 테이블 본체 */}
<div className="flex-1 overflow-auto">
<Table>
<TableHeader>
<TableRow>
{visibleColumns.length > 0 ? (
visibleColumns.map((col: any, index: number) => (
<TableHead key={col.id || index} className="text-xs">
{col.label || col.columnName}
{col.sortable && <span className="ml-1 text-gray-400"></span>}
</TableHead>
))
) : (
<>
<TableHead className="text-xs"> 1</TableHead>
<TableHead className="text-xs"> 2</TableHead>
<TableHead className="text-xs"> 3</TableHead>
</>
)}
</TableRow>
</TableHeader>
<TableBody>
{/* 샘플 데이터 행들 */}
{[1, 2, 3].map((rowIndex) => (
<TableRow key={rowIndex}>
{visibleColumns.length > 0 ? (
visibleColumns.map((col: any, colIndex: number) => (
<TableCell key={col.id || colIndex} className="text-xs">
{col.widgetType === "checkbox" ? (
<input type="checkbox" className="h-3 w-3" />
) : col.widgetType === "select" ? (
`옵션 ${rowIndex}`
) : col.widgetType === "date" ? (
"2024-01-01"
) : col.widgetType === "number" ? (
`${rowIndex * 100}`
) : (
`데이터 ${rowIndex}-${colIndex + 1}`
)}
</TableCell>
))
) : (
<>
<TableCell className="text-xs"> {rowIndex}-1</TableCell>
<TableCell className="text-xs"> {rowIndex}-2</TableCell>
<TableCell className="text-xs"> {rowIndex}-3</TableCell>
</>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* 페이지네이션 */}
{dataTableComponent.pagination && (
<div className="border-t bg-gray-50 px-4 py-2">
<div className="flex items-center justify-between text-xs text-gray-600">
<span> 3 </span>
<div className="flex items-center space-x-2">
<Button size="sm" variant="outline" disabled>
</Button>
<span>1 / 1</span>
<Button size="sm" variant="outline" disabled>
</Button>
</div>
</div>
</div>
)}
</div>
);
})()}
{/* 그룹 타입 */}
{type === "group" && (
<div className="relative h-full w-full">
<div className="absolute inset-0">{children}</div>
</div>
)}
{/* 위젯 타입 - 동적 렌더링 */}
{type === "widget" && (
<div className="flex h-full flex-col">
<div className="pointer-events-none flex-1">{renderWidget(component)}</div>
</div>
)}
{/* 파일 타입 */}
{type === "file" && (
<div className="flex h-full flex-col">
<div className="pointer-events-none flex-1">
<FileUpload disabled placeholder="파일 업로드 미리보기" />
</div>
</div>
)}
</div>
{/* 선택된 컴포넌트 정보 표시 */}
{isSelected && (
<div className="absolute -top-6 left-0 rounded bg-blue-600 px-2 py-1 text-xs text-white">
{type === "widget" && (
<div className="flex items-center gap-1">
{getWidgetIcon((component as WidgetComponent).widgetType)}
{(component as WidgetComponent).widgetType || "widget"}
</div>
)}
{type !== "widget" && type}
</div>
)}
</div>
);
};
// 기존 RealtimePreview와의 호환성을 위한 export
export { RealtimePreviewDynamic as RealtimePreview };
RealtimePreviewDynamic.displayName = "RealtimePreviewDynamic";