2025-09-01 14:52:25 +09:00
|
|
|
"use client";
|
|
|
|
|
|
2025-09-29 13:29:03 +09:00
|
|
|
import React, { useState, useEffect } from "react";
|
2025-09-19 18:43:55 +09:00
|
|
|
import { ComponentData, WebType, isWidgetComponent, isContainerComponent } from "@/types";
|
2025-09-29 13:29:03 +09:00
|
|
|
import { isFileComponent } from "@/lib/utils/componentTypeUtils";
|
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-26 13:11:34 +09:00
|
|
|
Image as ImageIcon,
|
|
|
|
|
FileText,
|
|
|
|
|
Video,
|
|
|
|
|
Music,
|
|
|
|
|
Archive,
|
|
|
|
|
Presentation,
|
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-10-24 14:11:12 +09:00
|
|
|
isDesignMode?: 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-10-23 17:26:14 +09:00
|
|
|
// 플로우 선택 데이터 전달용
|
|
|
|
|
onFlowSelectedDataChange?: (selectedData: any[], stepId: number | null) => void;
|
2025-09-01 14:52:25 +09:00
|
|
|
}
|
|
|
|
|
|
2025-09-08 13:10:09 +09:00
|
|
|
// 영역 레이아웃에 따른 아이콘 반환
|
2025-09-19 18:43:55 +09:00
|
|
|
const getAreaIcon = (layoutDirection?: "horizontal" | "vertical") => {
|
|
|
|
|
switch (layoutDirection) {
|
|
|
|
|
case "horizontal":
|
2025-10-20 10:55:33 +09:00
|
|
|
return <Layout className="text-primary h-4 w-4" />;
|
2025-09-19 18:43:55 +09:00
|
|
|
case "vertical":
|
2025-09-09 14:29:04 +09:00
|
|
|
return <Columns className="h-4 w-4 text-purple-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) => {
|
2025-09-19 18:43:55 +09:00
|
|
|
if (!isContainerComponent(component) || component.type !== "area") {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
const area = component;
|
|
|
|
|
const { layoutDirection, label } = 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-19 18:43:55 +09:00
|
|
|
{getAreaIcon(layoutDirection)}
|
2025-10-20 10:55:33 +09:00
|
|
|
<p className="text-muted-foreground mt-2 text-sm">{label || `${layoutDirection || "기본"} 영역`}</p>
|
2025-09-09 14:29:04 +09:00
|
|
|
<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
|
|
|
// 동적 웹 타입 위젯 렌더링 컴포넌트
|
2025-10-24 14:11:12 +09:00
|
|
|
const WidgetRenderer: React.FC<{ component: ComponentData; isDesignMode?: boolean }> = ({ component, isDesignMode = false }) => {
|
2025-09-05 12:04:13 +09:00
|
|
|
// 위젯 컴포넌트가 아닌 경우 빈 div 반환
|
2025-09-19 18:43:55 +09:00
|
|
|
if (!isWidgetComponent(component)) {
|
2025-09-05 12:04:13 +09:00
|
|
|
return <div className="text-xs text-gray-500">위젯이 아닙니다</div>;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-19 18:43:55 +09:00
|
|
|
const widget = component;
|
2025-09-05 12:04:13 +09:00
|
|
|
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-10-01 18:17:30 +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-29 17:21:47 +09:00
|
|
|
// 파일 컴포넌트는 별도 로직에서 처리하므로 여기서는 제외
|
2025-09-29 13:29:03 +09:00
|
|
|
if (isFileComponent(widget)) {
|
2025-10-01 18:17:30 +09:00
|
|
|
// console.log("🎯 RealtimePreview - 파일 컴포넌트 감지 (별도 처리):", {
|
2025-10-20 10:55:33 +09:00
|
|
|
// componentId: widget.id,
|
|
|
|
|
// widgetType: widgetType,
|
|
|
|
|
// isFileComponent: true
|
2025-10-01 18:17:30 +09:00
|
|
|
// });
|
2025-10-20 10:55:33 +09:00
|
|
|
|
|
|
|
|
return <div className="p-2 text-xs text-gray-500">파일 컴포넌트 (별도 렌더링)</div>;
|
2025-09-29 13:29:03 +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-10-24 14:11:12 +09:00
|
|
|
isDesignMode,
|
|
|
|
|
isInteractive: !isDesignMode,
|
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) {
|
2025-10-01 18:17:30 +09:00
|
|
|
// console.error(`웹타입 "${widgetType}" 렌더링 실패:`, error);
|
2025-09-09 14:29:04 +09:00
|
|
|
// 오류 발생 시 폴백으로 기본 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":
|
2025-10-20 10:55:33 +09:00
|
|
|
return <Type className="text-primary h-4 w-4" />;
|
2025-09-01 14:52:25 +09:00
|
|
|
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":
|
|
|
|
|
return <AlignLeft className="h-4 w-4 text-indigo-600" />;
|
|
|
|
|
case "boolean":
|
|
|
|
|
case "checkbox":
|
2025-10-20 10:55:33 +09:00
|
|
|
return <CheckSquare className="text-primary h-4 w-4" />;
|
2025-09-01 14:52:25 +09:00
|
|
|
case "radio":
|
2025-10-20 10:55:33 +09:00
|
|
|
return <Radio className="text-primary h-4 w-4" />;
|
2025-09-01 14:52:25 +09:00
|
|
|
case "code":
|
2025-10-20 10:55:33 +09:00
|
|
|
return <Code className="text-muted-foreground h-4 w-4" />;
|
2025-09-01 14:52:25 +09:00
|
|
|
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,
|
2025-10-24 14:11:12 +09:00
|
|
|
isDesignMode = false,
|
2025-09-01 14:52:25 +09:00
|
|
|
onClick,
|
|
|
|
|
onDragStart,
|
|
|
|
|
onDragEnd,
|
2025-09-01 15:22:47 +09:00
|
|
|
onGroupToggle,
|
2025-09-09 14:29:04 +09:00
|
|
|
children,
|
2025-10-23 17:26:14 +09:00
|
|
|
onFlowSelectedDataChange,
|
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;
|
2025-09-29 13:29:03 +09:00
|
|
|
const [fileUpdateTrigger, setFileUpdateTrigger] = useState(0);
|
2025-10-23 15:06:00 +09:00
|
|
|
const [actualHeight, setActualHeight] = useState<number | null>(null);
|
|
|
|
|
const contentRef = React.useRef<HTMLDivElement>(null);
|
|
|
|
|
|
|
|
|
|
// 플로우 위젯의 실제 높이 측정
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const isFlowWidget = type === "flow" || (type === "component" && (component as any).componentConfig?.type === "flow-widget");
|
|
|
|
|
|
|
|
|
|
if (isFlowWidget && contentRef.current) {
|
|
|
|
|
const measureHeight = () => {
|
|
|
|
|
if (contentRef.current) {
|
|
|
|
|
// getBoundingClientRect()로 실제 렌더링된 높이 측정
|
|
|
|
|
const rect = contentRef.current.getBoundingClientRect();
|
|
|
|
|
const measured = rect.height;
|
|
|
|
|
|
|
|
|
|
// scrollHeight도 함께 확인하여 더 큰 값 사용
|
|
|
|
|
const scrollHeight = contentRef.current.scrollHeight;
|
|
|
|
|
const rawHeight = Math.max(measured, scrollHeight);
|
|
|
|
|
|
|
|
|
|
// 40px 단위로 올림
|
|
|
|
|
const finalHeight = Math.ceil(rawHeight / 40) * 40;
|
|
|
|
|
|
|
|
|
|
if (finalHeight > 0 && Math.abs(finalHeight - (actualHeight || 0)) > 10) {
|
|
|
|
|
setActualHeight(finalHeight);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 초기 측정 (렌더링 완료 후)
|
|
|
|
|
const initialTimer = setTimeout(() => {
|
|
|
|
|
measureHeight();
|
|
|
|
|
}, 100);
|
|
|
|
|
|
|
|
|
|
// 추가 측정 (데이터 로딩 완료 대기)
|
|
|
|
|
const delayedTimer = setTimeout(() => {
|
|
|
|
|
measureHeight();
|
|
|
|
|
}, 500);
|
|
|
|
|
|
|
|
|
|
// 스텝 클릭 등으로 높이가 변경될 때를 위한 추가 측정
|
|
|
|
|
const extendedTimer = setTimeout(() => {
|
|
|
|
|
measureHeight();
|
|
|
|
|
}, 1000);
|
|
|
|
|
|
|
|
|
|
// ResizeObserver로 크기 변화 감지 (스텝 클릭 시 데이터 테이블 펼쳐짐)
|
|
|
|
|
const resizeObserver = new ResizeObserver(() => {
|
|
|
|
|
// 약간의 지연을 두고 측정 (DOM 업데이트 완료 대기)
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
measureHeight();
|
|
|
|
|
}, 100);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
resizeObserver.observe(contentRef.current);
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
clearTimeout(initialTimer);
|
|
|
|
|
clearTimeout(delayedTimer);
|
|
|
|
|
clearTimeout(extendedTimer);
|
|
|
|
|
resizeObserver.disconnect();
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}, [type, id]);
|
2025-09-29 13:29:03 +09:00
|
|
|
|
|
|
|
|
// 전역 파일 상태 변경 감지 (해당 컴포넌트만)
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handleGlobalFileStateChange = (event: CustomEvent) => {
|
2025-10-01 18:17:30 +09:00
|
|
|
// console.log("🎯🎯🎯 RealtimePreview 이벤트 수신:", {
|
2025-10-20 10:55:33 +09:00
|
|
|
// eventComponentId: event.detail.componentId,
|
|
|
|
|
// currentComponentId: component.id,
|
|
|
|
|
// isMatch: event.detail.componentId === component.id,
|
|
|
|
|
// filesCount: event.detail.files?.length || 0,
|
|
|
|
|
// action: event.detail.action,
|
|
|
|
|
// delayed: event.detail.delayed || false,
|
|
|
|
|
// attempt: event.detail.attempt || 1,
|
|
|
|
|
// eventDetail: event.detail
|
2025-10-01 18:17:30 +09:00
|
|
|
// });
|
2025-10-20 10:55:33 +09:00
|
|
|
|
2025-09-29 13:29:03 +09:00
|
|
|
if (event.detail.componentId === component.id) {
|
2025-10-01 18:17:30 +09:00
|
|
|
// console.log("✅✅✅ RealtimePreview 파일 상태 변경 감지 - 리렌더링 시작:", {
|
2025-10-20 10:55:33 +09:00
|
|
|
// componentId: component.id,
|
|
|
|
|
// filesCount: event.detail.files?.length || 0,
|
|
|
|
|
// action: event.detail.action,
|
|
|
|
|
// oldTrigger: fileUpdateTrigger,
|
|
|
|
|
// delayed: event.detail.delayed || false,
|
|
|
|
|
// attempt: event.detail.attempt || 1
|
2025-10-01 18:17:30 +09:00
|
|
|
// });
|
2025-10-20 10:55:33 +09:00
|
|
|
setFileUpdateTrigger((prev) => {
|
2025-09-29 17:21:47 +09:00
|
|
|
const newTrigger = prev + 1;
|
2025-10-20 10:55:33 +09:00
|
|
|
// console.log("🔄🔄🔄 fileUpdateTrigger 업데이트:", {
|
|
|
|
|
// old: prev,
|
|
|
|
|
// new: newTrigger,
|
|
|
|
|
// componentId: component.id,
|
|
|
|
|
// attempt: event.detail.attempt || 1
|
2025-10-01 18:17:30 +09:00
|
|
|
// });
|
2025-09-29 17:21:47 +09:00
|
|
|
return newTrigger;
|
|
|
|
|
});
|
|
|
|
|
} else {
|
2025-10-01 18:17:30 +09:00
|
|
|
// console.log("❌ 컴포넌트 ID 불일치:", {
|
2025-10-20 10:55:33 +09:00
|
|
|
// eventComponentId: event.detail.componentId,
|
|
|
|
|
// currentComponentId: component.id
|
2025-10-01 18:17:30 +09:00
|
|
|
// });
|
2025-09-29 17:21:47 +09:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 강제 업데이트 함수 등록
|
|
|
|
|
const forceUpdate = (componentId: string, files: any[]) => {
|
2025-10-01 18:17:30 +09:00
|
|
|
// console.log("🔥🔥🔥 RealtimePreview 강제 업데이트 호출:", {
|
2025-10-20 10:55:33 +09:00
|
|
|
// targetComponentId: componentId,
|
|
|
|
|
// currentComponentId: component.id,
|
|
|
|
|
// isMatch: componentId === component.id,
|
|
|
|
|
// filesCount: files.length
|
2025-10-01 18:17:30 +09:00
|
|
|
// });
|
2025-10-20 10:55:33 +09:00
|
|
|
|
2025-09-29 17:21:47 +09:00
|
|
|
if (componentId === component.id) {
|
2025-10-01 18:17:30 +09:00
|
|
|
// console.log("✅✅✅ RealtimePreview 강제 업데이트 적용:", {
|
2025-10-20 10:55:33 +09:00
|
|
|
// componentId: component.id,
|
|
|
|
|
// filesCount: files.length,
|
|
|
|
|
// oldTrigger: fileUpdateTrigger
|
2025-10-01 18:17:30 +09:00
|
|
|
// });
|
2025-10-20 10:55:33 +09:00
|
|
|
setFileUpdateTrigger((prev) => {
|
2025-09-29 17:21:47 +09:00
|
|
|
const newTrigger = prev + 1;
|
2025-10-20 10:55:33 +09:00
|
|
|
// console.log("🔄🔄🔄 강제 fileUpdateTrigger 업데이트:", {
|
|
|
|
|
// old: prev,
|
|
|
|
|
// new: newTrigger,
|
|
|
|
|
// componentId: component.id
|
2025-10-01 18:17:30 +09:00
|
|
|
// });
|
2025-09-29 17:21:47 +09:00
|
|
|
return newTrigger;
|
2025-09-29 13:29:03 +09:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-10-20 10:55:33 +09:00
|
|
|
if (typeof window !== "undefined") {
|
2025-09-29 19:32:20 +09:00
|
|
|
try {
|
2025-10-20 10:55:33 +09:00
|
|
|
window.addEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener);
|
|
|
|
|
|
2025-09-29 19:32:20 +09:00
|
|
|
// 전역 강제 업데이트 함수 등록
|
|
|
|
|
if (!(window as any).forceRealtimePreviewUpdate) {
|
|
|
|
|
(window as any).forceRealtimePreviewUpdate = forceUpdate;
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
2025-10-01 18:17:30 +09:00
|
|
|
// console.warn("RealtimePreview 이벤트 리스너 등록 실패:", error);
|
2025-09-29 17:21:47 +09:00
|
|
|
}
|
2025-10-20 10:55:33 +09:00
|
|
|
|
2025-09-29 13:29:03 +09:00
|
|
|
return () => {
|
2025-09-29 19:32:20 +09:00
|
|
|
try {
|
2025-10-20 10:55:33 +09:00
|
|
|
window.removeEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener);
|
2025-09-29 19:32:20 +09:00
|
|
|
} catch (error) {
|
2025-10-01 18:17:30 +09:00
|
|
|
// console.warn("RealtimePreview 이벤트 리스너 제거 실패:", error);
|
2025-09-29 19:32:20 +09:00
|
|
|
}
|
2025-09-29 13:29:03 +09:00
|
|
|
};
|
|
|
|
|
}
|
2025-09-29 17:21:47 +09:00
|
|
|
}, [component.id, fileUpdateTrigger]);
|
2025-09-09 14:29:04 +09:00
|
|
|
|
|
|
|
|
// 컴포넌트 스타일 계산
|
2025-10-23 15:06:00 +09:00
|
|
|
const isFlowWidget = type === "flow" || (type === "component" && (component as any).componentConfig?.type === "flow-widget");
|
|
|
|
|
|
|
|
|
|
// 높이 결정 로직
|
|
|
|
|
let finalHeight = size?.height || 40;
|
|
|
|
|
if (isFlowWidget && actualHeight) {
|
|
|
|
|
finalHeight = actualHeight;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-31 10:41:45 +09:00
|
|
|
// 🔍 디버깅: position.x 값 확인
|
|
|
|
|
const positionX = position?.x || 0;
|
|
|
|
|
console.log("🔍 RealtimePreview componentStyle 설정:", {
|
|
|
|
|
componentId: id,
|
|
|
|
|
positionX,
|
|
|
|
|
sizeWidth: size?.width,
|
|
|
|
|
styleWidth: style?.width,
|
|
|
|
|
willUse100Percent: positionX === 0,
|
|
|
|
|
});
|
|
|
|
|
|
2025-09-09 14:29:04 +09:00
|
|
|
const componentStyle = {
|
|
|
|
|
position: "absolute" as const,
|
2025-10-31 10:41:45 +09:00
|
|
|
...style, // 먼저 적용하고
|
|
|
|
|
left: positionX,
|
2025-09-09 14:29:04 +09:00
|
|
|
top: position?.y || 0,
|
2025-10-31 10:41:45 +09:00
|
|
|
// 🆕 left가 0이면 부모 너비를 100% 채우도록 수정 (우측 여백 제거)
|
|
|
|
|
width: positionX === 0 ? "100%" : (size?.width || 200),
|
2025-10-23 15:06:00 +09:00
|
|
|
height: finalHeight,
|
2025-09-09 14:29:04 +09:00
|
|
|
zIndex: position?.z || 1,
|
2025-10-31 10:41:45 +09:00
|
|
|
// right 속성 강제 제거
|
|
|
|
|
right: undefined,
|
2025-09-02 16:46:54 +09:00
|
|
|
};
|
|
|
|
|
|
2025-09-09 14:29:04 +09:00
|
|
|
// 선택된 컴포넌트 스타일
|
|
|
|
|
const selectionStyle = isSelected
|
|
|
|
|
? {
|
2025-10-20 10:55:33 +09:00
|
|
|
outline: "2px solid rgb(59, 130, 246)",
|
2025-09-09 14:29:04 +09:00
|
|
|
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
|
|
|
{/* 컴포넌트 타입별 렌더링 */}
|
2025-10-23 15:06:00 +09:00
|
|
|
<div
|
|
|
|
|
ref={isFlowWidget ? contentRef : undefined}
|
2025-10-24 15:40:08 +09:00
|
|
|
className="h-full w-full"
|
2025-10-23 15:06:00 +09:00
|
|
|
>
|
2025-09-09 14:29:04 +09:00
|
|
|
{/* 영역 타입 */}
|
|
|
|
|
{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-10-20 10:55:33 +09:00
|
|
|
{/* 플로우 위젯 타입 */}
|
|
|
|
|
{(type === "flow" || (type === "component" && (component as any).componentConfig?.type === "flow-widget")) &&
|
|
|
|
|
(() => {
|
|
|
|
|
const FlowWidget = require("@/components/screen/widgets/FlowWidget").FlowWidget;
|
|
|
|
|
// componentConfig에서 flowId 추출
|
|
|
|
|
const flowConfig = (component as any).componentConfig || {};
|
|
|
|
|
|
|
|
|
|
console.log("🔍 RealtimePreview 플로우 위젯 변환:", {
|
|
|
|
|
compType: component.type,
|
|
|
|
|
hasComponentConfig: !!(component as any).componentConfig,
|
|
|
|
|
flowConfig,
|
|
|
|
|
flowConfigFlowId: flowConfig.flowId,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const flowComponent = {
|
|
|
|
|
...component,
|
|
|
|
|
type: "flow" as const,
|
|
|
|
|
flowId: flowConfig.flowId,
|
|
|
|
|
flowName: flowConfig.flowName,
|
|
|
|
|
showStepCount: flowConfig.showStepCount !== false,
|
|
|
|
|
allowDataMove: flowConfig.allowDataMove || false,
|
|
|
|
|
displayMode: flowConfig.displayMode || "horizontal",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
console.log("🔍 RealtimePreview 최종 flowComponent:", flowComponent);
|
|
|
|
|
|
|
|
|
|
return (
|
2025-10-23 15:06:00 +09:00
|
|
|
<div className="h-auto w-full">
|
2025-10-23 17:26:14 +09:00
|
|
|
<FlowWidget
|
|
|
|
|
component={flowComponent as any}
|
|
|
|
|
onSelectedDataChange={onFlowSelectedDataChange}
|
|
|
|
|
/>
|
2025-10-20 10:55:33 +09:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})()}
|
|
|
|
|
|
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-29 17:21:47 +09:00
|
|
|
{/* 위젯 타입 - 동적 렌더링 (파일 컴포넌트 제외) */}
|
|
|
|
|
{type === "widget" && !isFileComponent(component) && (
|
2025-10-23 09:56:38 +09:00
|
|
|
<div className="pointer-events-none h-full w-full">
|
2025-10-24 14:11:12 +09:00
|
|
|
<WidgetRenderer component={component} isDesignMode={isDesignMode} />
|
2025-09-02 16:46:54 +09:00
|
|
|
</div>
|
|
|
|
|
)}
|
2025-09-05 21:52:19 +09:00
|
|
|
|
2025-09-29 13:29:03 +09:00
|
|
|
{/* 파일 타입 - 레거시 및 신규 타입 지원 */}
|
2025-10-20 10:55:33 +09:00
|
|
|
{isFileComponent(component) &&
|
|
|
|
|
(() => {
|
|
|
|
|
const fileComponent = component as any;
|
|
|
|
|
const uploadedFiles = fileComponent.uploadedFiles || [];
|
|
|
|
|
|
|
|
|
|
// 전역 상태에서 최신 파일 정보 가져오기
|
|
|
|
|
const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {};
|
|
|
|
|
const globalFiles = globalFileState[component.id] || [];
|
|
|
|
|
|
|
|
|
|
// 최신 파일 정보 사용 (전역 상태 > 컴포넌트 속성)
|
|
|
|
|
const currentFiles = globalFiles.length > 0 ? globalFiles : uploadedFiles;
|
|
|
|
|
|
|
|
|
|
// console.log("🔍 RealtimePreview 파일 컴포넌트 렌더링:", {
|
2025-10-01 18:17:30 +09:00
|
|
|
// componentId: component.id,
|
|
|
|
|
// uploadedFilesCount: uploadedFiles.length,
|
|
|
|
|
// globalFilesCount: globalFiles.length,
|
|
|
|
|
// currentFilesCount: currentFiles.length,
|
|
|
|
|
// currentFiles: currentFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName || f.name })),
|
|
|
|
|
// componentType: component.type,
|
|
|
|
|
// fileUpdateTrigger: fileUpdateTrigger,
|
|
|
|
|
// timestamp: new Date().toISOString()
|
2025-10-20 10:55:33 +09:00
|
|
|
// });
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div key={`file-component-${component.id}-${fileUpdateTrigger}`} className="flex h-full flex-col">
|
|
|
|
|
<div className="pointer-events-none flex-1 rounded border-2 border-dashed border-gray-300 bg-gray-50 p-2">
|
|
|
|
|
{currentFiles.length > 0 ? (
|
|
|
|
|
<div className="h-full overflow-y-auto">
|
|
|
|
|
<div className="mb-1 text-xs font-medium text-gray-700">
|
|
|
|
|
업로드된 파일 ({currentFiles.length})
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
{currentFiles.map((file: any, index: number) => {
|
|
|
|
|
// 파일 확장자에 따른 아이콘 선택
|
|
|
|
|
const getFileIcon = (fileName: string) => {
|
|
|
|
|
const ext = fileName.split(".").pop()?.toLowerCase() || "";
|
|
|
|
|
if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(ext)) {
|
|
|
|
|
return <ImageIcon className="h-4 w-4 flex-shrink-0 text-green-500" />;
|
|
|
|
|
}
|
|
|
|
|
if (["pdf", "doc", "docx", "txt", "rtf", "hwp", "hwpx", "hwpml", "pages"].includes(ext)) {
|
|
|
|
|
return <FileText className="h-4 w-4 flex-shrink-0 text-red-500" />;
|
|
|
|
|
}
|
|
|
|
|
if (["ppt", "pptx", "hpt", "keynote"].includes(ext)) {
|
|
|
|
|
return <Presentation className="h-4 w-4 flex-shrink-0 text-orange-600" />;
|
|
|
|
|
}
|
|
|
|
|
if (["xls", "xlsx", "hcdt", "numbers"].includes(ext)) {
|
|
|
|
|
return <FileText className="h-4 w-4 flex-shrink-0 text-green-600" />;
|
|
|
|
|
}
|
|
|
|
|
if (["mp4", "avi", "mov", "wmv", "webm", "ogg"].includes(ext)) {
|
|
|
|
|
return <Video className="h-4 w-4 flex-shrink-0 text-purple-500" />;
|
|
|
|
|
}
|
|
|
|
|
if (["mp3", "wav", "flac", "aac"].includes(ext)) {
|
|
|
|
|
return <Music className="h-4 w-4 flex-shrink-0 text-orange-500" />;
|
|
|
|
|
}
|
|
|
|
|
if (["zip", "rar", "7z", "tar"].includes(ext)) {
|
|
|
|
|
return <Archive className="h-4 w-4 flex-shrink-0 text-yellow-500" />;
|
|
|
|
|
}
|
|
|
|
|
return <File className="h-4 w-4 flex-shrink-0 text-blue-500" />;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={file.objid || index}
|
|
|
|
|
className="flex items-center space-x-2 rounded bg-white p-2 text-xs"
|
|
|
|
|
>
|
|
|
|
|
{getFileIcon(file.realFileName || file.name || "")}
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
<p className="truncate font-medium text-gray-900">
|
|
|
|
|
{file.realFileName || file.name || `파일 ${index + 1}`}
|
|
|
|
|
</p>
|
|
|
|
|
<p className="text-gray-500">
|
|
|
|
|
{file.fileSize ? `${Math.round(file.fileSize / 1024)} KB` : ""}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
2025-09-26 13:11:34 +09:00
|
|
|
</div>
|
2025-10-20 10:55:33 +09:00
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
2025-09-26 13:11:34 +09:00
|
|
|
</div>
|
2025-10-20 10:55:33 +09:00
|
|
|
) : (
|
|
|
|
|
<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="mb-1 text-xs font-medium text-gray-700">업로드된 파일 (0)</p>
|
|
|
|
|
<p className="text-muted-foreground text-sm">파일 업로드 영역</p>
|
|
|
|
|
<p className="mt-1 text-xs text-gray-400">상세설정에서 파일을 업로드하세요</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2025-09-09 14:29:04 +09:00
|
|
|
</div>
|
2025-10-20 10:55:33 +09:00
|
|
|
);
|
|
|
|
|
})()}
|
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">
|
2025-09-19 18:43:55 +09:00
|
|
|
{getWidgetIcon(isWidgetComponent(component) ? (component.widgetType as WebType) : undefined)}
|
|
|
|
|
{isWidgetComponent(component) ? component.widgetType || "widget" : component.type}
|
2025-09-09 14:29:04 +09:00
|
|
|
</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";
|