diff --git a/backend-node/src/services/entityJoinService.ts b/backend-node/src/services/entityJoinService.ts index 6877fedd..f1795ba0 100644 --- a/backend-node/src/services/entityJoinService.ts +++ b/backend-node/src/services/entityJoinService.ts @@ -24,20 +24,19 @@ export class EntityJoinService { try { logger.info(`Entity 컬럼 감지 시작: ${tableName}`); - // column_labels에서 entity 타입인 컬럼들 조회 + // column_labels에서 entity 및 category 타입인 컬럼들 조회 (input_type 사용) const entityColumns = await query<{ column_name: string; + input_type: string; reference_table: string; reference_column: string; display_column: string | null; }>( - `SELECT column_name, reference_table, reference_column, display_column + `SELECT column_name, input_type, reference_table, reference_column, display_column FROM column_labels WHERE table_name = $1 - AND web_type = $2 - AND reference_table IS NOT NULL - AND reference_column IS NOT NULL`, - [tableName, "entity"] + AND input_type IN ('entity', 'category')`, + [tableName] ); logger.info(`🔍 Entity 컬럼 조회 결과: ${entityColumns.length}개 발견`); @@ -77,18 +76,34 @@ export class EntityJoinService { } for (const column of entityColumns) { + // 카테고리 타입인 경우 자동으로 category_values 테이블 참조 설정 + let referenceTable = column.reference_table; + let referenceColumn = column.reference_column; + let displayColumn = column.display_column; + + if (column.input_type === 'category') { + // 카테고리 타입: reference 정보가 비어있어도 자동 설정 + referenceTable = referenceTable || 'table_column_category_values'; + referenceColumn = referenceColumn || 'value_code'; + displayColumn = displayColumn || 'value_label'; + + logger.info(`🏷️ 카테고리 타입 자동 설정: ${column.column_name}`, { + referenceTable, + referenceColumn, + displayColumn, + }); + } + logger.info(`🔍 Entity 컬럼 상세 정보:`, { column_name: column.column_name, - reference_table: column.reference_table, - reference_column: column.reference_column, - display_column: column.display_column, + input_type: column.input_type, + reference_table: referenceTable, + reference_column: referenceColumn, + display_column: displayColumn, }); - if ( - !column.column_name || - !column.reference_table || - !column.reference_column - ) { + if (!column.column_name || !referenceTable || !referenceColumn) { + logger.warn(`⚠️ 필수 정보 누락으로 스킵: ${column.column_name}`); continue; } @@ -112,27 +127,28 @@ export class EntityJoinService { separator, screenConfig, }); - } else if (column.display_column && column.display_column !== "none") { + } else if (displayColumn && displayColumn !== "none") { // 기존 설정된 단일 표시 컬럼 사용 (none이 아닌 경우만) - displayColumns = [column.display_column]; + displayColumns = [displayColumn]; logger.info( - `🔧 기존 display_column 사용: ${column.column_name} → ${column.display_column}` + `🔧 기존 display_column 사용: ${column.column_name} → ${displayColumn}` ); } else { // display_column이 "none"이거나 없는 경우 기본 표시 컬럼 설정 - // 🚨 display_column이 항상 "none"이므로 이 로직을 기본으로 사용 - let defaultDisplayColumn = column.reference_column; - if (column.reference_table === "dept_info") { + let defaultDisplayColumn = referenceColumn; + if (referenceTable === "dept_info") { defaultDisplayColumn = "dept_name"; - } else if (column.reference_table === "company_info") { + } else if (referenceTable === "company_info") { defaultDisplayColumn = "company_name"; - } else if (column.reference_table === "user_info") { + } else if (referenceTable === "user_info") { defaultDisplayColumn = "user_name"; + } else if (referenceTable === "category_values") { + defaultDisplayColumn = "category_name"; } displayColumns = [defaultDisplayColumn]; logger.info( - `🔧 Entity 조인 기본 표시 컬럼 설정: ${column.column_name} → ${defaultDisplayColumn} (${column.reference_table})` + `🔧 Entity 조인 기본 표시 컬럼 설정: ${column.column_name} → ${defaultDisplayColumn} (${referenceTable})` ); logger.info(`🔍 생성된 displayColumns 배열:`, displayColumns); } @@ -143,8 +159,8 @@ export class EntityJoinService { const joinConfig: EntityJoinConfig = { sourceTable: tableName, sourceColumn: column.column_name, - referenceTable: column.reference_table, - referenceColumn: column.reference_column, + referenceTable: referenceTable, // 카테고리의 경우 자동 설정된 값 사용 + referenceColumn: referenceColumn, // 카테고리의 경우 자동 설정된 값 사용 displayColumns: displayColumns, displayColumn: displayColumns[0], // 하위 호환성 aliasColumn: aliasColumn, @@ -245,11 +261,14 @@ export class EntityJoinService { config.displayColumn, ]; const separator = config.separator || " - "; + + // 결과 컬럼 배열 (aliasColumn + _label 필드) + const resultColumns: string[] = []; if (displayColumns.length === 0 || !displayColumns[0]) { // displayColumns가 빈 배열이거나 첫 번째 값이 null/undefined인 경우 // 조인 테이블의 referenceColumn을 기본값으로 사용 - return `COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}`; + resultColumns.push(`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}`); } else if (displayColumns.length === 1) { // 단일 컬럼인 경우 const col = displayColumns[0]; @@ -265,12 +284,18 @@ export class EntityJoinService { "company_name", "sales_yn", "status", + "value_label", // table_column_category_values + "user_name", // user_info ].includes(col); if (isJoinTableColumn) { - return `COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}`; + resultColumns.push(`COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}`); + + // _label 필드도 함께 SELECT (프론트엔드 getColumnUniqueValues용) + // sourceColumn_label 형식으로 추가 + resultColumns.push(`COALESCE(${alias}.${col}::TEXT, '') AS ${config.sourceColumn}_label`); } else { - return `COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`; + resultColumns.push(`COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`); } } else { // 여러 컬럼인 경우 CONCAT으로 연결 @@ -291,6 +316,8 @@ export class EntityJoinService { "company_name", "sales_yn", "status", + "value_label", // table_column_category_values + "user_name", // user_info ].includes(col); if (isJoinTableColumn) { @@ -303,8 +330,11 @@ export class EntityJoinService { }) .join(` || '${separator}' || `); - return `(${concatParts}) AS ${config.aliasColumn}`; + resultColumns.push(`(${concatParts}) AS ${config.aliasColumn}`); } + + // 모든 resultColumns를 반환 + return resultColumns.join(", "); }) .join(", "); @@ -320,6 +350,12 @@ export class EntityJoinService { const joinClauses = uniqueReferenceTableConfigs .map((config) => { const alias = aliasMap.get(config.referenceTable); + + // table_column_category_values는 특별한 조인 조건 필요 + if (config.referenceTable === 'table_column_category_values') { + return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}'`; + } + return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`; }) .join("\n"); @@ -380,6 +416,14 @@ export class EntityJoinService { return "join"; } + // table_column_category_values는 특수 조인 조건이 필요하므로 캐시 불가 + if (config.referenceTable === 'table_column_category_values') { + logger.info( + `🎯 table_column_category_values는 캐시 전략 불가: ${config.sourceColumn}` + ); + return "join"; + } + // 참조 테이블의 캐시 가능성 확인 const displayCol = config.displayColumn || diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index b45a0424..fd2e82a7 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -1494,6 +1494,7 @@ export class TableManagementService { search?: Record; sortBy?: string; sortOrder?: string; + companyCode?: string; } ): Promise<{ data: any[]; @@ -1503,7 +1504,7 @@ export class TableManagementService { totalPages: number; }> { try { - const { page, size, search = {}, sortBy, sortOrder = "asc" } = options; + const { page, size, search = {}, sortBy, sortOrder = "asc", companyCode } = options; const offset = (page - 1) * size; logger.info(`테이블 데이터 조회: ${tableName}`, options); @@ -1517,6 +1518,14 @@ export class TableManagementService { let searchValues: any[] = []; let paramIndex = 1; + // 멀티테넌시 필터 추가 (company_code) + if (companyCode) { + whereConditions.push(`company_code = $${paramIndex}`); + searchValues.push(companyCode); + paramIndex++; + logger.info(`🔒 멀티테넌시 필터 추가 (기본 조회): company_code = ${companyCode}`); + } + if (search && Object.keys(search).length > 0) { for (const [column, value] of Object.entries(search)) { if (value !== null && value !== undefined && value !== "") { @@ -2213,11 +2222,20 @@ export class TableManagementService { const selectColumns = columns.data.map((col: any) => col.column_name); // WHERE 절 구성 - const whereClause = await this.buildWhereClause( + let whereClause = await this.buildWhereClause( tableName, options.search ); + // 멀티테넌시 필터 추가 (company_code) + if (options.companyCode) { + const companyFilter = `main.company_code = '${options.companyCode.replace(/'/g, "''")}'`; + whereClause = whereClause + ? `${whereClause} AND ${companyFilter}` + : companyFilter; + logger.info(`🔒 멀티테넌시 필터 추가 (Entity 조인): company_code = ${options.companyCode}`); + } + // ORDER BY 절 구성 const orderBy = options.sortBy ? `main.${options.sortBy} ${options.sortOrder === "desc" ? "DESC" : "ASC"}` @@ -2343,6 +2361,7 @@ export class TableManagementService { search?: Record; sortBy?: string; sortOrder?: string; + companyCode?: string; }, startTime: number ): Promise { @@ -2530,11 +2549,11 @@ export class TableManagementService { ); } - basicResult = await this.getTableData(tableName, fallbackOptions); + basicResult = await this.getTableData(tableName, { ...fallbackOptions, companyCode: options.companyCode }); } } else { // Entity 조인 컬럼 검색이 없는 경우 기존 캐시 방식 사용 - basicResult = await this.getTableData(tableName, options); + basicResult = await this.getTableData(tableName, { ...options, companyCode: options.companyCode }); } // Entity 값들을 캐시에서 룩업하여 변환 @@ -2807,10 +2826,14 @@ export class TableManagementService { } // 모든 조인이 캐시 가능한 경우: 기본 쿼리 + 캐시 룩업 else { + // whereClause에서 company_code 추출 (멀티테넌시 필터) + const companyCodeMatch = whereClause.match(/main\.company_code\s*=\s*'([^']+)'/); + const companyCode = companyCodeMatch ? companyCodeMatch[1] : undefined; + return await this.executeCachedLookup( tableName, cacheableJoins, - { page: Math.floor(offset / limit) + 1, size: limit, search: {} }, + { page: Math.floor(offset / limit) + 1, size: limit, search: {}, companyCode }, startTime ); } @@ -2831,6 +2854,13 @@ export class TableManagementService { const dbJoins: EntityJoinConfig[] = []; for (const config of joinConfigs) { + // table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인 + if (config.referenceTable === 'table_column_category_values') { + dbJoins.push(config); + console.log(`🔗 DB 조인 (특수 조건): ${config.referenceTable}`); + continue; + } + // 캐시 가능성 확인 const cachedData = await referenceCacheService.getCachedReference( config.referenceTable, diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index c89c522d..22b29396 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -348,22 +348,60 @@ export const TableListComponent: React.FC = ({ // 컬럼의 고유 값 조회 함수 const getColumnUniqueValues = async (columnName: string) => { + console.log("🔍 [getColumnUniqueValues] 호출됨:", { + columnName, + dataLength: data.length, + columnMeta: columnMeta[columnName], + sampleData: data[0], + }); + + const meta = columnMeta[columnName]; + const inputType = meta?.inputType || "text"; + + // 카테고리, 엔티티, 코드 타입인 경우 _name 필드 사용 (백엔드 조인 결과) + const isLabelType = ["category", "entity", "code"].includes(inputType); + const labelField = isLabelType ? `${columnName}_name` : columnName; + + console.log("🔍 [getColumnUniqueValues] 필드 선택:", { + columnName, + inputType, + isLabelType, + labelField, + hasLabelField: data[0] && labelField in data[0], + sampleLabelValue: data[0] ? data[0][labelField] : undefined, + }); + // 현재 로드된 데이터에서 고유 값 추출 - const uniqueValues = new Set(); + const uniqueValuesMap = new Map(); // value -> label + data.forEach((row) => { const value = row[columnName]; if (value !== null && value !== undefined && value !== "") { - uniqueValues.add(String(value)); + // 백엔드 조인된 _name 필드 사용 (없으면 원본 값) + const label = isLabelType && row[labelField] ? row[labelField] : String(value); + uniqueValuesMap.set(String(value), label); } }); - // Set을 배열로 변환하고 정렬 - const sortedValues = Array.from(uniqueValues).sort(); - - return sortedValues.map((value) => ({ - label: value, - value: value, - })); + // Map을 배열로 변환하고 라벨 기준으로 정렬 + const result = Array.from(uniqueValuesMap.entries()) + .map(([value, label]) => ({ + value: value, + label: label, + })) + .sort((a, b) => a.label.localeCompare(b.label)); + + console.log("✅ [getColumnUniqueValues] 결과:", { + columnName, + inputType, + isLabelType, + labelField, + uniqueCount: result.length, + values: result, // 전체 값 출력 + allKeys: data[0] ? Object.keys(data[0]) : [], // 모든 키 출력 + }); + + return result; }; const registration = { @@ -396,7 +434,7 @@ export const TableListComponent: React.FC = ({ tableConfig.selectedTable, tableConfig.columns, columnLabels, - columnMeta, + columnMeta, // columnMeta가 변경되면 재등록 (inputType 정보 필요) columnWidths, tableLabel, data, // 데이터 자체가 변경되면 재등록 (고유 값 조회용) diff --git a/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx b/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx index 83c67c2f..662088ea 100644 --- a/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx +++ b/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx @@ -62,7 +62,7 @@ export function TableSearchWidget({ component }: TableSearchWidgetProps) { } }, [registeredTables, selectedTableId, autoSelectFirstTable, setSelectedTableId]); - // 현재 테이블의 저장된 필터 불러오기 및 select 옵션 로드 + // 현재 테이블의 저장된 필터 불러오기 useEffect(() => { if (currentTable?.tableName) { const storageKey = `table_filters_${currentTable.tableName}`; @@ -89,36 +89,54 @@ export function TableSearchWidget({ component }: TableSearchWidgetProps) { })); setActiveFilters(activeFiltersList); - - // select 타입 필터들의 옵션 로드 - const loadSelectOptions = async () => { - const newOptions: Record> = {}; - - for (const filter of activeFiltersList) { - if (filter.filterType === "select" && currentTable.getColumnUniqueValues) { - try { - const options = await currentTable.getColumnUniqueValues(filter.columnName); - newOptions[filter.columnName] = options; - console.log("✅ [TableSearchWidget] select 옵션 로드:", { - columnName: filter.columnName, - optionCount: options.length, - }); - } catch (error) { - console.error("select 옵션 로드 실패:", filter.columnName, error); - } - } - } - - setSelectOptions(newOptions); - }; - - loadSelectOptions(); } catch (error) { console.error("저장된 필터 불러오기 실패:", error); } } } - }, [currentTable?.tableName, currentTable?.getColumnUniqueValues]); + }, [currentTable?.tableName]); + + // select 옵션 로드 (activeFilters 또는 dataCount 변경 시) + useEffect(() => { + if (!currentTable?.getColumnUniqueValues || activeFilters.length === 0) { + return; + } + + const loadSelectOptions = async () => { + const selectFilters = activeFilters.filter(f => f.filterType === "select"); + + if (selectFilters.length === 0) { + return; + } + + console.log("🔄 [TableSearchWidget] select 옵션 로드 시작:", { + activeFiltersCount: activeFilters.length, + selectFiltersCount: selectFilters.length, + dataCount: currentTable.dataCount, + }); + + const newOptions: Record> = {}; + + for (const filter of selectFilters) { + try { + const options = await currentTable.getColumnUniqueValues(filter.columnName); + newOptions[filter.columnName] = options; + console.log("✅ [TableSearchWidget] select 옵션 로드:", { + columnName: filter.columnName, + optionCount: options.length, + options: options.slice(0, 5), + }); + } catch (error) { + console.error("❌ [TableSearchWidget] select 옵션 로드 실패:", filter.columnName, error); + } + } + + console.log("✅ [TableSearchWidget] 최종 selectOptions:", newOptions); + setSelectOptions(newOptions); + }; + + loadSelectOptions(); + }, [activeFilters, currentTable?.dataCount, currentTable?.getColumnUniqueValues]); // 디버깅: 현재 테이블 정보 로깅 useEffect(() => { @@ -193,13 +211,13 @@ export function TableSearchWidget({ component }: TableSearchWidgetProps) { onValueChange={(val) => handleFilterChange(filter.columnName, val)} > - + {options.length === 0 ? ( - +
옵션 없음 - +
) : ( options.map((option) => (