From 34e48993e4bc5e984dfdf7f4b3605a18d96c656c Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Thu, 8 Jan 2026 15:37:22 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(SplitPanelLayout2):=20=EA=B0=9C?= =?UTF-8?q?=EB=B3=84=20=EC=88=98=EC=A0=95=20=EB=B2=84=ED=8A=BC=EC=97=90=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=ED=99=94=EB=A9=B4=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?UI=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 좌측/우측 패널의 개별 수정 버튼 설정에 수정 모달 화면 선택 Combobox 추가 - 수정 버튼 ON 시 모달 화면 선택 UI 표시 - editModalScreenId 설정값 저장 및 사용 - 기존 폴백 로직 유지 (editModalScreenId 없으면 addModalScreenId 사용) --- .../SplitPanelLayout2ConfigPanel.tsx | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx index 8ff83b6f..cd93a3b5 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx @@ -87,6 +87,10 @@ export const SplitPanelLayout2ConfigPanel: React.FC updateConfig("leftPanel.showEditButton", checked)} /> + {/* 수정 버튼이 켜져 있을 때 모달 화면 선택 */} + {config.leftPanel?.showEditButton && ( +
+ + updateConfig("leftPanel.editModalScreenId", value)} + placeholder="수정 모달 화면 선택" + open={leftEditModalOpen} + onOpenChange={setLeftEditModalOpen} + /> +
+ )}
updateConfig("rightPanel.showEditButton", checked)} />
+ {/* 수정 버튼이 켜져 있을 때 모달 화면 선택 */} + {config.rightPanel?.showEditButton && ( +
+ + updateConfig("rightPanel.editModalScreenId", value)} + placeholder="수정 모달 화면 선택" + open={rightEditModalOpen} + onOpenChange={setRightEditModalOpen} + /> +
+ )}
Date: Fri, 9 Jan 2026 14:52:32 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(split-panel-layout):=20=EB=A9=80?= =?UTF-8?q?=ED=8B=B0=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=ED=83=AD=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80=20AdditionalTabConfig=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EC=A0=95=EC=9D=98=20(=EC=9A=B0=EC=B8=A1?= =?UTF-8?q?=20=ED=8C=A8=EB=84=90=EA=B3=BC=20=EB=8F=99=EC=9D=BC=ED=95=9C=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EA=B5=AC=EC=A1=B0)=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=ED=8C=A8=EB=84=90=EC=97=90=20=EC=B6=94=EA=B0=80=20=ED=83=AD?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20UI=20=EA=B5=AC=ED=98=84=20(=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94,=20=EC=A1=B0=EC=9D=B8=ED=82=A4,=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC,=20=ED=95=84=ED=84=B0,=20=EC=A4=91=EB=B3=B5=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0,=20=EB=B2=84=ED=8A=BC)=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=EC=97=90=20=ED=83=AD=20=EC=83=81=ED=83=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=A1=9C?= =?UTF-8?q?=EB=94=A9=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20=ED=83=AD?= =?UTF-8?q?=20=EB=B0=94=20UI=20=EB=B0=8F=20=ED=83=AD=EB=B3=84=20=EC=BB=A8?= =?UTF-8?q?=ED=85=90=EC=B8=A0=20=EB=A0=8C=EB=8D=94=EB=A7=81=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EA=B8=B0=EC=A1=B4=20=EA=B8=B0=EB=8A=A5=20=EC=9C=A0?= =?UTF-8?q?=EC=A7=80=20(=ED=83=AD=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=EC=8B=9C=20=EB=8F=99=EC=9D=BC=20=EB=8F=99=EC=9E=91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SplitPanelLayoutComponent.tsx | 520 +++++++++- .../SplitPanelLayoutConfigPanel.tsx | 913 +++++++++++++++++- .../components/split-panel-layout/types.ts | 102 ++ 3 files changed, 1503 insertions(+), 32 deletions(-) diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index ad7f5302..ac09652e 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -32,6 +32,7 @@ import { DialogFooter, DialogDescription, } from "@/components/ui/dialog"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Label } from "@/components/ui/label"; import { useTableOptions } from "@/contexts/TableOptionsContext"; import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-options"; @@ -162,6 +163,11 @@ export const SplitPanelLayoutComponent: React.FC 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>({}); // 탭별 데이터 캐시 + const [tabsLoading, setTabsLoading] = useState>({}); // 탭별 로딩 상태 const [rightTableColumns, setRightTableColumns] = useState([]); // 우측 테이블 컬럼 정보 const [expandedItems, setExpandedItems] = useState>(new Set()); // 펼쳐진 항목들 const [leftColumnLabels, setLeftColumnLabels] = useState>({}); // 좌측 컬럼 라벨 @@ -603,6 +609,41 @@ export const SplitPanelLayoutComponent: React.FC return result; }, []); + // 🆕 간단한 값 포맷팅 함수 (추가 탭용) + const formatValue = useCallback( + ( + value: any, + format?: { + type?: "number" | "currency" | "date" | "text"; + thousandSeparator?: boolean; + decimalPlaces?: number; + prefix?: string; + suffix?: string; + dateFormat?: string; + }, + ): string => { + if (value === null || value === undefined) return "-"; + + // 날짜 포맷 + if (format?.type === "date" || format?.dateFormat) { + return formatDateValue(value, format?.dateFormat || "YYYY-MM-DD"); + } + + // 숫자 포맷 + if ( + format?.type === "number" || + format?.type === "currency" || + format?.thousandSeparator || + format?.decimalPlaces !== undefined + ) { + return formatNumberValue(value, format); + } + + return String(value); + }, + [formatDateValue, formatNumberValue], + ); + // 셀 값 포맷팅 함수 (카테고리 타입 처리 + 날짜/숫자 포맷) const formatCellValue = useCallback( ( @@ -988,12 +1029,137 @@ export const SplitPanelLayoutComponent: React.FC ], ); + // 🆕 추가 탭 데이터 로딩 함수 + 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 = {}; + + 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(); + 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; @@ -1004,7 +1170,30 @@ export const SplitPanelLayoutComponent: React.FC }); } }, - [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], ); // 우측 항목 확장/축소 토글 @@ -1401,13 +1590,19 @@ export const SplitPanelLayoutComponent: React.FC // 수정 버튼 핸들러 const handleEditClick = useCallback( (panel: "left" | "right", item: any) => { + // 🆕 현재 활성 탭의 설정 가져오기 + const currentTabConfig = + activeTabIndex === 0 + ? componentConfig.rightPanel + : componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]; + // 🆕 우측 패널 수정 버튼 설정 확인 - if (panel === "right" && componentConfig.rightPanel?.editButton?.mode === "modal") { - const modalScreenId = componentConfig.rightPanel?.editButton?.modalScreenId; + if (panel === "right" && currentTabConfig?.editButton?.mode === "modal") { + const modalScreenId = currentTabConfig?.editButton?.modalScreenId; if (modalScreenId) { // 커스텀 모달 화면 열기 - const rightTableName = componentConfig.rightPanel?.tableName || ""; + const rightTableName = currentTabConfig?.tableName || ""; // Primary Key 찾기 (우선순위: id > ID > 첫 번째 필드) let primaryKeyName = "id"; @@ -1440,11 +1635,11 @@ export const SplitPanelLayoutComponent: React.FC }); // 🆕 groupByColumns 추출 - const groupByColumns = componentConfig.rightPanel?.editButton?.groupByColumns || []; + const groupByColumns = currentTabConfig?.editButton?.groupByColumns || []; console.log("🔧 [SplitPanel] 수정 버튼 클릭 - groupByColumns 확인:", { groupByColumns, - editButtonConfig: componentConfig.rightPanel?.editButton, + editButtonConfig: currentTabConfig?.editButton, hasGroupByColumns: groupByColumns.length > 0, }); @@ -1482,7 +1677,7 @@ export const SplitPanelLayoutComponent: React.FC setEditModalFormData({ ...item }); setShowEditModal(true); }, - [componentConfig], + [componentConfig, activeTabIndex], ); // 수정 모달 저장 @@ -1582,13 +1777,19 @@ export const SplitPanelLayoutComponent: React.FC // 삭제 확인 const handleDeleteConfirm = useCallback(async () => { + // 🆕 현재 활성 탭의 설정 가져오기 + const currentTabConfig = + activeTabIndex === 0 + ? componentConfig.rightPanel + : componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]; + // 우측 패널 삭제 시 중계 테이블 확인 let tableName = - deleteModalPanel === "left" ? componentConfig.leftPanel?.tableName : componentConfig.rightPanel?.tableName; + deleteModalPanel === "left" ? componentConfig.leftPanel?.tableName : currentTabConfig?.tableName; // 우측 패널 + 중계 테이블 모드인 경우 - if (deleteModalPanel === "right" && componentConfig.rightPanel?.addConfig?.targetTable) { - tableName = componentConfig.rightPanel.addConfig.targetTable; + if (deleteModalPanel === "right" && currentTabConfig?.addConfig?.targetTable) { + tableName = currentTabConfig.addConfig.targetTable; console.log("🔗 중계 테이블 모드: 삭제 대상 테이블 =", tableName); } @@ -1676,7 +1877,12 @@ export const SplitPanelLayoutComponent: React.FC setRightData(null); } } else if (deleteModalPanel === "right" && selectedLeftItem) { - loadRightData(selectedLeftItem); + // 🆕 현재 활성 탭에 따라 새로고침 + if (activeTabIndex === 0) { + loadRightData(selectedLeftItem); + } else { + loadTabData(activeTabIndex, selectedLeftItem); + } } } else { toast({ @@ -1700,7 +1906,7 @@ export const SplitPanelLayoutComponent: React.FC variant: "destructive", }); } - }, [deleteModalPanel, componentConfig, deleteModalItem, toast, selectedLeftItem, loadLeftData, loadRightData]); + }, [deleteModalPanel, componentConfig, deleteModalItem, toast, selectedLeftItem, loadLeftData, loadRightData, activeTabIndex, loadTabData]); // 항목별 추가 버튼 핸들러 (좌측 항목의 + 버튼 - 하위 항목 추가) const handleItemAddClick = useCallback( @@ -2521,6 +2727,34 @@ export const SplitPanelLayoutComponent: React.FC className="flex flex-shrink-0 flex-col" > + {/* 🆕 탭 바 (추가 탭이 있을 때만 표시) */} + {(componentConfig.rightPanel?.additionalTabs?.length || 0) > 0 && ( +
+ handleTabChange(Number(value))} + className="w-full" + > + + + {componentConfig.rightPanel?.title || "기본"} + + {componentConfig.rightPanel?.additionalTabs?.map((tab, index) => ( + + {tab.label || `탭 ${index + 1}`} + + ))} + + +
+ )} >
- {componentConfig.rightPanel?.title || "우측 패널"} + {activeTabIndex === 0 + ? componentConfig.rightPanel?.title || "우측 패널" + : componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]?.title || + componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]?.label || + "우측 패널"} {!isDesignMode && (
- {componentConfig.rightPanel?.showAdd && ( - - )} + {/* 현재 활성 탭에 따른 추가 버튼 */} + {activeTabIndex === 0 + ? componentConfig.rightPanel?.showAdd && ( + + ) + : componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]?.showAdd && ( + + )} {/* 우측 패널 수정/삭제는 각 카드에서 처리 */}
)} @@ -2562,16 +2808,228 @@ export const SplitPanelLayoutComponent: React.FC
)} - {/* 우측 데이터 */} - {isLoadingRight ? ( - // 로딩 중 -
-
- -

데이터를 불러오는 중...

-
-
- ) : rightData ? ( + {/* 🆕 추가 탭 데이터 렌더링 */} + {activeTabIndex > 0 ? ( + // 추가 탭 컨텐츠 + (() => { + const currentTabConfig = componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]; + const currentTabData = tabsData[activeTabIndex] || []; + const isTabLoading = tabsLoading[activeTabIndex]; + + if (isTabLoading) { + return ( +
+
+ +

데이터를 불러오는 중...

+
+
+ ); + } + + if (!selectedLeftItem) { + return ( +
+

좌측에서 항목을 선택하세요

+
+ ); + } + + if (currentTabData.length === 0) { + return ( +
+

데이터가 없습니다

+
+ ); + } + + // 탭 데이터 렌더링 (목록/테이블 모드) + 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 ( +
+ + + + {columnsToShow.map((col: any) => ( + + ))} + {(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && ( + + )} + + + + {currentTabData.map((item: any, idx: number) => ( + + {columnsToShow.map((col: any) => ( + + ))} + {(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && ( + + )} + + ))} + +
+ {col.label} + 작업
+ {formatValue(item[col.name], col.format)} + +
+ {currentTabConfig?.showEdit && ( + + )} + {currentTabConfig?.showDelete && ( + + )} +
+
+
+ ); + } else { + // 목록 (카드) 모드 + const displayColumns = currentTabConfig?.columns || []; + const summaryCount = currentTabConfig?.summaryColumnCount ?? 3; + const showLabel = currentTabConfig?.summaryShowLabel ?? true; + + return ( +
+ {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 ( +
+
toggleRightItemExpansion(itemId)} + > +
+
+ {summaryColumns.map((col: any) => ( +
+ {showLabel && ( + {col.label}: + )} + + {formatValue(item[col.name], col.format)} + +
+ ))} +
+
+
+ {currentTabConfig?.showEdit && ( + + )} + {currentTabConfig?.showDelete && ( + + )} + {detailColumns.length > 0 && ( + isExpanded ? ( + + ) : ( + + ) + )} +
+
+ {isExpanded && detailColumns.length > 0 && ( +
+
+ {detailColumns.map((col: any) => ( +
+ {col.label}: + {formatValue(item[col.name], col.format)} +
+ ))} +
+
+ )} +
+ ); + })} +
+ ); + } + })() + ) : ( + /* 기본 탭 (우측 패널) 데이터 */ + <> + {isLoadingRight ? ( + // 로딩 중 +
+
+ +

데이터를 불러오는 중...

+
+
+ ) : rightData ? ( // 실제 데이터 표시 Array.isArray(rightData) ? ( // 조인 모드: 여러 데이터를 테이블/리스트로 표시 @@ -3014,6 +3472,8 @@ export const SplitPanelLayoutComponent: React.FC
)} + + )} diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx index 5ca50ffb..f0b02ea2 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx @@ -11,10 +11,11 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; // Accordion 제거 - 단순 섹션으로 변경 -import { Check, ChevronsUpDown, ArrowRight, Plus, X, ArrowUp, ArrowDown } from "lucide-react"; +import { Check, ChevronsUpDown, ArrowRight, Plus, X, ArrowUp, ArrowDown, Trash2, GripVertical } from "lucide-react"; import { cn } from "@/lib/utils"; -import { SplitPanelLayoutConfig } from "./types"; +import { SplitPanelLayoutConfig, AdditionalTabConfig } from "./types"; import { TableInfo, ColumnInfo } from "@/types/screen"; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; import { tableTypeApi } from "@/lib/api/screen"; import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel"; @@ -189,6 +190,848 @@ const ScreenSelector: React.FC<{ ); }; +/** + * 추가 탭 설정 패널 (우측 패널과 동일한 구조) + */ +interface AdditionalTabConfigPanelProps { + tab: AdditionalTabConfig; + tabIndex: number; + config: SplitPanelLayoutConfig; + updateRightPanel: (updates: Partial) => void; + availableRightTables: TableInfo[]; + leftTableColumns: ColumnInfo[]; + menuObjid?: number; + // 공유 컬럼 로드 상태 + loadedTableColumns: Record; + loadTableColumns: (tableName: string) => Promise; + loadingColumns: Record; +} + +const AdditionalTabConfigPanel: React.FC = ({ + tab, + tabIndex, + config, + updateRightPanel, + availableRightTables, + leftTableColumns, + menuObjid, + loadedTableColumns, + loadTableColumns, + loadingColumns, +}) => { + // 탭 테이블 변경 시 컬럼 로드 + useEffect(() => { + if (tab.tableName && !loadedTableColumns[tab.tableName] && !loadingColumns[tab.tableName]) { + loadTableColumns(tab.tableName); + } + }, [tab.tableName, loadedTableColumns, loadingColumns, loadTableColumns]); + + // 현재 탭의 컬럼 목록 + const tabColumns = useMemo(() => { + return tab.tableName ? loadedTableColumns[tab.tableName] || [] : []; + }, [tab.tableName, loadedTableColumns]); + + // 로딩 상태 + const loadingTabColumns = tab.tableName ? loadingColumns[tab.tableName] || false : false; + + // 탭 업데이트 헬퍼 + const updateTab = (updates: Partial) => { + const newTabs = [...(config.rightPanel?.additionalTabs || [])]; + newTabs[tabIndex] = { ...tab, ...updates }; + updateRightPanel({ additionalTabs: newTabs }); + }; + + return ( + + +
+ + + {tab.label || `탭 ${tabIndex + 1}`} + + {tab.tableName && ( + ({tab.tableName}) + )} +
+
+ +
+ {/* ===== 1. 기본 정보 ===== */} +
+ +
+
+ + updateTab({ label: e.target.value })} + placeholder="탭 이름" + className="h-8 text-xs" + /> +
+
+ + updateTab({ title: e.target.value })} + placeholder="패널 제목" + className="h-8 text-xs" + /> +
+
+
+ + updateTab({ panelHeaderHeight: parseInt(e.target.value) || 48 })} + placeholder="48" + className="h-8 w-24 text-xs" + /> +
+
+ + {/* ===== 2. 테이블 선택 ===== */} +
+ +
+ + + + + + + + + 테이블을 찾을 수 없습니다. + + {availableRightTables.map((table) => ( + updateTab({ tableName: table.tableName, columns: [] })} + > + + {table.displayName || table.tableName} + + ))} + + + + +
+
+ + {/* ===== 3. 표시 모드 ===== */} +
+ +
+ + +
+ + {/* 요약 설정 (목록 모드) */} + {tab.displayMode === "list" && ( +
+
+ + updateTab({ summaryColumnCount: parseInt(e.target.value) || 3 })} + min={1} + max={10} + className="h-8 text-xs" + /> +
+
+ updateTab({ summaryShowLabel: !!checked })} + /> + +
+
+ )} +
+ + {/* ===== 4. 컬럼 매핑 (조인 키) ===== */} +
+ +

