From ced25c9a545af85e8be8f92597a284da5f881e52 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Wed, 11 Feb 2026 10:46:47 +0900 Subject: [PATCH 1/3] feat: Enhance SplitPanelLayoutComponent with improved data loading and filtering logic - Updated loadRightData function to support loading all data when no leftItem is selected, applying data filters as needed. - Enhanced loadTabData function to handle data loading for tabs, including support for data filters and entity joins. - Improved comments for clarity on data loading behavior based on leftItem selection. - Refactored UI components in SplitPanelLayoutConfigPanel for better styling and organization, including updates to table selection and display settings. --- .../SplitPanelLayoutComponent.tsx | 244 +++++-- .../SplitPanelLayoutConfigPanel.tsx | 620 ++++++++---------- 2 files changed, 461 insertions(+), 403 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 923c1aa3..f47da1aa 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -1091,7 +1091,7 @@ export const SplitPanelLayoutComponent: React.FC searchValues, ]); - // 우측 데이터 로드 + // 우측 데이터 로드 (leftItem이 null이면 전체 데이터 로드) const loadRightData = useCallback( async (leftItem: any) => { const relationshipType = componentConfig.rightPanel?.relation?.type || "detail"; @@ -1099,10 +1099,84 @@ export const SplitPanelLayoutComponent: React.FC if (!rightTableName || isDesignMode) return; + // 좌측 미선택 시: 전체 데이터 로드 (dataFilter 적용) + if (!leftItem && relationshipType === "join") { + setIsLoadingRight(true); + try { + const rightJoinColumns = extractAdditionalJoinColumns( + componentConfig.rightPanel?.columns, + rightTableName, + ); + + const result = await entityJoinApi.getTableDataWithJoins(rightTableName, { + enableEntityJoin: true, + size: 1000, + companyCodeOverride: companyCode, + additionalJoinColumns: rightJoinColumns, + dataFilter: componentConfig.rightPanel?.dataFilter, + }); + + // dataFilter 적용 + let filteredData = result.data || []; + const dataFilter = componentConfig.rightPanel?.dataFilter; + if (dataFilter?.enabled && dataFilter.filters?.length > 0) { + filteredData = filteredData.filter((item: any) => { + return dataFilter.filters.every((cond: any) => { + const value = item[cond.columnName]; + switch (cond.operator) { + case "equals": + return value === cond.value; + case "notEquals": + return value !== cond.value; + case "contains": + return String(value || "").includes(String(cond.value)); + case "is_null": + return value === null || value === undefined || value === ""; + case "is_not_null": + return value !== null && value !== undefined && value !== ""; + default: + return true; + } + }); + }); + } + + // conditions 형식 dataFilter도 지원 (하위 호환성) + const dataFilterConditions = componentConfig.rightPanel?.dataFilter; + if (dataFilterConditions?.enabled && dataFilterConditions.conditions?.length > 0) { + filteredData = filteredData.filter((item: any) => { + return dataFilterConditions.conditions.every((cond: any) => { + const value = item[cond.column]; + switch (cond.operator) { + case "equals": + return value === cond.value; + case "notEquals": + return value !== cond.value; + case "contains": + return String(value || "").includes(String(cond.value)); + default: + return true; + } + }); + }); + } + + setRightData(filteredData); + } catch (error) { + console.error("우측 전체 데이터 로드 실패:", error); + } finally { + setIsLoadingRight(false); + } + return; + } + + // leftItem이 null이면 join 모드 이외에는 데이터 로드 불가 + if (!leftItem) return; + setIsLoadingRight(true); try { if (relationshipType === "detail") { - // 상세 모드: 동일 테이블의 상세 정보 (🆕 엔티티 조인 활성화) + // 상세 모드: 동일 테이블의 상세 정보 (엔티티 조인 활성화) const primaryKey = leftItem.id || leftItem.ID || Object.values(leftItem)[0]; // 🆕 엔티티 조인 API 사용 @@ -1331,11 +1405,11 @@ export const SplitPanelLayoutComponent: React.FC ], ); - // 추가 탭 데이터 로딩 함수 + // 추가 탭 데이터 로딩 함수 (leftItem이 null이면 전체 데이터 로드) const loadTabData = useCallback( async (tabIndex: number, leftItem: any) => { const tabConfig = componentConfig.rightPanel?.additionalTabs?.[tabIndex - 1]; - if (!tabConfig || !leftItem || isDesignMode) return; + if (!tabConfig || isDesignMode) return; const tabTableName = tabConfig.tableName; if (!tabTableName) return; @@ -1346,7 +1420,7 @@ export const SplitPanelLayoutComponent: React.FC const leftColumn = tabConfig.relation?.leftColumn || keys?.[0]?.leftColumn; const rightColumn = tabConfig.relation?.foreignKey || keys?.[0]?.rightColumn; - // 🆕 탭 config의 Entity 조인 컬럼 추출 + // 탭 config의 Entity 조인 컬럼 추출 const tabJoinColumns = extractAdditionalJoinColumns(tabConfig.columns, tabTableName); if (tabJoinColumns) { console.log(`🔗 [분할패널] 탭 ${tabIndex} additionalJoinColumns:`, tabJoinColumns); @@ -1354,7 +1428,20 @@ export const SplitPanelLayoutComponent: React.FC let resultData: any[] = []; - if (leftColumn && rightColumn) { + // 탭의 dataFilter (API 전달용) + const tabDataFilterForApi = (tabConfig as any).dataFilter; + + if (!leftItem) { + // 좌측 미선택: 전체 데이터 로드 (dataFilter는 API에 전달) + const result = await entityJoinApi.getTableDataWithJoins(tabTableName, { + enableEntityJoin: true, + size: 1000, + companyCodeOverride: companyCode, + additionalJoinColumns: tabJoinColumns, + dataFilter: tabDataFilterForApi, + }); + resultData = result.data || []; + } else if (leftColumn && rightColumn) { const searchConditions: Record = {}; if (keys && keys.length > 0) { @@ -1380,18 +1467,46 @@ export const SplitPanelLayoutComponent: React.FC search: searchConditions, enableEntityJoin: true, size: 1000, - additionalJoinColumns: tabJoinColumns, // 🆕 Entity 조인 컬럼 전달 + companyCodeOverride: companyCode, + additionalJoinColumns: tabJoinColumns, + dataFilter: tabDataFilterForApi, }); resultData = result.data || []; } else { const result = await entityJoinApi.getTableDataWithJoins(tabTableName, { enableEntityJoin: true, size: 1000, - additionalJoinColumns: tabJoinColumns, // 🆕 Entity 조인 컬럼 전달 + companyCodeOverride: companyCode, + additionalJoinColumns: tabJoinColumns, + dataFilter: tabDataFilterForApi, }); resultData = result.data || []; } + // 탭별 dataFilter 적용 + const tabDataFilter = (tabConfig as any).dataFilter; + if (tabDataFilter?.enabled && tabDataFilter.filters?.length > 0) { + resultData = resultData.filter((item: any) => { + return tabDataFilter.filters.every((cond: any) => { + const value = item[cond.columnName]; + switch (cond.operator) { + case "equals": + return value === cond.value; + case "notEquals": + return value !== cond.value; + case "contains": + return String(value || "").includes(String(cond.value)); + case "is_null": + return value === null || value === undefined || value === ""; + case "is_not_null": + return value !== null && value !== undefined && value !== ""; + default: + return true; + } + }); + }); + } + setTabsData((prev) => ({ ...prev, [tabIndex]: resultData })); } catch (error) { console.error(`추가탭 ${tabIndex} 데이터 로드 실패:`, error); @@ -1407,29 +1522,55 @@ export const SplitPanelLayoutComponent: React.FC [componentConfig.rightPanel?.additionalTabs, isDesignMode, toast], ); - // 탭 변경 핸들러 + // 탭 변경 핸들러 (좌측 미선택 시에도 전체 데이터 로드) 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); - } + 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], ); - // 좌측 항목 선택 핸들러 + // 좌측 항목 선택 핸들러 (동일 항목 재클릭 시 선택 해제 → 전체 데이터 표시) const handleLeftItemSelect = useCallback( (item: any) => { + // 동일 항목 클릭 시 선택 해제 (전체 보기로 복귀) + const leftPk = componentConfig.rightPanel?.relation?.leftColumn || + componentConfig.rightPanel?.relation?.keys?.[0]?.leftColumn; + const isSameItem = selectedLeftItem && leftPk && + selectedLeftItem[leftPk] === item[leftPk]; + + if (isSameItem) { + // 선택 해제 → 전체 데이터 로드 + setSelectedLeftItem(null); + setExpandedRightItems(new Set()); + setTabsData({}); + if (activeTabIndex === 0) { + loadRightData(null); + } else { + loadTabData(activeTabIndex, null); + } + // 추가 탭들도 전체 데이터 로드 + const tabs = componentConfig.rightPanel?.additionalTabs; + if (tabs && tabs.length > 0) { + tabs.forEach((_: any, idx: number) => { + if (idx + 1 !== activeTabIndex) { + loadTabData(idx + 1, null); + } + }); + } + return; + } + setSelectedLeftItem(item); setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화 setTabsData({}); // 모든 탭 데이터 초기화 @@ -1450,7 +1591,7 @@ export const SplitPanelLayoutComponent: React.FC }); } }, - [loadRightData, loadTabData, activeTabIndex, componentConfig.leftPanel?.tableName, isDesignMode], + [loadRightData, loadTabData, activeTabIndex, componentConfig.leftPanel?.tableName, componentConfig.rightPanel?.relation, componentConfig.rightPanel?.additionalTabs, isDesignMode, selectedLeftItem], ); // 우측 항목 확장/축소 토글 @@ -2026,10 +2167,8 @@ export const SplitPanelLayoutComponent: React.FC if (editModalPanel === "left") { loadLeftData(); // 우측 패널도 새로고침 (FK가 변경되었을 수 있음) - if (selectedLeftItem) { - loadRightData(selectedLeftItem); - } - } else if (editModalPanel === "right" && selectedLeftItem) { + loadRightData(selectedLeftItem); + } else if (editModalPanel === "right") { loadRightData(selectedLeftItem); } } else { @@ -2160,7 +2299,7 @@ export const SplitPanelLayoutComponent: React.FC setSelectedLeftItem(null); setRightData(null); } - } else if (deleteModalPanel === "right" && selectedLeftItem) { + } else if (deleteModalPanel === "right") { loadRightData(selectedLeftItem); } } else { @@ -2317,7 +2456,7 @@ export const SplitPanelLayoutComponent: React.FC if (addModalPanel === "left" || addModalPanel === "left-item") { // 좌측 패널 데이터 새로고침 (일반 추가 또는 하위 항목 추가) loadLeftData(); - } else if (addModalPanel === "right" && selectedLeftItem) { + } else if (addModalPanel === "right") { // 우측 패널 데이터 새로고침 loadRightData(selectedLeftItem); } @@ -2405,10 +2544,22 @@ export const SplitPanelLayoutComponent: React.FC } }, [leftColumnVisibility, componentConfig.leftPanel?.tableName, currentUserId]); - // 초기 데이터 로드 + // 초기 데이터 로드 (좌측 + 우측 전체 데이터) useEffect(() => { if (!isDesignMode && componentConfig.autoLoad !== false) { loadLeftData(); + // 좌측 미선택 상태에서 우측 전체 데이터 기본 로드 + const relationshipType = componentConfig.rightPanel?.relation?.type || "detail"; + if (relationshipType === "join") { + loadRightData(null); + // 추가 탭도 전체 데이터 로드 + const tabs = componentConfig.rightPanel?.additionalTabs; + if (tabs && tabs.length > 0) { + tabs.forEach((_: any, idx: number) => { + loadTabData(idx + 1, null); + }); + } + } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isDesignMode, componentConfig.autoLoad]); @@ -2421,19 +2572,17 @@ export const SplitPanelLayoutComponent: React.FC // eslint-disable-next-line react-hooks/exhaustive-deps }, [leftFilters]); - // 🆕 전역 테이블 새로고침 이벤트 리스너 + // 전역 테이블 새로고침 이벤트 리스너 useEffect(() => { const handleRefreshTable = () => { if (!isDesignMode) { console.log("🔄 [SplitPanel] refreshTable 이벤트 수신 - 데이터 새로고침"); loadLeftData(); - // 선택된 항목이 있으면 현재 활성 탭 데이터 새로고침 - if (selectedLeftItem) { - if (activeTabIndex === 0) { - loadRightData(selectedLeftItem); - } else { - loadTabData(activeTabIndex, selectedLeftItem); - } + // 현재 활성 탭 데이터 새로고침 (좌측 미선택 시에도 전체 데이터 로드) + if (activeTabIndex === 0) { + loadRightData(selectedLeftItem); + } else { + loadTabData(activeTabIndex, selectedLeftItem); } } }; @@ -3339,15 +3488,7 @@ export const SplitPanelLayoutComponent: React.FC ); } - if (!selectedLeftItem) { - return ( -
-

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

