ERP-node/frontend/lib/registry/components/v2-tabs-widget/tabs-component.tsx

611 lines
21 KiB
TypeScript

"use client";
import React, { useState, useRef, useCallback } from "react";
import { ComponentRegistry } from "../../ComponentRegistry";
import { ComponentCategory } from "@/types/component";
import { Folder, Plus, Move, Settings, Trash2 } from "lucide-react";
import type { TabItem, TabInlineComponent } from "@/types/screen-management";
import { cn } from "@/lib/utils";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
// 디자인 모드용 탭 에디터 컴포넌트
const TabsDesignEditor: React.FC<{
component: any;
tabs: TabItem[];
onUpdateComponent?: (updatedComponent: any) => void;
onSelectTabComponent?: (tabId: string, compId: string, comp: TabInlineComponent) => void;
selectedTabComponentId?: string;
}> = ({ component, tabs, onUpdateComponent, onSelectTabComponent, selectedTabComponentId }) => {
const [activeTabId, setActiveTabId] = useState<string>(tabs[0]?.id || "");
const [draggingCompId, setDraggingCompId] = useState<string | null>(null);
const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const rafRef = useRef<number | null>(null);
const activeTab = tabs.find((t) => t.id === activeTabId);
const getTabStyle = (tab: TabItem) => {
const isActive = tab.id === activeTabId;
return cn(
"px-4 py-2 text-sm font-medium cursor-pointer transition-colors",
isActive
? "bg-background border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
);
};
// 컴포넌트 삭제
const handleDeleteComponent = useCallback(
(compId: string) => {
if (!onUpdateComponent) return;
const updatedTabs = tabs.map((tab) => {
if (tab.id === activeTabId) {
return {
...tab,
components: (tab.components || []).filter((c) => c.id !== compId),
};
}
return tab;
});
onUpdateComponent({
...component,
componentConfig: {
...component.componentConfig,
tabs: updatedTabs,
},
});
},
[activeTabId, component, onUpdateComponent, tabs]
);
// 컴포넌트 드래그 시작
const handleDragStart = useCallback(
(e: React.MouseEvent, comp: TabInlineComponent) => {
e.stopPropagation();
e.preventDefault();
// 드래그 시작 시 마우스 위치와 컴포넌트의 현재 위치 저장
const startMouseX = e.clientX;
const startMouseY = e.clientY;
const startLeft = comp.position?.x || 0;
const startTop = comp.position?.y || 0;
setDraggingCompId(comp.id);
setDragPosition({ x: startLeft, y: startTop });
const handleMouseMove = (moveEvent: MouseEvent) => {
// requestAnimationFrame으로 성능 최적화
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
}
rafRef.current = requestAnimationFrame(() => {
// 마우스 이동량 계산
const deltaX = moveEvent.clientX - startMouseX;
const deltaY = moveEvent.clientY - startMouseY;
// 새 위치 = 시작 위치 + 이동량
const newX = Math.max(0, startLeft + deltaX);
const newY = Math.max(0, startTop + deltaY);
// React 상태로 위치 업데이트 (리렌더링 트리거)
setDragPosition({ x: newX, y: newY });
});
};
const handleMouseUp = (upEvent: MouseEvent) => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
// 마우스 이동량 계산
const deltaX = upEvent.clientX - startMouseX;
const deltaY = upEvent.clientY - startMouseY;
// 새 위치 = 시작 위치 + 이동량
const newX = Math.max(0, startLeft + deltaX);
const newY = Math.max(0, startTop + deltaY);
setDraggingCompId(null);
setDragPosition(null);
// 탭 컴포넌트 위치 업데이트
if (onUpdateComponent) {
const updatedTabs = tabs.map((tab) => {
if (tab.id === activeTabId) {
return {
...tab,
components: (tab.components || []).map((c) =>
c.id === comp.id
? {
...c,
position: {
x: Math.max(0, Math.round(newX)),
y: Math.max(0, Math.round(newY)),
},
}
: c
),
};
}
return tab;
});
onUpdateComponent({
...component,
componentConfig: {
...component.componentConfig,
tabs: updatedTabs,
},
});
}
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
setDraggingCompId(null);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
},
[activeTabId, component, onUpdateComponent, tabs]
);
return (
<div className="flex h-full w-full flex-col overflow-hidden rounded-lg border bg-background">
{/* 탭 헤더 */}
<div className="flex items-center border-b bg-muted/30">
{tabs.length > 0 ? (
tabs.map((tab) => (
<div
key={tab.id}
className={getTabStyle(tab)}
onClick={(e) => {
e.stopPropagation();
setActiveTabId(tab.id);
onSelectTabComponent?.(null);
}}
>
{tab.label || "탭"}
{tab.components && tab.components.length > 0 && (
<span className="ml-1 text-xs text-muted-foreground">
({tab.components.length})
</span>
)}
</div>
))
) : (
<div className="px-4 py-2 text-sm text-muted-foreground">
</div>
)}
</div>
{/* 탭 컨텐츠 영역 - 드롭 영역 */}
<div
className="relative flex-1 overflow-hidden"
data-tabs-container="true"
data-component-id={component.id}
data-active-tab-id={activeTabId}
onClick={() => onSelectTabComponent?.(activeTabId, "", {} as TabInlineComponent)}
>
{activeTab ? (
<div
ref={containerRef}
className="absolute inset-0 overflow-auto p-2"
>
{activeTab.components && activeTab.components.length > 0 ? (
<div className="relative" style={{ minHeight: "100%", minWidth: "100%" }}>
{activeTab.components.map((comp: TabInlineComponent) => {
const isSelected = selectedTabComponentId === comp.id;
const isDragging = draggingCompId === comp.id;
// 컴포넌트 데이터를 DynamicComponentRenderer 형식으로 변환
const componentData = {
id: comp.id,
type: "component" as const,
componentType: comp.componentType,
label: comp.label,
position: comp.position || { x: 0, y: 0 },
size: comp.size || { width: 200, height: 100 },
componentConfig: comp.componentConfig || {},
style: comp.style || {},
};
// 드래그 중인 컴포넌트는 dragPosition 사용, 아니면 저장된 position 사용
const displayX = isDragging && dragPosition ? dragPosition.x : (comp.position?.x || 0);
const displayY = isDragging && dragPosition ? dragPosition.y : (comp.position?.y || 0);
return (
<div
key={comp.id}
data-tab-comp-id={comp.id}
className="absolute"
style={{
left: displayX,
top: displayY,
zIndex: isDragging ? 100 : isSelected ? 10 : 1,
}}
onClick={(e) => {
e.stopPropagation();
onSelectTabComponent?.(activeTabId, comp.id, comp);
}}
>
{/* 드래그 핸들 - 컴포넌트 외부 상단 */}
<div
className={cn(
"flex h-4 cursor-move items-center justify-between rounded-t border border-b-0 bg-gray-100 px-1",
isSelected ? "border-primary" : "border-gray-200"
)}
style={{ width: comp.size?.width || 200 }}
onMouseDown={(e) => handleDragStart(e, comp)}
>
<div className="flex items-center gap-0.5">
<Move className="h-2.5 w-2.5 text-gray-400" />
<span className="text-[9px] text-gray-500 truncate max-w-[100px]">
{comp.label || comp.componentType}
</span>
</div>
<div className="flex items-center">
<button
className="rounded p-0.5 hover:bg-gray-200"
onClick={(e) => {
e.stopPropagation();
onSelectTabComponent?.(activeTabId, comp.id, comp);
}}
title="설정"
>
<Settings className="h-2.5 w-2.5 text-gray-500" />
</button>
<button
className="rounded p-0.5 hover:bg-red-100"
onClick={(e) => {
e.stopPropagation();
handleDeleteComponent(comp.id);
}}
title="삭제"
>
<Trash2 className="h-2.5 w-2.5 text-red-500" />
</button>
</div>
</div>
{/* 실제 컴포넌트 렌더링 - 핸들 아래에 별도 영역 */}
<div
className={cn(
"rounded-b border bg-white shadow-sm overflow-hidden pointer-events-none",
isSelected
? "border-primary ring-2 ring-primary/30"
: "border-gray-200",
isDragging && "opacity-80 shadow-lg",
!isDragging && "transition-all"
)}
style={{
width: comp.size?.width || 200,
height: comp.size?.height || 100,
}}
>
<DynamicComponentRenderer
component={componentData as any}
isDesignMode={true}
formData={{}}
/>
</div>
</div>
);
})}
</div>
) : (
<div className="flex h-full w-full flex-col items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50/50">
<Plus className="mb-2 h-8 w-8 text-gray-400" />
<p className="text-sm font-medium text-gray-500">
</p>
<p className="mt-1 text-xs text-gray-400">
</p>
</div>
)}
</div>
) : (
<div className="flex h-full w-full items-center justify-center">
<p className="text-sm text-muted-foreground">
</p>
</div>
)}
</div>
</div>
);
};
// TabsWidget 래퍼 컴포넌트
const TabsWidgetWrapper: React.FC<any> = (props) => {
const {
component,
isDesignMode,
onUpdateComponent,
onSelectTabComponent,
selectedTabComponentId,
...restProps
} = props;
// componentConfig에서 탭 정보 추출
const tabsConfig = component.componentConfig || {};
const tabs: TabItem[] = tabsConfig.tabs || [];
// 🎯 디자인 모드에서는 드롭 가능한 에디터 UI 렌더링
if (isDesignMode) {
return (
<TabsDesignEditor
component={component}
tabs={tabs}
onUpdateComponent={onUpdateComponent}
onSelectTabComponent={onSelectTabComponent}
selectedTabComponentId={selectedTabComponentId}
/>
);
}
// 실행 모드에서는 TabsWidget 렌더링
const tabsComponent = {
...component,
type: "tabs" as const,
tabs: tabs,
defaultTab: tabsConfig.defaultTab,
orientation: tabsConfig.orientation || "horizontal",
variant: tabsConfig.variant || "default",
allowCloseable: tabsConfig.allowCloseable || false,
persistSelection: tabsConfig.persistSelection || false,
};
const TabsWidget =
require("@/components/screen/widgets/TabsWidget").TabsWidget;
return (
<div className="h-full w-full">
<TabsWidget component={tabsComponent} {...restProps} />
</div>
);
};
/**
* 탭 컴포넌트 정의
*
* 탭별로 컴포넌트를 자유롭게 배치할 수 있는 레이아웃 컴포넌트
*/
ComponentRegistry.registerComponent({
id: "v2-tabs-widget",
name: "탭 컴포넌트",
description:
"탭별로 컴포넌트를 자유롭게 배치할 수 있는 레이아웃 컴포넌트입니다.",
category: ComponentCategory.LAYOUT,
webType: "text" as any,
component: TabsWidgetWrapper,
defaultConfig: {
tabs: [
{
id: "tab-1",
label: "탭 1",
order: 0,
disabled: false,
components: [],
},
{
id: "tab-2",
label: "탭 2",
order: 1,
disabled: false,
components: [],
},
],
defaultTab: "tab-1",
orientation: "horizontal",
variant: "default",
allowCloseable: false,
persistSelection: false,
},
tags: ["tabs", "navigation", "layout", "container"],
icon: Folder,
version: "2.0.0",
defaultSize: {
width: 800,
height: 600,
},
defaultProps: {
type: "tabs" as const,
tabs: [
{
id: "tab-1",
label: "탭 1",
order: 0,
disabled: false,
components: [],
},
{
id: "tab-2",
label: "탭 2",
order: 1,
disabled: false,
components: [],
},
] as TabItem[],
defaultTab: "tab-1",
orientation: "horizontal" as const,
variant: "default" as const,
allowCloseable: false,
persistSelection: false,
},
// 에디터 모드에서의 렌더링 - 탭 선택 및 컴포넌트 드롭 지원
renderEditor: ({
component,
isSelected,
onClick,
onDragStart,
onDragEnd,
}) => {
const tabsConfig = (component as any).componentConfig || {};
const tabs: TabItem[] = tabsConfig.tabs || [];
// 에디터 모드에서 선택된 탭 상태 관리
const [activeTabId, setActiveTabId] = useState<string>(
tabs[0]?.id || ""
);
const activeTab = tabs.find((t) => t.id === activeTabId);
// 탭 스타일 클래스
const getTabStyle = (tab: TabItem) => {
const isActive = tab.id === activeTabId;
return cn(
"px-4 py-2 text-sm font-medium cursor-pointer transition-colors",
isActive
? "bg-background border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
);
};
return (
<div
className="flex h-full w-full flex-col overflow-hidden rounded-lg border bg-background"
onClick={onClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
{/* 탭 헤더 */}
<div className="flex items-center border-b bg-muted/30">
{tabs.length > 0 ? (
tabs.map((tab) => (
<div
key={tab.id}
className={getTabStyle(tab)}
onClick={(e) => {
e.stopPropagation();
setActiveTabId(tab.id);
}}
>
{tab.label || "탭"}
{tab.components && tab.components.length > 0 && (
<span className="ml-1 text-xs text-muted-foreground">
({tab.components.length})
</span>
)}
</div>
))
) : (
<div className="px-4 py-2 text-sm text-muted-foreground">
</div>
)}
</div>
{/* 탭 컨텐츠 영역 - 드롭 영역 */}
<div
className="relative flex-1 overflow-hidden"
data-tabs-container="true"
data-component-id={component.id}
data-active-tab-id={activeTabId}
>
{activeTab ? (
<div className="absolute inset-0 overflow-auto p-2">
{activeTab.components && activeTab.components.length > 0 ? (
<div className="relative h-full w-full">
{activeTab.components.map((comp: TabInlineComponent) => (
<div
key={comp.id}
className="absolute rounded border border-dashed border-gray-300 bg-white/80 p-2 shadow-sm"
style={{
left: comp.position?.x || 0,
top: comp.position?.y || 0,
width: comp.size?.width || 200,
height: comp.size?.height || 100,
}}
>
<div className="flex h-full flex-col items-center justify-center">
<span className="text-xs font-medium text-gray-600">
{comp.label || comp.componentType}
</span>
<span className="text-[10px] text-gray-400">
{comp.componentType}
</span>
</div>
</div>
))}
</div>
) : (
<div className="flex h-full w-full flex-col items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50/50">
<Plus className="mb-2 h-8 w-8 text-gray-400" />
<p className="text-sm font-medium text-gray-500">
</p>
<p className="mt-1 text-xs text-gray-400">
</p>
</div>
)}
</div>
) : (
<div className="flex h-full w-full items-center justify-center">
<p className="text-sm text-muted-foreground">
</p>
</div>
)}
</div>
{/* 선택 표시 */}
{isSelected && (
<div className="pointer-events-none absolute inset-0 rounded-lg ring-2 ring-primary ring-offset-2" />
)}
</div>
);
},
// 인터랙티브 모드에서의 렌더링
renderInteractive: ({ component }) => {
return null;
},
// 설정 패널
configPanel: React.lazy(() =>
import("@/components/screen/config-panels/TabsConfigPanel").then(
(module) => ({
default: module.TabsConfigPanel,
})
)
),
// 검증 함수
validate: (component) => {
const tabsConfig = (component as any).componentConfig || {};
const tabs: TabItem[] = tabsConfig.tabs || [];
const errors: string[] = [];
if (!tabs || tabs.length === 0) {
errors.push("최소 1개 이상의 탭이 필요합니다.");
}
if (tabs) {
const tabIds = tabs.map((t) => t.id);
const uniqueIds = new Set(tabIds);
if (tabIds.length !== uniqueIds.size) {
errors.push("탭 ID가 중복되었습니다.");
}
}
return {
isValid: errors.length === 0,
errors,
};
},
});