From 58d658e638f551312fed7d0028c59f46d2502035 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 20 Jan 2026 10:46:34 +0900 Subject: [PATCH] =?UTF-8?q?=ED=83=AD=20=EB=82=B4=EB=B6=80=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=84=A0=ED=83=9D=20=EB=B0=8F=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80:=20RealtimePreviewDynamic,=20ScreenDesigner,?= =?UTF-8?q?=20TabsWidget,=20DynamicComponentRenderer,=20TabsConfigPanel?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=ED=83=AD=20=EB=82=B4=EB=B6=80=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=EB=A5=BC=20=EC=84=A0=ED=83=9D?= =?UTF-8?q?=ED=95=98=EA=B3=A0=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8A=94=20=EC=BD=9C=EB=B0=B1?= =?UTF-8?q?=20=ED=95=A8=EC=88=98=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=98?= =?UTF-8?q?=EC=97=AC=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=9D=B8=ED=84=B0?= =?UTF-8?q?=EB=9E=99=EC=85=98=EC=9D=84=20=EA=B0=9C=EC=84=A0=ED=95=98?= =?UTF-8?q?=EC=98=80=EC=8A=B5=EB=8B=88=EB=8B=A4.=20=EC=9D=B4=EB=A5=BC=20?= =?UTF-8?q?=ED=86=B5=ED=95=B4=20=ED=83=AD=20=EB=82=B4=EC=97=90=EC=84=9C?= =?UTF-8?q?=EC=9D=98=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EA=B0=80=20=EC=9A=A9=EC=9D=B4=ED=95=B4=EC=A1=8C?= =?UTF-8?q?=EC=8A=B5=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screen/RealtimePreviewDynamic.tsx | 9 + frontend/components/screen/ScreenDesigner.tsx | 410 ++++++++++++- .../screen/config-panels/TabsConfigPanel.tsx | 464 +++++++------- .../components/screen/widgets/TabsWidget.tsx | 230 +++---- .../lib/registry/DynamicComponentRenderer.tsx | 10 + .../v2-tabs-widget/tabs-component.tsx | 577 ++++++++++++++++-- frontend/types/screen-management.ts | 17 +- 7 files changed, 1287 insertions(+), 430 deletions(-) diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index 72baff79..61c7e77c 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -36,6 +36,9 @@ interface RealtimePreviewProps { onZoneComponentDrop?: (e: React.DragEvent, zoneId: string, layoutId: string) => void; // 존별 드롭 핸들러 onZoneClick?: (zoneId: string) => void; // 존 클릭 핸들러 onConfigChange?: (config: any) => void; // 설정 변경 핸들러 + onUpdateComponent?: (updatedComponent: any) => void; // 🆕 컴포넌트 업데이트 콜백 + onSelectTabComponent?: (tabId: string, compId: string, comp: any) => void; // 🆕 탭 내부 컴포넌트 선택 콜백 + selectedTabComponentId?: string; // 🆕 선택된 탭 컴포넌트 ID // 버튼 액션을 위한 props screenId?: number; @@ -133,6 +136,9 @@ const RealtimePreviewDynamicComponent: React.FC = ({ onFormDataChange, onHeightChange, // 🆕 조건부 컨테이너 높이 변화 콜백 conditionalDisabled, // 🆕 조건부 비활성화 상태 + onUpdateComponent, // 🆕 컴포넌트 업데이트 콜백 + onSelectTabComponent, // 🆕 탭 내부 컴포넌트 선택 콜백 + selectedTabComponentId, // 🆕 선택된 탭 컴포넌트 ID }) => { // 🆕 화면 다국어 컨텍스트 const { getTranslatedText } = useScreenMultiLang(); @@ -518,6 +524,9 @@ const RealtimePreviewDynamicComponent: React.FC = ({ columnOrder={columnOrder} onHeightChange={onHeightChange} conditionalDisabled={conditionalDisabled} + onUpdateComponent={onUpdateComponent} + onSelectTabComponent={onSelectTabComponent} + selectedTabComponentId={selectedTabComponentId} /> diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 96e07e69..ba4a39c2 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -164,10 +164,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD const [selectedComponent, setSelectedComponent] = useState(null); + // 🆕 탭 내부 컴포넌트 선택 상태 + const [selectedTabComponentInfo, setSelectedTabComponentInfo] = useState<{ + tabsComponentId: string; // 탭 컴포넌트 ID + tabId: string; // 탭 ID + componentId: string; // 탭 내부 컴포넌트 ID + component: any; // 탭 내부 컴포넌트 데이터 + } | null>(null); + // 컴포넌트 선택 시 통합 패널 자동 열기 const handleComponentSelect = useCallback( (component: ComponentData | null) => { setSelectedComponent(component); + // 일반 컴포넌트 선택 시 탭 내부 컴포넌트 선택 해제 + if (component) { + setSelectedTabComponentInfo(null); + } // 컴포넌트가 선택되면 통합 패널 자동 열기 if (component) { @@ -177,6 +189,29 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD [openPanel], ); + // 🆕 탭 내부 컴포넌트 선택 핸들러 + const handleSelectTabComponent = useCallback( + (tabsComponentId: string, tabId: string, compId: string, comp: any) => { + if (!compId) { + // 탭 영역 빈 공간 클릭 시 선택 해제 + setSelectedTabComponentInfo(null); + return; + } + + setSelectedTabComponentInfo({ + tabsComponentId, + tabId, + componentId: compId, + component: comp, + }); + // 탭 내부 컴포넌트 선택 시 일반 컴포넌트 선택 해제 + setSelectedComponent(null); + openPanel("unified"); + }, + [openPanel], + ); + + // 클립보드 상태 const [clipboard, setClipboard] = useState([]); @@ -380,6 +415,96 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD [historyIndex], ); + // 🆕 탭 내부 컴포넌트 설정 업데이트 핸들러 + const handleUpdateTabComponentConfig = useCallback( + (path: string, value: any) => { + if (!selectedTabComponentInfo) return; + + const { tabsComponentId, tabId, componentId } = selectedTabComponentInfo; + + setLayout((prevLayout) => { + const tabsComponent = prevLayout.components.find((c) => c.id === tabsComponentId); + if (!tabsComponent) return prevLayout; + + const currentConfig = (tabsComponent as any).componentConfig || {}; + const tabs = currentConfig.tabs || []; + + const updatedTabs = tabs.map((tab: any) => { + if (tab.id === tabId) { + return { + ...tab, + components: (tab.components || []).map((comp: any) => { + if (comp.id === componentId) { + // path에 따라 적절한 속성 업데이트 + if (path.startsWith("componentConfig.")) { + const configPath = path.replace("componentConfig.", ""); + return { + ...comp, + componentConfig: { + ...comp.componentConfig, + [configPath]: value, + }, + }; + } else if (path.startsWith("style.")) { + const stylePath = path.replace("style.", ""); + return { + ...comp, + style: { + ...comp.style, + [stylePath]: value, + }, + }; + } else if (path.startsWith("size.")) { + const sizePath = path.replace("size.", ""); + return { + ...comp, + size: { + ...comp.size, + [sizePath]: value, + }, + }; + } else { + return { ...comp, [path]: value }; + } + } + return comp; + }), + }; + } + return tab; + }); + + const updatedComponent = { + ...tabsComponent, + componentConfig: { + ...currentConfig, + tabs: updatedTabs, + }, + }; + + const newLayout = { + ...prevLayout, + components: prevLayout.components.map((c) => + c.id === tabsComponentId ? updatedComponent : c + ), + }; + + // 선택된 컴포넌트 정보도 업데이트 + const updatedComp = updatedTabs + .find((t: any) => t.id === tabId) + ?.components?.find((c: any) => c.id === componentId); + if (updatedComp) { + setSelectedTabComponentInfo((prev) => + prev ? { ...prev, component: updatedComp } : null + ); + } + + return newLayout; + }); + }, + [selectedTabComponentInfo], + ); + // 실행취소 const undo = useCallback(() => { setHistoryIndex((prevIndex) => { @@ -2271,6 +2396,67 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD } } + // 🎯 탭 컨테이너 내부 드롭 처리 + const tabsContainer = dropTarget.closest('[data-tabs-container="true"]'); + if (tabsContainer) { + const containerId = tabsContainer.getAttribute("data-component-id"); + const activeTabId = tabsContainer.getAttribute("data-active-tab-id"); + if (containerId && activeTabId) { + const targetComponent = layout.components.find((c) => c.id === containerId); + const compType = (targetComponent as any)?.componentType; + if (targetComponent && (compType === "tabs-widget" || compType === "v2-tabs-widget")) { + const currentConfig = (targetComponent as any).componentConfig || {}; + const tabs = currentConfig.tabs || []; + + // 활성 탭의 드롭 위치 계산 + const tabContentRect = tabsContainer.getBoundingClientRect(); + const dropX = (e.clientX - tabContentRect.left) / zoomLevel; + const dropY = (e.clientY - tabContentRect.top) / zoomLevel; + + // 새 컴포넌트 생성 + const newTabComponent = { + id: `tab_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + componentType: component.id || component.componentType || "text-display", + label: component.name || component.label || "새 컴포넌트", + position: { x: Math.max(0, dropX), y: Math.max(0, dropY) }, + size: component.defaultSize || { width: 200, height: 100 }, + componentConfig: component.defaultConfig || {}, + }; + + // 해당 탭에 컴포넌트 추가 + const updatedTabs = tabs.map((tab: any) => { + if (tab.id === activeTabId) { + return { + ...tab, + components: [...(tab.components || []), newTabComponent], + }; + } + return tab; + }); + + const updatedComponent = { + ...targetComponent, + componentConfig: { + ...currentConfig, + tabs: updatedTabs, + }, + }; + + const newLayout = { + ...layout, + components: layout.components.map((c) => + c.id === containerId ? updatedComponent : c + ), + }; + + setLayout(newLayout); + saveToHistory(newLayout); + toast.success("컴포넌트가 탭에 추가되었습니다"); + return; // 탭 컨테이너 처리 완료 + } + } + } + const rect = canvasRef.current?.getBoundingClientRect(); if (!rect) return; @@ -2655,6 +2841,70 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD } } + // 🎯 탭 컨테이너 내부에 컬럼 드롭 시 처리 + const tabsContainer = dropTarget.closest('[data-tabs-container="true"]'); + if (tabsContainer && type === "column" && column) { + const containerId = tabsContainer.getAttribute("data-component-id"); + const activeTabId = tabsContainer.getAttribute("data-active-tab-id"); + if (containerId && activeTabId) { + const targetComponent = layout.components.find((c) => c.id === containerId); + const compType = (targetComponent as any)?.componentType; + if (targetComponent && (compType === "tabs-widget" || compType === "v2-tabs-widget")) { + const currentConfig = (targetComponent as any).componentConfig || {}; + const tabs = currentConfig.tabs || []; + + // 드롭 위치 계산 + const tabContentRect = tabsContainer.getBoundingClientRect(); + const dropX = (e.clientX - tabContentRect.left) / zoomLevel; + const dropY = (e.clientY - tabContentRect.top) / zoomLevel; + + // 새 컴포넌트 생성 (컬럼 기반) + const newTabComponent = { + id: `tab_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + componentType: column.widgetType || "unified-input", + label: column.columnLabel || column.columnName, + position: { x: Math.max(0, dropX), y: Math.max(0, dropY) }, + size: { width: 200, height: 60 }, + componentConfig: { + columnName: column.columnName, + tableName: column.tableName, + }, + }; + + // 해당 탭에 컴포넌트 추가 + const updatedTabs = tabs.map((tab: any) => { + if (tab.id === activeTabId) { + return { + ...tab, + components: [...(tab.components || []), newTabComponent], + }; + } + return tab; + }); + + const updatedComponent = { + ...targetComponent, + componentConfig: { + ...currentConfig, + tabs: updatedTabs, + }, + }; + + const newLayout = { + ...layout, + components: layout.components.map((c) => + c.id === containerId ? updatedComponent : c + ), + }; + + setLayout(newLayout); + saveToHistory(newLayout); + toast.success("컬럼이 탭에 추가되었습니다"); + return; + } + } + } + const rect = canvasRef.current?.getBoundingClientRect(); if (!rect) return; @@ -4605,24 +4855,125 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD - 0 ? tables[0] : undefined} - currentTableName={selectedScreen?.tableName} - currentScreenCompanyCode={selectedScreen?.companyCode} - dragState={dragState} - onStyleChange={(style) => { - if (selectedComponent) { - updateComponentProperty(selectedComponent.id, "style", style); - } - }} - allComponents={layout.components} // 🆕 플로우 위젯 감지용 - menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 - /> + {/* 🆕 탭 내부 컴포넌트가 선택된 경우 별도 패널 표시 */} + {selectedTabComponentInfo ? ( +
+
+
+

탭 내부 컴포넌트 설정

+

+ {selectedTabComponentInfo.component.label || selectedTabComponentInfo.component.componentType} +

+
+ +
+ {/* DynamicComponentConfigPanel 렌더링 */} +
+ {(() => { + const DynamicConfigPanel = require("@/lib/utils/getComponentConfigPanel").DynamicComponentConfigPanel; + const tabComp = selectedTabComponentInfo.component; + + // 탭 내부 컴포넌트를 일반 컴포넌트 형식으로 변환 + const componentForConfig = { + id: tabComp.id, + type: "component", + componentType: tabComp.componentType, + label: tabComp.label, + position: tabComp.position, + size: tabComp.size, + componentConfig: tabComp.componentConfig || {}, + style: tabComp.style || {}, + }; + + return ( + { + // componentConfig 전체 업데이트 + const { tabsComponentId, tabId, componentId } = selectedTabComponentInfo; + const tabsComponent = layout.components.find((c) => c.id === tabsComponentId); + if (!tabsComponent) return; + + const currentConfig = (tabsComponent as any).componentConfig || {}; + const tabs = currentConfig.tabs || []; + + const updatedTabs = tabs.map((tab: any) => { + if (tab.id === tabId) { + return { + ...tab, + components: (tab.components || []).map((comp: any) => + comp.id === componentId + ? { ...comp, componentConfig: newConfig } + : comp + ), + }; + } + return tab; + }); + + const updatedComponent = { + ...tabsComponent, + componentConfig: { + ...currentConfig, + tabs: updatedTabs, + }, + }; + + const newLayout = { + ...layout, + components: layout.components.map((c) => + c.id === tabsComponentId ? updatedComponent : c + ), + }; + + setLayout(newLayout); + saveToHistory(newLayout); + + // 선택된 컴포넌트 정보 업데이트 + const updatedComp = updatedTabs + .find((t: any) => t.id === tabId) + ?.components?.find((c: any) => c.id === componentId); + if (updatedComp) { + setSelectedTabComponentInfo({ + ...selectedTabComponentInfo, + component: updatedComp, + }); + } + }} + tables={tables} + allComponents={layout.components} + /> + ); + })()} +
+
+ ) : ( + 0 ? tables[0] : undefined} + currentTableName={selectedScreen?.tableName} + currentScreenCompanyCode={selectedScreen?.companyCode} + dragState={dragState} + onStyleChange={(style) => { + if (selectedComponent) { + updateComponentProperty(selectedComponent.id, "style", style); + } + }} + allComponents={layout.components} // 🆕 플로우 위젯 감지용 + menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 + /> + )}
@@ -4952,6 +5303,29 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD updatedConfig: config, }); }} + // 🆕 컴포넌트 전체 업데이트 핸들러 (탭 내부 컴포넌트 위치 조정 등) + onUpdateComponent={(updatedComponent) => { + const updatedComponents = layout.components.map((comp) => + comp.id === updatedComponent.id ? updatedComponent : comp + ); + + const newLayout = { + ...layout, + components: updatedComponents, + }; + + setLayout(newLayout); + saveToHistory(newLayout); + }} + // 🆕 탭 내부 컴포넌트 선택 핸들러 + onSelectTabComponent={(tabId, compId, comp) => + handleSelectTabComponent(component.id, tabId, compId, comp) + } + selectedTabComponentId={ + selectedTabComponentInfo?.tabsComponentId === component.id + ? selectedTabComponentInfo.componentId + : undefined + } > {/* 컨테이너, 그룹, 영역, 컴포넌트의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */} {(component.type === "group" || diff --git a/frontend/components/screen/config-panels/TabsConfigPanel.tsx b/frontend/components/screen/config-panels/TabsConfigPanel.tsx index 778a6b6e..8369b115 100644 --- a/frontend/components/screen/config-panels/TabsConfigPanel.tsx +++ b/frontend/components/screen/config-panels/TabsConfigPanel.tsx @@ -5,70 +5,59 @@ import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; -import { Check, ChevronsUpDown, Plus, X, GripVertical, Loader2 } from "lucide-react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Plus, + X, + GripVertical, + ChevronDown, + ChevronRight, + Trash2, + Move, +} from "lucide-react"; import { cn } from "@/lib/utils"; -import type { TabItem, TabsComponent } from "@/types/screen-management"; +import type { TabItem, TabInlineComponent } from "@/types/screen-management"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; interface TabsConfigPanelProps { config: any; onChange: (config: any) => void; } -interface ScreenInfo { - screenId: number; - screenName: string; - screenCode: string; - tableName: string; -} - export function TabsConfigPanel({ config, onChange }: TabsConfigPanelProps) { - const [screens, setScreens] = useState([]); - const [loading, setLoading] = useState(false); const [localTabs, setLocalTabs] = useState(config.tabs || []); - - // 화면 목록 로드 - useEffect(() => { - const loadScreens = async () => { - try { - setLoading(true); - - // API 클라이언트 동적 import (named export 사용) - const { apiClient } = await import("@/lib/api/client"); - - // 전체 화면 목록 조회 (페이징 사이즈 크게) - const response = await apiClient.get("/screen-management/screens", { - params: { size: 1000 } - }); - - console.log("화면 목록 조회 성공:", response.data); - - if (response.data.success && response.data.data) { - setScreens(response.data.data); - } - } catch (error: any) { - console.error("Failed to load screens:", error); - console.error("Error response:", error.response?.data); - } finally { - setLoading(false); - } - }; - - loadScreens(); - }, []); - - // 컴포넌트 변경 시 로컬 상태 동기화 (초기화만, 입력 중에는 동기화하지 않음) + const [expandedTabs, setExpandedTabs] = useState>(new Set()); const [isUserEditing, setIsUserEditing] = useState(false); - + useEffect(() => { - // 사용자가 입력 중이 아닐 때만 동기화 if (!isUserEditing) { setLocalTabs(config.tabs || []); } }, [config.tabs, isUserEditing]); + // 탭 확장/축소 토글 + const toggleTabExpand = (tabId: string) => { + setExpandedTabs((prev) => { + const newSet = new Set(prev); + if (newSet.has(tabId)) { + newSet.delete(tabId); + } else { + newSet.add(tabId); + } + return newSet; + }); + }; + // 탭 추가 const handleAddTab = () => { const newTab: TabItem = { @@ -76,11 +65,15 @@ export function TabsConfigPanel({ config, onChange }: TabsConfigPanelProps) { label: `새 탭 ${localTabs.length + 1}`, order: localTabs.length, disabled: false, + components: [], }; const updatedTabs = [...localTabs, newTab]; setLocalTabs(updatedTabs); onChange({ ...config, tabs: updatedTabs }); + + // 새 탭 자동 확장 + setExpandedTabs((prev) => new Set([...prev, newTab.id])); }; // 탭 제거 @@ -93,27 +86,23 @@ export function TabsConfigPanel({ config, onChange }: TabsConfigPanelProps) { // 탭 라벨 변경 (입력 중) const handleLabelChange = (tabId: string, label: string) => { setIsUserEditing(true); - const updatedTabs = localTabs.map((tab) => (tab.id === tabId ? { ...tab, label } : tab)); + const updatedTabs = localTabs.map((tab) => + tab.id === tabId ? { ...tab, label } : tab + ); setLocalTabs(updatedTabs); - // onChange는 onBlur에서 호출 }; - // 탭 라벨 변경 완료 (포커스 아웃 시) + // 탭 라벨 변경 완료 const handleLabelBlur = () => { setIsUserEditing(false); onChange({ ...config, tabs: localTabs }); }; - // 탭 화면 선택 - const handleScreenSelect = (tabId: string, screenId: number, screenName: string) => { - const updatedTabs = localTabs.map((tab) => (tab.id === tabId ? { ...tab, screenId, screenName } : tab)); - setLocalTabs(updatedTabs); - onChange({ ...config, tabs: updatedTabs }); - }; - // 탭 비활성화 토글 const handleDisabledToggle = (tabId: string, disabled: boolean) => { - const updatedTabs = localTabs.map((tab) => (tab.id === tabId ? { ...tab, disabled } : tab)); + const updatedTabs = localTabs.map((tab) => + tab.id === tabId ? { ...tab, disabled } : tab + ); setLocalTabs(updatedTabs); onChange({ ...config, tabs: updatedTabs }); }; @@ -130,14 +119,68 @@ export function TabsConfigPanel({ config, onChange }: TabsConfigPanelProps) { const newTabs = [...localTabs]; const targetIndex = direction === "up" ? index - 1 : index + 1; - [newTabs[index], newTabs[targetIndex]] = [newTabs[targetIndex], newTabs[index]]; + [newTabs[index], newTabs[targetIndex]] = [ + newTabs[targetIndex], + newTabs[index], + ]; - // order 값 재조정 const updatedTabs = newTabs.map((tab, idx) => ({ ...tab, order: idx })); setLocalTabs(updatedTabs); onChange({ ...config, tabs: updatedTabs }); }; + // 컴포넌트 제거 + const handleRemoveComponent = (tabId: string, componentId: string) => { + const updatedTabs = localTabs.map((tab) => { + if (tab.id === tabId) { + return { + ...tab, + components: (tab.components || []).filter( + (comp) => comp.id !== componentId + ), + }; + } + return tab; + }); + setLocalTabs(updatedTabs); + onChange({ ...config, tabs: updatedTabs }); + }; + + // 컴포넌트 위치 변경 + const handleComponentPositionChange = ( + tabId: string, + componentId: string, + field: "x" | "y" | "width" | "height", + value: number + ) => { + const updatedTabs = localTabs.map((tab) => { + if (tab.id === tabId) { + return { + ...tab, + components: (tab.components || []).map((comp) => { + if (comp.id === componentId) { + if (field === "x" || field === "y") { + return { + ...comp, + position: { ...comp.position, [field]: value }, + }; + } else { + return { + ...comp, + size: { ...comp.size, [field]: value }, + }; + } + } + return comp; + }), + }; + } + return tab; + }); + setLocalTabs(updatedTabs); + onChange({ ...config, tabs: updatedTabs }); + }; + return (
@@ -193,7 +236,9 @@ export function TabsConfigPanel({ config, onChange }: TabsConfigPanelProps) {
onChange({ ...config, persistSelection: checked })} + onCheckedChange={(checked) => + onChange({ ...config, persistSelection: checked }) + } />
@@ -207,7 +252,9 @@ export function TabsConfigPanel({ config, onChange }: TabsConfigPanelProps) { onChange({ ...config, allowCloseable: checked })} + onCheckedChange={(checked) => + onChange({ ...config, allowCloseable: checked }) + } /> @@ -237,168 +284,157 @@ export function TabsConfigPanel({ config, onChange }: TabsConfigPanelProps) { ) : (
{localTabs.map((tab, index) => ( -
toggleTabExpand(tab.id)} > -
-
- - 탭 {index + 1} -
-
- - - -
-
- -
- {/* 탭 라벨 */} -
- - handleLabelChange(tab.id, e.target.value)} - onBlur={handleLabelBlur} - placeholder="탭 이름" - className="h-8 text-xs sm:h-9 sm:text-sm" - /> +
+ {/* 탭 헤더 */} +
+
+ + + + + + {tab.label || `탭 ${index + 1}`} + + {tab.components && tab.components.length > 0 && ( + + {tab.components.length}개 컴포넌트 + + )} +
+
+ + + +
- {/* 화면 선택 */} -
- - {loading ? ( -
- - 로딩 중... + {/* 탭 컨텐츠 */} + +
+ {/* 탭 라벨 */} +
+ + + handleLabelChange(tab.id, e.target.value) + } + onBlur={handleLabelBlur} + placeholder="탭 이름" + className="h-8 text-xs sm:h-9 sm:text-sm" + />
- ) : ( - - handleScreenSelect(tab.id, screenId, screenName) - } - /> - )} - {tab.screenName && ( -

- 선택된 화면: {tab.screenName} -

- )} -
- {/* 비활성화 */} -
- - handleDisabledToggle(tab.id, checked)} - /> -
+ {/* 비활성화 */} +
+ + + handleDisabledToggle(tab.id, checked) + } + /> +
+ + {/* 컴포넌트 목록 */} +
+ + {!tab.components || tab.components.length === 0 ? ( +
+ +

+ 디자인 화면에서 컴포넌트를 드래그하여 추가하세요 +

+
+ ) : ( +
+ {tab.components.map((comp: TabInlineComponent) => ( +
+
+

+ {comp.label || comp.componentType} +

+

+ {comp.componentType} | 위치: ({comp.position?.x || 0},{" "} + {comp.position?.y || 0}) | 크기: {comp.size?.width || 0}x + {comp.size?.height || 0} +

+
+ +
+ ))} +
+ )} +
+
+
-
+ ))}
)}
+ + {/* 사용 안내 */} +
+