+ 좌측 패널 선택 시 관련 데이터만 표시합니다 +

+
+
+ + +
+
+ + +
+
+
+ + {/* ===== 5. 기능 버튼 ===== */} +
+ +
+
+ updateTab({ showSearch: !!checked })} + /> + +
+
+ updateTab({ showAdd: !!checked })} + /> + +
+
+ updateTab({ showEdit: !!checked })} + /> + +
+
+ updateTab({ showDelete: !!checked })} + /> + +
+
+
+ + {/* ===== 6. 표시 컬럼 설정 ===== */} +
+
+ + +
+

+ 표시할 컬럼을 선택하세요. 선택하지 않으면 모든 컬럼이 표시됩니다. +

+ + {/* 테이블 미선택 상태 */} + {!tab.tableName && ( +
+

먼저 테이블을 선택하세요

+
+ )} + + {/* 테이블 선택됨 - 컬럼 목록 */} + {tab.tableName && ( +
+ {/* 로딩 상태 */} + {loadingTabColumns && ( +
+

컬럼을 불러오는 중...

+
+ )} + + {/* 설정된 컬럼이 없을 때 */} + {!loadingTabColumns && (tab.columns || []).length === 0 && ( +
+

설정된 컬럼이 없습니다

+

컬럼을 추가하지 않으면 모든 컬럼이 표시됩니다

+
+ )} + + {/* 설정된 컬럼 목록 */} + {!loadingTabColumns && (tab.columns || []).length > 0 && ( + (tab.columns || []).map((col, colIndex) => ( +
+ {/* 상단: 순서 변경 + 삭제 버튼 */} +
+
+ + + #{colIndex + 1} +
+ +
+ + {/* 컬럼 선택 */} +
+ + +
+ + {/* 라벨 + 너비 */} +
+
+ + { + const newColumns = [...(tab.columns || [])]; + newColumns[colIndex] = { ...col, label: e.target.value }; + updateTab({ columns: newColumns }); + }} + placeholder="표시 라벨" + className="h-8 text-xs" + /> +
+
+ + { + const newColumns = [...(tab.columns || [])]; + newColumns[colIndex] = { ...col, width: parseInt(e.target.value) || 100 }; + updateTab({ columns: newColumns }); + }} + placeholder="100" + className="h-8 text-xs" + /> +
+
+
+ )) + )} +
+ )} +
+ + {/* ===== 7. 추가 모달 컬럼 설정 (showAdd일 때) ===== */} + {tab.showAdd && ( +
+
+ + +
+ +
+ {(tab.addModalColumns || []).length === 0 ? ( +
+

추가 모달에 표시할 컬럼을 설정하세요

+
+ ) : ( + (tab.addModalColumns || []).map((col, colIndex) => ( +
+ + { + const newColumns = [...(tab.addModalColumns || [])]; + newColumns[colIndex] = { ...col, label: e.target.value }; + updateTab({ addModalColumns: newColumns }); + }} + placeholder="라벨" + className="h-8 w-24 text-xs" + /> +
+ { + const newColumns = [...(tab.addModalColumns || [])]; + newColumns[colIndex] = { ...col, required: !!checked }; + updateTab({ addModalColumns: newColumns }); + }} + /> + 필수 +
+ +
+ )) + )} +
+
+ )} + + {/* ===== 8. 데이터 필터링 ===== */} +
+ + updateTab({ dataFilter })} + menuObjid={menuObjid} + /> +
+ + {/* ===== 9. 중복 데이터 제거 ===== */} +
+
+ + { + if (checked) { + updateTab({ + deduplication: { + enabled: true, + groupByColumn: "", + keepStrategy: "latest", + sortColumn: "start_date", + }, + }); + } else { + updateTab({ deduplication: undefined }); + } + }} + /> +
+ {tab.deduplication?.enabled && ( +
+
+ + +
+
+ + +
+
+ + +
+
+ )} +
+ + {/* ===== 10. 수정 버튼 설정 ===== */} + {tab.showEdit && ( +
+ +
+
+ + +
+ + {tab.editButton?.mode === "modal" && ( +
+ + { + updateTab({ + editButton: { ...tab.editButton, enabled: true, mode: "modal", modalScreenId: screenId }, + }); + }} + /> +
+ )} + +
+
+ + { + updateTab({ + editButton: { ...tab.editButton, enabled: true, buttonLabel: e.target.value || undefined }, + }); + }} + placeholder="수정" + className="h-7 text-xs" + /> +
+
+ + +
+
+ + {/* 그룹핑 기준 컬럼 */} +
+ +

