2025-09-01 14:52:25 +09:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import React from "react";
|
2025-09-09 14:29:04 +09:00
|
|
|
import { ComponentData, WebType, WidgetComponent, FileComponent, AreaComponent, AreaLayoutType } from "@/types/screen";
|
2025-09-01 14:52:25 +09:00
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
import { Label } from "@/components/ui/label";
|
2025-09-03 15:23:12 +09:00
|
|
|
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";
|
2025-09-05 21:52:19 +09:00
|
|
|
import { FileUpload } from "./widgets/FileUpload";
|
|
|
|
|
import { useAuth } from "@/hooks/useAuth";
|
2025-09-09 14:29:04 +09:00
|
|
|
import { DynamicWebTypeRenderer, WebTypeRegistry } from "@/lib/registry";
|
2025-09-09 17:42:23 +09:00
|
|
|
import { DataTableTemplate } from "@/components/screen/templates/DataTableTemplate";
|
2025-09-01 15:22:47 +09:00
|
|
|
import {
|
|
|
|
|
Database,
|
|
|
|
|
Type,
|
|
|
|
|
Hash,
|
|
|
|
|
List,
|
|
|
|
|
AlignLeft,
|
|
|
|
|
CheckSquare,
|
|
|
|
|
Radio,
|
|
|
|
|
Calendar,
|
|
|
|
|
Code,
|
|
|
|
|
Building,
|
|
|
|
|
File,
|
|
|
|
|
Group,
|
|
|
|
|
ChevronDown,
|
|
|
|
|
ChevronRight,
|
2025-09-03 15:23:12 +09:00
|
|
|
Search,
|
|
|
|
|
RotateCcw,
|
2025-09-03 16:38:10 +09:00
|
|
|
Plus,
|
|
|
|
|
Edit,
|
|
|
|
|
Trash2,
|
2025-09-05 12:04:13 +09:00
|
|
|
Upload,
|
2025-09-08 13:10:09 +09:00
|
|
|
Square,
|
|
|
|
|
CreditCard,
|
|
|
|
|
Layout,
|
|
|
|
|
Grid3x3,
|
|
|
|
|
Columns,
|
|
|
|
|
Rows,
|
|
|
|
|
SidebarOpen,
|
|
|
|
|
Folder,
|
|
|
|
|
ChevronUp,
|
2025-09-01 15:22:47 +09:00
|
|
|
} from "lucide-react";
|
2025-09-01 14:52:25 +09:00
|
|
|
|
|
|
|
|
interface RealtimePreviewProps {
|
|
|
|
|
component: ComponentData;
|
|
|
|
|
isSelected?: boolean;
|
2025-09-01 16:40:24 +09:00
|
|
|
onClick?: (e?: React.MouseEvent) => void;
|
2025-09-01 14:52:25 +09:00
|
|
|
onDragStart?: (e: React.DragEvent) => void;
|
|
|
|
|
onDragEnd?: () => void;
|
2025-09-01 15:22:47 +09:00
|
|
|
onGroupToggle?: (groupId: string) => void; // 그룹 접기/펼치기
|
|
|
|
|
children?: React.ReactNode; // 그룹 내 자식 컴포넌트들
|
2025-09-01 14:52:25 +09:00
|
|
|
}
|
|
|
|
|
|
2025-09-08 13:10:09 +09:00
|
|
|
// 영역 레이아웃에 따른 아이콘 반환
|
|
|
|
|
const getAreaIcon = (layoutType: AreaLayoutType) => {
|
|
|
|
|
switch (layoutType) {
|
|
|
|
|
case "flex-row":
|
2025-09-09 14:29:04 +09:00
|
|
|
return <Layout className="h-4 w-4 text-blue-600" />;
|
|
|
|
|
case "grid":
|
|
|
|
|
return <Grid3x3 className="h-4 w-4 text-green-600" />;
|
2025-09-08 13:10:09 +09:00
|
|
|
case "flex-column":
|
2025-09-09 14:29:04 +09:00
|
|
|
return <Columns className="h-4 w-4 text-purple-600" />;
|
|
|
|
|
case "panel":
|
|
|
|
|
return <Rows className="h-4 w-4 text-orange-600" />;
|
2025-09-08 13:10:09 +09:00
|
|
|
case "sidebar":
|
2025-09-09 14:29:04 +09:00
|
|
|
return <SidebarOpen className="h-4 w-4 text-indigo-600" />;
|
2025-09-08 13:10:09 +09:00
|
|
|
case "tabs":
|
2025-09-09 14:29:04 +09:00
|
|
|
return <Folder className="h-4 w-4 text-pink-600" />;
|
2025-09-08 13:10:09 +09:00
|
|
|
default:
|
2025-09-09 14:29:04 +09:00
|
|
|
return <Square className="h-4 w-4 text-gray-500" />;
|
2025-09-08 13:10:09 +09:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-09-09 14:29:04 +09:00
|
|
|
// 영역 렌더링
|
|
|
|
|
const renderArea = (component: ComponentData, children?: React.ReactNode) => {
|
|
|
|
|
const area = component as AreaComponent;
|
|
|
|
|
const { layoutType, title } = area;
|
2025-09-08 13:10:09 +09:00
|
|
|
|
|
|
|
|
const renderPlaceholder = () => (
|
2025-09-09 14:29:04 +09:00
|
|
|
<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">
|
2025-09-08 13:10:09 +09:00
|
|
|
{getAreaIcon(layoutType)}
|
2025-09-09 14:29:04 +09:00
|
|
|
<p className="mt-2 text-sm text-gray-600">{title || `${layoutType} 영역`}</p>
|
|
|
|
|
<p className="text-xs text-gray-400">컴포넌트를 드래그해서 추가하세요</p>
|
2025-09-08 13:10:09 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return (
|
2025-09-09 14:29:04 +09:00
|
|
|
<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>
|
2025-09-08 13:10:09 +09:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2025-09-09 14:29:04 +09:00
|
|
|
// 동적 웹 타입 위젯 렌더링 컴포넌트
|
|
|
|
|
const WidgetRenderer: React.FC<{ component: ComponentData }> = ({ component }) => {
|
2025-09-05 12:04:13 +09:00
|
|
|
// 위젯 컴포넌트가 아닌 경우 빈 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;
|
2025-09-01 15:22:47 +09:00
|
|
|
|
2025-09-01 14:52:25 +09:00
|
|
|
// 디버깅: 실제 widgetType 값 확인
|
2025-09-09 14:29:04 +09:00
|
|
|
console.log("RealtimePreviewDynamic - widgetType:", widgetType, "columnName:", columnName);
|
2025-09-01 14:52:25 +09:00
|
|
|
|
2025-09-01 17:05:36 +09:00
|
|
|
// 사용자가 테두리를 설정했는지 확인
|
|
|
|
|
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
|
|
|
|
|
|
|
|
|
|
// 기본 테두리 제거 여부 결정 - Shadcn UI 기본 border 클래스를 덮어쓰기
|
|
|
|
|
const borderClass = hasCustomBorder ? "!border-0" : "";
|
|
|
|
|
|
2025-09-01 14:52:25 +09:00
|
|
|
const commonProps = {
|
2025-09-04 11:33:52 +09:00
|
|
|
placeholder: placeholder || "입력하세요...",
|
2025-09-01 14:52:25 +09:00
|
|
|
disabled: readonly,
|
|
|
|
|
required: required,
|
2025-09-01 17:05:36 +09:00
|
|
|
className: `w-full h-full ${borderClass}`,
|
2025-09-01 14:52:25 +09:00
|
|
|
};
|
|
|
|
|
|
2025-09-09 14:29:04 +09:00
|
|
|
// 동적 웹타입 렌더링 사용
|
|
|
|
|
if (widgetType) {
|
|
|
|
|
try {
|
2025-09-01 14:52:25 +09:00
|
|
|
return (
|
2025-09-09 14:29:04 +09:00
|
|
|
<DynamicWebTypeRenderer
|
|
|
|
|
webType={widgetType}
|
|
|
|
|
props={{
|
|
|
|
|
...commonProps,
|
|
|
|
|
component: widget,
|
|
|
|
|
value: undefined, // 미리보기이므로 값은 없음
|
|
|
|
|
readonly: readonly,
|
2025-09-03 11:32:09 +09:00
|
|
|
}}
|
2025-09-09 14:29:04 +09:00
|
|
|
config={widget.webTypeConfig}
|
2025-09-03 11:32:09 +09:00
|
|
|
/>
|
|
|
|
|
);
|
2025-09-09 14:29:04 +09:00
|
|
|
} catch (error) {
|
|
|
|
|
console.error(`웹타입 "${widgetType}" 렌더링 실패:`, error);
|
|
|
|
|
// 오류 발생 시 폴백으로 기본 input 렌더링
|
|
|
|
|
return <Input type="text" {...commonProps} placeholder={`${widgetType} (렌더링 오류)`} />;
|
2025-09-03 11:32:09 +09:00
|
|
|
}
|
2025-09-09 14:29:04 +09:00
|
|
|
}
|
2025-09-01 14:52:25 +09:00
|
|
|
|
2025-09-09 14:29:04 +09:00
|
|
|
// 웹타입이 없는 경우 기본 input 렌더링 (하위 호환성)
|
|
|
|
|
return <Input type="text" {...commonProps} />;
|
|
|
|
|
};
|
2025-09-01 14:52:25 +09:00
|
|
|
|
2025-09-09 14:29:04 +09:00
|
|
|
// 동적 위젯 타입 아이콘 (레지스트리에서 조회)
|
|
|
|
|
const getWidgetIcon = (widgetType: WebType | undefined) => {
|
|
|
|
|
if (!widgetType) {
|
|
|
|
|
return <Type className="h-4 w-4 text-gray-500" />;
|
|
|
|
|
}
|
2025-09-04 11:33:52 +09:00
|
|
|
|
2025-09-09 14:29:04 +09:00
|
|
|
// 레지스트리에서 웹타입 정의 조회
|
|
|
|
|
const webTypeDefinition = WebTypeRegistry.getWebType(widgetType);
|
|
|
|
|
if (webTypeDefinition && webTypeDefinition.icon) {
|
|
|
|
|
const IconComponent = webTypeDefinition.icon;
|
|
|
|
|
return <IconComponent className="h-4 w-4" />;
|
2025-09-01 14:52:25 +09:00
|
|
|
}
|
|
|
|
|
|
2025-09-09 14:29:04 +09:00
|
|
|
// 기본 아이콘 매핑 (하위 호환성)
|
2025-09-01 14:52:25 +09:00
|
|
|
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" />;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-09-09 14:29:04 +09:00
|
|
|
export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
2025-09-01 14:52:25 +09:00
|
|
|
component,
|
|
|
|
|
isSelected = false,
|
|
|
|
|
onClick,
|
|
|
|
|
onDragStart,
|
|
|
|
|
onDragEnd,
|
2025-09-01 15:22:47 +09:00
|
|
|
onGroupToggle,
|
2025-09-09 14:29:04 +09:00
|
|
|
children,
|
2025-09-01 14:52:25 +09:00
|
|
|
}) => {
|
2025-09-09 14:29:04 +09:00
|
|
|
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,
|
2025-09-02 16:46:54 +09:00
|
|
|
};
|
|
|
|
|
|
2025-09-09 14:29:04 +09:00
|
|
|
// 선택된 컴포넌트 스타일
|
|
|
|
|
const selectionStyle = isSelected
|
|
|
|
|
? {
|
|
|
|
|
outline: "2px solid #3b82f6",
|
|
|
|
|
outlineOffset: "2px",
|
|
|
|
|
}
|
|
|
|
|
: {};
|
2025-09-03 15:23:12 +09:00
|
|
|
|
2025-09-09 14:29:04 +09:00
|
|
|
const handleClick = (e: React.MouseEvent) => {
|
2025-09-09 18:02:07 +09:00
|
|
|
// 컴포넌트 영역 내에서만 클릭 이벤트 처리
|
2025-09-09 14:29:04 +09:00
|
|
|
e.stopPropagation();
|
|
|
|
|
onClick?.(e);
|
|
|
|
|
};
|
2025-09-03 15:23:12 +09:00
|
|
|
|
2025-09-09 14:29:04 +09:00
|
|
|
const handleDragStart = (e: React.DragEvent) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
onDragStart?.(e);
|
|
|
|
|
};
|
2025-09-03 15:23:12 +09:00
|
|
|
|
2025-09-09 14:29:04 +09:00
|
|
|
const handleDragEnd = () => {
|
|
|
|
|
onDragEnd?.();
|
|
|
|
|
};
|
2025-09-03 15:23:12 +09:00
|
|
|
|
2025-09-01 14:52:25 +09:00
|
|
|
return (
|
|
|
|
|
<div
|
2025-09-09 14:29:04 +09:00
|
|
|
id={`component-${id}`}
|
|
|
|
|
className="absolute cursor-pointer"
|
|
|
|
|
style={{ ...componentStyle, ...selectionStyle }}
|
|
|
|
|
onClick={handleClick}
|
2025-09-01 14:52:25 +09:00
|
|
|
draggable
|
2025-09-09 14:29:04 +09:00
|
|
|
onDragStart={handleDragStart}
|
|
|
|
|
onDragEnd={handleDragEnd}
|
2025-09-01 14:52:25 +09:00
|
|
|
>
|
2025-09-09 14:29:04 +09:00
|
|
|
{/* 컴포넌트 타입별 렌더링 */}
|
|
|
|
|
<div className="h-full w-full">
|
|
|
|
|
{/* 영역 타입 */}
|
|
|
|
|
{type === "area" && renderArea(component, children)}
|
2025-09-01 15:22:47 +09:00
|
|
|
|
2025-09-09 14:29:04 +09:00
|
|
|
{/* 데이터 테이블 타입 */}
|
|
|
|
|
{type === "datatable" &&
|
2025-09-03 15:23:12 +09:00
|
|
|
(() => {
|
|
|
|
|
const dataTableComponent = component as any; // DataTableComponent 타입
|
2025-09-09 14:29:04 +09:00
|
|
|
|
2025-09-03 15:23:12 +09:00
|
|
|
return (
|
2025-09-09 17:42:23 +09:00
|
|
|
<DataTableTemplate
|
|
|
|
|
title={dataTableComponent.title || dataTableComponent.label}
|
|
|
|
|
description={`${dataTableComponent.label}을 표시하는 데이터 테이블`}
|
|
|
|
|
columns={dataTableComponent.columns}
|
|
|
|
|
filters={dataTableComponent.filters}
|
|
|
|
|
pagination={dataTableComponent.pagination}
|
|
|
|
|
actions={
|
|
|
|
|
dataTableComponent.actions || {
|
|
|
|
|
showSearchButton: dataTableComponent.showSearchButton ?? true,
|
|
|
|
|
searchButtonText: dataTableComponent.searchButtonText || "검색",
|
|
|
|
|
enableExport: dataTableComponent.enableExport ?? true,
|
|
|
|
|
enableRefresh: dataTableComponent.enableRefresh ?? true,
|
|
|
|
|
enableAdd: dataTableComponent.enableAdd ?? true,
|
|
|
|
|
enableEdit: dataTableComponent.enableEdit ?? true,
|
|
|
|
|
enableDelete: dataTableComponent.enableDelete ?? true,
|
|
|
|
|
addButtonText: dataTableComponent.addButtonText || "추가",
|
|
|
|
|
editButtonText: dataTableComponent.editButtonText || "수정",
|
|
|
|
|
deleteButtonText: dataTableComponent.deleteButtonText || "삭제",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
style={component.style}
|
|
|
|
|
className="h-full w-full"
|
|
|
|
|
isPreview={true}
|
|
|
|
|
/>
|
2025-09-03 15:23:12 +09:00
|
|
|
);
|
|
|
|
|
})()}
|
|
|
|
|
|
2025-09-09 14:29:04 +09:00
|
|
|
{/* 그룹 타입 */}
|
2025-09-02 16:46:54 +09:00
|
|
|
{type === "group" && (
|
|
|
|
|
<div className="relative h-full w-full">
|
|
|
|
|
<div className="absolute inset-0">{children}</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2025-09-09 14:29:04 +09:00
|
|
|
{/* 위젯 타입 - 동적 렌더링 */}
|
2025-09-02 16:46:54 +09:00
|
|
|
{type === "widget" && (
|
|
|
|
|
<div className="flex h-full flex-col">
|
2025-09-09 14:29:04 +09:00
|
|
|
<div className="pointer-events-none flex-1">
|
|
|
|
|
<WidgetRenderer component={component} />
|
|
|
|
|
</div>
|
2025-09-02 16:46:54 +09:00
|
|
|
</div>
|
|
|
|
|
)}
|
2025-09-05 21:52:19 +09:00
|
|
|
|
2025-09-09 14:29:04 +09:00
|
|
|
{/* 파일 타입 */}
|
2025-09-05 21:52:19 +09:00
|
|
|
{type === "file" && (
|
|
|
|
|
<div className="flex h-full flex-col">
|
2025-09-09 14:29:04 +09:00
|
|
|
<div className="pointer-events-none flex-1 rounded border-2 border-dashed border-gray-300 bg-gray-50 p-4">
|
|
|
|
|
<div className="flex h-full flex-col items-center justify-center text-center">
|
|
|
|
|
<File className="mb-2 h-8 w-8 text-gray-400" />
|
|
|
|
|
<p className="text-sm text-gray-600">파일 업로드 영역</p>
|
|
|
|
|
<p className="mt-1 text-xs text-gray-400">미리보기 모드</p>
|
|
|
|
|
</div>
|
2025-09-05 21:52:19 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-09-02 16:46:54 +09:00
|
|
|
</div>
|
2025-09-09 14:29:04 +09:00
|
|
|
|
|
|
|
|
{/* 선택된 컴포넌트 정보 표시 */}
|
|
|
|
|
{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>
|
|
|
|
|
)}
|
2025-09-01 14:52:25 +09:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2025-09-09 14:29:04 +09:00
|
|
|
// 기존 RealtimePreview와의 호환성을 위한 export
|
|
|
|
|
export { RealtimePreviewDynamic as RealtimePreview };
|
|
|
|
|
|
|
|
|
|
RealtimePreviewDynamic.displayName = "RealtimePreviewDynamic";
|