2025-11-04 16:17:19 +09:00
|
|
|
"use client";
|
|
|
|
|
|
2025-12-03 10:03:24 +09:00
|
|
|
import React, { useState, useEffect, useMemo } from "react";
|
2025-11-04 16:17:19 +09:00
|
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
2025-11-24 17:24:47 +09:00
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import { X, Loader2 } from "lucide-react";
|
|
|
|
|
import type { TabsComponent, TabItem } from "@/types/screen-management";
|
2025-11-04 16:17:19 +09:00
|
|
|
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
|
2025-12-03 10:03:24 +09:00
|
|
|
import { cn } from "@/lib/utils";
|
2025-11-04 16:17:19 +09:00
|
|
|
|
|
|
|
|
interface TabsWidgetProps {
|
|
|
|
|
component: TabsComponent;
|
2025-11-24 17:24:47 +09:00
|
|
|
className?: string;
|
|
|
|
|
style?: React.CSSProperties;
|
2025-11-25 15:55:05 +09:00
|
|
|
menuObjid?: number; // 🆕 부모 화면의 메뉴 OBJID
|
2025-11-04 16:17:19 +09:00
|
|
|
}
|
|
|
|
|
|
2025-11-25 15:55:05 +09:00
|
|
|
export function TabsWidget({ component, className, style, menuObjid }: TabsWidgetProps) {
|
2025-11-24 17:24:47 +09:00
|
|
|
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<TabItem[]>(tabs);
|
2025-11-04 16:17:19 +09:00
|
|
|
const [loadingScreens, setLoadingScreens] = useState<Record<string, boolean>>({});
|
2025-11-24 17:24:47 +09:00
|
|
|
const [screenLayouts, setScreenLayouts] = useState<Record<number, any>>({});
|
2025-12-03 10:03:24 +09:00
|
|
|
// 🆕 한 번이라도 선택된 탭 추적 (지연 로딩 + 캐싱)
|
|
|
|
|
const [mountedTabs, setMountedTabs] = useState<Set<string>>(() => new Set([getInitialTab()]));
|
2025-11-04 16:17:19 +09:00
|
|
|
|
2025-11-24 17:24:47 +09:00
|
|
|
// 컴포넌트 탭 목록 변경 시 동기화
|
2025-11-04 16:17:19 +09:00
|
|
|
useEffect(() => {
|
2025-11-24 17:24:47 +09:00
|
|
|
setVisibleTabs(tabs.filter((tab) => !tab.disabled));
|
|
|
|
|
}, [tabs]);
|
2025-11-04 16:17:19 +09:00
|
|
|
|
2025-11-24 17:24:47 +09:00
|
|
|
// 선택된 탭 변경 시 localStorage에 저장
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (persistSelection && typeof window !== "undefined") {
|
|
|
|
|
localStorage.setItem(storageKey, selectedTab);
|
|
|
|
|
}
|
|
|
|
|
}, [selectedTab, persistSelection, storageKey]);
|
2025-11-04 16:17:19 +09:00
|
|
|
|
2025-11-25 10:06:56 +09:00
|
|
|
// 초기 로드 시 선택된 탭의 화면 불러오기
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const currentTab = visibleTabs.find((t) => t.id === selectedTab);
|
|
|
|
|
if (currentTab && currentTab.screenId && !screenLayouts[currentTab.screenId]) {
|
|
|
|
|
loadScreenLayout(currentTab.screenId);
|
|
|
|
|
}
|
|
|
|
|
}, [selectedTab, visibleTabs]);
|
|
|
|
|
|
2025-11-24 17:24:47 +09:00
|
|
|
// 화면 레이아웃 로드
|
|
|
|
|
const loadScreenLayout = async (screenId: number) => {
|
|
|
|
|
if (screenLayouts[screenId]) {
|
|
|
|
|
return; // 이미 로드됨
|
|
|
|
|
}
|
2025-11-04 16:17:19 +09:00
|
|
|
|
2025-11-24 17:24:47 +09:00
|
|
|
setLoadingScreens((prev) => ({ ...prev, [screenId]: true }));
|
2025-11-04 16:17:19 +09:00
|
|
|
|
|
|
|
|
try {
|
2025-11-25 10:06:56 +09:00
|
|
|
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 }));
|
2025-11-04 16:17:19 +09:00
|
|
|
}
|
2025-11-24 17:24:47 +09:00
|
|
|
} catch (error) {
|
2025-12-16 11:49:10 +09:00
|
|
|
console.error(`화면 레이아웃 로드 실패 ${screenId}:`, error);
|
2025-11-04 16:17:19 +09:00
|
|
|
} finally {
|
2025-11-24 17:24:47 +09:00
|
|
|
setLoadingScreens((prev) => ({ ...prev, [screenId]: false }));
|
2025-11-04 16:17:19 +09:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-24 17:24:47 +09:00
|
|
|
// 탭 변경 핸들러
|
|
|
|
|
const handleTabChange = (tabId: string) => {
|
|
|
|
|
setSelectedTab(tabId);
|
2025-12-03 10:03:24 +09:00
|
|
|
|
2025-12-16 11:49:10 +09:00
|
|
|
// 마운트된 탭 목록에 추가 (한 번 마운트되면 유지)
|
2025-12-03 10:03:24 +09:00
|
|
|
setMountedTabs(prev => {
|
|
|
|
|
if (prev.has(tabId)) return prev;
|
|
|
|
|
const newSet = new Set(prev);
|
|
|
|
|
newSet.add(tabId);
|
|
|
|
|
return newSet;
|
|
|
|
|
});
|
2025-11-04 16:17:19 +09:00
|
|
|
|
2025-11-24 17:24:47 +09:00
|
|
|
// 해당 탭의 화면 로드
|
|
|
|
|
const tab = visibleTabs.find((t) => t.id === tabId);
|
|
|
|
|
if (tab && tab.screenId && !screenLayouts[tab.screenId]) {
|
|
|
|
|
loadScreenLayout(tab.screenId);
|
2025-11-04 16:17:19 +09:00
|
|
|
}
|
2025-11-24 17:24:47 +09:00
|
|
|
};
|
2025-11-04 16:17:19 +09:00
|
|
|
|
2025-11-24 17:24:47 +09:00
|
|
|
// 탭 닫기 핸들러
|
|
|
|
|
const handleCloseTab = (tabId: string, e: React.MouseEvent) => {
|
|
|
|
|
e.stopPropagation();
|
2025-11-04 16:17:19 +09:00
|
|
|
|
2025-11-24 17:24:47 +09:00
|
|
|
const updatedTabs = visibleTabs.filter((tab) => tab.id !== tabId);
|
|
|
|
|
setVisibleTabs(updatedTabs);
|
|
|
|
|
|
|
|
|
|
// 닫은 탭이 선택된 탭이었다면 다음 탭 선택
|
|
|
|
|
if (selectedTab === tabId && updatedTabs.length > 0) {
|
|
|
|
|
setSelectedTab(updatedTabs[0].id);
|
2025-11-04 16:17:19 +09:00
|
|
|
}
|
2025-11-24 17:24:47 +09:00
|
|
|
};
|
2025-11-04 16:17:19 +09:00
|
|
|
|
2025-11-24 17:24:47 +09:00
|
|
|
// 탭 스타일 클래스
|
|
|
|
|
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}`;
|
2025-11-04 16:17:19 +09:00
|
|
|
};
|
|
|
|
|
|
2025-11-24 17:24:47 +09:00
|
|
|
if (visibleTabs.length === 0) {
|
2025-11-04 16:17:19 +09:00
|
|
|
return (
|
2025-11-24 17:24:47 +09:00
|
|
|
<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>
|
2025-11-04 16:17:19 +09:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
2025-11-25 10:06:56 +09:00
|
|
|
<div className="flex h-full w-full flex-col pt-4" 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}
|
|
|
|
|
</TabsTrigger>
|
|
|
|
|
{allowCloseable && (
|
|
|
|
|
<Button
|
|
|
|
|
onClick={(e) => handleCloseTab(tab.id, e)}
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="absolute right-1 top-1/2 h-5 w-5 -translate-y-1/2 p-0 hover:bg-destructive/10"
|
|
|
|
|
>
|
|
|
|
|
<X className="h-3 w-3" />
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
2025-11-24 17:24:47 +09:00
|
|
|
</div>
|
2025-11-25 10:06:56 +09:00
|
|
|
))}
|
|
|
|
|
</TabsList>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-12-03 10:03:24 +09:00
|
|
|
{/* 🆕 forceMount + CSS 숨김으로 탭 전환 시 리렌더링 방지 */}
|
2025-11-25 10:06:56 +09:00
|
|
|
<div className="relative flex-1 overflow-hidden">
|
2025-12-03 10:03:24 +09:00
|
|
|
{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로 숨김
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{/* 한 번 마운트된 탭만 내용 렌더링 */}
|
|
|
|
|
{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;
|
|
|
|
|
|
2025-11-25 10:06:56 +09:00
|
|
|
|
2025-12-03 10:03:24 +09:00
|
|
|
const designWidth = screenResolution?.width || 1920;
|
|
|
|
|
const designHeight = screenResolution?.height || 1080;
|
2025-11-25 10:06:56 +09:00
|
|
|
|
2025-12-03 10:03:24 +09:00
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
className="relative h-full w-full overflow-auto bg-background"
|
|
|
|
|
style={{
|
|
|
|
|
minHeight: `${designHeight}px`,
|
2025-11-25 15:55:05 +09:00
|
|
|
}}
|
2025-12-03 10:03:24 +09:00
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
className="relative"
|
|
|
|
|
style={{
|
|
|
|
|
width: `${designWidth}px`,
|
|
|
|
|
height: `${designHeight}px`,
|
|
|
|
|
margin: "0 auto",
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{components.map((component: any) => (
|
|
|
|
|
<InteractiveScreenViewerDynamic
|
|
|
|
|
key={component.id}
|
|
|
|
|
component={component}
|
|
|
|
|
allComponents={components}
|
|
|
|
|
screenInfo={{
|
|
|
|
|
id: tab.screenId,
|
|
|
|
|
tableName: layoutData.tableName,
|
|
|
|
|
}}
|
|
|
|
|
menuObjid={menuObjid}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})()
|
|
|
|
|
) : (
|
|
|
|
|
<div className="flex h-full w-full items-center justify-center">
|
|
|
|
|
<p className="text-muted-foreground text-sm">화면을 불러올 수 없습니다</p>
|
2025-11-25 10:06:56 +09:00
|
|
|
</div>
|
2025-12-03 10:03:24 +09:00
|
|
|
)
|
|
|
|
|
) : (
|
|
|
|
|
<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>
|
2025-11-25 10:06:56 +09:00
|
|
|
</div>
|
2025-12-03 10:03:24 +09:00
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</TabsContent>
|
|
|
|
|
);
|
|
|
|
|
})}
|
2025-11-25 10:06:56 +09:00
|
|
|
</div>
|
|
|
|
|
</Tabs>
|
|
|
|
|
</div>
|
2025-11-04 16:17:19 +09:00
|
|
|
);
|
2025-11-24 17:24:47 +09:00
|
|
|
}
|