수정 시 같은 값을 가진 레코드를 함께 불러옵니다

+
+ {tabColumns.map((col) => ( +
+ { + const current = tab.editButton?.groupByColumns || []; + const newColumns = checked + ? [...current, col.columnName] + : current.filter((c) => c !== col.columnName); + updateTab({ + editButton: { ...tab.editButton, enabled: true, groupByColumns: newColumns }, + }); + }} + /> + +
+ ))} +
+
+
+
+ )} + + {/* ===== 11. 삭제 버튼 설정 ===== */} + {tab.showDelete && ( +
+ +
+
+
+ + { + updateTab({ + deleteButton: { ...tab.deleteButton, enabled: true, buttonLabel: e.target.value || undefined }, + }); + }} + placeholder="삭제" + className="h-7 text-xs" + /> +
+
+ + +
+
+
+ + { + updateTab({ + deleteButton: { ...tab.deleteButton, enabled: true, confirmMessage: e.target.value || undefined }, + }); + }} + placeholder="정말 삭제하시겠습니까?" + className="h-7 text-xs" + /> +
+
+
+ )} + + {/* ===== 탭 삭제 버튼 ===== */} +
+ +
+
+
+
+ ); +}; + /** * SplitPanelLayout 설정 패널 */ @@ -2973,6 +3816,72 @@ export const SplitPanelLayoutConfigPanel: React.FC + {/* ======================================== */} + {/* 추가 탭 설정 (우측 패널과 동일한 구조) */} + {/* ======================================== */} +
+
+
+

