"use client"; import React, { useState, useEffect, useMemo } from "react"; import { ComponentData, WebType, isWidgetComponent, isContainerComponent } from "@/types"; import { isFileComponent } from "@/lib/utils/componentTypeUtils"; 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 { DataTableTemplate } from "@/components/screen/templates/DataTableTemplate"; import { useSplitPanel } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; 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, Image as ImageIcon, FileText, Video, Music, Archive, Presentation, } from "lucide-react"; interface RealtimePreviewProps { component: ComponentData; isSelected?: boolean; isDesignMode?: boolean; onClick?: (e?: React.MouseEvent) => void; onDragStart?: (e: React.DragEvent) => void; onDragEnd?: () => void; onGroupToggle?: (groupId: string) => void; // 그룹 접기/펼치기 children?: React.ReactNode; // 그룹 내 자식 컴포넌트들 // 플로우 선택 데이터 전달용 onFlowSelectedDataChange?: (selectedData: any[], stepId: number | null) => void; // 테이블 정렬 정보 전달용 sortBy?: string; sortOrder?: "asc" | "desc"; tableDisplayData?: any[]; // 🆕 화면 표시 데이터 [key: string]: any; // 추가 props 허용 } // 영역 레이아웃에 따른 아이콘 반환 const getAreaIcon = (layoutDirection?: "horizontal" | "vertical") => { switch (layoutDirection) { case "horizontal": return ; case "vertical": return ; default: return ; } }; // 영역 렌더링 const renderArea = (component: ComponentData, children?: React.ReactNode) => { if (!isContainerComponent(component) || component.type !== "area") { return null; } const area = component; const { layoutDirection, label } = area; const renderPlaceholder = () => (
{getAreaIcon(layoutDirection)}

{label || `${layoutDirection || "기본"} 영역`}

컴포넌트를 드래그해서 추가하세요

); return (
{children && React.Children.count(children) > 0 ? children : renderPlaceholder()}
); }; // 동적 웹 타입 위젯 렌더링 컴포넌트 const WidgetRenderer: React.FC<{ component: ComponentData; isDesignMode?: boolean; sortBy?: string; sortOrder?: "asc" | "desc"; tableDisplayData?: any[]; [key: string]: any; }> = ({ component, isDesignMode = false, sortBy, sortOrder, tableDisplayData, ...restProps }) => { // 위젯 컴포넌트가 아닌 경우 빈 div 반환 if (!isWidgetComponent(component)) { return
위젯이 아닙니다
; } const widget = component; 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 (isFileComponent(widget)) { // console.log("🎯 RealtimePreview - 파일 컴포넌트 감지 (별도 처리):", { // componentId: widget.id, // widgetType: widgetType, // isFileComponent: true // }); return
파일 컴포넌트 (별도 렌더링)
; } // 동적 웹타입 렌더링 사용 if (widgetType) { try { return ( ); } catch (error) { // console.error(`웹타입 "${widgetType}" 렌더링 실패:`, error); // 오류 발생 시 폴백으로 기본 input 렌더링 return ; } } // 웹타입이 없는 경우 기본 input 렌더링 (하위 호환성) return ; }; // 동적 위젯 타입 아이콘 (레지스트리에서 조회) const getWidgetIcon = (widgetType: WebType | undefined) => { if (!widgetType) { return ; } // 레지스트리에서 웹타입 정의 조회 const webTypeDefinition = WebTypeRegistry.getWebType(widgetType); if (webTypeDefinition && webTypeDefinition.icon) { const IconComponent = webTypeDefinition.icon; return ; } // 기본 아이콘 매핑 (하위 호환성) switch (widgetType) { case "text": case "email": case "tel": return ; case "number": case "decimal": return ; case "date": case "datetime": return ; case "select": case "dropdown": return ; case "textarea": return ; case "boolean": case "checkbox": return ; case "radio": return ; case "code": return ; case "entity": return ; case "file": return ; default: return ; } }; export const RealtimePreviewDynamic: React.FC = ({ component, isSelected = false, isDesignMode = false, onClick, onDragStart, onDragEnd, onGroupToggle, children, onFlowSelectedDataChange, sortBy, sortOrder, tableDisplayData, // 🆕 화면 표시 데이터 ...restProps }) => { const { user } = useAuth(); const { type, id, position, size, style = {} } = component; const [fileUpdateTrigger, setFileUpdateTrigger] = useState(0); const [actualHeight, setActualHeight] = useState(null); const contentRef = React.useRef(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]); // 전역 파일 상태 변경 감지 (해당 컴포넌트만) useEffect(() => { const handleGlobalFileStateChange = (event: CustomEvent) => { // console.log("🎯🎯🎯 RealtimePreview 이벤트 수신:", { // 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 // }); if (event.detail.componentId === component.id) { // console.log("✅✅✅ RealtimePreview 파일 상태 변경 감지 - 리렌더링 시작:", { // 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 // }); setFileUpdateTrigger((prev) => { const newTrigger = prev + 1; // console.log("🔄🔄🔄 fileUpdateTrigger 업데이트:", { // old: prev, // new: newTrigger, // componentId: component.id, // attempt: event.detail.attempt || 1 // }); return newTrigger; }); } else { // console.log("❌ 컴포넌트 ID 불일치:", { // eventComponentId: event.detail.componentId, // currentComponentId: component.id // }); } }; // 강제 업데이트 함수 등록 const forceUpdate = (componentId: string, files: any[]) => { // console.log("🔥🔥🔥 RealtimePreview 강제 업데이트 호출:", { // targetComponentId: componentId, // currentComponentId: component.id, // isMatch: componentId === component.id, // filesCount: files.length // }); if (componentId === component.id) { // console.log("✅✅✅ RealtimePreview 강제 업데이트 적용:", { // componentId: component.id, // filesCount: files.length, // oldTrigger: fileUpdateTrigger // }); setFileUpdateTrigger((prev) => { const newTrigger = prev + 1; // console.log("🔄🔄🔄 강제 fileUpdateTrigger 업데이트:", { // old: prev, // new: newTrigger, // componentId: component.id // }); return newTrigger; }); } }; if (typeof window !== "undefined") { try { window.addEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener); // 전역 강제 업데이트 함수 등록 if (!(window as any).forceRealtimePreviewUpdate) { (window as any).forceRealtimePreviewUpdate = forceUpdate; } } catch (error) { // console.warn("RealtimePreview 이벤트 리스너 등록 실패:", error); } return () => { try { window.removeEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener); } catch (error) { // console.warn("RealtimePreview 이벤트 리스너 제거 실패:", error); } }; } }, [component.id, fileUpdateTrigger]); // 컴포넌트 스타일 계산 const isFlowWidget = type === "flow" || (type === "component" && (component as any).componentConfig?.type === "flow-widget"); const isSectionPaper = type === "component" && (component as any).componentConfig?.type === "section-paper"; const positionX = position?.x || 0; const positionY = position?.y || 0; // 🆕 분할 패널 리사이즈 Context const { getAdjustedX, getOverlappingSplitPanel } = useSplitPanel(); // 버튼 컴포넌트인지 확인 (분할 패널 위치 조정 대상) const componentType = (component as any).componentType || ""; const componentId = (component as any).componentId || ""; const widgetType = (component as any).widgetType || ""; const isButtonComponent = (type === "widget" && widgetType === "button") || (type === "component" && (["button-primary", "button-secondary"].includes(componentType) || ["button-primary", "button-secondary"].includes(componentId))); // 디버깅: 모든 컴포넌트의 타입 정보 출력 (버튼 관련만) if (componentType.includes("button") || componentId.includes("button") || widgetType.includes("button")) { console.log("🔘 [RealtimePreview] 버튼 컴포넌트 발견:", { id: component.id, type, componentType, componentId, widgetType, isButtonComponent, positionX, positionY, }); } // 🆕 분할 패널 위 버튼 위치 자동 조정 const { adjustedPositionX, isOnSplitPanel, isDraggingSplitPanel } = useMemo(() => { // 버튼이 아니거나 분할 패널 컴포넌트 자체인 경우 조정하지 않음 const isSplitPanelComponent = type === "component" && ["split-panel-layout", "split-panel-layout2"].includes((component as any).componentType || ""); if (!isButtonComponent || isSplitPanelComponent) { return { adjustedPositionX: positionX, isOnSplitPanel: false, isDraggingSplitPanel: false }; } const componentWidth = size?.width || 100; const componentHeight = size?.height || 40; // 분할 패널 위에 있는지 확인 const overlap = getOverlappingSplitPanel(positionX, positionY, componentWidth, componentHeight); // 디버깅: 버튼이 분할 패널 위에 있는지 확인 if (isButtonComponent) { console.log("🔍 [RealtimePreview] 버튼 분할 패널 감지:", { componentId: component.id, componentType: (component as any).componentType, positionX, positionY, componentWidth, componentHeight, hasOverlap: !!overlap, isInLeftPanel: overlap?.isInLeftPanel, panelInfo: overlap ? { panelId: overlap.panelId, panelX: overlap.panel.x, panelY: overlap.panel.y, panelWidth: overlap.panel.width, leftWidthPercent: overlap.panel.leftWidthPercent, initialLeftWidthPercent: overlap.panel.initialLeftWidthPercent, } : null, }); } if (!overlap || !overlap.isInLeftPanel) { // 분할 패널 위에 없거나 우측 패널 위에 있음 return { adjustedPositionX: positionX, isOnSplitPanel: !!overlap, isDraggingSplitPanel: overlap?.panel.isDragging ?? false, }; } // 좌측 패널 위에 있음 - 위치 조정 const adjusted = getAdjustedX(positionX, positionY, componentWidth, componentHeight); console.log("✅ [RealtimePreview] 버튼 위치 조정 적용:", { componentId: component.id, originalX: positionX, adjustedX: adjusted, delta: adjusted - positionX, }); return { adjustedPositionX: adjusted, isOnSplitPanel: true, isDraggingSplitPanel: overlap.panel.isDragging, }; }, [ positionX, positionY, size?.width, size?.height, isButtonComponent, type, component, getAdjustedX, getOverlappingSplitPanel, ]); // 너비 결정 로직: style.width (퍼센트) > 조건부 100% > size.width (픽셀) const getWidth = () => { // 1순위: style.width가 있으면 우선 사용 (퍼센트 값) if (style?.width) { return style.width; } // 2순위: left가 0이면 100% if (positionX === 0) { return "100%"; } // 3순위: size.width 픽셀 값 return size?.width || 200; }; // 높이 결정 로직: style.height > actualHeight (Flow Widget) > size.height const getHeight = () => { // 1순위: style.height가 있으면 우선 사용 (픽셀/퍼센트 값) if (style?.height) { return style.height; } // 2순위: Flow Widget의 실제 측정 높이 if (isFlowWidget && actualHeight) { return actualHeight; } // 3순위: size.height 픽셀 값 return size?.height || 10; }; const componentStyle = { position: "absolute" as const, ...style, // 먼저 적용하고 left: adjustedPositionX, // 🆕 분할 패널 위 버튼은 조정된 X 좌표 사용 top: positionY, width: getWidth(), // 우선순위에 따른 너비 height: getHeight(), // 우선순위에 따른 높이 zIndex: position?.z || 1, // right 속성 강제 제거 right: undefined, // 🆕 분할 패널 드래그 중에는 트랜지션 없이 즉시 이동 transition: isOnSplitPanel && isButtonComponent ? (isDraggingSplitPanel ? "none" : "left 0.1s ease-out") : undefined, }; // 선택된 컴포넌트 스타일 // Section Paper는 자체적으로 선택 상태 테두리를 처리하므로 outline 제거 const selectionStyle = isSelected && !isSectionPaper ? { outline: "2px solid rgb(59, 130, 246)", outlineOffset: "2px", } : {}; const handleClick = (e: React.MouseEvent) => { // 컴포넌트 영역 내에서만 클릭 이벤트 처리 e.stopPropagation(); onClick?.(e); }; const handleDragStart = (e: React.DragEvent) => { e.stopPropagation(); onDragStart?.(e); }; const handleDragEnd = () => { onDragEnd?.(); }; return (
{/* 컴포넌트 타입별 렌더링 */}
{/* 영역 타입 */} {type === "area" && renderArea(component, children)} {/* 데이터 테이블 타입 */} {type === "datatable" && (() => { const dataTableComponent = component as any; // DataTableComponent 타입 return ( ); })()} {/* 플로우 위젯 타입 */} {(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 (
); })()} {/* 탭 컴포넌트 타입 */} {(type === "tabs" || (type === "component" && ((component as any).componentType === "tabs-widget" || (component as any).componentId === "tabs-widget"))) && (() => { console.log("🎯 탭 컴포넌트 조건 충족:", { type, componentType: (component as any).componentType, componentId: (component as any).componentId, isDesignMode, }); if (isDesignMode) { // 디자인 모드: 미리보기 표시 const tabsComponent = component as any; const tabs = tabsComponent.componentConfig?.tabs || tabsComponent.tabs || []; return (

탭 컴포넌트

{tabs.length > 0 ? `${tabs.length}개의 탭 (실제 화면에서 표시됩니다)` : "탭이 없습니다. 설정 패널에서 탭을 추가하세요"}

{tabs.length > 0 && (
{tabs.map((tab: any, index: number) => ( {tab.label || `탭 ${index + 1}`} {tab.screenName && ( ({tab.screenName}) )} ))}
)}
); } else { // 실제 화면: TabsWidget 렌더링 const TabsWidget = require("@/components/screen/widgets/TabsWidget").TabsWidget; const tabsConfig = (component as any).componentConfig || {}; const tabsComponent = { ...component, type: "tabs" as const, tabs: tabsConfig.tabs || [], defaultTab: tabsConfig.defaultTab, orientation: tabsConfig.orientation || "horizontal", variant: tabsConfig.variant || "default", allowCloseable: tabsConfig.allowCloseable || false, persistSelection: tabsConfig.persistSelection || false, }; return (
); } })()} {/* 그룹 타입 */} {type === "group" && (
{children}
)} {/* 컴포넌트 타입 - 레지스트리 기반 렌더링 (Section Paper, Section Card 등) */} {type === "component" && (() => { const { DynamicComponentRenderer } = require("@/lib/registry/DynamicComponentRenderer"); return ( {children} ); })()} {/* 위젯 타입 - 동적 렌더링 (파일 컴포넌트 제외) */} {type === "widget" && !isFileComponent(component) && (
)} {/* 파일 타입 - 레거시 및 신규 타입 지원 */} {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 파일 컴포넌트 렌더링:", { // 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() // }); return (
{currentFiles.length > 0 ? (
업로드된 파일 ({currentFiles.length})
{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 ; } if (["pdf", "doc", "docx", "txt", "rtf", "hwp", "hwpx", "hwpml", "pages"].includes(ext)) { return ; } if (["ppt", "pptx", "hpt", "keynote"].includes(ext)) { return ; } if (["xls", "xlsx", "hcdt", "numbers"].includes(ext)) { return ; } if (["mp4", "avi", "mov", "wmv", "webm", "ogg"].includes(ext)) { return
) : (

업로드된 파일 (0)

파일 업로드 영역

상세설정에서 파일을 업로드하세요

)}
); })()}
{/* 선택된 컴포넌트 정보 표시 */} {isSelected && (
{type === "widget" && (
{getWidgetIcon(isWidgetComponent(component) ? (component.widgetType as WebType) : undefined)} {isWidgetComponent(component) ? component.widgetType || "widget" : component.type}
)} {type !== "widget" && type}
)}
); }; // 기존 RealtimePreview와의 호환성을 위한 export export { RealtimePreviewDynamic as RealtimePreview }; RealtimePreviewDynamic.displayName = "RealtimePreviewDynamic";