탭 내부 컴포넌트 선택 및 업데이트 기능 추가: RealtimePreviewDynamic, ScreenDesigner, TabsWidget, DynamicComponentRenderer, TabsConfigPanel에서 탭 내부 컴포넌트를 선택하고 업데이트할 수 있는 콜백 함수를 추가하여 사용자 인터랙션을 개선하였습니다. 이를 통해 탭 내에서의 컴포넌트 관리가 용이해졌습니다.
This commit is contained in:
parent
a67b53038f
commit
58d658e638
|
|
@ -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<RealtimePreviewProps> = ({
|
|||
onFormDataChange,
|
||||
onHeightChange, // 🆕 조건부 컨테이너 높이 변화 콜백
|
||||
conditionalDisabled, // 🆕 조건부 비활성화 상태
|
||||
onUpdateComponent, // 🆕 컴포넌트 업데이트 콜백
|
||||
onSelectTabComponent, // 🆕 탭 내부 컴포넌트 선택 콜백
|
||||
selectedTabComponentId, // 🆕 선택된 탭 컴포넌트 ID
|
||||
}) => {
|
||||
// 🆕 화면 다국어 컨텍스트
|
||||
const { getTranslatedText } = useScreenMultiLang();
|
||||
|
|
@ -518,6 +524,9 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
|||
columnOrder={columnOrder}
|
||||
onHeightChange={onHeightChange}
|
||||
conditionalDisabled={conditionalDisabled}
|
||||
onUpdateComponent={onUpdateComponent}
|
||||
onSelectTabComponent={onSelectTabComponent}
|
||||
selectedTabComponentId={selectedTabComponentId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -164,10 +164,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
|
||||
const [selectedComponent, setSelectedComponent] = useState<ComponentData | null>(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<ComponentData[]>([]);
|
||||
|
||||
|
|
@ -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
|
|||
</TabsContent>
|
||||
|
||||
<TabsContent value="properties" className="mt-0 flex-1 overflow-hidden">
|
||||
<UnifiedPropertiesPanel
|
||||
selectedComponent={selectedComponent || undefined}
|
||||
tables={tables}
|
||||
onUpdateProperty={updateComponentProperty}
|
||||
onDeleteComponent={deleteComponent}
|
||||
onCopyComponent={copyComponent}
|
||||
currentTable={tables.length > 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 ? (
|
||||
<div className="flex h-full flex-col p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">탭 내부 컴포넌트 설정</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{selectedTabComponentInfo.component.label || selectedTabComponentInfo.component.componentType}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setSelectedTabComponentInfo(null)}
|
||||
>
|
||||
선택 해제
|
||||
</button>
|
||||
</div>
|
||||
{/* DynamicComponentConfigPanel 렌더링 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{(() => {
|
||||
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 (
|
||||
<DynamicConfigPanel
|
||||
componentId={tabComp.componentType}
|
||||
component={componentForConfig}
|
||||
config={tabComp.componentConfig || {}}
|
||||
onChange={(newConfig: any) => {
|
||||
// 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}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<UnifiedPropertiesPanel
|
||||
selectedComponent={selectedComponent || undefined}
|
||||
tables={tables}
|
||||
onUpdateProperty={updateComponentProperty}
|
||||
onDeleteComponent={deleteComponent}
|
||||
onCopyComponent={copyComponent}
|
||||
currentTable={tables.length > 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 전달
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
|
@ -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" ||
|
||||
|
|
|
|||
|
|
@ -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<ScreenInfo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [localTabs, setLocalTabs] = useState<TabItem[]>(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<Set<string>>(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 (
|
||||
<div className="space-y-6 p-4">
|
||||
<div>
|
||||
|
|
@ -193,7 +236,9 @@ export function TabsConfigPanel({ config, onChange }: TabsConfigPanelProps) {
|
|||
</div>
|
||||
<Switch
|
||||
checked={config.persistSelection || false}
|
||||
onCheckedChange={(checked) => onChange({ ...config, persistSelection: checked })}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({ ...config, persistSelection: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -207,7 +252,9 @@ export function TabsConfigPanel({ config, onChange }: TabsConfigPanelProps) {
|
|||
</div>
|
||||
<Switch
|
||||
checked={config.allowCloseable || false}
|
||||
onCheckedChange={(checked) => onChange({ ...config, allowCloseable: checked })}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({ ...config, allowCloseable: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -237,168 +284,157 @@ export function TabsConfigPanel({ config, onChange }: TabsConfigPanelProps) {
|
|||
) : (
|
||||
<div className="space-y-3">
|
||||
{localTabs.map((tab, index) => (
|
||||
<div
|
||||
<Collapsible
|
||||
key={tab.id}
|
||||
className="rounded-lg border bg-card p-3 shadow-sm"
|
||||
open={expandedTabs.has(tab.id)}
|
||||
onOpenChange={() => toggleTabExpand(tab.id)}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-xs font-medium">탭 {index + 1}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
onClick={() => handleMoveTab(tab.id, "up")}
|
||||
disabled={index === 0}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
↑
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleMoveTab(tab.id, "down")}
|
||||
disabled={index === localTabs.length - 1}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
↓
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleRemoveTab(tab.id)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* 탭 라벨 */}
|
||||
<div>
|
||||
<Label className="text-xs">탭 라벨</Label>
|
||||
<Input
|
||||
value={tab.label}
|
||||
onChange={(e) => handleLabelChange(tab.id, e.target.value)}
|
||||
onBlur={handleLabelBlur}
|
||||
placeholder="탭 이름"
|
||||
className="h-8 text-xs sm:h-9 sm:text-sm"
|
||||
/>
|
||||
<div className="rounded-lg border bg-card shadow-sm">
|
||||
{/* 탭 헤더 */}
|
||||
<div className="flex items-center justify-between p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<GripVertical className="h-4 w-4 cursor-grab text-muted-foreground" />
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
|
||||
{expandedTabs.has(tab.id) ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<span className="text-xs font-medium">
|
||||
{tab.label || `탭 ${index + 1}`}
|
||||
</span>
|
||||
{tab.components && tab.components.length > 0 && (
|
||||
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] text-primary">
|
||||
{tab.components.length}개 컴포넌트
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
onClick={() => handleMoveTab(tab.id, "up")}
|
||||
disabled={index === 0}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
↑
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleMoveTab(tab.id, "down")}
|
||||
disabled={index === localTabs.length - 1}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
↓
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleRemoveTab(tab.id)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 화면 선택 */}
|
||||
<div>
|
||||
<Label className="text-xs">연결된 화면</Label>
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 rounded-md border px-3 py-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span className="text-muted-foreground text-xs">로딩 중...</span>
|
||||
{/* 탭 컨텐츠 */}
|
||||
<CollapsibleContent>
|
||||
<div className="space-y-4 border-t p-3">
|
||||
{/* 탭 라벨 */}
|
||||
<div>
|
||||
<Label className="text-xs">탭 라벨</Label>
|
||||
<Input
|
||||
value={tab.label}
|
||||
onChange={(e) =>
|
||||
handleLabelChange(tab.id, e.target.value)
|
||||
}
|
||||
onBlur={handleLabelBlur}
|
||||
placeholder="탭 이름"
|
||||
className="h-8 text-xs sm:h-9 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ScreenSelectCombobox
|
||||
screens={screens}
|
||||
selectedScreenId={tab.screenId}
|
||||
onSelect={(screenId, screenName) =>
|
||||
handleScreenSelect(tab.id, screenId, screenName)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{tab.screenName && (
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
선택된 화면: {tab.screenName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 비활성화 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">비활성화</Label>
|
||||
<Switch
|
||||
checked={tab.disabled || false}
|
||||
onCheckedChange={(checked) => handleDisabledToggle(tab.id, checked)}
|
||||
/>
|
||||
</div>
|
||||
{/* 비활성화 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">비활성화</Label>
|
||||
<Switch
|
||||
checked={tab.disabled || false}
|
||||
onCheckedChange={(checked) =>
|
||||
handleDisabledToggle(tab.id, checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 컴포넌트 목록 */}
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs">
|
||||
배치된 컴포넌트
|
||||
</Label>
|
||||
{!tab.components || tab.components.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed bg-muted/30 p-4 text-center">
|
||||
<Move className="mx-auto mb-2 h-6 w-6 text-muted-foreground" />
|
||||
<p className="text-muted-foreground text-xs">
|
||||
디자인 화면에서 컴포넌트를 드래그하여 추가하세요
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{tab.components.map((comp: TabInlineComponent) => (
|
||||
<div
|
||||
key={comp.id}
|
||||
className="flex items-center justify-between rounded-md border bg-background p-2"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<p className="text-xs font-medium">
|
||||
{comp.label || comp.componentType}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
{comp.componentType} | 위치: ({comp.position?.x || 0},{" "}
|
||||
{comp.position?.y || 0}) | 크기: {comp.size?.width || 0}x
|
||||
{comp.size?.height || 0}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() =>
|
||||
handleRemoveComponent(tab.id, comp.id)
|
||||
}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 사용 안내 */}
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3">
|
||||
<h4 className="mb-1 text-xs font-semibold text-blue-900">
|
||||
컴포넌트 추가 방법
|
||||
</h4>
|
||||
<ol className="list-inside list-decimal space-y-1 text-[10px] text-blue-800">
|
||||
<li>디자인 화면에서 탭을 선택합니다</li>
|
||||
<li>좌측 패널에서 원하는 컴포넌트를 드래그합니다</li>
|
||||
<li>선택한 탭 영역에 드롭하여 배치합니다</li>
|
||||
<li>컴포넌트를 드래그하여 위치를 조정합니다</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 화면 선택 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 (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="h-8 w-full justify-between text-xs sm:h-9 sm:text-sm"
|
||||
>
|
||||
{selectedScreen ? selectedScreen.screenName : "화면 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50 sm:h-4 sm:w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="화면 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm">
|
||||
화면을 찾을 수 없습니다.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{screens.map((screen) => (
|
||||
<CommandItem
|
||||
key={screen.screenId}
|
||||
value={screen.screenName}
|
||||
onSelect={() => {
|
||||
onSelect(screen.screenId, screen.screenName);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3 sm:h-4 sm:w-4",
|
||||
selectedScreenId === screen.screenId ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{screen.screenName}</span>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
코드: {screen.screenCode} | 테이블: {screen.tableName}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, any>;
|
||||
onFormDataChange?: (data: Record<string, any>) => 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<string>(getInitialTab());
|
||||
const [visibleTabs, setVisibleTabs] = useState<TabItem[]>(tabs);
|
||||
const [loadingScreens, setLoadingScreens] = useState<Record<string, boolean>>({});
|
||||
const [screenLayouts, setScreenLayouts] = useState<Record<number, any>>({});
|
||||
// 🆕 한 번이라도 선택된 탭 추적 (지연 로딩 + 캐싱)
|
||||
const [mountedTabs, setMountedTabs] = useState<Set<string>>(() => 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 (
|
||||
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{isDesignMode ? "컴포넌트를 드래그하여 추가하세요" : "컴포넌트가 없습니다"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
{components.map((comp: TabInlineComponent) => {
|
||||
const isSelected = selectedComponentId === comp.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={comp.id}
|
||||
className={cn(
|
||||
"absolute",
|
||||
isDesignMode && "cursor-move",
|
||||
isDesignMode && isSelected && "ring-2 ring-primary ring-offset-2"
|
||||
)}
|
||||
style={{
|
||||
left: comp.position?.x || 0,
|
||||
top: comp.position?.y || 0,
|
||||
width: comp.size?.width || 200,
|
||||
height: comp.size?.height || 100,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (isDesignMode && onComponentSelect) {
|
||||
e.stopPropagation();
|
||||
onComponentSelect(tab.id, comp.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DynamicComponentRenderer
|
||||
component={{
|
||||
id: comp.id,
|
||||
componentType: comp.componentType,
|
||||
label: comp.label,
|
||||
position: comp.position,
|
||||
size: comp.size,
|
||||
componentConfig: comp.componentConfig || {},
|
||||
style: comp.style,
|
||||
}}
|
||||
formData={formData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
menuObjid={menuObjid}
|
||||
isDesignMode={isDesignMode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (visibleTabs.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
|
||||
|
|
@ -162,7 +194,7 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col pt-4" style={style}>
|
||||
<div className={cn("flex h-full w-full flex-col pt-4", className)} style={style}>
|
||||
<Tabs
|
||||
value={selectedTab}
|
||||
onValueChange={handleTabChange}
|
||||
|
|
@ -175,6 +207,11 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
|
|||
<div key={tab.id} className="relative">
|
||||
<TabsTrigger value={tab.id} disabled={tab.disabled} className="relative pr-8">
|
||||
{tab.label}
|
||||
{tab.components && tab.components.length > 0 && (
|
||||
<span className="ml-1 text-xs text-muted-foreground">
|
||||
({tab.components.length})
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
{allowCloseable && (
|
||||
<Button
|
||||
|
|
@ -191,86 +228,19 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
|
|||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* 🆕 forceMount + CSS 숨김으로 탭 전환 시 리렌더링 방지 */}
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
{visibleTabs.map((tab) => {
|
||||
// 한 번도 선택되지 않은 탭은 렌더링하지 않음 (지연 로딩)
|
||||
const shouldRender = mountedTabs.has(tab.id);
|
||||
const isActive = selectedTab === tab.id;
|
||||
|
||||
|
||||
return (
|
||||
<TabsContent
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
forceMount // 🆕 DOM에 항상 유지
|
||||
className={cn(
|
||||
"h-full",
|
||||
!isActive && "hidden" // 🆕 비활성 탭은 CSS로 숨김
|
||||
)}
|
||||
<TabsContent
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
forceMount
|
||||
className={cn("h-full", !isActive && "hidden")}
|
||||
>
|
||||
{/* 한 번 마운트된 탭만 내용 렌더링 */}
|
||||
{shouldRender && (
|
||||
<>
|
||||
{tab.screenId ? (
|
||||
loadingScreens[tab.screenId] ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<span className="text-muted-foreground ml-2">화면 로딩 중...</span>
|
||||
</div>
|
||||
) : screenLayouts[tab.screenId] ? (
|
||||
(() => {
|
||||
const layoutData = screenLayouts[tab.screenId];
|
||||
const { components = [], screenResolution } = layoutData;
|
||||
|
||||
|
||||
const designWidth = screenResolution?.width || 1920;
|
||||
const designHeight = screenResolution?.height || 1080;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative h-full w-full overflow-auto bg-background"
|
||||
style={{
|
||||
minHeight: `${designHeight}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
width: `${designWidth}px`,
|
||||
height: `${designHeight}px`,
|
||||
margin: "0 auto",
|
||||
}}
|
||||
>
|
||||
{components.map((comp: any) => (
|
||||
<InteractiveScreenViewerDynamic
|
||||
key={comp.id}
|
||||
component={comp}
|
||||
allComponents={components}
|
||||
screenInfo={{
|
||||
id: tab.screenId,
|
||||
tableName: layoutData.tableName,
|
||||
}}
|
||||
menuObjid={menuObjid}
|
||||
parentTabId={tab.id}
|
||||
parentTabsComponentId={component.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">화면을 불러올 수 없습니다</p>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
|
||||
<p className="text-muted-foreground text-sm">연결된 화면이 없습니다</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{shouldRender && renderTabComponents(tab)}
|
||||
</TabsContent>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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<DynamicComponentRendererProps> =
|
|||
// 🆕 탭 관련 정보 전달 (탭 내부의 테이블 컴포넌트에서 사용)
|
||||
parentTabId: props.parentTabId,
|
||||
parentTabsComponentId: props.parentTabsComponentId,
|
||||
// 🆕 컴포넌트 업데이트 콜백 (탭 내부 컴포넌트 위치 조정 등)
|
||||
onUpdateComponent: props.onUpdateComponent,
|
||||
// 🆕 탭 내부 컴포넌트 선택 콜백
|
||||
onSelectTabComponent: props.onSelectTabComponent,
|
||||
selectedTabComponentId: props.selectedTabComponentId,
|
||||
};
|
||||
|
||||
// 렌더러가 클래스인지 함수인지 확인
|
||||
|
|
|
|||
|
|
@ -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<string>(tabs[0]?.id || "");
|
||||
const [draggingCompId, setDraggingCompId] = useState<string | null>(null);
|
||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||
const containerRef = useRef<HTMLDivElement>(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 (
|
||||
<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
|
||||
ref={containerRef}
|
||||
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 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) => {
|
||||
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 (
|
||||
<div
|
||||
key={comp.id}
|
||||
data-tab-comp-id={comp.id}
|
||||
className={cn(
|
||||
"absolute rounded border bg-white shadow-sm transition-all",
|
||||
isSelected
|
||||
? "border-primary ring-2 ring-primary/30"
|
||||
: "border-gray-200 hover:border-primary/50",
|
||||
isDragging && "opacity-80 shadow-lg"
|
||||
)}
|
||||
style={{
|
||||
left: comp.position?.x || 0,
|
||||
top: comp.position?.y || 0,
|
||||
width: comp.size?.width || 200,
|
||||
height: comp.size?.height || 100,
|
||||
zIndex: isDragging ? 100 : isSelected ? 10 : 1,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectTabComponent?.(activeTabId, comp.id, comp);
|
||||
}}
|
||||
>
|
||||
{/* 드래그 핸들 - 상단 */}
|
||||
<div
|
||||
className="absolute left-0 right-0 top-0 z-10 flex h-5 cursor-move items-center justify-between bg-gray-100/80 px-1"
|
||||
onMouseDown={(e) => handleDragStart(e, comp)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<Move className="h-3 w-3 text-gray-400" />
|
||||
<span className="text-[10px] text-gray-500 truncate max-w-[120px]">
|
||||
{comp.label || comp.componentType}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
className="rounded p-0.5 hover:bg-gray-200"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectTabComponent?.(activeTabId, comp.id, comp);
|
||||
}}
|
||||
title="설정"
|
||||
>
|
||||
<Settings className="h-3 w-3 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-3 w-3 text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 실제 컴포넌트 렌더링 */}
|
||||
<div className="h-full w-full pt-5 overflow-hidden pointer-events-none">
|
||||
<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, ...restProps } = 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: tabsConfig.tabs || [],
|
||||
tabs: tabs,
|
||||
defaultTab: tabsConfig.defaultTab,
|
||||
orientation: tabsConfig.orientation || "horizontal",
|
||||
variant: tabsConfig.variant || "default",
|
||||
|
|
@ -23,10 +359,9 @@ const TabsWidgetWrapper: React.FC<any> = (props) => {
|
|||
persistSelection: tabsConfig.persistSelection || false,
|
||||
};
|
||||
|
||||
const TabsWidget =
|
||||
require("@/components/screen/widgets/TabsWidget").TabsWidget;
|
||||
|
||||
// TabsWidget 동적 로드
|
||||
const TabsWidget = require("@/components/screen/widgets/TabsWidget").TabsWidget;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<TabsWidget component={tabsComponent} {...restProps} />
|
||||
|
|
@ -36,26 +371,49 @@ const TabsWidgetWrapper: React.FC<any> = (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<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 items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50"
|
||||
className="flex h-full w-full flex-col overflow-hidden rounded-lg border bg-background"
|
||||
onClick={onClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center">
|
||||
<Folder className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-2 text-sm font-medium">탭 컴포넌트</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{tabs.length > 0
|
||||
? `${tabs.length}개의 탭 (실제 화면에서 표시됩니다)`
|
||||
: "탭이 없습니다. 설정 패널에서 탭을 추가하세요"}
|
||||
</p>
|
||||
{tabs.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap justify-center gap-1">
|
||||
{tabs.map((tab: TabItem, index: number) => (
|
||||
<span
|
||||
key={tab.id}
|
||||
className="rounded-md border bg-white px-2 py-1 text-xs"
|
||||
>
|
||||
{tab.label || `탭 ${index + 1}`}
|
||||
</span>
|
||||
))}
|
||||
{/* 탭 헤더 */}
|
||||
<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 }) => {
|
||||
// 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("✅ 탭 컴포넌트 등록 완료");
|
||||
|
||||
|
|
|
|||
|
|
@ -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[]; // 탭 내부 컴포넌트들
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue