From 0a6922edeb81504dd4bffe989f74e007b54f8dc9 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 26 Mar 2026 12:12:56 +0900 Subject: [PATCH] feat: enhance v2-split-panel-layout component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SplitPanelLayoutComponent, ConfigPanel, types 개선 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../SplitPanelLayoutComponent.tsx | 195 ++++++++++++++++-- .../SplitPanelLayoutConfigPanel.tsx | 35 +++- .../components/v2-split-panel-layout/types.ts | 27 +-- 3 files changed, 224 insertions(+), 33 deletions(-) diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index 254255af..b16d9faa 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -667,8 +667,62 @@ export const SplitPanelLayoutComponent: React.FC const [showAddModal, setShowAddModal] = useState(false); const [addModalPanel, setAddModalPanel] = useState<"left" | "right" | "left-item" | null>(null); const [addModalFormData, setAddModalFormData] = useState>({}); + + // 엔티티 관계 자동 감지 캐시 (좌측↔우측 테이블 간 FK 매핑) + const [autoDetectedTabRelations, setAutoDetectedTabRelations] = useState< + Record> + >({}); const [bomExcelUploadOpen, setBomExcelUploadOpen] = useState(false); + // 좌측↔우측 테이블 간 엔티티 관계 자동 감지 (table_type_columns 기반) + useEffect(() => { + const leftTable = componentConfig.leftPanel?.tableName; + if (!leftTable) return; + + const detectAll = async () => { + const { tableManagementApi } = await import("@/lib/api/tableManagement"); + const cache: Record> = {}; + + // 기본 우측 패널 + const rightTable = componentConfig.rightPanel?.tableName; + if (rightTable && rightTable !== leftTable) { + try { + const res = await tableManagementApi.getTableEntityRelations(leftTable, rightTable); + if (res.success && res.data?.relations?.length) { + cache[rightTable] = res.data.relations.map((r: any) => ({ + leftColumn: r.leftColumn, + rightColumn: r.rightColumn, + })); + } + } catch { /* ignore */ } + } + + // 추가 탭들 + const tabs = componentConfig.rightPanel?.additionalTabs || []; + for (const tab of tabs) { + const tabTable = tab.tableName; + if (!tabTable || cache[tabTable] !== undefined) continue; + try { + const res = await tableManagementApi.getTableEntityRelations(leftTable, tabTable); + if (res.success && res.data?.relations?.length) { + cache[tabTable] = res.data.relations.map((r: any) => ({ + leftColumn: r.leftColumn, + rightColumn: r.rightColumn, + })); + } else { + cache[tabTable] = []; + } + } catch { + cache[tabTable] = []; + } + } + + setAutoDetectedTabRelations(cache); + }; + + detectAll(); + }, [componentConfig.leftPanel?.tableName, componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.additionalTabs]); + // 수정 모달 상태 const [showEditModal, setShowEditModal] = useState(false); const [editModalPanel, setEditModalPanel] = useState<"left" | "right" | null>(null); @@ -2518,6 +2572,15 @@ export const SplitPanelLayoutComponent: React.FC } } + // table_type_columns 기반 엔티티 관계 자동 감지 (패널 설정 없어도 동작) + if (currentTableName && autoDetectedTabRelations[currentTableName]) { + for (const rel of autoDetectedTabRelations[currentTableName]) { + if (parentData[rel.rightColumn] == null && selectedLeftItem[rel.leftColumn] != null) { + parentData[rel.rightColumn] = selectedLeftItem[rel.leftColumn]; + } + } + } + window.dispatchEvent( new CustomEvent("openScreenModal", { detail: { @@ -2539,23 +2602,73 @@ export const SplitPanelLayoutComponent: React.FC setAddModalPanel(panel); // 우측 패널 추가 시, 좌측에서 선택된 항목의 조인 컬럼 값을 자동으로 채움 - if ( - panel === "right" && - selectedLeftItem && - componentConfig.leftPanel?.leftColumn && - componentConfig.rightPanel?.rightColumn - ) { - const leftColumnValue = selectedLeftItem[componentConfig.leftPanel.leftColumn]; - setAddModalFormData({ - [componentConfig.rightPanel.rightColumn]: leftColumnValue, - }); + if (panel === "right" && selectedLeftItem) { + const prefill: Record = {}; + + // 현재 활성 탭의 설정 가져오기 (기본 탭 or 추가 탭) + const currentAddConfig = + activeTabIndex === 0 + ? componentConfig.rightPanel?.addConfig + : (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.addConfig; + const currentRelation = + activeTabIndex === 0 + ? componentConfig.rightPanel?.relation + : (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.relation; + + // 1) relation.keys 기반 FK 자동 채움 + if (currentRelation?.keys && Array.isArray(currentRelation.keys)) { + for (const key of currentRelation.keys) { + if (key.leftColumn && key.rightColumn && selectedLeftItem[key.leftColumn] != null) { + prefill[key.rightColumn] = selectedLeftItem[key.leftColumn]; + } + } + } else if (currentRelation) { + const leftCol = currentRelation.leftColumn || componentConfig.leftPanel?.leftColumn; + const rightCol = currentRelation.foreignKey || currentRelation.rightColumn || componentConfig.rightPanel?.rightColumn; + if (leftCol && rightCol && selectedLeftItem[leftCol] != null) { + prefill[rightCol] = selectedLeftItem[leftCol]; + } + } else if (componentConfig.leftPanel?.leftColumn && componentConfig.rightPanel?.rightColumn) { + // 하위호환: leftPanel.leftColumn → rightPanel.rightColumn + prefill[componentConfig.rightPanel.rightColumn] = selectedLeftItem[componentConfig.leftPanel.leftColumn]; + } + + // 2) addConfig.leftPanelColumn → targetColumn (단일 키, 하위호환) + if (currentAddConfig?.leftPanelColumn && currentAddConfig?.targetColumn) { + const val = selectedLeftItem[currentAddConfig.leftPanelColumn]; + if (val != null) prefill[currentAddConfig.targetColumn] = val; + } + + // 3) addConfig.autoFillFromLeft — 복수 컬럼 자동 채움 + if (currentAddConfig?.autoFillFromLeft) { + for (const mapping of currentAddConfig.autoFillFromLeft) { + const val = selectedLeftItem[mapping.source]; + if (val != null) prefill[mapping.target] = val; + } + } + + // 4) table_type_columns 기반 엔티티 관계 자동 감지 (패널 설정 없어도 동작) + const currentTableName = + activeTabIndex === 0 + ? componentConfig.rightPanel?.tableName + : (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.tableName; + if (currentTableName && autoDetectedTabRelations[currentTableName]) { + for (const rel of autoDetectedTabRelations[currentTableName]) { + // 이미 다른 방식으로 채워진 값이 없을 때만 자동 채움 + if (prefill[rel.rightColumn] == null && selectedLeftItem[rel.leftColumn] != null) { + prefill[rel.rightColumn] = selectedLeftItem[rel.leftColumn]; + } + } + } + + setAddModalFormData(prefill); } else { setAddModalFormData({}); } setShowAddModal(true); }, - [selectedLeftItem, componentConfig, activeTabIndex], + [selectedLeftItem, componentConfig, activeTabIndex, autoDetectedTabRelations], ); // 수정 버튼 핸들러 @@ -3234,21 +3347,38 @@ export const SplitPanelLayoutComponent: React.FC tableName = componentConfig.leftPanel?.tableName; modalColumns = componentConfig.leftPanel?.addModalColumns; } else if (addModalPanel === "right") { - // 우측 패널: 중계 테이블 설정이 있는지 확인 - const addConfig = componentConfig.rightPanel?.addConfig; + // 현재 활성 탭의 설정 가져오기 (기본 탭 or 추가 탭) + const isAdditionalTab = activeTabIndex > 0; + const tabConfig = isAdditionalTab + ? (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any) + : null; + + const addConfig = isAdditionalTab + ? tabConfig?.addConfig + : componentConfig.rightPanel?.addConfig; if (addConfig?.targetTable) { // 중계 테이블 모드 tableName = addConfig.targetTable; - modalColumns = componentConfig.rightPanel?.addModalColumns; + modalColumns = isAdditionalTab + ? tabConfig?.addModalColumns + : componentConfig.rightPanel?.addModalColumns; - // 좌측 패널에서 선택된 값 자동 채우기 + // 좌측 패널에서 선택된 값 자동 채우기 (단일 키, 하위호환) if (addConfig.leftPanelColumn && addConfig.targetColumn && selectedLeftItem) { const leftValue = selectedLeftItem[addConfig.leftPanelColumn]; finalData[addConfig.targetColumn] = leftValue; } - // 자동 채움 컬럼 추가 + // autoFillFromLeft — 복수 컬럼 자동 채움 + if (addConfig.autoFillFromLeft && selectedLeftItem) { + for (const mapping of addConfig.autoFillFromLeft) { + const val = selectedLeftItem[mapping.source]; + if (val != null) finalData[mapping.target] = val; + } + } + + // 자동 채움 컬럼 추가 (정적 값) if (addConfig.autoFillColumns) { Object.entries(addConfig.autoFillColumns).forEach(([key, value]) => { finalData[key] = value; @@ -3256,8 +3386,29 @@ export const SplitPanelLayoutComponent: React.FC } } else { // 일반 테이블 모드 - tableName = componentConfig.rightPanel?.tableName; - modalColumns = componentConfig.rightPanel?.addModalColumns; + tableName = isAdditionalTab + ? tabConfig?.tableName + : componentConfig.rightPanel?.tableName; + modalColumns = isAdditionalTab + ? tabConfig?.addModalColumns + : componentConfig.rightPanel?.addModalColumns; + + // 일반 모드에서도 autoFillFromLeft 적용 + if (addConfig?.autoFillFromLeft && selectedLeftItem) { + for (const mapping of addConfig.autoFillFromLeft) { + const val = selectedLeftItem[mapping.source]; + if (val != null) finalData[mapping.target] = val; + } + } + } + + // table_type_columns 기반 엔티티 관계 자동 감지 (패널 설정 없어도 동작) + if (tableName && autoDetectedTabRelations[tableName] && selectedLeftItem) { + for (const rel of autoDetectedTabRelations[tableName]) { + if (finalData[rel.rightColumn] == null && selectedLeftItem[rel.leftColumn] != null) { + finalData[rel.rightColumn] = selectedLeftItem[rel.leftColumn]; + } + } } } else if (addModalPanel === "left-item") { // 하위 항목 추가 (좌측 테이블에 추가) @@ -3305,8 +3456,12 @@ export const SplitPanelLayoutComponent: React.FC // 좌측 패널 데이터 새로고침 (일반 추가 또는 하위 항목 추가) loadLeftData(); } else if (addModalPanel === "right") { - // 우측 패널 데이터 새로고침 - loadRightData(selectedLeftItem); + // 우측 패널 데이터 새로고침 (추가 탭이면 loadTabData) + if (activeTabIndex > 0) { + loadTabData(activeTabIndex, selectedLeftItem); + } else { + loadRightData(selectedLeftItem); + } } } else { toast({ diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx index e2895037..b646c15d 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx @@ -422,6 +422,7 @@ interface AdditionalTabConfigPanelProps { availableRightTables: TableInfo[]; leftTableColumns: ColumnInfo[]; menuObjid?: number; + screenTableName?: string; // 공유 컬럼 로드 상태 loadedTableColumns: Record; loadTableColumns: (tableName: string) => Promise; @@ -466,14 +467,45 @@ const AdditionalTabConfigPanel: React.FC = ({ loadTableColumns, loadingColumns, entityJoinColumns: entityJoinColumnsMap, + screenTableName, }) => { - // 탭 테이블 변경 시 컬럼 로드 + // 탭 테이블 변경 시 컬럼 로드 + 엔티티 관계 자동 감지 useEffect(() => { if (tab.tableName && !loadedTableColumns[tab.tableName] && !loadingColumns[tab.tableName]) { loadTableColumns(tab.tableName); } }, [tab.tableName, loadedTableColumns, loadingColumns, loadTableColumns]); + // 탭 테이블 변경 시 좌측 테이블과의 관계 자동 감지 + useEffect(() => { + const leftTable = config.leftPanel?.tableName || screenTableName; + const rightTable = tab.tableName; + if (!leftTable || !rightTable) return; + // 이미 relation이 설정되어 있으면 스킵 + if (tab.relation?.keys && tab.relation.keys.length > 0) return; + + const detectRelations = async () => { + try { + const { tableManagementApi } = await import("@/lib/api/tableManagement"); + const response = await tableManagementApi.getTableEntityRelations(leftTable, rightTable); + if (response.success && response.data?.relations?.length > 0) { + const firstRel = response.data.relations[0]; + updateTab({ + relation: { + type: "join", + keys: [{ leftColumn: firstRel.leftColumn, rightColumn: firstRel.rightColumn }], + }, + }); + console.log(`✅ 추가 탭 [${tab.label}] 엔티티 관계 자동 감지:`, firstRel); + } + } catch (error) { + console.warn(`추가 탭 [${tab.label}] 관계 감지 실패:`, error); + } + }; + detectRelations(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tab.tableName, config.leftPanel?.tableName, screenTableName]); + // 현재 탭의 컬럼 목록 const tabColumns = useMemo(() => { return tab.tableName ? loadedTableColumns[tab.tableName] || [] : []; @@ -3707,6 +3739,7 @@ export const SplitPanelLayoutConfigPanel: React.FC; // 자동으로 채워질 컬럼과 기본값 + leftPanelColumn?: string; // 좌측 패널의 어떤 컬럼값을 가져올지 (단일 키, 하위호환) + targetColumn?: string; // targetTable의 어떤 컬럼에 넣을지 (단일 키, 하위호환) + /** 좌측 선택 데이터에서 복수 컬럼을 자동으로 채움 (엔티티 관계 포함) */ + autoFillFromLeft?: Array<{ + source: string; // 좌측 데이터의 컬럼명 + target: string; // 저장할 테이블의 컬럼명 + }>; +} + /** 페이징 처리 설정 (좌측/우측 패널 공통) */ export interface PaginationConfig { enabled: boolean; @@ -88,12 +101,7 @@ export interface AdditionalTabConfig { }>; }; - addConfig?: { - targetTable?: string; - autoFillColumns?: Record; - leftPanelColumn?: string; - targetColumn?: string; - }; + addConfig?: AddConfig; tableConfig?: { showCheckbox?: boolean; @@ -304,12 +312,7 @@ export interface SplitPanelLayoutConfig { }; // 우측 패널 추가 시 중계 테이블 설정 (N:M 관계) - addConfig?: { - targetTable?: string; // 실제로 INSERT할 테이블 (중계 테이블) - autoFillColumns?: Record; // 자동으로 채워질 컬럼과 기본값 - leftPanelColumn?: string; // 좌측 패널의 어떤 컬럼값을 가져올지 - targetColumn?: string; // targetTable의 어떤 컬럼에 넣을지 - }; + addConfig?: AddConfig; // 테이블 모드 설정 tableConfig?: {