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

425 lines
14 KiB
TypeScript
Raw Normal View History

2025-09-09 14:29:04 +09:00
"use client";
import React from "react";
2025-09-10 14:09:32 +09:00
import { ComponentData, WebType, WidgetComponent } from "@/types/screen";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
2025-09-09 14:29:04 +09:00
import {
Database,
Type,
Hash,
List,
AlignLeft,
CheckSquare,
Radio,
Calendar,
Code,
Building,
File,
} from "lucide-react";
2025-09-10 14:09:32 +09:00
// 컴포넌트 렌더러들 자동 등록
import "@/lib/registry/components";
2025-09-09 14:29:04 +09:00
interface RealtimePreviewProps {
component: ComponentData;
isSelected?: boolean;
2025-09-12 14:24:25 +09:00
isDesignMode?: boolean; // 편집 모드 여부
2025-09-09 14:29:04 +09:00
onClick?: (e?: React.MouseEvent) => void;
onDoubleClick?: (e?: React.MouseEvent) => void; // 더블클릭 핸들러 추가
2025-09-09 14:29:04 +09:00
onDragStart?: (e: React.DragEvent) => void;
onDragEnd?: () => void;
onGroupToggle?: (groupId: string) => void; // 그룹 접기/펼치기
children?: React.ReactNode; // 그룹 내 자식 컴포넌트들
2025-09-11 16:21:00 +09:00
selectedScreen?: any;
onZoneComponentDrop?: (e: React.DragEvent, zoneId: string, layoutId: string) => void; // 존별 드롭 핸들러
onZoneClick?: (zoneId: string) => void; // 존 클릭 핸들러
onConfigChange?: (config: any) => void; // 설정 변경 핸들러
2025-10-24 16:34:21 +09:00
2025-10-23 13:15:52 +09:00
// 버튼 액션을 위한 props
screenId?: number;
tableName?: string;
2025-10-29 11:26:00 +09:00
userId?: string; // 🆕 현재 사용자 ID
userName?: string; // 🆕 현재 사용자 이름
companyCode?: string; // 🆕 현재 사용자의 회사 코드
2025-10-23 13:15:52 +09:00
selectedRowsData?: any[];
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void;
flowSelectedData?: any[];
flowSelectedStepId?: number | null;
onFlowSelectedDataChange?: (selectedData: any[], stepId: number | null) => void;
2025-10-23 13:15:52 +09:00
refreshKey?: number;
onRefresh?: () => void;
2025-10-23 17:55:04 +09:00
flowRefreshKey?: number;
onFlowRefresh?: () => void;
2025-10-24 16:34:21 +09:00
2025-10-23 15:06:00 +09:00
// 폼 데이터 관련 props
formData?: Record<string, any>;
onFormDataChange?: (fieldName: string, value: any) => void;
2025-09-09 14:29:04 +09:00
}
// 동적 위젯 타입 아이콘 (레지스트리에서 조회)
2025-09-10 14:09:32 +09:00
const getWidgetIcon = (widgetType: WebType | undefined): React.ReactNode => {
if (!widgetType) return <Type className="h-3 w-3" />;
const iconMap: Record<string, React.ReactNode> = {
text: <span className="text-xs">Aa</span>,
number: <Hash className="h-3 w-3" />,
decimal: <Hash className="h-3 w-3" />,
date: <Calendar className="h-3 w-3" />,
datetime: <Calendar className="h-3 w-3" />,
select: <List className="h-3 w-3" />,
dropdown: <List className="h-3 w-3" />,
textarea: <AlignLeft className="h-3 w-3" />,
boolean: <CheckSquare className="h-3 w-3" />,
checkbox: <CheckSquare className="h-3 w-3" />,
radio: <Radio className="h-3 w-3" />,
code: <Code className="h-3 w-3" />,
entity: <Building className="h-3 w-3" />,
file: <File className="h-3 w-3" />,
email: <span className="text-xs">@</span>,
tel: <span className="text-xs"></span>,
button: <span className="text-xs">BTN</span>,
};
2025-09-09 14:29:04 +09:00
2025-09-10 14:09:32 +09:00
return iconMap[widgetType] || <Type className="h-3 w-3" />;
2025-09-09 14:29:04 +09:00
};
export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
component,
isSelected = false,
2025-09-12 14:24:25 +09:00
isDesignMode = true, // 기본값은 편집 모드
2025-09-09 14:29:04 +09:00
onClick,
onDoubleClick,
2025-09-09 14:29:04 +09:00
onDragStart,
onDragEnd,
onGroupToggle,
children,
2025-09-11 12:22:39 +09:00
selectedScreen,
2025-09-11 16:21:00 +09:00
onZoneComponentDrop,
onZoneClick,
onConfigChange,
2025-10-23 13:15:52 +09:00
screenId,
tableName,
2025-10-29 11:26:00 +09:00
userId, // 🆕 사용자 ID
userName, // 🆕 사용자 이름
companyCode, // 🆕 회사 코드
2025-10-23 13:15:52 +09:00
selectedRowsData,
onSelectedRowsChange,
flowSelectedData,
flowSelectedStepId,
onFlowSelectedDataChange,
2025-10-23 13:15:52 +09:00
refreshKey,
onRefresh,
2025-10-23 17:55:04 +09:00
flowRefreshKey,
onFlowRefresh,
2025-10-23 15:06:00 +09:00
formData,
onFormDataChange,
2025-09-09 14:29:04 +09:00
}) => {
2025-10-23 15:06:00 +09:00
const [actualHeight, setActualHeight] = React.useState<number | null>(null);
const contentRef = React.useRef<HTMLDivElement>(null);
const lastUpdatedHeight = React.useRef<number | null>(null);
// 플로우 위젯의 실제 높이 측정
React.useEffect(() => {
const isFlowWidget = component.type === "component" && (component as any).componentType === "flow-widget";
2025-10-24 16:34:21 +09:00
2025-10-23 15:06:00 +09:00
if (isFlowWidget && contentRef.current) {
const measureHeight = () => {
if (contentRef.current) {
// getBoundingClientRect()로 실제 렌더링된 높이 측정
const rect = contentRef.current.getBoundingClientRect();
const measured = rect.height;
2025-10-24 16:34:21 +09:00
2025-10-23 15:06:00 +09:00
// scrollHeight도 함께 확인하여 더 큰 값 사용
const scrollHeight = contentRef.current.scrollHeight;
const rawHeight = Math.max(measured, scrollHeight);
2025-10-24 16:34:21 +09:00
2025-10-23 15:06:00 +09:00
// 40px 단위로 올림
const finalHeight = Math.ceil(rawHeight / 40) * 40;
2025-10-24 16:34:21 +09:00
2025-10-23 15:06:00 +09:00
if (finalHeight > 0 && Math.abs(finalHeight - (actualHeight || 0)) > 10) {
setActualHeight(finalHeight);
2025-10-24 16:34:21 +09:00
2025-10-23 15:06:00 +09:00
// 컴포넌트의 실제 size.height도 업데이트 (중복 업데이트 방지)
if (onConfigChange && finalHeight !== lastUpdatedHeight.current && finalHeight !== component.size?.height) {
lastUpdatedHeight.current = finalHeight;
// size는 별도 속성이므로 직접 업데이트
2025-10-24 16:34:21 +09:00
const event = new CustomEvent("updateComponentSize", {
2025-10-23 15:06:00 +09:00
detail: {
componentId: component.id,
2025-10-24 16:34:21 +09:00
height: finalHeight,
},
2025-10-23 15:06:00 +09:00
});
window.dispatchEvent(event);
}
}
}
};
// 초기 측정 (렌더링 완료 후)
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();
};
}
}, [component.type, component.id, actualHeight, component.size?.height, onConfigChange]);
2025-09-10 14:09:32 +09:00
const { id, type, position, size, style: componentStyle } = component;
2025-09-09 14:29:04 +09:00
// 선택 상태에 따른 스타일 (z-index 낮춤 - 패널과 모달보다 아래)
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)",
outlineOffset: "0px", // 스크롤 방지를 위해 0으로 설정
2025-10-17 16:21:08 +09:00
zIndex: 20,
2025-09-09 14:29:04 +09:00
}
: {};
2025-09-11 16:21:00 +09:00
// 컴포넌트 기본 스타일 - 레이아웃은 항상 맨 아래
// 너비 우선순위: style.width > 조건부 100% > size.width (픽셀값)
2025-10-14 13:27:02 +09:00
const getWidth = () => {
// 1순위: style.width가 있으면 우선 사용 (퍼센트 값)
2025-10-14 13:27:02 +09:00
if (componentStyle?.width) {
console.log("✅ [getWidth] style.width 사용:", {
componentId: id,
label: component.label,
styleWidth: componentStyle.width,
gridColumns: (component as any).gridColumns,
componentStyle: componentStyle,
baseStyle: {
left: `${position.x}px`,
top: `${position.y}px`,
width: componentStyle.width,
height: getHeight(),
},
});
2025-10-14 13:27:02 +09:00
return componentStyle.width;
}
// 2순위: x=0인 컴포넌트는 전체 너비 사용 (버튼 제외)
const isButtonComponent =
(component.type === "widget" && (component as WidgetComponent).widgetType === "button") ||
(component.type === "component" && (component as any).componentType?.includes("button"));
if (position.x === 0 && !isButtonComponent) {
console.log("⚠️ [getWidth] 100% 사용 (x=0):", {
componentId: id,
label: component.label,
});
return "100%";
}
// 3순위: size.width (픽셀)
2025-10-14 13:27:02 +09:00
if (component.componentConfig?.type === "table-list") {
const width = `${Math.max(size?.width || 120, 120)}px`;
console.log("📏 [getWidth] 픽셀 사용 (table-list):", {
componentId: id,
label: component.label,
width,
});
return width;
2025-10-14 13:27:02 +09:00
}
const width = `${size?.width || 100}px`;
console.log("📏 [getWidth] 픽셀 사용 (기본):", {
componentId: id,
label: component.label,
width,
sizeWidth: size?.width,
});
return width;
2025-10-14 13:27:02 +09:00
};
const getHeight = () => {
2025-10-23 15:06:00 +09:00
// 플로우 위젯의 경우 측정된 높이 사용
const isFlowWidget = component.type === "component" && (component as any).componentType === "flow-widget";
if (isFlowWidget && actualHeight) {
return `${actualHeight}px`;
}
2025-10-14 13:27:02 +09:00
// 1순위: style.height가 있으면 우선 사용
if (componentStyle?.height) {
return componentStyle.height;
}
// 2순위: size.height (픽셀)
if (component.componentConfig?.type === "table-list") {
return `${Math.max(size?.height || 200, 200)}px`;
}
2025-11-05 15:23:57 +09:00
// size.height가 있으면 그대로 사용, 없으면 최소 10px
return `${size?.height || 10}px`;
2025-10-14 13:27:02 +09:00
};
2025-09-10 14:09:32 +09:00
const baseStyle = {
left: `${position.x}px`,
top: `${position.y}px`,
width: getWidth(), // getWidth()가 모든 우선순위를 처리
2025-11-04 11:41:20 +09:00
height: getHeight(),
zIndex: component.type === "layout" ? 1 : position.z || 2,
2025-09-10 14:09:32 +09:00
...componentStyle,
right: undefined,
2025-09-10 14:09:32 +09:00
};
// 🔍 DOM 렌더링 후 실제 크기 측정
const innerDivRef = React.useRef<HTMLDivElement>(null);
const outerDivRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (outerDivRef.current && innerDivRef.current) {
const outerRect = outerDivRef.current.getBoundingClientRect();
const innerRect = innerDivRef.current.getBoundingClientRect();
const computedOuter = window.getComputedStyle(outerDivRef.current);
const computedInner = window.getComputedStyle(innerDivRef.current);
console.log("📐 [DOM 실제 크기 상세]:", {
componentId: id,
label: component.label,
gridColumns: (component as any).gridColumns,
"1. baseStyle.width": baseStyle.width,
"2. 외부 div (파란 테두리)": {
width: `${outerRect.width}px`,
height: `${outerRect.height}px`,
computedWidth: computedOuter.width,
computedHeight: computedOuter.height,
},
"3. 내부 div (컨텐츠 래퍼)": {
width: `${innerRect.width}px`,
height: `${innerRect.height}px`,
computedWidth: computedInner.width,
computedHeight: computedInner.height,
className: innerDivRef.current.className,
inlineStyle: innerDivRef.current.getAttribute("style"),
},
"4. 너비 비교": {
"외부 / 내부": `${outerRect.width}px / ${innerRect.width}px`,
"비율": `${((innerRect.width / outerRect.width) * 100).toFixed(2)}%`,
},
});
}
}, [id, component.label, (component as any).gridColumns, baseStyle.width]);
2025-09-09 14:29:04 +09:00
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.(e);
};
const handleDoubleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onDoubleClick?.(e);
};
2025-09-09 14:29:04 +09:00
const handleDragStart = (e: React.DragEvent) => {
e.stopPropagation();
onDragStart?.(e);
};
const handleDragEnd = () => {
onDragEnd?.();
};
return (
<div
ref={outerDivRef}
2025-09-09 14:29:04 +09:00
id={`component-${id}`}
data-component-id={id}
className="absolute cursor-pointer transition-all duration-200 ease-out"
2025-09-10 14:09:32 +09:00
style={{ ...baseStyle, ...selectionStyle }}
2025-09-09 14:29:04 +09:00
onClick={handleClick}
onDoubleClick={handleDoubleClick}
draggable={isDesignMode} // 디자인 모드에서만 드래그 가능
onDragStart={isDesignMode ? handleDragStart : undefined}
onDragEnd={isDesignMode ? handleDragEnd : undefined}
2025-09-09 14:29:04 +09:00
>
2025-09-10 14:09:32 +09:00
{/* 동적 컴포넌트 렌더링 */}
2025-10-14 16:45:30 +09:00
<div
ref={(node) => {
// 멀티 ref 처리
innerDivRef.current = node;
if (component.type === "component" && (component as any).componentType === "flow-widget") {
(contentRef as any).current = node;
}
}}
className={`${component.type === "component" && (component as any).componentType === "flow-widget" ? "h-auto" : "h-full"} overflow-visible`}
style={{ width: "100%", maxWidth: "100%" }}
2025-10-14 16:45:30 +09:00
>
2025-09-10 14:09:32 +09:00
<DynamicComponentRenderer
component={component}
isSelected={isSelected}
2025-09-12 14:24:25 +09:00
isDesignMode={isDesignMode}
isInteractive={!isDesignMode} // 편집 모드가 아닐 때만 인터랙티브
2025-09-10 14:09:32 +09:00
onClick={onClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
children={children}
2025-09-11 12:22:39 +09:00
selectedScreen={selectedScreen}
2025-09-11 16:21:00 +09:00
onZoneComponentDrop={onZoneComponentDrop}
onZoneClick={onZoneClick}
onConfigChange={onConfigChange}
2025-10-23 13:15:52 +09:00
screenId={screenId}
tableName={tableName}
2025-10-29 11:26:00 +09:00
userId={userId}
userName={userName}
companyCode={companyCode}
2025-10-23 13:15:52 +09:00
selectedRowsData={selectedRowsData}
onSelectedRowsChange={onSelectedRowsChange}
flowSelectedData={flowSelectedData}
flowSelectedStepId={flowSelectedStepId}
onFlowSelectedDataChange={onFlowSelectedDataChange}
2025-10-23 13:15:52 +09:00
refreshKey={refreshKey}
onRefresh={onRefresh}
2025-10-23 17:55:04 +09:00
flowRefreshKey={flowRefreshKey}
onFlowRefresh={onFlowRefresh}
2025-10-23 15:06:00 +09:00
formData={formData}
onFormDataChange={onFormDataChange}
2025-09-10 14:09:32 +09:00
/>
2025-09-09 14:29:04 +09:00
</div>
{/* 선택된 컴포넌트 정보 표시 */}
{isSelected && (
2025-10-17 16:21:08 +09:00
<div className="bg-primary text-primary-foreground absolute -top-7 left-0 rounded-md px-2.5 py-1 text-xs font-medium shadow-sm">
2025-09-09 14:29:04 +09:00
{type === "widget" && (
2025-10-17 16:21:08 +09:00
<div className="flex items-center gap-1.5">
2025-09-09 14:29:04 +09:00
{getWidgetIcon((component as WidgetComponent).widgetType)}
2025-10-17 16:21:08 +09:00
<span>{(component as WidgetComponent).widgetType || "widget"}</span>
2025-09-09 14:29:04 +09:00
</div>
)}
2025-09-10 14:09:32 +09:00
{type !== "widget" && (
2025-10-17 16:21:08 +09:00
<div className="flex items-center gap-1.5">
<span>{component.componentConfig?.type || type}</span>
2025-09-10 14:09:32 +09:00
</div>
)}
2025-09-09 14:29:04 +09:00
</div>
)}
</div>
);
};
// 기존 RealtimePreview와의 호환성을 위한 export
export { RealtimePreviewDynamic as RealtimePreview };
2025-09-10 14:09:32 +09:00
export default RealtimePreviewDynamic;