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

630 lines
23 KiB
TypeScript

"use client";
import React, { useState, useEffect } 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 {
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;
onClick?: (e?: React.MouseEvent) => void;
onDragStart?: (e: React.DragEvent) => void;
onDragEnd?: () => void;
onGroupToggle?: (groupId: string) => void; // 그룹 접기/펼치기
children?: React.ReactNode; // 그룹 내 자식 컴포넌트들
}
// 영역 레이아웃에 따른 아이콘 반환
const getAreaIcon = (layoutDirection?: "horizontal" | "vertical") => {
switch (layoutDirection) {
case "horizontal":
return <Layout className="text-primary h-4 w-4" />;
case "vertical":
return <Columns className="h-4 w-4 text-purple-600" />;
default:
return <Square className="h-4 w-4 text-gray-500" />;
}
};
// 영역 렌더링
const renderArea = (component: ComponentData, children?: React.ReactNode) => {
if (!isContainerComponent(component) || component.type !== "area") {
return null;
}
const area = component;
const { layoutDirection, 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(layoutDirection)}
<p className="text-muted-foreground mt-2 text-sm">{label || `${layoutDirection || "기본"} 영역`}</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 WidgetRenderer: React.FC<{ component: ComponentData }> = ({ component }) => {
// 위젯 컴포넌트가 아닌 경우 빈 div 반환
if (!isWidgetComponent(component)) {
return <div className="text-xs text-gray-500"> </div>;
}
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 <div className="p-2 text-xs text-gray-500"> ( )</div>;
}
// 동적 웹타입 렌더링 사용
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="text-primary h-4 w-4" />;
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":
return <CheckSquare className="text-primary h-4 w-4" />;
case "radio":
return <Radio className="text-primary h-4 w-4" />;
case "code":
return <Code className="text-muted-foreground h-4 w-4" />;
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 [fileUpdateTrigger, setFileUpdateTrigger] = useState(0);
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]);
// 전역 파일 상태 변경 감지 (해당 컴포넌트만)
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");
// 높이 결정 로직
let finalHeight = size?.height || 40;
if (isFlowWidget && actualHeight) {
finalHeight = actualHeight;
}
const componentStyle = {
position: "absolute" as const,
left: position?.x || 0,
top: position?.y || 0,
width: size?.width || 200,
height: finalHeight,
zIndex: position?.z || 1,
...style,
};
// 선택된 컴포넌트 스타일
const selectionStyle = isSelected
? {
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 (
<div
id={`component-${id}`}
className="absolute cursor-pointer"
style={{ ...componentStyle, ...selectionStyle }}
onClick={handleClick}
draggable
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
{/* 컴포넌트 타입별 렌더링 */}
<div
ref={isFlowWidget ? contentRef : undefined}
className={isFlowWidget ? "h-auto w-full" : "h-full w-full"}
>
{/* 영역 타입 */}
{type === "area" && renderArea(component, children)}
{/* 데이터 테이블 타입 */}
{type === "datatable" &&
(() => {
const dataTableComponent = component as any; // DataTableComponent 타입
return (
<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}
/>
);
})()}
{/* 플로우 위젯 타입 */}
{(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 (
<div className="h-auto w-full">
<FlowWidget component={flowComponent as any} />
</div>
);
})()}
{/* 그룹 타입 */}
{type === "group" && (
<div className="relative h-full w-full">
<div className="absolute inset-0">{children}</div>
</div>
)}
{/* 위젯 타입 - 동적 렌더링 (파일 컴포넌트 제외) */}
{type === "widget" && !isFileComponent(component) && (
<div className="pointer-events-none h-full w-full">
<WidgetRenderer component={component} />
</div>
)}
{/* 파일 타입 - 레거시 및 신규 타입 지원 */}
{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 (
<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>
</div>
);
})}
</div>
</div>
) : (
<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>
</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(isWidgetComponent(component) ? (component.widgetType as WebType) : undefined)}
{isWidgetComponent(component) ? component.widgetType || "widget" : component.type}
</div>
)}
{type !== "widget" && type}
</div>
)}
</div>
);
};
// 기존 RealtimePreview와의 호환성을 위한 export
export { RealtimePreviewDynamic as RealtimePreview };
RealtimePreviewDynamic.displayName = "RealtimePreviewDynamic";