-
- ); - } - - if (currentTabData.length === 0) { + if (currentTabData.length === 0 && !isTabLoading) { return (

관련 데이터가 없습니다.

@@ -4107,11 +4248,20 @@ export const SplitPanelLayoutComponent: React.FC
) : ( - // 선택 없음 + // 데이터 없음 또는 초기 로딩 대기
-

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

-

선택한 항목의 상세 정보가 여기에 표시됩니다

+ {componentConfig.rightPanel?.relation?.type === "join" ? ( + <> + +

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

+ + ) : ( + <> +

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

+

선택한 항목의 상세 정보가 여기에 표시됩니다

+ + )}
)} 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 b2fff2cd..25a57448 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx @@ -328,7 +328,7 @@ const AdditionalTabConfigPanel: React.FC = ({
@@ -341,11 +341,11 @@ const AdditionalTabConfigPanel: React.FC = ({ )}
- -
+ + {/* ===== 1. 기본 정보 ===== */} -
- +
+

기본 정보

@@ -366,123 +366,120 @@ const AdditionalTabConfigPanel: React.FC = ({ />
-
- - 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} - - ))} - - - - -
+
+

테이블 설정

+ + + + + + + + 테이블을 찾을 수 없습니다. + + {availableRightTables.map((table) => ( + updateTab({ tableName: table.tableName, columns: [] })} + > + + {table.displayName || table.tableName} + {table.displayName && ({table.tableName})} + + ))} + + + +
- {/* ===== 3. 표시 모드 ===== */} -
- -
+ {/* ===== 3. 표시 모드 + 요약 설정 ===== */} +
+

