From bc66f3bba1b92c62e5de44f46cee48547ca58dd7 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 4 Dec 2025 18:26:35 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B1=B0=EB=9E=98=EC=B2=98=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/entityJoinController.ts | 15 + .../src/services/tableManagementService.ts | 46 +++ frontend/lib/api/entityJoin.ts | 9 + .../button-primary/ButtonPrimaryComponent.tsx | 26 +- .../SplitPanelLayoutComponent.tsx | 112 ++++-- .../table-list/TableListComponent.tsx | 56 +++ .../table-list/TableListConfigPanel.tsx | 335 ++++++++++++++++++ .../registry/components/table-list/types.ts | 18 + frontend/lib/utils/buttonActions.ts | 19 + ..._임베딩_및_데이터_전달_시스템_구현_계획서.md | 1 + 화면_임베딩_시스템_Phase1-4_구현_완료.md | 1 + 화면_임베딩_시스템_충돌_분석_보고서.md | 1 + 12 files changed, 600 insertions(+), 39 deletions(-) diff --git a/backend-node/src/controllers/entityJoinController.ts b/backend-node/src/controllers/entityJoinController.ts index 66e20ccd..00727f1d 100644 --- a/backend-node/src/controllers/entityJoinController.ts +++ b/backend-node/src/controllers/entityJoinController.ts @@ -29,6 +29,7 @@ export class EntityJoinController { screenEntityConfigs, // 화면별 엔티티 설정 (JSON 문자열) autoFilter, // 🔒 멀티테넌시 자동 필터 dataFilter, // 🆕 데이터 필터 (JSON 문자열) + excludeFilter, // 🆕 제외 필터 (JSON 문자열) - 다른 테이블에 이미 존재하는 데이터 제외 userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함 ...otherParams } = req.query; @@ -125,6 +126,19 @@ export class EntityJoinController { } } + // 🆕 제외 필터 처리 (다른 테이블에 이미 존재하는 데이터 제외) + let parsedExcludeFilter: any = undefined; + if (excludeFilter) { + try { + parsedExcludeFilter = + typeof excludeFilter === "string" ? JSON.parse(excludeFilter) : excludeFilter; + logger.info("제외 필터 파싱 완료:", parsedExcludeFilter); + } catch (error) { + logger.warn("제외 필터 파싱 오류:", error); + parsedExcludeFilter = undefined; + } + } + const result = await tableManagementService.getTableDataWithEntityJoins( tableName, { @@ -141,6 +155,7 @@ export class EntityJoinController { additionalJoinColumns: parsedAdditionalJoinColumns, screenEntityConfigs: parsedScreenEntityConfigs, dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달 + excludeFilter: parsedExcludeFilter, // 🆕 제외 필터 전달 } ); diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 8e01903b..781a9498 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -2462,6 +2462,14 @@ export class TableManagementService { }>; screenEntityConfigs?: Record; // 화면별 엔티티 설정 dataFilter?: any; // 🆕 데이터 필터 + excludeFilter?: { + enabled: boolean; + referenceTable: string; + referenceColumn: string; + sourceColumn: string; + filterColumn?: string; + filterValue?: any; + }; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외) } ): Promise { const startTime = Date.now(); @@ -2716,6 +2724,44 @@ export class TableManagementService { } } + // 🆕 제외 필터 적용 (다른 테이블에 이미 존재하는 데이터 제외) + if (options.excludeFilter && options.excludeFilter.enabled) { + const { + referenceTable, + referenceColumn, + sourceColumn, + filterColumn, + filterValue, + } = options.excludeFilter; + + if (referenceTable && referenceColumn && sourceColumn) { + // 서브쿼리로 이미 존재하는 데이터 제외 + let excludeSubquery = `main."${sourceColumn}" NOT IN ( + SELECT "${referenceColumn}" FROM "${referenceTable}" + WHERE "${referenceColumn}" IS NOT NULL`; + + // 추가 필터 조건이 있으면 적용 (예: 특정 거래처의 품목만 제외) + if (filterColumn && filterValue !== undefined && filterValue !== null) { + excludeSubquery += ` AND "${filterColumn}" = '${String(filterValue).replace(/'/g, "''")}'`; + } + + excludeSubquery += ")"; + + whereClause = whereClause + ? `${whereClause} AND ${excludeSubquery}` + : excludeSubquery; + + logger.info(`🚫 제외 필터 적용 (Entity 조인):`, { + referenceTable, + referenceColumn, + sourceColumn, + filterColumn, + filterValue, + excludeSubquery, + }); + } + } + // ORDER BY 절 구성 const orderBy = options.sortBy ? `main.${options.sortBy} ${options.sortOrder === "desc" ? "DESC" : "ASC"}` diff --git a/frontend/lib/api/entityJoin.ts b/frontend/lib/api/entityJoin.ts index a84f3355..a3206df9 100644 --- a/frontend/lib/api/entityJoin.ts +++ b/frontend/lib/api/entityJoin.ts @@ -69,6 +69,14 @@ export const entityJoinApi = { }>; screenEntityConfigs?: Record; // 🎯 화면별 엔티티 설정 dataFilter?: any; // 🆕 데이터 필터 + excludeFilter?: { + enabled: boolean; + referenceTable: string; + referenceColumn: string; + sourceColumn: string; + filterColumn?: string; + filterValue?: any; + }; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외) } = {}, ): Promise => { // 🔒 멀티테넌시: company_code 자동 필터링 활성화 @@ -90,6 +98,7 @@ export const entityJoinApi = { screenEntityConfigs: params.screenEntityConfigs ? JSON.stringify(params.screenEntityConfigs) : undefined, // 🎯 화면별 엔티티 설정 autoFilter: JSON.stringify(autoFilter), // 🔒 멀티테넌시 필터링 dataFilter: params.dataFilter ? JSON.stringify(params.dataFilter) : undefined, // 🆕 데이터 필터 + excludeFilter: params.excludeFilter ? JSON.stringify(params.excludeFilter) : undefined, // 🆕 제외 필터 }, }); return response.data.data; diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index 0bf8bea2..5816940a 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -663,9 +663,29 @@ export const ButtonPrimaryComponent: React.FC = ({ return; } + // 🆕 modalDataStore에서 선택된 데이터 가져오기 (분할 패널 등에서 선택한 데이터) + let effectiveSelectedRowsData = selectedRowsData; + if ((!selectedRowsData || selectedRowsData.length === 0) && effectiveTableName) { + try { + const { useModalDataStore } = await import("@/stores/modalDataStore"); + const dataRegistry = useModalDataStore.getState().dataRegistry; + const modalData = dataRegistry[effectiveTableName]; + if (modalData && modalData.length > 0) { + effectiveSelectedRowsData = modalData; + console.log("🔗 [ButtonPrimaryComponent] modalDataStore에서 선택된 데이터 가져옴:", { + tableName: effectiveTableName, + count: modalData.length, + data: modalData, + }); + } + } catch (error) { + console.warn("modalDataStore 접근 실패:", error); + } + } + // 삭제 액션인데 선택된 데이터가 없으면 경고 메시지 표시하고 중단 const hasDataToDelete = - (selectedRowsData && selectedRowsData.length > 0) || (flowSelectedData && flowSelectedData.length > 0); + (effectiveSelectedRowsData && effectiveSelectedRowsData.length > 0) || (flowSelectedData && flowSelectedData.length > 0); if (processedConfig.action.type === "delete" && !hasDataToDelete) { toast.warning("삭제할 항목을 먼저 선택해주세요."); @@ -724,9 +744,9 @@ export const ButtonPrimaryComponent: React.FC = ({ onClose, onFlowRefresh, // 플로우 새로고침 콜백 추가 onSave: finalOnSave, // 🆕 EditModal의 handleSave 콜백 (props에서도 추출) - // 테이블 선택된 행 정보 추가 + // 테이블 선택된 행 정보 추가 (modalDataStore에서 가져온 데이터 우선) selectedRows, - selectedRowsData, + selectedRowsData: effectiveSelectedRowsData, // 테이블 정렬 정보 추가 sortBy, // 🆕 정렬 컬럼 sortOrder, // 🆕 정렬 방향 diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index fdaddfc3..ac44eded 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -293,8 +293,17 @@ export const SplitPanelLayoutComponent: React.FC ) => { if (value === null || value === undefined) return "-"; - // 카테고리 매핑이 있는지 확인 - const mapping = categoryMappings[columnName]; + // 🆕 카테고리 매핑 찾기 (여러 키 형태 시도) + // 1. 전체 컬럼명 (예: "item_info.material") + // 2. 컬럼명만 (예: "material") + let mapping = categoryMappings[columnName]; + + if (!mapping && columnName.includes(".")) { + // 조인된 컬럼의 경우 컬럼명만으로 다시 시도 + const simpleColumnName = columnName.split(".").pop() || columnName; + mapping = categoryMappings[simpleColumnName]; + } + if (mapping && mapping[String(value)]) { const categoryData = mapping[String(value)]; const displayLabel = categoryData.label || String(value); @@ -690,43 +699,69 @@ export const SplitPanelLayoutComponent: React.FC loadLeftCategoryMappings(); }, [componentConfig.leftPanel?.tableName, isDesignMode]); - // 우측 테이블 카테고리 매핑 로드 + // 우측 테이블 카테고리 매핑 로드 (조인된 테이블 포함) useEffect(() => { const loadRightCategoryMappings = async () => { const rightTableName = componentConfig.rightPanel?.tableName; if (!rightTableName || isDesignMode) return; try { - // 1. 컬럼 메타 정보 조회 - const columnsResponse = await tableTypeApi.getColumns(rightTableName); - const categoryColumns = columnsResponse.filter((col: any) => col.inputType === "category"); - - if (categoryColumns.length === 0) { - setRightCategoryMappings({}); - return; - } - - // 2. 각 카테고리 컬럼에 대한 값 조회 const mappings: Record> = {}; - for (const col of categoryColumns) { - const columnName = col.columnName || col.column_name; - try { - const response = await apiClient.get(`/table-categories/${rightTableName}/${columnName}/values`); + // 🆕 우측 패널 컬럼 설정에서 조인된 테이블 추출 + const rightColumns = componentConfig.rightPanel?.columns || []; + const tablesToLoad = new Set([rightTableName]); + + // 컬럼명에서 테이블명 추출 (예: "item_info.material" -> "item_info") + rightColumns.forEach((col: any) => { + const colName = col.name || col.columnName; + if (colName && colName.includes(".")) { + const joinTableName = colName.split(".")[0]; + tablesToLoad.add(joinTableName); + } + }); - if (response.data.success && response.data.data) { - const valueMap: Record = {}; - response.data.data.forEach((item: any) => { - valueMap[item.value_code || item.valueCode] = { - label: item.value_label || item.valueLabel, - color: item.color, - }; - }); - mappings[columnName] = valueMap; - console.log(`✅ 우측 카테고리 매핑 로드 [${columnName}]:`, valueMap); + console.log("🔍 우측 패널 카테고리 로드 대상 테이블:", Array.from(tablesToLoad)); + + // 각 테이블에 대해 카테고리 매핑 로드 + for (const tableName of tablesToLoad) { + try { + // 1. 컬럼 메타 정보 조회 + const columnsResponse = await tableTypeApi.getColumns(tableName); + const categoryColumns = columnsResponse.filter((col: any) => col.inputType === "category"); + + // 2. 각 카테고리 컬럼에 대한 값 조회 + for (const col of categoryColumns) { + const columnName = col.columnName || col.column_name; + try { + const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`); + + if (response.data.success && response.data.data) { + const valueMap: Record = {}; + response.data.data.forEach((item: any) => { + valueMap[item.value_code || item.valueCode] = { + label: item.value_label || item.valueLabel, + color: item.color, + }; + }); + + // 조인된 테이블의 경우 "테이블명.컬럼명" 형태로 저장 + const mappingKey = tableName === rightTableName ? columnName : `${tableName}.${columnName}`; + mappings[mappingKey] = valueMap; + + // 🆕 컬럼명만으로도 접근할 수 있도록 추가 저장 (모든 테이블) + // 기존 매핑이 있으면 병합, 없으면 새로 생성 + mappings[columnName] = { ...(mappings[columnName] || {}), ...valueMap }; + + console.log(`✅ 우측 카테고리 매핑 로드 [${mappingKey}]:`, valueMap); + console.log(`✅ 우측 카테고리 매핑 (컬럼명만) [${columnName}]:`, mappings[columnName]); + } + } catch (error) { + console.error(`우측 카테고리 값 조회 실패 [${tableName}.${columnName}]:`, error); + } } } catch (error) { - console.error(`우측 카테고리 값 조회 실패 [${columnName}]:`, error); + console.error(`테이블 ${tableName} 컬럼 정보 조회 실패:`, error); } } @@ -737,7 +772,7 @@ export const SplitPanelLayoutComponent: React.FC }; loadRightCategoryMappings(); - }, [componentConfig.rightPanel?.tableName, isDesignMode]); + }, [componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.columns, isDesignMode]); // 항목 펼치기/접기 토글 const toggleExpand = useCallback((itemId: any) => { @@ -2149,9 +2184,12 @@ export const SplitPanelLayoutComponent: React.FC const format = colConfig?.format; const boldValue = colConfig?.bold ?? false; - // 숫자 포맷 적용 - let displayValue = String(value || "-"); - if (value !== null && value !== undefined && value !== "" && format) { + // 🆕 카테고리 매핑 적용 + const formattedValue = formatCellValue(key, value, rightCategoryMappings); + + // 숫자 포맷 적용 (카테고리가 아닌 경우만) + let displayValue: React.ReactNode = formattedValue; + if (typeof formattedValue === 'string' && value !== null && value !== undefined && value !== "" && format) { const numValue = typeof value === 'number' ? value : parseFloat(String(value)); if (!isNaN(numValue)) { displayValue = numValue.toLocaleString('ko-KR', { @@ -2175,7 +2213,6 @@ export const SplitPanelLayoutComponent: React.FC )} {displayValue} @@ -2240,9 +2277,12 @@ export const SplitPanelLayoutComponent: React.FC const colConfig = rightColumns?.find(c => c.name === key); const format = colConfig?.format; - // 숫자 포맷 적용 - let displayValue = String(value); - if (value !== null && value !== undefined && value !== "" && format) { + // 🆕 카테고리 매핑 적용 + const formattedValue = formatCellValue(key, value, rightCategoryMappings); + + // 숫자 포맷 적용 (카테고리가 아닌 경우만) + let displayValue: React.ReactNode = formattedValue; + if (typeof formattedValue === 'string' && value !== null && value !== undefined && value !== "" && format) { const numValue = typeof value === 'number' ? value : parseFloat(String(value)); if (!isNaN(numValue)) { displayValue = numValue.toLocaleString('ko-KR', { diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 6a1f01fe..4f78ed23 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -179,6 +179,7 @@ export const TableListComponent: React.FC = ({ config, className, style, + formData: propFormData, // 🆕 부모에서 전달받은 formData onFormDataChange, componentConfig, onSelectedRowsChange, @@ -1198,6 +1199,60 @@ export const TableListComponent: React.FC = ({ console.log("🎯 [TableList] 화면별 엔티티 설정:", screenEntityConfigs); + // 🆕 제외 필터 처리 (다른 테이블에 이미 존재하는 데이터 제외) + let excludeFilterParam: any = undefined; + if (tableConfig.excludeFilter?.enabled) { + const excludeConfig = tableConfig.excludeFilter; + let filterValue: any = undefined; + + // 필터 값 소스에 따라 값 가져오기 (우선순위: formData > URL > 분할패널) + if (excludeConfig.filterColumn && excludeConfig.filterValueField) { + const fieldName = excludeConfig.filterValueField; + + // 1순위: props로 전달받은 formData에서 값 가져오기 (모달에서 사용) + if (propFormData && propFormData[fieldName]) { + filterValue = propFormData[fieldName]; + console.log("🔗 [TableList] formData에서 excludeFilter 값 가져오기:", { + field: fieldName, + value: filterValue, + }); + } + // 2순위: URL 파라미터에서 값 가져오기 + else if (typeof window !== "undefined") { + const urlParams = new URLSearchParams(window.location.search); + filterValue = urlParams.get(fieldName); + if (filterValue) { + console.log("🔗 [TableList] URL에서 excludeFilter 값 가져오기:", { + field: fieldName, + value: filterValue, + }); + } + } + // 3순위: 분할 패널 부모 데이터에서 값 가져오기 + if (!filterValue && splitPanelContext?.selectedLeftData) { + filterValue = splitPanelContext.selectedLeftData[fieldName]; + if (filterValue) { + console.log("🔗 [TableList] 분할패널에서 excludeFilter 값 가져오기:", { + field: fieldName, + value: filterValue, + }); + } + } + } + + if (filterValue || !excludeConfig.filterColumn) { + excludeFilterParam = { + enabled: true, + referenceTable: excludeConfig.referenceTable, + referenceColumn: excludeConfig.referenceColumn, + sourceColumn: excludeConfig.sourceColumn, + filterColumn: excludeConfig.filterColumn, + filterValue: filterValue, + }; + console.log("🚫 [TableList] 제외 필터 적용:", excludeFilterParam); + } + } + // 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원) response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, { page, @@ -1209,6 +1264,7 @@ export const TableListComponent: React.FC = ({ additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined, screenEntityConfigs: Object.keys(screenEntityConfigs).length > 0 ? screenEntityConfigs : undefined, // 🎯 화면별 엔티티 설정 전달 dataFilter: tableConfig.dataFilter, // 🆕 데이터 필터 전달 + excludeFilter: excludeFilterParam, // 🆕 제외 필터 전달 }); // 실제 데이터의 item_number만 추출하여 중복 확인 diff --git a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx index 823424cc..209b3d2d 100644 --- a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx +++ b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx @@ -9,6 +9,7 @@ import { Badge } from "@/components/ui/badge"; import { TableListConfig, ColumnConfig } from "./types"; import { entityJoinApi } from "@/lib/api/entityJoin"; import { tableTypeApi } from "@/lib/api/screen"; +import { tableManagementApi } from "@/lib/api/tableManagement"; import { Plus, Trash2, ArrowUp, ArrowDown, ChevronsUpDown, Check } from "lucide-react"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; @@ -73,6 +74,12 @@ export const TableListConfigPanel: React.FC = ({ const [loadingEntityJoins, setLoadingEntityJoins] = useState(false); + // 🆕 제외 필터용 참조 테이블 컬럼 목록 + const [referenceTableColumns, setReferenceTableColumns] = useState< + Array<{ columnName: string; dataType: string; label?: string }> + >([]); + const [loadingReferenceColumns, setLoadingReferenceColumns] = useState(false); + // 🔄 외부에서 config가 변경될 때 내부 상태 동기화 (표의 페이지네이션 변경 감지) useEffect(() => { // console.log("🔄 TableListConfigPanel - 외부 config 변경 감지:", { @@ -237,6 +244,42 @@ export const TableListConfigPanel: React.FC = ({ fetchEntityJoinColumns(); }, [config.selectedTable, screenTableName]); + // 🆕 제외 필터용 참조 테이블 컬럼 가져오기 + useEffect(() => { + const fetchReferenceColumns = async () => { + const refTable = config.excludeFilter?.referenceTable; + if (!refTable) { + setReferenceTableColumns([]); + return; + } + + setLoadingReferenceColumns(true); + try { + console.log("🔗 참조 테이블 컬럼 정보 가져오기:", refTable); + const result = await tableManagementApi.getColumnList(refTable); + if (result.success && result.data) { + // result.data는 { columns: [], total, page, size, totalPages } 형태 + const columns = result.data.columns || []; + setReferenceTableColumns( + columns.map((col: any) => ({ + columnName: col.columnName || col.column_name, + dataType: col.dataType || col.data_type || "text", + label: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name, + })) + ); + console.log("✅ 참조 테이블 컬럼 로드 완료:", columns.length, "개"); + } + } catch (error) { + console.error("❌ 참조 테이블 컬럼 조회 오류:", error); + setReferenceTableColumns([]); + } finally { + setLoadingReferenceColumns(false); + } + }; + + fetchReferenceColumns(); + }, [config.excludeFilter?.referenceTable]); + // 🎯 엔티티 컬럼 자동 로드 useEffect(() => { const entityColumns = config.columns?.filter((col) => col.isEntityJoin && col.entityDisplayConfig); @@ -1333,6 +1376,298 @@ export const TableListConfigPanel: React.FC = ({

+ + {/* 🆕 제외 필터 설정 (다른 테이블에 이미 존재하는 데이터 제외) */} +
+
+

제외 필터

+

+ 다른 테이블에 이미 존재하는 데이터를 목록에서 제외합니다 +

+
+
+ + {/* 제외 필터 활성화 */} +
+ { + handleChange("excludeFilter", { + ...config.excludeFilter, + enabled: checked as boolean, + }); + }} + /> + +
+ + {config.excludeFilter?.enabled && ( +
+ {/* 참조 테이블 선택 */} +
+ + + + + + + + + + 테이블을 찾을 수 없습니다 + + {availableTables.map((table) => ( + { + handleChange("excludeFilter", { + ...config.excludeFilter, + referenceTable: table.tableName, + referenceColumn: undefined, + sourceColumn: undefined, + filterColumn: undefined, + filterValueField: undefined, + }); + }} + className="text-xs" + > + + {table.displayName || table.tableName} + + ))} + + + + + +
+ + {config.excludeFilter?.referenceTable && ( + <> + {/* 비교 컬럼 설정 - 한 줄에 두 개 */} +
+ {/* 참조 컬럼 (매핑 테이블) */} +
+ + + + + + + + + + 없음 + + {referenceTableColumns.map((col) => ( + { + handleChange("excludeFilter", { + ...config.excludeFilter, + referenceColumn: col.columnName, + }); + }} + className="text-xs" + > + + {col.label || col.columnName} + + ))} + + + + + +
+ + {/* 소스 컬럼 (현재 테이블) */} +
+ + + + + + + + + + 없음 + + {availableColumns.map((col) => ( + { + handleChange("excludeFilter", { + ...config.excludeFilter, + sourceColumn: col.columnName, + }); + }} + className="text-xs" + > + + {col.label || col.columnName} + + ))} + + + + + +
+
+ + {/* 조건 필터 - 특정 조건의 데이터만 제외 */} +
+ +

+ 특정 조건의 데이터만 제외하려면 설정하세요 (예: 특정 거래처의 품목만) +

+
+ {/* 필터 컬럼 (매핑 테이블) */} + + + + + + + + + 없음 + + { + handleChange("excludeFilter", { + ...config.excludeFilter, + filterColumn: undefined, + filterValueField: undefined, + }); + }} + className="text-xs text-muted-foreground" + > + + 사용 안함 + + {referenceTableColumns.map((col) => ( + { + // 필터 컬럼 선택 시 같은 이름의 필드를 자동으로 설정 + handleChange("excludeFilter", { + ...config.excludeFilter, + filterColumn: col.columnName, + filterValueField: col.columnName, // 같은 이름으로 자동 설정 + filterValueSource: "url", + }); + }} + className="text-xs" + > + + {col.label || col.columnName} + + ))} + + + + + + + {/* 필터 값 필드명 (부모 화면에서 전달받는 필드) */} + { + handleChange("excludeFilter", { + ...config.excludeFilter, + filterValueField: e.target.value, + }); + }} + disabled={!config.excludeFilter?.filterColumn} + className="h-8 text-xs" + /> +
+
+ + )} + + {/* 설정 요약 */} + {config.excludeFilter?.referenceTable && config.excludeFilter?.referenceColumn && config.excludeFilter?.sourceColumn && ( +
+ 설정 요약: {config.selectedTable || screenTableName}.{config.excludeFilter.sourceColumn} 가 + {" "}{config.excludeFilter.referenceTable}.{config.excludeFilter.referenceColumn} 에 + {config.excludeFilter.filterColumn && config.excludeFilter.filterValueField && ( + <> ({config.excludeFilter.filterColumn}=URL의 {config.excludeFilter.filterValueField}일 때) + )} + {" "}이미 있으면 제외 +
+ )} +
+ )} +
); diff --git a/frontend/lib/registry/components/table-list/types.ts b/frontend/lib/registry/components/table-list/types.ts index b69b9238..2475f58f 100644 --- a/frontend/lib/registry/components/table-list/types.ts +++ b/frontend/lib/registry/components/table-list/types.ts @@ -185,6 +185,21 @@ export interface LinkedFilterConfig { enabled?: boolean; // 활성화 여부 (기본: true) } +/** + * 제외 필터 설정 + * 다른 테이블에 이미 존재하는 데이터를 제외하고 표시 + * 예: 거래처에 이미 등록된 품목을 품목 선택 모달에서 제외 + */ +export interface ExcludeFilterConfig { + enabled: boolean; // 제외 필터 활성화 여부 + referenceTable: string; // 참조 테이블 (예: customer_item_mapping) + referenceColumn: string; // 참조 테이블의 비교 컬럼 (예: item_id) + sourceColumn: string; // 현재 테이블의 비교 컬럼 (예: item_number) + filterColumn?: string; // 참조 테이블의 필터 컬럼 (예: customer_id) + filterValueSource?: "url" | "formData" | "parentData"; // 필터 값 소스 (기본: url) + filterValueField?: string; // 필터 값 필드명 (예: customer_code) +} + /** * TableList 컴포넌트 설정 타입 */ @@ -249,6 +264,9 @@ export interface TableListConfig extends ComponentConfig { // 🆕 연결된 필터 (다른 컴포넌트 값으로 필터링) linkedFilters?: LinkedFilterConfig[]; + // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외) + excludeFilter?: ExcludeFilterConfig; + // 이벤트 핸들러 onRowClick?: (row: any) => void; onRowDoubleClick?: (row: any) => void; diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 522f8651..22e491f6 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -1236,8 +1236,13 @@ export class ButtonActionExecutor { } else { console.log("🔄 테이블 데이터 삭제 완료, 테이블 새로고침 호출"); context.onRefresh?.(); // 테이블 새로고침 + + // 🆕 분할 패널 등 전역 테이블 새로고침 이벤트 발생 + window.dispatchEvent(new CustomEvent("refreshTable")); + console.log("🔄 refreshTable 전역 이벤트 발생"); } + toast.success(config.successMessage || `${dataToDelete.length}개 항목이 삭제되었습니다.`); return true; } @@ -1258,6 +1263,12 @@ export class ButtonActionExecutor { } context.onRefresh?.(); + + // 🆕 분할 패널 등 전역 테이블 새로고침 이벤트 발생 + window.dispatchEvent(new CustomEvent("refreshTable")); + console.log("🔄 refreshTable 전역 이벤트 발생 (단일 삭제)"); + + toast.success(config.successMessage || "삭제되었습니다."); return true; } catch (error) { console.error("삭제 오류:", error); @@ -1536,6 +1547,13 @@ export class ButtonActionExecutor { } } + // 🆕 부모 화면의 선택된 데이터 가져오기 (excludeFilter에서 사용) + const parentData = dataRegistry[dataSourceId]?.[0]?.originalData || dataRegistry[dataSourceId]?.[0] || {}; + console.log("📦 [openModalWithData] 부모 데이터 전달:", { + dataSourceId, + parentData, + }); + // 🆕 전역 모달 상태 업데이트를 위한 이벤트 발생 (URL 파라미터 포함) const modalEvent = new CustomEvent("openScreenModal", { detail: { @@ -1544,6 +1562,7 @@ export class ButtonActionExecutor { description: description, size: config.modalSize || "lg", // 데이터 입력 화면은 기본 large urlParams: { dataSourceId }, // 🆕 주 데이터 소스만 전달 (나머지는 modalDataStore에서 자동으로 찾음) + splitPanelParentData: parentData, // 🆕 부모 데이터 전달 (excludeFilter에서 사용) }, }); diff --git a/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md b/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md index f8f84f1f..74d9d0ed 100644 --- a/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md +++ b/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md @@ -1679,3 +1679,4 @@ const 출고등록_설정: ScreenSplitPanel = { 화면 임베딩 및 데이터 전달 시스템은 복잡한 업무 워크플로우를 효율적으로 처리할 수 있는 강력한 기능입니다. 단계별로 체계적으로 구현하면 약 3.5개월 내에 완성할 수 있으며, 이를 통해 사용자 경험을 크게 향상시킬 수 있습니다. + diff --git a/화면_임베딩_시스템_Phase1-4_구현_완료.md b/화면_임베딩_시스템_Phase1-4_구현_완료.md index 2a3e16de..47526bb1 100644 --- a/화면_임베딩_시스템_Phase1-4_구현_완료.md +++ b/화면_임베딩_시스템_Phase1-4_구현_완료.md @@ -526,3 +526,4 @@ const { data: config } = await getScreenSplitPanel(screenId); 이제 입고 등록과 같은 복잡한 워크플로우를 구현할 수 있습니다. 다음 단계는 각 컴포넌트 타입별 DataReceivable 인터페이스 구현과 설정 UI 개발입니다. + diff --git a/화면_임베딩_시스템_충돌_분석_보고서.md b/화면_임베딩_시스템_충돌_분석_보고서.md index 7334e8b3..135d36d8 100644 --- a/화면_임베딩_시스템_충돌_분석_보고서.md +++ b/화면_임베딩_시스템_충돌_분석_보고서.md @@ -513,3 +513,4 @@ function ScreenViewPage() { 새로운 시스템은 기존 시스템과 **독립적으로 동작**하며, 최소한의 수정만으로 통합 가능합니다. 화면 페이지에 조건 분기만 추가하면 바로 사용할 수 있습니다. +