+ 컴포넌트 추가 방법 +

+
    +
  1. 디자인 화면에서 탭을 선택합니다
  2. +
  3. 좌측 패널에서 원하는 컴포넌트를 드래그합니다
  4. +
  5. 선택한 탭 영역에 드롭하여 배치합니다
  6. +
  7. 컴포넌트를 드래그하여 위치를 조정합니다
  8. +
+
); } - -// 화면 선택 Combobox 컴포넌트 -function ScreenSelectCombobox({ - screens, - selectedScreenId, - onSelect, -}: { - screens: ScreenInfo[]; - selectedScreenId?: number; - onSelect: (screenId: number, screenName: string) => void; -}) { - const [open, setOpen] = useState(false); - - const selectedScreen = screens.find((s) => s.screenId === selectedScreenId); - - return ( - - - - - - - - - - 화면을 찾을 수 없습니다. - - - {screens.map((screen) => ( - { - onSelect(screen.screenId, screen.screenName); - setOpen(false); - }} - className="text-xs sm:text-sm" - > - -
- {screen.screenName} - - 코드: {screen.screenCode} | 테이블: {screen.tableName} - -
-
- ))} -
-
-
-
-
- ); -} - diff --git a/frontend/components/screen/widgets/TabsWidget.tsx b/frontend/components/screen/widgets/TabsWidget.tsx index 4d8147c9..5aa1a5b2 100644 --- a/frontend/components/screen/widgets/TabsWidget.tsx +++ b/frontend/components/screen/widgets/TabsWidget.tsx @@ -1,23 +1,37 @@ "use client"; -import React, { useState, useEffect, useMemo } from "react"; +import React, { useState, useEffect } from "react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; -import { X, Loader2 } from "lucide-react"; -import type { TabsComponent, TabItem } from "@/types/screen-management"; -import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic"; +import { X } from "lucide-react"; +import type { TabsComponent, TabItem, TabInlineComponent } from "@/types/screen-management"; import { cn } from "@/lib/utils"; import { useActiveTab } from "@/contexts/ActiveTabContext"; +import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; interface TabsWidgetProps { component: TabsComponent; className?: string; style?: React.CSSProperties; - menuObjid?: number; // 부모 화면의 메뉴 OBJID + menuObjid?: number; + formData?: Record; + onFormDataChange?: (data: Record) => void; + isDesignMode?: boolean; // 디자인 모드 여부 + onComponentSelect?: (tabId: string, componentId: string) => void; // 컴포넌트 선택 콜백 + selectedComponentId?: string; // 선택된 컴포넌트 ID } -export function TabsWidget({ component, className, style, menuObjid }: TabsWidgetProps) { - // ActiveTab context 사용 +export function TabsWidget({ + component, + className, + style, + menuObjid, + formData = {}, + onFormDataChange, + isDesignMode = false, + onComponentSelect, + selectedComponentId, +}: TabsWidgetProps) { const { setActiveTab, removeTabsComponent } = useActiveTab(); const { tabs = [], @@ -28,7 +42,6 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge persistSelection = false, } = component; - const storageKey = `tabs-${component.id}-selected`; // 초기 선택 탭 결정 @@ -44,9 +57,6 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge const [selectedTab, setSelectedTab] = useState(getInitialTab()); const [visibleTabs, setVisibleTabs] = useState(tabs); - const [loadingScreens, setLoadingScreens] = useState>({}); - const [screenLayouts, setScreenLayouts] = useState>({}); - // 🆕 한 번이라도 선택된 탭 추적 (지연 로딩 + 캐싱) const [mountedTabs, setMountedTabs] = useState>(() => new Set([getInitialTab()])); // 컴포넌트 탭 목록 변경 시 동기화 @@ -59,14 +69,12 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge if (persistSelection && typeof window !== "undefined") { localStorage.setItem(storageKey, selectedTab); } - - // ActiveTab Context에 현재 활성 탭 정보 등록 - const currentTabInfo = visibleTabs.find(t => t.id === selectedTab); + + const currentTabInfo = visibleTabs.find((t) => t.id === selectedTab); if (currentTabInfo) { setActiveTab(component.id, { tabId: selectedTab, tabsComponentId: component.id, - screenId: currentTabInfo.screenId, label: currentTabInfo.label, }); } @@ -79,53 +87,16 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge }; }, [component.id, removeTabsComponent]); - // 초기 로드 시 선택된 탭의 화면 불러오기 - useEffect(() => { - const currentTab = visibleTabs.find((t) => t.id === selectedTab); - if (currentTab && currentTab.screenId && !screenLayouts[currentTab.screenId]) { - loadScreenLayout(currentTab.screenId); - } - }, [selectedTab, visibleTabs]); - - // 화면 레이아웃 로드 - const loadScreenLayout = async (screenId: number) => { - if (screenLayouts[screenId]) { - return; // 이미 로드됨 - } - - setLoadingScreens((prev) => ({ ...prev, [screenId]: true })); - - try { - const { apiClient } = await import("@/lib/api/client"); - const response = await apiClient.get(`/screen-management/screens/${screenId}/layout`); - - if (response.data.success && response.data.data) { - setScreenLayouts((prev) => ({ ...prev, [screenId]: response.data.data })); - } - } catch (error) { - console.error(`화면 레이아웃 로드 실패 ${screenId}:`, error); - } finally { - setLoadingScreens((prev) => ({ ...prev, [screenId]: false })); - } - }; - // 탭 변경 핸들러 const handleTabChange = (tabId: string) => { setSelectedTab(tabId); - - // 마운트된 탭 목록에 추가 (한 번 마운트되면 유지) - setMountedTabs(prev => { + + setMountedTabs((prev) => { if (prev.has(tabId)) return prev; const newSet = new Set(prev); newSet.add(tabId); return newSet; }); - - // 해당 탭의 화면 로드 - const tab = visibleTabs.find((t) => t.id === tabId); - if (tab && tab.screenId && !screenLayouts[tab.screenId]) { - loadScreenLayout(tab.screenId); - } }; // 탭 닫기 핸들러 @@ -135,7 +106,6 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge const updatedTabs = visibleTabs.filter((tab) => tab.id !== tabId); setVisibleTabs(updatedTabs); - // 닫은 탭이 선택된 탭이었다면 다음 탭 선택 if (selectedTab === tabId && updatedTabs.length > 0) { setSelectedTab(updatedTabs[0].id); } @@ -153,6 +123,68 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge return `${baseClass} ${variantClass}`; }; + // 인라인 컴포넌트 렌더링 + const renderTabComponents = (tab: TabItem) => { + const components = tab.components || []; + + if (components.length === 0) { + return ( +
+

+ {isDesignMode ? "컴포넌트를 드래그하여 추가하세요" : "컴포넌트가 없습니다"} +

+
+ ); + } + + return ( +
+ {components.map((comp: TabInlineComponent) => { + const isSelected = selectedComponentId === comp.id; + + return ( +
{ + if (isDesignMode && onComponentSelect) { + e.stopPropagation(); + onComponentSelect(tab.id, comp.id); + } + }} + > + +
+ ); + })} +
+ ); + }; + if (visibleTabs.length === 0) { return (
@@ -162,7 +194,7 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge } return ( -
+
{tab.label} + {tab.components && tab.components.length > 0 && ( + + ({tab.components.length}) + + )} {allowCloseable && (
- {/* 🆕 forceMount + CSS 숨김으로 탭 전환 시 리렌더링 방지 */}
{visibleTabs.map((tab) => { - // 한 번도 선택되지 않은 탭은 렌더링하지 않음 (지연 로딩) const shouldRender = mountedTabs.has(tab.id); const isActive = selectedTab === tab.id; - + return ( - - {/* 한 번 마운트된 탭만 내용 렌더링 */} - {shouldRender && ( - <> - {tab.screenId ? ( - loadingScreens[tab.screenId] ? ( -
- - 화면 로딩 중... -
- ) : screenLayouts[tab.screenId] ? ( - (() => { - const layoutData = screenLayouts[tab.screenId]; - const { components = [], screenResolution } = layoutData; - - - const designWidth = screenResolution?.width || 1920; - const designHeight = screenResolution?.height || 1080; - - return ( -
-
- {components.map((comp: any) => ( - - ))} -
-
- ); - })() - ) : ( -
-

화면을 불러올 수 없습니다

-
- ) - ) : ( -
-

연결된 화면이 없습니다

-
- )} - - )} + {shouldRender && renderTabComponents(tab)}
); })} diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 31861599..6dd9a839 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -152,6 +152,11 @@ export interface DynamicComponentRendererProps { tableDisplayData?: any[]; // 🆕 화면 표시 데이터 // 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용) flowSelectedData?: any[]; + // 🆕 컴포넌트 업데이트 콜백 (탭 내부 컴포넌트 위치 조정 등) + onUpdateComponent?: (updatedComponent: any) => void; + // 🆕 탭 내부 컴포넌트 선택 콜백 + onSelectTabComponent?: (tabId: string, compId: string, comp: any) => void; + selectedTabComponentId?: string; flowSelectedStepId?: number | null; onFlowSelectedDataChange?: (selectedData: any[], stepId: number | null) => void; // 테이블 새로고침 키 @@ -754,6 +759,11 @@ export const DynamicComponentRenderer: React.FC = // 🆕 탭 관련 정보 전달 (탭 내부의 테이블 컴포넌트에서 사용) parentTabId: props.parentTabId, parentTabsComponentId: props.parentTabsComponentId, + // 🆕 컴포넌트 업데이트 콜백 (탭 내부 컴포넌트 위치 조정 등) + onUpdateComponent: props.onUpdateComponent, + // 🆕 탭 내부 컴포넌트 선택 콜백 + onSelectTabComponent: props.onSelectTabComponent, + selectedTabComponentId: props.selectedTabComponentId, }; // 렌더러가 클래스인지 함수인지 확인 diff --git a/frontend/lib/registry/components/v2-tabs-widget/tabs-component.tsx b/frontend/lib/registry/components/v2-tabs-widget/tabs-component.tsx index 8be1a72f..16e275cd 100644 --- a/frontend/lib/registry/components/v2-tabs-widget/tabs-component.tsx +++ b/frontend/lib/registry/components/v2-tabs-widget/tabs-component.tsx @@ -1,21 +1,357 @@ "use client"; -import React from "react"; +import React, { useState, useRef, useCallback } from "react"; import { ComponentRegistry } from "../../ComponentRegistry"; import { ComponentCategory } from "@/types/component"; -import { Folder } from "lucide-react"; -import type { TabsComponent, TabItem } from "@/types/screen-management"; +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(tabs[0]?.id || ""); + const [draggingCompId, setDraggingCompId] = useState(null); + const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); + const containerRef = useRef(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(); + + if (!containerRef.current) return; + + const targetElement = (e.currentTarget as HTMLElement); + const targetRect = targetElement.getBoundingClientRect(); + const containerRect = containerRef.current.getBoundingClientRect(); + + // 스크롤 위치 고려 + const scrollLeft = containerRef.current.scrollLeft; + const scrollTop = containerRef.current.scrollTop; + + // 마우스 클릭 위치에서 컴포넌트의 좌상단까지의 오프셋 + const offsetX = e.clientX - targetRect.left; + const offsetY = e.clientY - targetRect.top; + + // 초기 컨테이너 위치 저장 + const initialContainerX = containerRect.left; + const initialContainerY = containerRect.top; + + setDraggingCompId(comp.id); + setDragOffset({ x: offsetX, y: offsetY }); + + const handleMouseMove = (moveEvent: MouseEvent) => { + if (!containerRef.current) return; + + // 현재 컨테이너의 위치 가져오기 (스크롤/리사이즈 고려) + const currentContainerRect = containerRef.current.getBoundingClientRect(); + const currentScrollLeft = containerRef.current.scrollLeft; + const currentScrollTop = containerRef.current.scrollTop; + + // 컨테이너 내에서의 위치 계산 (스크롤 포함) + const newX = moveEvent.clientX - currentContainerRect.left - offsetX + currentScrollLeft; + const newY = moveEvent.clientY - currentContainerRect.top - offsetY + currentScrollTop; + + // 실시간 위치 업데이트 (시각적 피드백) + const draggedElement = document.querySelector( + `[data-tab-comp-id="${comp.id}"]` + ) as HTMLElement; + if (draggedElement) { + draggedElement.style.left = `${Math.max(0, newX)}px`; + draggedElement.style.top = `${Math.max(0, newY)}px`; + } + }; + + const handleMouseUp = (upEvent: MouseEvent) => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + setDraggingCompId(null); + + if (!containerRef.current) { + return; + } + + const currentContainerRect = containerRef.current.getBoundingClientRect(); + const currentScrollLeft = containerRef.current.scrollLeft; + const currentScrollTop = containerRef.current.scrollTop; + + const newX = upEvent.clientX - currentContainerRect.left - offsetX + currentScrollLeft; + const newY = upEvent.clientY - currentContainerRect.top - offsetY + currentScrollTop; + + // 탭 컴포넌트 위치 업데이트 + 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 ( +
+ {/* 탭 헤더 */} +
+ {tabs.length > 0 ? ( + tabs.map((tab) => ( +
{ + e.stopPropagation(); + setActiveTabId(tab.id); + onSelectTabComponent?.(null); + }} + > + {tab.label || "탭"} + {tab.components && tab.components.length > 0 && ( + + ({tab.components.length}) + + )} +
+ )) + ) : ( +
+ 탭이 없습니다 +
+ )} +
+ + {/* 탭 컨텐츠 영역 - 드롭 영역 */} +
onSelectTabComponent?.(activeTabId, "", {} as TabInlineComponent)} + > + {activeTab ? ( +
+ {activeTab.components && activeTab.components.length > 0 ? ( +
+ {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 || {}, + }; + + return ( +
{ + e.stopPropagation(); + onSelectTabComponent?.(activeTabId, comp.id, comp); + }} + > + {/* 드래그 핸들 - 상단 */} +
handleDragStart(e, comp)} + > +
+ + + {comp.label || comp.componentType} + +
+
+ + +
+
+ + {/* 실제 컴포넌트 렌더링 */} +
+ +
+
+ ); + })} +
+ ) : ( +
+ +

+ 컴포넌트를 드래그하여 추가 +

+

+ 좌측 패널에서 컴포넌트를 이 영역에 드롭하세요 +

+
+ )} +
+ ) : ( +
+

+ 설정 패널에서 탭을 추가하세요 +

+
+ )} +
+
+ ); +}; // TabsWidget 래퍼 컴포넌트 const TabsWidgetWrapper: React.FC = (props) => { - const { component, ...restProps } = props; - + const { + component, + isDesignMode, + onUpdateComponent, + onSelectTabComponent, + selectedTabComponentId, + ...restProps + } = props; + // componentConfig에서 탭 정보 추출 const tabsConfig = component.componentConfig || {}; + const tabs: TabItem[] = tabsConfig.tabs || []; + + // 🎯 디자인 모드에서는 드롭 가능한 에디터 UI 렌더링 + if (isDesignMode) { + return ( + + ); + } + + // 실행 모드에서는 TabsWidget 렌더링 const tabsComponent = { ...component, type: "tabs" as const, - tabs: tabsConfig.tabs || [], + tabs: tabs, defaultTab: tabsConfig.defaultTab, orientation: tabsConfig.orientation || "horizontal", variant: tabsConfig.variant || "default", @@ -23,10 +359,9 @@ const TabsWidgetWrapper: React.FC = (props) => { persistSelection: tabsConfig.persistSelection || false, }; + const TabsWidget = + require("@/components/screen/widgets/TabsWidget").TabsWidget; - // TabsWidget 동적 로드 - const TabsWidget = require("@/components/screen/widgets/TabsWidget").TabsWidget; - return (
@@ -36,26 +371,49 @@ const TabsWidgetWrapper: React.FC = (props) => { /** * 탭 컴포넌트 정의 - * - * 여러 화면을 탭으로 구분하여 전환할 수 있는 컴포넌트 + * + * 탭별로 컴포넌트를 자유롭게 배치할 수 있는 레이아웃 컴포넌트 */ ComponentRegistry.registerComponent({ id: "v2-tabs-widget", name: "탭 컴포넌트", - description: "화면을 탭으로 전환할 수 있는 컴포넌트입니다. 각 탭마다 다른 화면을 연결할 수 있습니다.", + description: + "탭별로 컴포넌트를 자유롭게 배치할 수 있는 레이아웃 컴포넌트입니다.", category: ComponentCategory.LAYOUT, - webType: "text" as any, // 레이아웃 컴포넌트이므로 임시값 - component: TabsWidgetWrapper, // ✅ 실제 TabsWidget 렌더러 - defaultConfig: {}, - tags: ["tabs", "navigation", "layout", "screen"], + 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: "1.0.0", - + version: "2.0.0", + defaultSize: { width: 800, height: 600, }, - + defaultProps: { type: "tabs" as const, tabs: [ @@ -64,12 +422,14 @@ ComponentRegistry.registerComponent({ label: "탭 1", order: 0, disabled: false, + components: [], }, { id: "tab-2", label: "탭 2", order: 1, disabled: false, + components: [], }, ] as TabItem[], defaultTab: "tab-1", @@ -78,82 +438,167 @@ ComponentRegistry.registerComponent({ allowCloseable: false, persistSelection: false, }, - - // 에디터 모드에서의 렌더링 - renderEditor: ({ component, isSelected, onClick, onDragStart, onDragEnd, children }) => { - const tabsComponent = component as TabsComponent; - const tabs = tabsComponent.tabs || []; - + + // 에디터 모드에서의 렌더링 - 탭 선택 및 컴포넌트 드롭 지원 + renderEditor: ({ + component, + isSelected, + onClick, + onDragStart, + onDragEnd, + }) => { + const tabsConfig = (component as any).componentConfig || {}; + const tabs: TabItem[] = tabsConfig.tabs || []; + + // 에디터 모드에서 선택된 탭 상태 관리 + const [activeTabId, setActiveTabId] = useState( + 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 (
-
-
- -
-

탭 컴포넌트

-

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

- {tabs.length > 0 && ( -
- {tabs.map((tab: TabItem, index: number) => ( - - {tab.label || `탭 ${index + 1}`} - - ))} + {/* 탭 헤더 */} +
+ {tabs.length > 0 ? ( + tabs.map((tab) => ( +
{ + e.stopPropagation(); + setActiveTabId(tab.id); + }} + > + {tab.label || "탭"} + {tab.components && tab.components.length > 0 && ( + + ({tab.components.length}) + + )} +
+ )) + ) : ( +
+ 탭이 없습니다
)}
+ + {/* 탭 컨텐츠 영역 - 드롭 영역 */} +
+ {activeTab ? ( +
+ {activeTab.components && activeTab.components.length > 0 ? ( +
+ {activeTab.components.map((comp: TabInlineComponent) => ( +
+
+ + {comp.label || comp.componentType} + + + {comp.componentType} + +
+
+ ))} +
+ ) : ( +
+ +

+ 컴포넌트를 드래그하여 추가 +

+

+ 좌측 패널에서 컴포넌트를 이 영역에 드롭하세요 +

+
+ )} +
+ ) : ( +
+

+ 설정 패널에서 탭을 추가하세요 +

+
+ )} +
+ + {/* 선택 표시 */} + {isSelected && ( +
+ )}
); }, - - // 인터랙티브 모드에서의 렌더링 (실제 동작) + + // 인터랙티브 모드에서의 렌더링 renderInteractive: ({ component }) => { - // InteractiveScreenViewer에서 TabsWidget을 사용하므로 여기서는 null 반환 return null; }, - - // 설정 패널 (동적 로딩) - configPanel: React.lazy(() => - import("@/components/screen/config-panels/TabsConfigPanel").then(module => ({ - default: module.TabsConfigPanel - })) + + // 설정 패널 + configPanel: React.lazy(() => + import("@/components/screen/config-panels/TabsConfigPanel").then( + (module) => ({ + default: module.TabsConfigPanel, + }) + ) ), - + // 검증 함수 validate: (component) => { - const tabsComponent = component as TabsComponent; + const tabsConfig = (component as any).componentConfig || {}; + const tabs: TabItem[] = tabsConfig.tabs || []; const errors: string[] = []; - - if (!tabsComponent.tabs || tabsComponent.tabs.length === 0) { + + if (!tabs || tabs.length === 0) { errors.push("최소 1개 이상의 탭이 필요합니다."); } - - if (tabsComponent.tabs) { - const tabIds = tabsComponent.tabs.map((t) => t.id); + + 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, }; }, }); - -// console.log("✅ 탭 컴포넌트 등록 완료"); - diff --git a/frontend/types/screen-management.ts b/frontend/types/screen-management.ts index cdfef5c3..aefc48c4 100644 --- a/frontend/types/screen-management.ts +++ b/frontend/types/screen-management.ts @@ -211,14 +211,27 @@ export interface ComponentComponent extends BaseComponent { /** * 탭 아이템 인터페이스 */ +/** + * 탭 내부 컴포넌트 (자유 배치) + */ +export interface TabInlineComponent { + id: string; + componentType: string; // 컴포넌트 타입 (예: "v2-text-display", "v2-table-list") + label?: string; + position: Position; // 탭 내부에서의 위치 + size: Size; // 컴포넌트 크기 + componentConfig?: any; // 컴포넌트별 설정 + style?: ComponentStyle; +} + export interface TabItem { id: string; label: string; - screenId?: number; // 연결된 화면 ID - screenName?: string; // 화면 이름 (표시용) icon?: string; // 아이콘 (선택사항) disabled?: boolean; // 비활성화 여부 order: number; // 탭 순서 + // 🆕 인라인 컴포넌트 배치 + components?: TabInlineComponent[]; // 탭 내부 컴포넌트들 } /**