- Removed the display of the component count next to the tab labels in the TabsWidget. - This change simplifies the tab interface by eliminating unnecessary information, enhancing the overall user experience. These updates aim to streamline the visual presentation of the TabsWidget component.
517 lines
18 KiB
TypeScript
517 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useCallback } 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";
|
|
import { ResponsiveGridRenderer } from "@/components/screen/ResponsiveGridRenderer";
|
|
|
|
// 확장된 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;
|
|
// 추가 props (부모에서 전달받은 나머지 props)
|
|
[key: string]: any;
|
|
}
|
|
|
|
export function TabsWidget({
|
|
component,
|
|
className,
|
|
style,
|
|
menuObjid,
|
|
formData = {},
|
|
onFormDataChange,
|
|
isDesignMode = false,
|
|
onComponentSelect,
|
|
selectedComponentId,
|
|
selectedRowsData: _externalSelectedRowsData,
|
|
onSelectedRowsChange: externalOnSelectedRowsChange,
|
|
...restProps
|
|
}: TabsWidgetProps) {
|
|
const { setActiveTab, removeTabsComponent } = useActiveTab();
|
|
const {
|
|
tabs = [],
|
|
defaultTab,
|
|
orientation = "horizontal",
|
|
variant = "default",
|
|
allowCloseable = false,
|
|
persistSelection = false,
|
|
} = component;
|
|
|
|
const storageKey = `tabs-${component.id}-selected`;
|
|
|
|
// 탭 내부 자체 selectedRowsData 상태 관리 (항상 로컬 상태 사용)
|
|
// 부모에서 빈 배열 []이 전달되어도 로컬 상태를 우선하여 탭 내부 버튼이 즉시 인식
|
|
const [localSelectedRowsData, setLocalSelectedRowsData] = useState<any[]>([]);
|
|
|
|
// 선택 변경 핸들러: 로컬 상태 업데이트 + 부모 콜백 호출
|
|
const handleSelectedRowsChange = useCallback(
|
|
(
|
|
selectedRows: any[],
|
|
selectedRowsDataNew: any[],
|
|
sortBy?: string,
|
|
sortOrder?: "asc" | "desc",
|
|
columnOrder?: string[],
|
|
) => {
|
|
// 로컬 상태 업데이트 (탭 내부 버튼이 즉시 인식)
|
|
setLocalSelectedRowsData(selectedRowsDataNew);
|
|
|
|
// 부모 콜백 호출 (부모 상태도 업데이트)
|
|
if (externalOnSelectedRowsChange) {
|
|
externalOnSelectedRowsChange(selectedRows, selectedRowsDataNew, sortBy, sortOrder, columnOrder);
|
|
}
|
|
},
|
|
[externalOnSelectedRowsChange],
|
|
);
|
|
|
|
// 초기 선택 탭 결정
|
|
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>>({});
|
|
// 탭별 화면 정보 (screenId, tableName) - 인라인 컴포넌트의 테이블 설정에서 추출
|
|
const screenInfoMap = React.useMemo(() => {
|
|
const map: Record<string, { id?: number; tableName?: string }> = {};
|
|
for (const tab of tabs as ExtendedTabItem[]) {
|
|
const inlineComponents = tab.components || [];
|
|
if (inlineComponents.length > 0) {
|
|
// 인라인 컴포넌트에서 테이블 컴포넌트의 selectedTable 추출
|
|
const tableComp = inlineComponents.find(
|
|
(c) => c.componentType === "v2-table-list" || c.componentType === "table-list",
|
|
);
|
|
const selectedTable = tableComp?.componentConfig?.selectedTable;
|
|
if (selectedTable || tab.screenId) {
|
|
map[tab.id] = {
|
|
id: tab.screenId,
|
|
tableName: selectedTable,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
return map;
|
|
}, [tabs]);
|
|
|
|
// 컴포넌트 탭 목록 변경 시 동기화
|
|
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(tab, 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-input bg-muted">
|
|
<p className="text-muted-foreground text-sm">
|
|
{isDesignMode ? "컴포넌트를 드래그하여 추가하세요" : "컴포넌트가 없습니다"}
|
|
</p>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// screenId로 로드한 화면 컴포넌트 렌더링
|
|
const renderScreenComponents = (tab: ExtendedTabItem, components: ComponentData[]) => {
|
|
const InteractiveScreenViewerDynamic =
|
|
require("@/components/screen/InteractiveScreenViewerDynamic").InteractiveScreenViewerDynamic;
|
|
|
|
const canvasWidth = Math.max(
|
|
...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)),
|
|
800,
|
|
);
|
|
const canvasHeight = Math.max(
|
|
...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)),
|
|
400,
|
|
);
|
|
|
|
return (
|
|
<ResponsiveGridRenderer
|
|
components={components}
|
|
canvasWidth={canvasWidth}
|
|
canvasHeight={canvasHeight}
|
|
renderComponent={(comp) => (
|
|
<InteractiveScreenViewerDynamic
|
|
component={comp}
|
|
allComponents={components}
|
|
formData={formData}
|
|
onFormDataChange={onFormDataChange}
|
|
screenInfo={screenInfoMap[tab.id]}
|
|
menuObjid={menuObjid}
|
|
parentTabId={tab.id}
|
|
parentTabsComponentId={component.id}
|
|
/>
|
|
)}
|
|
/>
|
|
);
|
|
};
|
|
|
|
// 인라인 컴포넌트 렌더링 (v2 방식)
|
|
const renderInlineComponents = (tab: ExtendedTabItem, components: TabInlineComponent[]) => {
|
|
if (isDesignMode) {
|
|
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 cursor-move", 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 (onComponentSelect) {
|
|
e.stopPropagation();
|
|
onComponentSelect(tab.id, comp.id);
|
|
}
|
|
}}
|
|
>
|
|
<DynamicComponentRenderer
|
|
{...restProps}
|
|
component={{
|
|
id: comp.id,
|
|
type: "component" as const,
|
|
componentType: comp.componentType,
|
|
label: comp.label,
|
|
position: comp.position,
|
|
size: comp.size,
|
|
componentConfig: comp.componentConfig || {},
|
|
style: comp.style,
|
|
} as any}
|
|
formData={formData}
|
|
onFormDataChange={onFormDataChange}
|
|
menuObjid={menuObjid}
|
|
isDesignMode={true}
|
|
isInteractive={false}
|
|
selectedRowsData={localSelectedRowsData}
|
|
onSelectedRowsChange={handleSelectedRowsChange}
|
|
parentTabId={tab.id}
|
|
parentTabsComponentId={component.id}
|
|
{...(screenInfoMap[tab.id]
|
|
? { tableName: screenInfoMap[tab.id].tableName, screenId: screenInfoMap[tab.id].id }
|
|
: {})}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 런타임: ResponsiveGridRenderer 사용
|
|
const canvasWidth = Math.max(
|
|
...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)),
|
|
800,
|
|
);
|
|
const canvasHeight = Math.max(
|
|
...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)),
|
|
400,
|
|
);
|
|
|
|
const componentDataList: ComponentData[] = components.map((comp) => ({
|
|
id: comp.id,
|
|
componentType: comp.componentType,
|
|
label: comp.label,
|
|
position: comp.position,
|
|
size: comp.size,
|
|
componentConfig: comp.componentConfig || {},
|
|
style: comp.style,
|
|
type: "component",
|
|
})) as any;
|
|
|
|
return (
|
|
<ResponsiveGridRenderer
|
|
components={componentDataList}
|
|
canvasWidth={canvasWidth}
|
|
canvasHeight={canvasHeight}
|
|
renderComponent={(comp) => (
|
|
<DynamicComponentRenderer
|
|
{...restProps}
|
|
component={comp}
|
|
formData={formData}
|
|
onFormDataChange={onFormDataChange}
|
|
menuObjid={menuObjid}
|
|
isDesignMode={false}
|
|
isInteractive={true}
|
|
selectedRowsData={localSelectedRowsData}
|
|
onSelectedRowsChange={handleSelectedRowsChange}
|
|
parentTabId={tab.id}
|
|
parentTabsComponentId={component.id}
|
|
{...(screenInfoMap[tab.id]
|
|
? { tableName: screenInfoMap[tab.id].tableName, screenId: screenInfoMap[tab.id].id }
|
|
: {})}
|
|
/>
|
|
)}
|
|
/>
|
|
);
|
|
};
|
|
|
|
if (visibleTabs.length === 0) {
|
|
return (
|
|
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-input bg-muted">
|
|
<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}
|
|
</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>
|
|
);
|
|
}
|