431 lines
15 KiB
TypeScript
431 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useMemo } 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, TabInlineComponent, ComponentData } from "@/types/screen-management";
|
|
import { cn } from "@/lib/utils";
|
|
import { useActiveTab } from "@/contexts/ActiveTabContext";
|
|
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
|
import { screenApi } from "@/lib/api/screen";
|
|
|
|
// 확장된 TabItem 타입 (screenId 지원)
|
|
interface ExtendedTabItem extends TabItem {
|
|
screenId?: number;
|
|
screenName?: string;
|
|
}
|
|
|
|
interface TabsWidgetProps {
|
|
component: TabsComponent;
|
|
className?: string;
|
|
style?: React.CSSProperties;
|
|
menuObjid?: number;
|
|
formData?: Record<string, any>;
|
|
onFormDataChange?: (fieldName: string, value: any) => void;
|
|
isDesignMode?: boolean;
|
|
onComponentSelect?: (tabId: string, componentId: string) => void;
|
|
selectedComponentId?: string;
|
|
// 테이블 선택된 행 데이터 (버튼 활성화 및 수정/삭제 동작에 필요)
|
|
selectedRowsData?: any[];
|
|
onSelectedRowsChange?: (
|
|
selectedRows: any[],
|
|
selectedRowsData: any[],
|
|
sortBy?: string,
|
|
sortOrder?: "asc" | "desc",
|
|
columnOrder?: string[],
|
|
) => void;
|
|
}
|
|
|
|
export function TabsWidget({
|
|
component,
|
|
className,
|
|
style,
|
|
menuObjid,
|
|
formData = {},
|
|
onFormDataChange,
|
|
isDesignMode = false,
|
|
onComponentSelect,
|
|
selectedComponentId,
|
|
selectedRowsData,
|
|
onSelectedRowsChange,
|
|
}: TabsWidgetProps) {
|
|
const { setActiveTab, removeTabsComponent } = useActiveTab();
|
|
const {
|
|
tabs = [],
|
|
defaultTab,
|
|
orientation = "horizontal",
|
|
variant = "default",
|
|
allowCloseable = false,
|
|
persistSelection = false,
|
|
} = component;
|
|
|
|
const storageKey = `tabs-${component.id}-selected`;
|
|
|
|
// 초기 선택 탭 결정
|
|
const getInitialTab = () => {
|
|
if (persistSelection && typeof window !== "undefined") {
|
|
const saved = localStorage.getItem(storageKey);
|
|
if (saved && tabs.some((t) => t.id === saved)) {
|
|
return saved;
|
|
}
|
|
}
|
|
return defaultTab || tabs[0]?.id || "";
|
|
};
|
|
|
|
const [selectedTab, setSelectedTab] = useState<string>(getInitialTab());
|
|
const [visibleTabs, setVisibleTabs] = useState<ExtendedTabItem[]>(tabs as ExtendedTabItem[]);
|
|
const [mountedTabs, setMountedTabs] = useState<Set<string>>(() => new Set([getInitialTab()]));
|
|
|
|
// 🆕 화면 진입 시 첫 번째 탭 자동 선택 및 마운트
|
|
useEffect(() => {
|
|
// 현재 선택된 탭이 유효하지 않거나 비어있으면 첫 번째 탭 선택
|
|
const validTabs = (tabs as ExtendedTabItem[]).filter((tab) => !tab.disabled);
|
|
const firstValidTabId = validTabs[0]?.id;
|
|
|
|
if (firstValidTabId) {
|
|
// 선택된 탭이 없거나 유효하지 않으면 첫 번째 탭으로 설정
|
|
setSelectedTab((currentSelected) => {
|
|
if (!currentSelected || !validTabs.some((t) => t.id === currentSelected)) {
|
|
return firstValidTabId;
|
|
}
|
|
return currentSelected;
|
|
});
|
|
|
|
// 첫 번째 탭이 mountedTabs에 없으면 추가
|
|
setMountedTabs((prev) => {
|
|
const newSet = new Set(prev);
|
|
// 첫 번째 탭 추가
|
|
if (firstValidTabId && !newSet.has(firstValidTabId)) {
|
|
newSet.add(firstValidTabId);
|
|
}
|
|
return newSet;
|
|
});
|
|
}
|
|
}, [tabs]); // tabs가 변경될 때마다 실행
|
|
|
|
// screenId 기반 화면 로드 상태
|
|
const [screenLayouts, setScreenLayouts] = useState<Record<string, ComponentData[]>>({});
|
|
const [screenLoadingStates, setScreenLoadingStates] = useState<Record<string, boolean>>({});
|
|
const [screenErrors, setScreenErrors] = useState<Record<string, string>>({});
|
|
|
|
// 컴포넌트 탭 목록 변경 시 동기화
|
|
useEffect(() => {
|
|
setVisibleTabs((tabs as ExtendedTabItem[]).filter((tab) => !tab.disabled));
|
|
}, [tabs]);
|
|
|
|
// screenId가 있는 탭의 화면 레이아웃 로드
|
|
useEffect(() => {
|
|
const loadScreenLayouts = async () => {
|
|
for (const tab of visibleTabs) {
|
|
const extTab = tab as ExtendedTabItem;
|
|
// screenId가 있고, 아직 로드하지 않았으며, 인라인 컴포넌트가 없는 경우만 로드
|
|
if (
|
|
extTab.screenId &&
|
|
!screenLayouts[tab.id] &&
|
|
!screenLoadingStates[tab.id] &&
|
|
(!extTab.components || extTab.components.length === 0)
|
|
) {
|
|
setScreenLoadingStates((prev) => ({ ...prev, [tab.id]: true }));
|
|
try {
|
|
const layoutData = await screenApi.getLayout(extTab.screenId);
|
|
if (layoutData && layoutData.components) {
|
|
setScreenLayouts((prev) => ({ ...prev, [tab.id]: layoutData.components }));
|
|
}
|
|
} catch (error) {
|
|
console.error(`탭 "${tab.label}" 화면 로드 실패:`, error);
|
|
setScreenErrors((prev) => ({ ...prev, [tab.id]: "화면을 불러올 수 없습니다." }));
|
|
} finally {
|
|
setScreenLoadingStates((prev) => ({ ...prev, [tab.id]: false }));
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
loadScreenLayouts();
|
|
}, [visibleTabs, screenLayouts, screenLoadingStates]);
|
|
|
|
// 선택된 탭 변경 시 localStorage에 저장 + ActiveTab Context 업데이트
|
|
useEffect(() => {
|
|
if (persistSelection && typeof window !== "undefined") {
|
|
localStorage.setItem(storageKey, selectedTab);
|
|
}
|
|
|
|
const currentTabInfo = visibleTabs.find((t) => t.id === selectedTab);
|
|
if (currentTabInfo) {
|
|
setActiveTab(component.id, {
|
|
tabId: selectedTab,
|
|
tabsComponentId: component.id,
|
|
label: currentTabInfo.label,
|
|
});
|
|
}
|
|
}, [selectedTab, persistSelection, storageKey, component.id, visibleTabs, setActiveTab]);
|
|
|
|
// 컴포넌트 언마운트 시 ActiveTab Context에서 제거
|
|
useEffect(() => {
|
|
return () => {
|
|
removeTabsComponent(component.id);
|
|
};
|
|
}, [component.id, removeTabsComponent]);
|
|
|
|
// 탭 변경 핸들러
|
|
const handleTabChange = (tabId: string) => {
|
|
setSelectedTab(tabId);
|
|
|
|
setMountedTabs((prev) => {
|
|
if (prev.has(tabId)) return prev;
|
|
const newSet = new Set(prev);
|
|
newSet.add(tabId);
|
|
return newSet;
|
|
});
|
|
};
|
|
|
|
// 탭 닫기 핸들러
|
|
const handleCloseTab = (tabId: string, e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
|
|
const updatedTabs = visibleTabs.filter((tab) => tab.id !== tabId);
|
|
setVisibleTabs(updatedTabs);
|
|
|
|
if (selectedTab === tabId && updatedTabs.length > 0) {
|
|
setSelectedTab(updatedTabs[0].id);
|
|
}
|
|
};
|
|
|
|
// 탭 스타일 클래스
|
|
const getTabsListClass = () => {
|
|
const baseClass = orientation === "vertical" ? "flex-col" : "";
|
|
const variantClass =
|
|
variant === "pills" ? "bg-muted p-1 rounded-lg" : variant === "underline" ? "border-b" : "bg-muted p-1";
|
|
return `${baseClass} ${variantClass}`;
|
|
};
|
|
|
|
// 탭 컨텐츠 렌더링 (screenId 또는 인라인 컴포넌트)
|
|
const renderTabContent = (tab: ExtendedTabItem) => {
|
|
const extTab = tab as ExtendedTabItem;
|
|
const inlineComponents = tab.components || [];
|
|
|
|
// 1. screenId가 있고 인라인 컴포넌트가 없는 경우 -> 화면 로드 방식
|
|
if (extTab.screenId && inlineComponents.length === 0) {
|
|
// 로딩 중
|
|
if (screenLoadingStates[tab.id]) {
|
|
return (
|
|
<div className="flex h-full w-full items-center justify-center">
|
|
<Loader2 className="text-primary h-8 w-8 animate-spin" />
|
|
<span className="text-muted-foreground ml-2">화면을 불러오는 중...</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 에러 발생
|
|
if (screenErrors[tab.id]) {
|
|
return (
|
|
<div className="border-destructive/50 bg-destructive/5 flex h-full w-full items-center justify-center rounded border-2 border-dashed">
|
|
<p className="text-destructive text-sm">{screenErrors[tab.id]}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 화면 레이아웃이 로드된 경우
|
|
const loadedComponents = screenLayouts[tab.id];
|
|
if (loadedComponents && loadedComponents.length > 0) {
|
|
return renderScreenComponents(loadedComponents);
|
|
}
|
|
|
|
// 아직 로드되지 않은 경우
|
|
return (
|
|
<div className="flex h-full w-full items-center justify-center">
|
|
<Loader2 className="text-primary h-8 w-8 animate-spin" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 2. 인라인 컴포넌트가 있는 경우 -> 기존 v2 방식
|
|
if (inlineComponents.length > 0) {
|
|
return renderInlineComponents(tab, inlineComponents);
|
|
}
|
|
|
|
// 3. 둘 다 없는 경우
|
|
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>
|
|
);
|
|
};
|
|
|
|
// screenId로 로드한 화면 컴포넌트 렌더링
|
|
const renderScreenComponents = (components: ComponentData[]) => {
|
|
// InteractiveScreenViewerDynamic 동적 로드
|
|
const InteractiveScreenViewerDynamic =
|
|
require("@/components/screen/InteractiveScreenViewerDynamic").InteractiveScreenViewerDynamic;
|
|
|
|
// 컴포넌트들의 최대 위치를 계산하여 스크롤 가능한 영역 확보
|
|
const maxBottom = Math.max(...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)), 300);
|
|
const maxRight = Math.max(...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)), 400);
|
|
|
|
return (
|
|
<div
|
|
className="relative h-full w-full overflow-auto"
|
|
style={{
|
|
minHeight: maxBottom + 20,
|
|
minWidth: maxRight + 20,
|
|
}}
|
|
>
|
|
{components.map((comp) => (
|
|
<div
|
|
key={comp.id}
|
|
className="absolute"
|
|
style={{
|
|
left: comp.position?.x || 0,
|
|
top: comp.position?.y || 0,
|
|
width: comp.size?.width || "auto",
|
|
height: comp.size?.height || "auto",
|
|
}}
|
|
>
|
|
<InteractiveScreenViewerDynamic
|
|
component={comp}
|
|
allComponents={components}
|
|
formData={formData}
|
|
onFormDataChange={onFormDataChange}
|
|
menuObjid={menuObjid}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// 인라인 컴포넌트 렌더링 (v2 방식)
|
|
const renderInlineComponents = (tab: TabItem, components: TabInlineComponent[]) => {
|
|
// 컴포넌트들의 최대 위치를 계산하여 스크롤 가능한 영역 확보
|
|
const maxBottom = Math.max(
|
|
...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)),
|
|
300, // 최소 높이
|
|
);
|
|
const maxRight = Math.max(
|
|
...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)),
|
|
400, // 최소 너비
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className="relative"
|
|
style={{
|
|
minHeight: maxBottom + 20,
|
|
minWidth: maxRight + 20,
|
|
}}
|
|
>
|
|
{components.map((comp: TabInlineComponent) => {
|
|
const isSelected = selectedComponentId === comp.id;
|
|
|
|
return (
|
|
<div
|
|
key={comp.id}
|
|
className={cn(
|
|
"absolute",
|
|
isDesignMode && "cursor-move",
|
|
isDesignMode && isSelected && "ring-primary ring-2 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}
|
|
isInteractive={!isDesignMode}
|
|
selectedRowsData={selectedRowsData}
|
|
onSelectedRowsChange={onSelectedRowsChange}
|
|
/>
|
|
</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">
|
|
<p className="text-muted-foreground text-sm">탭이 없습니다</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={cn("flex h-full w-full flex-col pt-4", className)} style={style}>
|
|
<Tabs
|
|
value={selectedTab}
|
|
onValueChange={handleTabChange}
|
|
orientation={orientation}
|
|
className="flex h-full w-full flex-col"
|
|
>
|
|
<div className="relative z-10">
|
|
<TabsList className={getTabsListClass()}>
|
|
{visibleTabs.map((tab) => (
|
|
<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="text-muted-foreground ml-1 text-xs">({tab.components.length})</span>
|
|
)}
|
|
</TabsTrigger>
|
|
{allowCloseable && (
|
|
<Button
|
|
onClick={(e) => handleCloseTab(tab.id, e)}
|
|
variant="ghost"
|
|
size="sm"
|
|
className="hover:bg-destructive/10 absolute top-1/2 right-1 h-5 w-5 -translate-y-1/2 p-0"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</TabsList>
|
|
</div>
|
|
|
|
<div className="relative flex-1 overflow-auto">
|
|
{visibleTabs.map((tab) => {
|
|
const shouldRender = mountedTabs.has(tab.id);
|
|
const isActive = selectedTab === tab.id;
|
|
|
|
return (
|
|
<TabsContent
|
|
key={tab.id}
|
|
value={tab.id}
|
|
forceMount
|
|
className={cn("h-full overflow-auto", !isActive && "hidden")}
|
|
>
|
|
{shouldRender && renderTabContent(tab)}
|
|
</TabsContent>
|
|
);
|
|
})}
|
|
</div>
|
|
</Tabs>
|
|
</div>
|
|
);
|
|
}
|