fix: 분할패널 화면 복구

This commit is contained in:
SeongHyun Kim 2026-01-19 18:48:18 +09:00
parent d4b5bdd835
commit b62a0b7e3b
1 changed files with 439 additions and 31 deletions

View File

@ -33,6 +33,7 @@ import {
DialogDescription,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useTableOptions } from "@/contexts/TableOptionsContext";
import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-options";
import { useAuth } from "@/hooks/useAuth";
@ -171,6 +172,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const [rightSearchQuery, setRightSearchQuery] = useState("");
const [isLoadingLeft, setIsLoadingLeft] = useState(false);
const [isLoadingRight, setIsLoadingRight] = useState(false);
// 🆕 추가 탭 관련 상태
const [activeTabIndex, setActiveTabIndex] = useState(0); // 0 = 기본 탭 (우측 패널), 1+ = 추가 탭
const [tabsData, setTabsData] = useState<Record<number, any[]>>({}); // 탭별 데이터 캐시
const [tabsLoading, setTabsLoading] = useState<Record<number, boolean>>({}); // 탭별 로딩 상태
const [rightTableColumns, setRightTableColumns] = useState<any[]>([]); // 우측 테이블 컬럼 정보
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
const [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({}); // 좌측 컬럼 라벨
@ -1001,12 +1008,137 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
],
);
// 🆕 추가 탭 데이터 로딩 함수
const loadTabData = useCallback(
async (tabIndex: number, leftItem: any) => {
const tabConfig = componentConfig.rightPanel?.additionalTabs?.[tabIndex - 1];
if (!tabConfig || !leftItem || isDesignMode) return;
const tabTableName = tabConfig.tableName;
if (!tabTableName) return;
setTabsLoading((prev) => ({ ...prev, [tabIndex]: true }));
try {
// 조인 키 확인
const keys = tabConfig.relation?.keys;
const leftColumn = tabConfig.relation?.leftColumn || keys?.[0]?.leftColumn;
const rightColumn = tabConfig.relation?.foreignKey || keys?.[0]?.rightColumn;
let resultData: any[] = [];
if (leftColumn && rightColumn) {
// 조인 조건이 있는 경우
const { entityJoinApi } = await import("@/lib/api/entityJoin");
const searchConditions: Record<string, any> = {};
if (keys && keys.length > 0) {
// 복합키
keys.forEach((key) => {
if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) {
searchConditions[key.rightColumn] = leftItem[key.leftColumn];
}
});
} else {
// 단일키
const leftValue = leftItem[leftColumn];
if (leftValue !== undefined) {
searchConditions[rightColumn] = leftValue;
}
}
console.log(`🔗 [추가탭 ${tabIndex}] 조회 조건:`, searchConditions);
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, {
search: searchConditions,
enableEntityJoin: true,
size: 1000,
});
resultData = result.data || [];
} else {
// 조인 조건이 없는 경우: 전체 데이터 조회 (독립 탭)
const { entityJoinApi } = await import("@/lib/api/entityJoin");
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, {
enableEntityJoin: true,
size: 1000,
});
resultData = result.data || [];
}
// 데이터 필터 적용
const dataFilter = tabConfig.dataFilter;
if (dataFilter?.enabled && dataFilter.conditions?.length > 0) {
resultData = resultData.filter((item: any) => {
return dataFilter.conditions.every((cond: any) => {
const value = item[cond.column];
const condValue = cond.value;
switch (cond.operator) {
case "equals":
return value === condValue;
case "notEquals":
return value !== condValue;
case "contains":
return String(value).includes(String(condValue));
default:
return true;
}
});
});
}
// 중복 제거 적용
const deduplication = tabConfig.deduplication;
if (deduplication?.enabled && deduplication.groupByColumn) {
const groupedMap = new Map<string, any>();
resultData.forEach((item) => {
const key = String(item[deduplication.groupByColumn] || "");
const existing = groupedMap.get(key);
if (!existing) {
groupedMap.set(key, item);
} else {
// keepStrategy에 따라 유지할 항목 결정
const sortCol = deduplication.sortColumn || "start_date";
const existingVal = existing[sortCol];
const newVal = item[sortCol];
if (deduplication.keepStrategy === "latest" && newVal > existingVal) {
groupedMap.set(key, item);
} else if (deduplication.keepStrategy === "earliest" && newVal < existingVal) {
groupedMap.set(key, item);
}
}
});
resultData = Array.from(groupedMap.values());
}
console.log(`🔗 [추가탭 ${tabIndex}] 결과 데이터:`, resultData.length);
setTabsData((prev) => ({ ...prev, [tabIndex]: resultData }));
} catch (error) {
console.error(`추가탭 ${tabIndex} 데이터 로드 실패:`, error);
toast({
title: "데이터 로드 실패",
description: `탭 데이터를 불러올 수 없습니다.`,
variant: "destructive",
});
} finally {
setTabsLoading((prev) => ({ ...prev, [tabIndex]: false }));
}
},
[componentConfig.rightPanel?.additionalTabs, isDesignMode, toast],
);
// 좌측 항목 선택 핸들러
const handleLeftItemSelect = useCallback(
(item: any) => {
setSelectedLeftItem(item);
setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화
loadRightData(item);
setTabsData({}); // 🆕 모든 탭 데이터 초기화
// 🆕 현재 활성 탭에 따라 데이터 로드
if (activeTabIndex === 0) {
loadRightData(item);
} else {
loadTabData(activeTabIndex, item);
}
// 🆕 modalDataStore에 선택된 좌측 항목 저장 (단일 선택)
const leftTableName = componentConfig.leftPanel?.tableName;
@ -1017,7 +1149,30 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
});
}
},
[loadRightData, componentConfig.leftPanel?.tableName, isDesignMode],
[loadRightData, loadTabData, activeTabIndex, componentConfig.leftPanel?.tableName, isDesignMode],
);
// 🆕 탭 변경 핸들러
const handleTabChange = useCallback(
(newTabIndex: number) => {
setActiveTabIndex(newTabIndex);
// 선택된 좌측 항목이 있으면 해당 탭의 데이터 로드
if (selectedLeftItem) {
if (newTabIndex === 0) {
// 기본 탭: 우측 패널 데이터가 없으면 로드
if (!rightData || (Array.isArray(rightData) && rightData.length === 0)) {
loadRightData(selectedLeftItem);
}
} else {
// 추가 탭: 해당 탭 데이터가 없으면 로드
if (!tabsData[newTabIndex]) {
loadTabData(newTabIndex, selectedLeftItem);
}
}
}
},
[selectedLeftItem, rightData, tabsData, loadRightData, loadTabData],
);
// 우측 항목 확장/축소 토글
@ -2534,6 +2689,34 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
className="flex flex-shrink-0 flex-col"
>
<Card className="flex flex-col border-0 shadow-none" style={{ height: "100%" }}>
{/* 🆕 탭 바 (추가 탭이 있을 때만 표시) */}
{(componentConfig.rightPanel?.additionalTabs?.length || 0) > 0 && (
<div className="flex-shrink-0 border-b">
<Tabs
value={String(activeTabIndex)}
onValueChange={(value) => handleTabChange(Number(value))}
className="w-full"
>
<TabsList className="h-9 w-full justify-start rounded-none border-b-0 bg-transparent p-0 px-2">
<TabsTrigger
value="0"
className="h-8 rounded-none border-b-2 border-transparent px-4 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
>
{componentConfig.rightPanel?.title || "기본"}
</TabsTrigger>
{componentConfig.rightPanel?.additionalTabs?.map((tab, index) => (
<TabsTrigger
key={tab.tabId}
value={String(index + 1)}
className="h-8 rounded-none border-b-2 border-transparent px-4 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
>
{tab.label || `${index + 1}`}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
)}
<CardHeader
className="flex-shrink-0 border-b"
style={{
@ -2546,16 +2729,28 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
>
<div className="flex w-full items-center justify-between">
<CardTitle className="text-base font-semibold">
{componentConfig.rightPanel?.title || "우측 패널"}
{activeTabIndex === 0
? componentConfig.rightPanel?.title || "우측 패널"
: componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]?.title ||
componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]?.label ||
"우측 패널"}
</CardTitle>
{!isDesignMode && (
<div className="flex items-center gap-2">
{componentConfig.rightPanel?.showAdd && (
<Button size="sm" variant="outline" onClick={() => handleAddClick("right")}>
<Plus className="mr-1 h-4 w-4" />
</Button>
)}
{/* 🆕 현재 활성 탭에 따른 추가 버튼 */}
{activeTabIndex === 0
? componentConfig.rightPanel?.showAdd && (
<Button size="sm" variant="outline" onClick={() => handleAddClick("right")}>
<Plus className="mr-1 h-4 w-4" />
</Button>
)
: componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]?.showAdd && (
<Button size="sm" variant="outline" onClick={() => handleAddClick("right")}>
<Plus className="mr-1 h-4 w-4" />
</Button>
)}
{/* 우측 패널 수정/삭제는 각 카드에서 처리 */}
</div>
)}
@ -2575,20 +2770,231 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</div>
)}
<CardContent className="flex-1 overflow-auto p-4">
{/* 우측 데이터 */}
{isLoadingRight ? (
// 로딩 중
<div className="flex h-full items-center justify-center">
<div className="text-center">
<Loader2 className="text-primary mx-auto h-8 w-8 animate-spin" />
<p className="text-muted-foreground mt-2 text-sm"> ...</p>
</div>
</div>
) : rightData ? (
// 실제 데이터 표시
Array.isArray(rightData) ? (
// 조인 모드: 여러 데이터를 테이블/리스트로 표시
(() => {
{/* 🆕 추가 탭 데이터 렌더링 */}
{activeTabIndex > 0 ? (
// 추가 탭 컨텐츠
(() => {
const currentTabConfig = componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1];
const currentTabData = tabsData[activeTabIndex] || [];
const isTabLoading = tabsLoading[activeTabIndex];
if (isTabLoading) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<Loader2 className="text-primary mx-auto h-8 w-8 animate-spin" />
<p className="text-muted-foreground mt-2 text-sm"> ...</p>
</div>
</div>
);
}
if (!selectedLeftItem) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm"> </p>
</div>
);
}
if (currentTabData.length === 0) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm"> </p>
</div>
);
}
// 탭 데이터 렌더링 (목록/테이블 모드)
const isTableMode = currentTabConfig?.displayMode === "table";
if (isTableMode) {
// 테이블 모드
const displayColumns = currentTabConfig?.columns || [];
const columnsToShow =
displayColumns.length > 0
? displayColumns.map((col) => ({
...col,
label: col.label || col.name,
}))
: Object.keys(currentTabData[0] || {})
.filter(shouldShowField)
.slice(0, 8)
.map((key) => ({ name: key, label: key }));
return (
<div className="overflow-auto rounded-lg border">
<table className="w-full text-sm">
<thead className="bg-muted/50 sticky top-0">
<tr>
{columnsToShow.map((col: any) => (
<th
key={col.name}
className="px-3 py-2 text-left font-medium"
style={{ width: col.width ? `${col.width}px` : "auto" }}
>
{col.label}
</th>
))}
{(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && (
<th className="w-20 px-3 py-2 text-center font-medium"></th>
)}
</tr>
</thead>
<tbody>
{currentTabData.map((item: any, idx: number) => (
<tr key={item.id || idx} className="hover:bg-muted/30 border-t">
{columnsToShow.map((col: any) => (
<td key={col.name} className="px-3 py-2">
{formatCellValue(col.name, item[col.name], {}, col.format)}
</td>
))}
{(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && (
<td className="px-3 py-2 text-center">
<div className="flex items-center justify-center gap-1">
{currentTabConfig?.showEdit && (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => handleEditClick("right", item)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
)}
{currentTabConfig?.showDelete && (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-red-500 hover:text-red-600"
onClick={() => handleDeleteClick("right", item)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
);
} else {
// 목록 (카드) 모드
const displayColumns = currentTabConfig?.columns || [];
const summaryCount = currentTabConfig?.summaryColumnCount ?? 3;
const showLabel = currentTabConfig?.summaryShowLabel ?? true;
return (
<div className="space-y-2">
{currentTabData.map((item: any, idx: number) => {
const itemId = item.id || idx;
const isExpanded = expandedRightItems.has(itemId);
// 표시할 컬럼 결정
const columnsToShow =
displayColumns.length > 0
? displayColumns
: Object.keys(item)
.filter(shouldShowField)
.slice(0, 8)
.map((key) => ({ name: key, label: key }));
const summaryColumns = columnsToShow.slice(0, summaryCount);
const detailColumns = columnsToShow.slice(summaryCount);
return (
<div key={itemId} className="rounded-lg border bg-white p-3">
<div
className="flex cursor-pointer items-start justify-between"
onClick={() => toggleRightItemExpansion(itemId)}
>
<div className="flex-1">
<div className="flex flex-wrap gap-x-4 gap-y-1">
{summaryColumns.map((col: any) => (
<div key={col.name} className="text-sm">
{showLabel && (
<span className="text-muted-foreground mr-1">{col.label}:</span>
)}
<span className={col.bold ? "font-semibold" : ""}>
{formatCellValue(col.name, item[col.name], {}, col.format)}
</span>
</div>
))}
</div>
</div>
<div className="ml-2 flex items-center gap-1">
{currentTabConfig?.showEdit && (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={(e) => {
e.stopPropagation();
handleEditClick("right", item);
}}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
)}
{currentTabConfig?.showDelete && (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-red-500 hover:text-red-600"
onClick={(e) => {
e.stopPropagation();
handleDeleteClick("right", item);
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
{detailColumns.length > 0 &&
(isExpanded ? (
<ChevronUp className="h-4 w-4 text-gray-400" />
) : (
<ChevronDown className="h-4 w-4 text-gray-400" />
))}
</div>
</div>
{isExpanded && detailColumns.length > 0 && (
<div className="mt-2 border-t pt-2">
<div className="grid grid-cols-2 gap-2">
{detailColumns.map((col: any) => (
<div key={col.name} className="text-sm">
<span className="text-muted-foreground">{col.label}:</span>
<span className="ml-1">{formatCellValue(col.name, item[col.name], {}, col.format)}</span>
</div>
))}
</div>
</div>
)}
</div>
);
})}
</div>
);
}
})()
) : (
/* 기본 탭 (우측 패널) 데이터 */
<>
{isLoadingRight ? (
// 로딩 중
<div className="flex h-full items-center justify-center">
<div className="text-center">
<Loader2 className="text-primary mx-auto h-8 w-8 animate-spin" />
<p className="text-muted-foreground mt-2 text-sm"> ...</p>
</div>
</div>
) : rightData ? (
// 실제 데이터 표시
Array.isArray(rightData) ? (
// 조인 모드: 여러 데이터를 테이블/리스트로 표시
(() => {
// 검색 필터링
const filteredData = rightSearchQuery
? rightData.filter((item) => {
@ -3018,14 +3424,16 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</div>
</div>
</div>
) : (
// 선택 없음
<div className="flex h-full items-center justify-center">
<div className="text-muted-foreground text-center text-sm">
<p className="mb-2"> </p>
<p className="text-xs"> </p>
</div>
</div>
) : (
// 선택 없음
<div className="flex h-full items-center justify-center">
<div className="text-muted-foreground text-center text-sm">
<p className="mb-2"> </p>
<p className="text-xs"> </p>
</div>
</div>
)}
</>
)}
</CardContent>
</Card>