표시 설정

+
{/* 요약 설정 (목록 모드) */} - {tab.displayMode === "list" && ( -
-
- + {(tab.displayMode || "list") === "list" && ( +
+ +
+ updateTab({ summaryColumnCount: parseInt(e.target.value) || 3 })} - min={1} - max={10} - className="h-8 text-xs" + className="bg-white" /> +

접기 전에 표시할 컬럼 개수 (기본: 3개)

-
+
+
+ +

컬럼명 표시 여부

+
updateTab({ summaryShowLabel: !!checked })} + onCheckedChange={(checked) => updateTab({ summaryShowLabel: checked as boolean })} /> -
)}
{/* ===== 4. 컬럼 매핑 (연결 키) ===== */} -
- -

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

-
+
+

컬럼 매핑 (연결 키)

+

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

+
@@ -515,10 +508,7 @@ const AdditionalTabConfigPanel: React.FC = ({ value={tab.relation?.keys?.[0]?.rightColumn || tab.relation?.foreignKey || "__none__"} onValueChange={(value) => { if (value === "__none__") { - // 선택 안 함 - 조인 키 제거 - updateTab({ - relation: undefined, - }); + updateTab({ relation: undefined }); } else { updateTab({ relation: { @@ -530,17 +520,13 @@ const AdditionalTabConfigPanel: React.FC = ({ } }} > - + - - 선택 안 함 (전체 데이터) - + 선택 안 함 (전체 데이터) {tabColumns.map((col) => ( - - {col.columnLabel || col.columnName} - + {col.columnLabel || col.columnName} ))} @@ -549,215 +535,202 @@ const AdditionalTabConfigPanel: React.FC = ({
{/* ===== 5. 기능 버튼 ===== */} -
- +
+

기능 버튼

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

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

+ {/* ===== 6. 표시할 컬럼 - DnD + Entity 조인 통합 ===== */} + {(() => { + const selectedColumns = tab.columns || []; + const filteredTabCols = tabColumns.filter((c) => !["company_code", "company_name"].includes(c.columnName)); + const unselectedCols = filteredTabCols.filter((c) => !selectedColumns.some((sc) => sc.name === c.columnName)); + const dbNumericTypes = ["numeric", "decimal", "integer", "bigint", "double precision", "real", "smallint", "int4", "int8", "float4", "float8"]; + const inputNumericTypes = ["number", "decimal", "currency", "integer"]; - {/* 테이블 미선택 상태 */} - {!tab.tableName && ( -
-

먼저 테이블을 선택하세요

-
- )} + const handleTabDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (over && active.id !== over.id) { + const oldIndex = selectedColumns.findIndex((c) => c.name === active.id); + const newIndex = selectedColumns.findIndex((c) => c.name === over.id); + if (oldIndex !== -1 && newIndex !== -1) { + updateTab({ columns: arrayMove([...selectedColumns], oldIndex, newIndex) }); + } + } + }; - {/* 테이블 선택됨 - 컬럼 목록 */} - {tab.tableName && ( -
- {/* 로딩 상태 */} - {loadingTabColumns && ( -
-

컬럼을 불러오는 중...

-
- )} + return ( +
+

표시할 컬럼 ({selectedColumns.length}개 선택)

+
+ {!tab.tableName ? ( +

테이블을 선택해주세요

+ ) : loadingTabColumns ? ( +

컬럼을 불러오는 중...

+ ) : ( + <> + {selectedColumns.length > 0 && ( + + c.name)} strategy={verticalListSortingStrategy}> +
+ {selectedColumns.map((col, index) => { + const colInfo = tabColumns.find((c) => c.columnName === col.name); + const isNumeric = colInfo && ( + dbNumericTypes.includes(colInfo.dataType?.toLowerCase() || "") || + inputNumericTypes.includes(colInfo.input_type?.toLowerCase() || "") || + inputNumericTypes.includes(colInfo.webType?.toLowerCase() || "") + ); + return ( + { + const newColumns = [...selectedColumns]; + newColumns[index] = { ...newColumns[index], label: value }; + updateTab({ columns: newColumns }); + }} + onWidthChange={(value) => { + const newColumns = [...selectedColumns]; + newColumns[index] = { ...newColumns[index], width: value }; + updateTab({ columns: newColumns }); + }} + onFormatChange={(checked) => { + const newColumns = [...selectedColumns]; + newColumns[index] = { ...newColumns[index], format: { ...newColumns[index].format, type: "number", thousandSeparator: checked } }; + updateTab({ columns: newColumns }); + }} + onRemove={() => updateTab({ columns: selectedColumns.filter((_, i) => i !== index) })} + /> + ); + })} +
+
+
+ )} - {/* 설정된 컬럼이 없을 때 */} - {!loadingTabColumns && (tab.columns || []).length === 0 && ( -
-

설정된 컬럼이 없습니다

-

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

-
- )} + {selectedColumns.length > 0 && unselectedCols.length > 0 && ( +
+ 미선택 컬럼 +
+ )} - {/* 설정된 컬럼 목록 */} - {!loadingTabColumns && (tab.columns || []).length > 0 && ( - (tab.columns || []).map((col, colIndex) => ( -
- {/* 상단: 순서 변경 + 삭제 버튼 */} -
-
- - - #{colIndex + 1} -
- + + {column.columnLabel || column.columnName} +
+ ))}
- {/* 컬럼 선택 */} -
- - -
+ {/* Entity 조인 컬럼 - 아코디언 (접기/펼치기) */} + {(() => { + const joinData = tab.tableName ? entityJoinColumnsMap?.[tab.tableName] : null; + if (!joinData || joinData.joinTables.length === 0) return null; - {/* 라벨 + 너비 */} -
-
- - { - 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" - /> -
-
-
- )) - )} + return joinData.joinTables.map((joinTable, tableIndex) => { + const joinColumnsToShow = joinTable.availableColumns.filter((column) => { + const matchingJoinColumn = joinData.availableColumns.find( + (jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName, + ); + if (!matchingJoinColumn) return false; + return !selectedColumns.some((c) => c.name === matchingJoinColumn.joinAlias); + }); + const addedCount = joinTable.availableColumns.length - joinColumnsToShow.length; + if (joinColumnsToShow.length === 0 && addedCount === 0) return null; + + return ( +
+ + + + {joinTable.tableName} + {addedCount > 0 && ( + {addedCount}개 선택 + )} + {joinColumnsToShow.length}개 남음 + +
+ {joinColumnsToShow.map((column, colIndex) => { + const matchingJoinColumn = joinData.availableColumns.find( + (jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName, + ); + if (!matchingJoinColumn) return null; + + return ( +
{ + updateTab({ + columns: [...selectedColumns, { + name: matchingJoinColumn.joinAlias, + label: matchingJoinColumn.suggestedLabel || matchingJoinColumn.columnLabel, + width: 100, + isEntityJoin: true, + joinInfo: { + sourceTable: tab.tableName!, + sourceColumn: (joinTable as any).joinConfig?.sourceColumn || "", + referenceTable: matchingJoinColumn.tableName, + joinAlias: matchingJoinColumn.joinAlias, + }, + }], + }); + }} + > + + + {column.columnLabel || column.columnName} +
+ ); + })} + {joinColumnsToShow.length === 0 && ( +

모든 컬럼이 이미 추가되었습니다

+ )} +
+
+ ); + }); + })()} + + )} +
- )} -
+ ); + })()} {/* ===== 7. 추가 모달 컬럼 설정 (showAdd일 때) ===== */} {tab.showAdd && ( -
+
- +

추가 모달 컬럼 설정

)} - {/* ===== 7.5 Entity 조인 컬럼 ===== */} - {(() => { - const joinData = tab.tableName ? entityJoinColumnsMap?.[tab.tableName] : null; - if (!joinData || joinData.joinTables.length === 0) return null; - - return ( -
- -

연관 테이블의 컬럼을 추가합니다

- {joinData.joinTables.map((joinTable, tableIndex) => ( -
-
- - {joinTable.tableName} - {joinTable.currentDisplayColumn} -
-
- {joinTable.availableColumns.map((column, colIndex) => { - const matchingJoinColumn = joinData.availableColumns.find( - (jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName, - ); - if (!matchingJoinColumn) return null; - const tabColumns2 = tab.columns || []; - const isAdded = tabColumns2.some((c) => c.name === matchingJoinColumn.joinAlias); - - return ( -
{ - if (isAdded) { - updateTab({ columns: tabColumns2.filter((c) => c.name !== matchingJoinColumn.joinAlias) }); - } else { - updateTab({ - columns: [...tabColumns2, { - name: matchingJoinColumn.joinAlias, - label: matchingJoinColumn.suggestedLabel || matchingJoinColumn.columnLabel, - width: 100, - isEntityJoin: true, - joinInfo: { - sourceTable: tab.tableName!, - sourceColumn: (joinTable as any).joinConfig?.sourceColumn || "", - referenceTable: matchingJoinColumn.tableName, - joinAlias: matchingJoinColumn.joinAlias, - }, - }], - }); - } - }} - > - - - {column.columnLabel} - {column.dataType} -
- ); - })} -
-
- ))} -
- ); - })()} + {/* Entity 조인 컬럼은 표시 컬럼 목록에 통합됨 */} {/* ===== 8. 데이터 필터링 ===== */} -
- +
+