추가 탭

+

+ 우측 패널에 다른 테이블 데이터를 탭으로 추가합니다 +

+
+ +
+ + {/* 추가된 탭 목록 */} + {(config.rightPanel?.additionalTabs?.length || 0) > 0 ? ( + + {config.rightPanel?.additionalTabs?.map((tab, tabIndex) => ( + + ))} + + ) : ( +
+

+ 추가된 탭이 없습니다. [탭 추가] 버튼을 클릭하여 새 탭을 추가하세요. +

+
+ )} +
+ {/* 레이아웃 설정 */}
diff --git a/frontend/lib/registry/components/split-panel-layout/types.ts b/frontend/lib/registry/components/split-panel-layout/types.ts index 0e9d6db9..17edc100 100644 --- a/frontend/lib/registry/components/split-panel-layout/types.ts +++ b/frontend/lib/registry/components/split-panel-layout/types.ts @@ -4,6 +4,105 @@ import { DataFilterConfig } from "@/types/screen-management"; +/** + * 추가 탭 설정 (우측 패널과 동일한 구조 + tabId, label) + */ +export interface AdditionalTabConfig { + // 탭 고유 정보 + tabId: string; + label: string; + + // === 우측 패널과 동일한 설정 === + title: string; + panelHeaderHeight?: number; + tableName?: string; + dataSource?: string; + displayMode?: "list" | "table"; + showSearch?: boolean; + showAdd?: boolean; + showEdit?: boolean; + showDelete?: boolean; + summaryColumnCount?: number; + summaryShowLabel?: boolean; + + columns?: Array<{ + name: string; + label: string; + width?: number; + sortable?: boolean; + align?: "left" | "center" | "right"; + bold?: boolean; + format?: { + type?: "number" | "currency" | "date" | "text"; + thousandSeparator?: boolean; + decimalPlaces?: number; + prefix?: string; + suffix?: string; + dateFormat?: string; + }; + }>; + + addModalColumns?: Array<{ + name: string; + label: string; + required?: boolean; + }>; + + relation?: { + type?: "join" | "detail"; + leftColumn?: string; + rightColumn?: string; + foreignKey?: string; + keys?: Array<{ + leftColumn: string; + rightColumn: string; + }>; + }; + + addConfig?: { + targetTable?: string; + autoFillColumns?: Record; + leftPanelColumn?: string; + targetColumn?: string; + }; + + tableConfig?: { + showCheckbox?: boolean; + showRowNumber?: boolean; + rowHeight?: number; + headerHeight?: number; + striped?: boolean; + bordered?: boolean; + hoverable?: boolean; + stickyHeader?: boolean; + }; + + dataFilter?: DataFilterConfig; + + deduplication?: { + enabled: boolean; + groupByColumn: string; + keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; + sortColumn?: string; + }; + + editButton?: { + enabled: boolean; + mode: "auto" | "modal"; + modalScreenId?: number; + buttonLabel?: string; + buttonVariant?: "default" | "outline" | "ghost"; + groupByColumns?: string[]; + }; + + deleteButton?: { + enabled: boolean; + buttonLabel?: string; + buttonVariant?: "default" | "outline" | "ghost" | "destructive"; + confirmMessage?: string; + }; +} + export interface SplitPanelLayoutConfig { // 좌측 패널 설정 leftPanel: { @@ -165,6 +264,9 @@ export interface SplitPanelLayoutConfig { buttonVariant?: "default" | "outline" | "ghost" | "destructive"; // 버튼 스타일 (기본: "ghost") confirmMessage?: string; // 삭제 확인 메시지 }; + + // 🆕 추가 탭 설정 (멀티 테이블 탭) + additionalTabs?: AdditionalTabConfig[]; }; // 레이아웃 설정