데이터 필터링

= ({
{/* ===== 9. 중복 데이터 제거 ===== */} -
+
- +

중복 데이터 제거

{ @@ -1019,8 +927,8 @@ const AdditionalTabConfigPanel: React.FC = ({ {/* ===== 10. 수정 버튼 설정 ===== */} {tab.showEdit && ( -
- +
+

수정 버튼 설정

@@ -1125,8 +1033,8 @@ const AdditionalTabConfigPanel: React.FC = ({ {/* ===== 11. 삭제 버튼 설정 ===== */} {tab.showDelete && ( -
- +
+

삭제 버튼 설정

@@ -1196,7 +1104,7 @@ const AdditionalTabConfigPanel: React.FC = ({ 탭 삭제
-
+ ); From 4e12f93da4bbc02dd9010ae6a0fc2268837665f0 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Wed, 11 Feb 2026 17:45:43 +0900 Subject: [PATCH 2/3] feat: Enhance SplitPanelLayoutComponent with delete modal improvements - Added a new state to manage the table name for the delete modal, allowing for more specific deletion handling based on the context of the item being deleted. - Updated the delete button handler to accept an optional table name parameter, improving the flexibility of the delete functionality. - Enhanced the delete confirmation logic to prioritize the specified table name when available, ensuring accurate deletion operations. - Refactored related logic to maintain clarity and improve the overall user experience during item deletion in the split panel layout. --- .../SplitPanelLayoutComponent.tsx | 50 ++++++++++++------- 1 file changed, 32 insertions(+), 18 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 f47da1aa..9a952bc0 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -462,6 +462,7 @@ export const SplitPanelLayoutComponent: React.FC const [showDeleteModal, setShowDeleteModal] = useState(false); const [deleteModalPanel, setDeleteModalPanel] = useState<"left" | "right" | null>(null); const [deleteModalItem, setDeleteModalItem] = useState(null); + const [deleteModalTableName, setDeleteModalTableName] = useState(null); // 추가 탭 삭제 시 테이블명 // 리사이저 드래그 상태 const [isDragging, setIsDragging] = useState(false); @@ -2197,32 +2198,39 @@ export const SplitPanelLayoutComponent: React.FC loadRightData, ]); - // 삭제 버튼 핸들러 - const handleDeleteClick = useCallback((panel: "left" | "right", item: any) => { + // 삭제 버튼 핸들러 (tableName: 추가 탭 등 특정 테이블 지정 시 사용) + const handleDeleteClick = useCallback((panel: "left" | "right", item: any, tableName?: string) => { setDeleteModalPanel(panel); setDeleteModalItem(item); + setDeleteModalTableName(tableName || null); setShowDeleteModal(true); }, []); // 삭제 확인 const handleDeleteConfirm = useCallback(async () => { - // 우측 패널 삭제 시 중계 테이블 확인 - let tableName = - deleteModalPanel === "left" ? componentConfig.leftPanel?.tableName : componentConfig.rightPanel?.tableName; + // 1. 테이블명 결정: deleteModalTableName이 있으면 우선 사용 (추가 탭 등) + let tableName = deleteModalTableName; - // 우측 패널 + 중계 테이블 모드인 경우 - if (deleteModalPanel === "right" && componentConfig.rightPanel?.addConfig?.targetTable) { - tableName = componentConfig.rightPanel.addConfig.targetTable; - console.log("🔗 중계 테이블 모드: 삭제 대상 테이블 =", tableName); + if (!tableName) { + tableName = + deleteModalPanel === "left" ? componentConfig.leftPanel?.tableName : componentConfig.rightPanel?.tableName; + + // 우측 패널 + 중계 테이블 모드인 경우 + if (deleteModalPanel === "right" && componentConfig.rightPanel?.addConfig?.targetTable) { + tableName = componentConfig.rightPanel.addConfig.targetTable; + console.log("🔗 중계 테이블 모드: 삭제 대상 테이블 =", tableName); + } } - const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id"; - let primaryKey: any = deleteModalItem[sourceColumn] || deleteModalItem.id || deleteModalItem.ID; + // 2. Primary Key 추출: id 필드를 우선 사용, 없으면 전체 객체 전달 (복합키) + let primaryKey: any = deleteModalItem?.id || deleteModalItem?.ID; - // 복합키 처리: deleteModalItem 전체를 전달 (백엔드에서 복합키 자동 처리) - if (deleteModalItem && typeof deleteModalItem === "object") { + if (!primaryKey && deleteModalItem && typeof deleteModalItem === "object") { + // id가 없는 경우에만 전체 객체 전달 (복합키 테이블) primaryKey = deleteModalItem; - console.log("🔑 복합키 가능성: 전체 객체 전달", primaryKey); + console.log("🔑 복합키: 전체 객체 전달", Object.keys(primaryKey)); + } else { + console.log("🔑 단일키 삭제: id =", primaryKey, "테이블 =", tableName); } if (!tableName || !primaryKey) { @@ -2290,6 +2298,7 @@ export const SplitPanelLayoutComponent: React.FC // 모달 닫기 setShowDeleteModal(false); setDeleteModalItem(null); + setDeleteModalTableName(null); // 데이터 새로고침 if (deleteModalPanel === "left") { @@ -2300,7 +2309,12 @@ export const SplitPanelLayoutComponent: React.FC setRightData(null); } } else if (deleteModalPanel === "right") { - loadRightData(selectedLeftItem); + // 추가 탭에서 삭제한 경우 해당 탭 데이터 리로드 + if (deleteModalTableName && activeTabIndex > 0) { + loadTabData(activeTabIndex, selectedLeftItem); + } else { + loadRightData(selectedLeftItem); + } } } else { toast({ @@ -2324,7 +2338,7 @@ export const SplitPanelLayoutComponent: React.FC variant: "destructive", }); } - }, [deleteModalPanel, componentConfig, deleteModalItem, toast, selectedLeftItem, loadLeftData, loadRightData]); + }, [deleteModalPanel, deleteModalTableName, componentConfig, deleteModalItem, toast, selectedLeftItem, loadLeftData, loadRightData, loadTabData, activeTabIndex]); // 항목별 추가 버튼 핸들러 (좌측 항목의 + 버튼 - 하위 항목 추가) const handleItemAddClick = useCallback( @@ -3541,7 +3555,7 @@ export const SplitPanelLayoutComponent: React.FC )} {currentTabConfig?.showDelete && ( @@ -3585,7 +3599,7 @@ export const SplitPanelLayoutComponent: React.FC )} {currentTabConfig?.showDelete && ( From 0512a3214c259c125d2ca47ee2cba0b39b4a726d Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Wed, 11 Feb 2026 18:05:32 +0900 Subject: [PATCH 3/3] Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into gbpark-node --- .../src/services/nodeFlowExecutionService.ts | 85 ++++++- .../panels/properties/ConditionProperties.tsx | 209 +++++++++++++++++- frontend/types/node-editor.ts | 12 +- 3 files changed, 297 insertions(+), 9 deletions(-) diff --git a/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts index 9bc59d97..a2a8aef1 100644 --- a/backend-node/src/services/nodeFlowExecutionService.ts +++ b/backend-node/src/services/nodeFlowExecutionService.ts @@ -2830,12 +2830,12 @@ export class NodeFlowExecutionService { inputData: any, context: ExecutionContext ): Promise { - const { conditions, logic } = node.data; + const { conditions, logic, targetLookup } = node.data; logger.info( `🔍 조건 노드 실행 - inputData 타입: ${typeof inputData}, 배열 여부: ${Array.isArray(inputData)}, 길이: ${Array.isArray(inputData) ? inputData.length : "N/A"}` ); - logger.info(`🔍 조건 개수: ${conditions?.length || 0}, 로직: ${logic}`); + logger.info(`🔍 조건 개수: ${conditions?.length || 0}, 로직: ${logic}, 타겟조회: ${targetLookup ? targetLookup.tableName : "없음"}`); if (inputData) { console.log( @@ -2865,6 +2865,9 @@ export class NodeFlowExecutionService { // 배열의 각 항목에 대해 조건 평가 (EXISTS 조건은 비동기) for (const item of inputData) { + // 타겟 테이블 조회 (DB 기존값 비교용) + const targetRow = await this.lookupTargetRow(targetLookup, item, context); + const results: boolean[] = []; for (const condition of conditions) { @@ -2887,9 +2890,14 @@ export class NodeFlowExecutionService { `🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}` ); } else { - // 일반 연산자 처리 + // 비교값 결정: static(고정값) / field(같은 데이터 내 필드) / target(DB 기존값) let compareValue = condition.value; - if (condition.valueType === "field") { + if (condition.valueType === "target" && targetRow) { + compareValue = targetRow[condition.value]; + logger.info( + `🎯 타겟(DB) 비교: ${condition.field} (${fieldValue}) vs DB.${condition.value} (${compareValue})` + ); + } else if (condition.valueType === "field") { compareValue = item[condition.value]; logger.info( `🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})` @@ -2931,6 +2939,9 @@ export class NodeFlowExecutionService { } // 단일 객체인 경우 + // 타겟 테이블 조회 (DB 기존값 비교용) + const targetRow = await this.lookupTargetRow(targetLookup, inputData, context); + const results: boolean[] = []; for (const condition of conditions) { @@ -2953,9 +2964,14 @@ export class NodeFlowExecutionService { `🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}` ); } else { - // 일반 연산자 처리 + // 비교값 결정: static(고정값) / field(같은 데이터 내 필드) / target(DB 기존값) let compareValue = condition.value; - if (condition.valueType === "field") { + if (condition.valueType === "target" && targetRow) { + compareValue = targetRow[condition.value]; + logger.info( + `🎯 타겟(DB) 비교: ${condition.field} (${fieldValue}) vs DB.${condition.value} (${compareValue})` + ); + } else if (condition.valueType === "field") { compareValue = inputData[condition.value]; logger.info( `🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})` @@ -2990,6 +3006,63 @@ export class NodeFlowExecutionService { }; } + /** + * 조건 노드의 타겟 테이블 조회 (DB 기존값 비교용) + * targetLookup 설정이 있을 때, 소스 데이터의 키값으로 DB에서 기존 레코드를 조회 + */ + private static async lookupTargetRow( + targetLookup: any, + sourceRow: any, + context: ExecutionContext + ): Promise { + if (!targetLookup?.tableName || !targetLookup?.lookupKeys?.length) { + return null; + } + + try { + const whereConditions = targetLookup.lookupKeys + .map((key: any, idx: number) => `"${key.targetField}" = $${idx + 1}`) + .join(" AND "); + + const lookupValues = targetLookup.lookupKeys.map( + (key: any) => sourceRow[key.sourceField] + ); + + // 키값이 비어있으면 조회 불필요 + if (lookupValues.some((v: any) => v === null || v === undefined || v === "")) { + logger.info(`⚠️ 조건 노드 타겟 조회: 키값이 비어있어 스킵`); + return null; + } + + // company_code 필터링 (멀티테넌시) + const companyCode = context.buttonContext?.companyCode || sourceRow.company_code; + let sql = `SELECT * FROM "${targetLookup.tableName}" WHERE ${whereConditions}`; + const params = [...lookupValues]; + + if (companyCode && companyCode !== "*") { + sql += ` AND company_code = $${params.length + 1}`; + params.push(companyCode); + } + + sql += " LIMIT 1"; + + logger.info(`🎯 조건 노드 타겟 조회: ${targetLookup.tableName}, 조건: ${whereConditions}, 값: ${JSON.stringify(lookupValues)}`); + + const targetRow = await queryOne(sql, params); + + if (targetRow) { + logger.info(`🎯 타겟 데이터 조회 성공`); + } else { + logger.info(`🎯 타겟 데이터 없음 (신규 레코드)`); + } + + return targetRow; + } catch (error: any) { + logger.warn(`⚠️ 조건 노드 타겟 조회 실패: ${error.message}`); + return null; + } + } + /** * EXISTS_IN / NOT_EXISTS_IN 조건 평가 * 다른 테이블에 값이 존재하는지 확인 diff --git a/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx index a2d060d4..76354925 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx @@ -251,6 +251,14 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) const [logic, setLogic] = useState<"AND" | "OR">(data.logic || "AND"); const [availableFields, setAvailableFields] = useState([]); + // 타겟 조회 설정 (DB 기존값 비교용) + const [targetLookup, setTargetLookup] = useState<{ + tableName: string; + tableLabel?: string; + lookupKeys: Array<{ sourceField: string; targetField: string; sourceFieldLabel?: string }>; + } | undefined>(data.targetLookup); + const [targetLookupColumns, setTargetLookupColumns] = useState([]); + // EXISTS 연산자용 상태 const [allTables, setAllTables] = useState([]); const [tableColumnsCache, setTableColumnsCache] = useState>({}); @@ -262,8 +270,20 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) setDisplayName(data.displayName || "조건 분기"); setConditions(data.conditions || []); setLogic(data.logic || "AND"); + setTargetLookup(data.targetLookup); }, [data]); + // targetLookup 테이블 변경 시 컬럼 목록 로드 + useEffect(() => { + if (targetLookup?.tableName) { + loadTableColumns(targetLookup.tableName).then((cols) => { + setTargetLookupColumns(cols); + }); + } else { + setTargetLookupColumns([]); + } + }, [targetLookup?.tableName]); + // 전체 테이블 목록 로드 (EXISTS 연산자용) useEffect(() => { const loadAllTables = async () => { @@ -559,6 +579,47 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) }); }; + // 타겟 조회 테이블 변경 + const handleTargetLookupTableChange = async (tableName: string) => { + await ensureTablesLoaded(); + const tableInfo = allTables.find((t) => t.tableName === tableName); + const newLookup = { + tableName, + tableLabel: tableInfo?.tableLabel || tableName, + lookupKeys: targetLookup?.lookupKeys || [], + }; + setTargetLookup(newLookup); + updateNode(nodeId, { targetLookup: newLookup }); + + // 컬럼 로드 + const cols = await loadTableColumns(tableName); + setTargetLookupColumns(cols); + }; + + // 타겟 조회 키 필드 변경 + const handleTargetLookupKeyChange = (sourceField: string, targetField: string) => { + if (!targetLookup) return; + const sourceFieldInfo = availableFields.find((f) => f.name === sourceField); + const newLookup = { + ...targetLookup, + lookupKeys: [{ sourceField, targetField, sourceFieldLabel: sourceFieldInfo?.label || sourceField }], + }; + setTargetLookup(newLookup); + updateNode(nodeId, { targetLookup: newLookup }); + }; + + // 타겟 조회 제거 + const handleRemoveTargetLookup = () => { + setTargetLookup(undefined); + updateNode(nodeId, { targetLookup: undefined }); + // target 타입 조건들을 field로 변경 + const newConditions = conditions.map((c) => + (c as any).valueType === "target" ? { ...c, valueType: "field" } : c + ); + setConditions(newConditions); + updateNode(nodeId, { conditions: newConditions }); + }; + return (
@@ -597,6 +658,119 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
+ {/* 타겟 조회 (DB 기존값 비교) */} +
+
+

+ + 타겟 조회 (DB 기존값) +

+
+ + {!targetLookup ? ( +
+
+ DB의 기존값과 비교하려면 타겟 테이블을 설정하세요. +
+ +
+ ) : ( +
+
+ 타겟 테이블 + +
+ + {/* 테이블 선택 */} + {allTables.length > 0 ? ( + + ) : ( +
+ 테이블 로딩 중... +
+ )} + + {/* 키 필드 매핑 */} + {targetLookup.tableName && ( +
+ +
+ + = + {targetLookupColumns.length > 0 ? ( + + ) : ( +
+ 컬럼 로딩 중... +
+ )} +
+
+ 비교 값 타입에서 "타겟 필드 (DB 기존값)"을 선택하면 이 테이블의 기존값과 비교합니다. +
+
+ )} +
+ )} +
+ {/* 조건식 */}
@@ -738,15 +912,46 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) 고정값 필드 참조 + {targetLookup?.tableName && ( + 타겟 필드 (DB 기존값) + )}
- {(condition as any).valueType === "field" ? ( + {(condition as any).valueType === "target" ? ( + // 타겟 필드 (DB 기존값): 타겟 테이블 컬럼에서 선택 + targetLookupColumns.length > 0 ? ( + + ) : ( +
+ 타겟 조회를 먼저 설정하세요 +
+ ) + ) : (condition as any).valueType === "field" ? ( // 필드 참조: 드롭다운으로 선택 availableFields.length > 0 ? (