diff --git a/backend-node/src/controllers/entityJoinController.ts b/backend-node/src/controllers/entityJoinController.ts index d4f45477..53c5de22 100644 --- a/backend-node/src/controllers/entityJoinController.ts +++ b/backend-node/src/controllers/entityJoinController.ts @@ -25,6 +25,7 @@ export class EntityJoinController { sortBy, sortOrder = "asc", enableEntityJoin = true, + additionalJoinColumns, // 추가 조인 컬럼 정보 (JSON 문자열) userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함 ...otherParams } = req.query; @@ -49,6 +50,21 @@ export class EntityJoinController { } } + // 추가 조인 컬럼 정보 처리 + let parsedAdditionalJoinColumns: any[] = []; + if (additionalJoinColumns) { + try { + parsedAdditionalJoinColumns = + typeof additionalJoinColumns === "string" + ? JSON.parse(additionalJoinColumns) + : additionalJoinColumns; + logger.info("추가 조인 컬럼 파싱 완료:", parsedAdditionalJoinColumns); + } catch (error) { + logger.warn("추가 조인 컬럼 파싱 오류:", error); + parsedAdditionalJoinColumns = []; + } + } + const result = await tableManagementService.getTableDataWithEntityJoins( tableName, { @@ -62,6 +78,7 @@ export class EntityJoinController { sortOrder: sortOrder as string, enableEntityJoin: enableEntityJoin === "true" || enableEntityJoin === true, + additionalJoinColumns: parsedAdditionalJoinColumns, } ); @@ -295,6 +312,124 @@ export class EntityJoinController { } } + /** + * Entity 조인된 테이블의 추가 컬럼 목록 조회 + * GET /api/table-management/tables/:tableName/entity-join-columns + */ + async getEntityJoinColumns(req: Request, res: Response): Promise { + try { + const { tableName } = req.params; + + logger.info(`Entity 조인 컬럼 조회: ${tableName}`); + + // 1. 현재 테이블의 Entity 조인 설정 조회 + const joinConfigs = await entityJoinService.detectEntityJoins(tableName); + + if (joinConfigs.length === 0) { + res.status(200).json({ + success: true, + message: "Entity 조인 설정이 없습니다.", + data: { + tableName, + joinTables: [], + availableColumns: [], + }, + }); + return; + } + + // 2. 각 조인 테이블의 컬럼 정보 조회 + const joinTablesInfo = await Promise.all( + joinConfigs.map(async (config) => { + try { + const columns = + await tableManagementService.getReferenceTableColumns( + config.referenceTable + ); + + // 현재 display_column으로 사용 중인 컬럼 제외 + const availableColumns = columns.filter( + (col) => col.columnName !== config.displayColumn + ); + + return { + joinConfig: config, + tableName: config.referenceTable, + currentDisplayColumn: config.displayColumn, + availableColumns: availableColumns.map((col) => ({ + columnName: col.columnName, + columnLabel: col.displayName || col.columnName, + dataType: col.dataType, + isNullable: true, // 기본값으로 설정 + maxLength: undefined, // 정보가 없으므로 undefined + description: col.displayName, + })), + }; + } catch (error) { + logger.warn( + `참조 테이블 컬럼 조회 실패: ${config.referenceTable}`, + error + ); + return { + joinConfig: config, + tableName: config.referenceTable, + currentDisplayColumn: config.displayColumn, + availableColumns: [], + error: error instanceof Error ? error.message : "Unknown error", + }; + } + }) + ); + + // 3. 사용 가능한 모든 컬럼 목록 생성 (중복 제거) + const allAvailableColumns: Array<{ + tableName: string; + columnName: string; + columnLabel: string; + dataType: string; + joinAlias: string; + suggestedLabel: string; + }> = []; + + joinTablesInfo.forEach((info) => { + info.availableColumns.forEach((col) => { + const joinAlias = `${info.joinConfig.sourceColumn}_${col.columnName}`; + const suggestedLabel = col.columnLabel; // 라벨명만 사용 + + allAvailableColumns.push({ + tableName: info.tableName, + columnName: col.columnName, + columnLabel: col.columnLabel, + dataType: col.dataType, + joinAlias, + suggestedLabel, + }); + }); + }); + + res.status(200).json({ + success: true, + message: "Entity 조인 컬럼 조회 성공", + data: { + tableName, + joinTables: joinTablesInfo, + availableColumns: allAvailableColumns, + summary: { + totalJoinTables: joinConfigs.length, + totalAvailableColumns: allAvailableColumns.length, + }, + }, + }); + } catch (error) { + logger.error("Entity 조인 컬럼 조회 실패", error); + res.status(500).json({ + success: false, + message: "Entity 조인 컬럼 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + /** * 공통 참조 테이블 자동 캐싱 * POST /api/table-management/cache/preload diff --git a/backend-node/src/routes/entityJoinRoutes.ts b/backend-node/src/routes/entityJoinRoutes.ts index 2e2efc1a..0e023770 100644 --- a/backend-node/src/routes/entityJoinRoutes.ts +++ b/backend-node/src/routes/entityJoinRoutes.ts @@ -120,6 +120,71 @@ router.put( // 🎯 참조 테이블 정보 // ======================================== +/** + * Entity 조인된 테이블의 추가 컬럼 목록 조회 (화면편집기용) + * GET /api/table-management/tables/:tableName/entity-join-columns + * + * 특정 테이블에 설정된 모든 Entity 조인의 참조 테이블들에서 + * 추가로 표시할 수 있는 컬럼들의 목록을 반환합니다. + * + * Response: + * { + * success: true, + * data: { + * tableName: "companies", + * joinTables: [ + * { + * joinConfig: { sourceColumn: "writer", referenceTable: "user_info", ... }, + * tableName: "user_info", + * currentDisplayColumn: "user_name", + * availableColumns: [ + * { + * columnName: "email", + * columnLabel: "이메일", + * dataType: "character varying", + * isNullable: true, + * description: "사용자 이메일" + * }, + * { + * columnName: "dept_code", + * columnLabel: "부서코드", + * dataType: "character varying", + * isNullable: false, + * description: "소속 부서" + * } + * ] + * } + * ], + * availableColumns: [ + * { + * tableName: "user_info", + * columnName: "email", + * columnLabel: "이메일", + * dataType: "character varying", + * joinAlias: "writer_email", + * suggestedLabel: "writer (이메일)" + * }, + * { + * tableName: "user_info", + * columnName: "dept_code", + * columnLabel: "부서코드", + * dataType: "character varying", + * joinAlias: "writer_dept_code", + * suggestedLabel: "writer (부서코드)" + * } + * ], + * summary: { + * totalJoinTables: 1, + * totalAvailableColumns: 2 + * } + * } + * } + */ +router.get( + "/tables/:tableName/entity-join-columns", + entityJoinController.getEntityJoinColumns.bind(entityJoinController) +); + /** * 참조 테이블의 표시 가능한 컬럼 목록 조회 * GET /api/table-management/reference-tables/:tableName/columns diff --git a/backend-node/src/services/entityJoinService.ts b/backend-node/src/services/entityJoinService.ts index 509f1eb8..d470e8b8 100644 --- a/backend-node/src/services/entityJoinService.ts +++ b/backend-node/src/services/entityJoinService.ts @@ -93,11 +93,11 @@ export class EntityJoinService { // 기본 SELECT 컬럼들 const baseColumns = selectColumns.map((col) => `main.${col}`).join(", "); - // Entity 조인 컬럼들 + // Entity 조인 컬럼들 (COALESCE로 NULL을 빈 문자열로 처리) const joinColumns = joinConfigs .map( (config) => - `${config.referenceTable.substring(0, 3)}.${config.displayColumn} AS ${config.aliasColumn}` + `COALESCE(${config.referenceTable.substring(0, 3)}.${config.displayColumn}, '') AS ${config.aliasColumn}` ) .join(", "); diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 51ee2e82..92db9b71 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -1412,6 +1412,11 @@ export class TableManagementService { sortBy?: string; sortOrder?: string; enableEntityJoin?: boolean; + additionalJoinColumns?: Array<{ + sourceTable: string; + sourceColumn: string; + joinAlias: string; + }>; } ): Promise { const startTime = Date.now(); @@ -1432,7 +1437,41 @@ export class TableManagementService { } // Entity 조인 설정 감지 - const joinConfigs = await entityJoinService.detectEntityJoins(tableName); + let joinConfigs = await entityJoinService.detectEntityJoins(tableName); + + // 추가 조인 컬럼 정보가 있으면 조인 설정에 추가 + if ( + options.additionalJoinColumns && + options.additionalJoinColumns.length > 0 + ) { + logger.info( + `추가 조인 컬럼 처리: ${options.additionalJoinColumns.length}개` + ); + + for (const additionalColumn of options.additionalJoinColumns) { + // 기존 조인 설정에서 같은 참조 테이블을 사용하는 설정 찾기 + const baseJoinConfig = joinConfigs.find( + (config) => config.referenceTable === additionalColumn.sourceTable + ); + + if (baseJoinConfig) { + // 추가 조인 컬럼 설정 생성 + const additionalJoinConfig: EntityJoinConfig = { + sourceTable: tableName, + sourceColumn: baseJoinConfig.sourceColumn, // 원본 컬럼 (writer) + referenceTable: additionalColumn.sourceTable, // 참조 테이블 (user_info) + referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (user_id) + displayColumn: additionalColumn.sourceColumn, // 표시할 컬럼 (email) + aliasColumn: additionalColumn.joinAlias, // 별칭 (writer_email) + }; + + joinConfigs.push(additionalJoinConfig); + logger.info( + `추가 조인 컬럼 설정 추가: ${additionalJoinConfig.aliasColumn}` + ); + } + } + } if (joinConfigs.length === 0) { logger.info(`Entity 조인 설정이 없음: ${tableName}`); @@ -1624,7 +1663,11 @@ export class TableManagementService { String(sourceValue) ); - enhancedRow[config.aliasColumn] = lookupValue || sourceValue; + // null이나 undefined인 경우 빈 문자열로 설정 + enhancedRow[config.aliasColumn] = lookupValue || ""; + } else { + // sourceValue가 없는 경우도 빈 문자열로 설정 + enhancedRow[config.aliasColumn] = ""; } } @@ -1946,11 +1989,18 @@ export class TableManagementService { const keyValue = row[config.sourceColumn]; if (keyValue) { const lookupValue = cachedData.get(String(keyValue)); - if (lookupValue) { - row[config.aliasColumn] = lookupValue; - } + // null이나 undefined인 경우 빈 문자열로 설정 + row[config.aliasColumn] = lookupValue || ""; + } else { + // sourceValue가 없는 경우도 빈 문자열로 설정 + row[config.aliasColumn] = ""; } }); + } else { + // 캐시가 없는 경우 모든 행에 빈 문자열 설정 + enhancedData.forEach((row) => { + row[config.aliasColumn] = ""; + }); } } diff --git a/frontend/lib/api/entityJoin.ts b/frontend/lib/api/entityJoin.ts index bb339ccc..ab531b29 100644 --- a/frontend/lib/api/entityJoin.ts +++ b/frontend/lib/api/entityJoin.ts @@ -62,6 +62,11 @@ export const entityJoinApi = { sortBy?: string; sortOrder?: "asc" | "desc"; enableEntityJoin?: boolean; + additionalJoinColumns?: Array<{ + sourceTable: string; + sourceColumn: string; + joinAlias: string; + }>; } = {}, ): Promise => { const searchParams = new URLSearchParams(); @@ -87,6 +92,7 @@ export const entityJoinApi = { params: { ...params, search: params.search ? JSON.stringify(params.search) : undefined, + additionalJoinColumns: params.additionalJoinColumns ? JSON.stringify(params.additionalJoinColumns) : undefined, }, }); return response.data.data; @@ -153,6 +159,40 @@ export const entityJoinApi = { await apiClient.delete(`/table-management/cache`, { params }); }, + /** + * Entity 조인된 테이블의 추가 컬럼 목록 조회 (화면편집기용) + */ + getEntityJoinColumns: async ( + tableName: string, + ): Promise<{ + tableName: string; + joinTables: Array<{ + tableName: string; + currentDisplayColumn: string; + availableColumns: Array<{ + columnName: string; + columnLabel: string; + dataType: string; + description?: string; + }>; + }>; + availableColumns: Array<{ + tableName: string; + columnName: string; + columnLabel: string; + dataType: string; + joinAlias: string; + suggestedLabel: string; + }>; + summary: { + totalJoinTables: number; + totalAvailableColumns: number; + }; + }> => { + const response = await apiClient.get(`/table-management/tables/${tableName}/entity-join-columns`); + return response.data.data; + }, + /** * 공통 참조 테이블 자동 캐싱 */ diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 0b620d7f..5ce5842e 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -206,6 +206,16 @@ export const TableListComponent: React.FC = ({ // 🎯 Entity 조인 API 사용 - Entity 조인이 포함된 데이터 조회 console.log("🔗 Entity 조인 데이터 조회 시작:", tableConfig.selectedTable); + // Entity 조인 컬럼 추출 (isEntityJoin === true인 컬럼들) + const entityJoinColumns = tableConfig.columns?.filter((col) => col.isEntityJoin && col.entityJoinInfo) || []; + const additionalJoinColumns = entityJoinColumns.map((col) => ({ + sourceTable: col.entityJoinInfo!.sourceTable, + sourceColumn: col.entityJoinInfo!.sourceColumn, + joinAlias: col.entityJoinInfo!.joinAlias, + })); + + console.log("🔗 추가 Entity 조인 컬럼:", additionalJoinColumns); + const result = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, { page: currentPage, size: localPageSize, @@ -262,6 +272,7 @@ export const TableListComponent: React.FC = ({ sortBy: sortColumn || undefined, sortOrder: sortDirection, enableEntityJoin: true, // 🎯 Entity 조인 활성화 + additionalJoinColumns: additionalJoinColumns.length > 0 ? additionalJoinColumns : undefined, // 추가 조인 컬럼 }); if (result) { diff --git a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx index 8ce02cc3..817f6f34 100644 --- a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx +++ b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx @@ -12,6 +12,7 @@ import { Badge } from "@/components/ui/badge"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Separator } from "@/components/ui/separator"; import { TableListConfig, ColumnConfig } from "./types"; +import { entityJoinApi } from "@/lib/api/entityJoin"; import { Plus, Trash2, @@ -50,6 +51,27 @@ export const TableListConfigPanel: React.FC = ({ const [availableColumns, setAvailableColumns] = useState< Array<{ columnName: string; dataType: string; label?: string }> >([]); + const [entityJoinColumns, setEntityJoinColumns] = useState<{ + availableColumns: Array<{ + tableName: string; + columnName: string; + columnLabel: string; + dataType: string; + joinAlias: string; + suggestedLabel: string; + }>; + joinTables: Array<{ + tableName: string; + currentDisplayColumn: string; + availableColumns: Array<{ + columnName: string; + columnLabel: string; + dataType: string; + description?: string; + }>; + }>; + }>({ availableColumns: [], joinTables: [] }); + const [loadingEntityJoins, setLoadingEntityJoins] = useState(false); // 화면 테이블명이 있으면 자동으로 설정 useEffect(() => { @@ -137,6 +159,36 @@ export const TableListConfigPanel: React.FC = ({ } }, [config.selectedTable, screenTableName, tableColumns]); + // Entity 조인 컬럼 정보 가져오기 + useEffect(() => { + const fetchEntityJoinColumns = async () => { + const tableName = config.selectedTable || screenTableName; + if (!tableName) { + setEntityJoinColumns({ availableColumns: [], joinTables: [] }); + return; + } + + setLoadingEntityJoins(true); + try { + console.log("🔗 Entity 조인 컬럼 정보 가져오기:", tableName); + const result = await entityJoinApi.getEntityJoinColumns(tableName); + console.log("✅ Entity 조인 컬럼 응답:", result); + + setEntityJoinColumns({ + availableColumns: result.availableColumns || [], + joinTables: result.joinTables || [], + }); + } catch (error) { + console.error("❌ Entity 조인 컬럼 조회 오류:", error); + setEntityJoinColumns({ availableColumns: [], joinTables: [] }); + } finally { + setLoadingEntityJoins(false); + } + }; + + fetchEntityJoinColumns(); + }, [config.selectedTable, screenTableName]); + const handleChange = (key: keyof TableListConfig, value: any) => { onChange({ [key]: value }); }; @@ -176,6 +228,32 @@ export const TableListConfigPanel: React.FC = ({ handleChange("columns", [...(config.columns || []), newColumn]); }; + // Entity 조인 컬럼 추가 + const addEntityJoinColumn = (joinColumn: (typeof entityJoinColumns.availableColumns)[0]) => { + const existingColumn = config.columns?.find((col) => col.columnName === joinColumn.joinAlias); + if (existingColumn) return; + + const newColumn: ColumnConfig = { + columnName: joinColumn.joinAlias, + displayName: joinColumn.columnLabel, // 라벨명만 사용 + visible: true, + sortable: true, + searchable: true, + align: "left", + format: "text", + order: config.columns?.length || 0, + isEntityJoin: true, // Entity 조인 컬럼임을 표시 + entityJoinInfo: { + sourceTable: joinColumn.tableName, + sourceColumn: joinColumn.columnName, + joinAlias: joinColumn.joinAlias, + }, + }; + + handleChange("columns", [...(config.columns || []), newColumn]); + console.log("🔗 Entity 조인 컬럼 추가됨:", newColumn); + }; + // 컬럼 제거 const removeColumn = (columnName: string) => { const updatedColumns = config.columns?.filter((col) => col.columnName !== columnName) || []; @@ -214,7 +292,7 @@ export const TableListConfigPanel: React.FC = ({
테이블 리스트 설정
- + 기본 @@ -223,6 +301,10 @@ export const TableListConfigPanel: React.FC = ({ 컬럼 + + + 조인 + 필터 @@ -610,6 +692,127 @@ export const TableListConfigPanel: React.FC = ({ )} + {/* Entity 조인 컬럼 추가 탭 */} + + + + Entity 조인 컬럼 추가 + Entity 조인된 테이블의 다른 컬럼들을 추가로 표시할 수 있습니다. + + + + {loadingEntityJoins ? ( +
조인 정보를 가져오는 중...
+ ) : entityJoinColumns.joinTables.length === 0 ? ( +
+
Entity 조인이 설정된 컬럼이 없습니다.
+
+ 먼저 컬럼의 웹타입을 'entity'로 설정하고 참조 테이블을 지정해주세요. +
+
+ ) : ( +
+ {/* 조인 테이블별 그룹 */} + {entityJoinColumns.joinTables.map((joinTable, tableIndex) => ( + + + + 📊 {joinTable.tableName} + + 현재: {joinTable.currentDisplayColumn} + + + + + {joinTable.availableColumns.length === 0 ? ( +
추가할 수 있는 컬럼이 없습니다.
+ ) : ( +
+ {joinTable.availableColumns.map((column, colIndex) => { + const matchingJoinColumn = entityJoinColumns.availableColumns.find( + (jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName, + ); + + const isAlreadyAdded = config.columns?.some( + (col) => col.columnName === matchingJoinColumn?.joinAlias, + ); + + return ( +
+
+
{column.columnLabel}
+
+ {column.columnName} ({column.dataType}) +
+ {column.description && ( +
{column.description}
+ )} +
+
+ {isAlreadyAdded ? ( + + 추가됨 + + ) : ( + matchingJoinColumn && ( + + ) + )} +
+
+ ); + })} +
+ )} +
+
+ ))} + + {/* 전체 사용 가능한 컬럼 요약 */} + {entityJoinColumns.availableColumns.length > 0 && ( + + + 📋 추가 가능한 컬럼 요약 + + +
+ 총 {entityJoinColumns.availableColumns.length}개의 컬럼을 추가할 수 있습니다. +
+
+ {entityJoinColumns.availableColumns.map((column, index) => { + const isAlreadyAdded = config.columns?.some((col) => col.columnName === column.joinAlias); + + return ( + !isAlreadyAdded && addEntityJoinColumn(column)} + > + {column.columnLabel} + {!isAlreadyAdded && } + + ); + })} +
+
+
+ )} +
+ )} +
+
+
+
+ {/* 필터 설정 탭 */} diff --git a/frontend/lib/registry/components/table-list/types.ts b/frontend/lib/registry/components/table-list/types.ts index b8ee0469..993fe9ec 100644 --- a/frontend/lib/registry/components/table-list/types.ts +++ b/frontend/lib/registry/components/table-list/types.ts @@ -2,6 +2,15 @@ import { ComponentConfig } from "@/types/component"; +/** + * Entity 조인 정보 + */ +export interface EntityJoinInfo { + sourceTable: string; + sourceColumn: string; + joinAlias: string; +} + /** * 테이블 컬럼 설정 */ @@ -16,7 +25,8 @@ export interface ColumnConfig { format?: "text" | "number" | "date" | "currency" | "boolean"; order: number; dataType?: string; // 컬럼 데이터 타입 (검색 컬럼 선택에 사용) - isEntityJoined?: boolean; // 🎯 Entity 조인된 컬럼인지 여부 + isEntityJoin?: boolean; // Entity 조인된 컬럼인지 여부 + entityJoinInfo?: EntityJoinInfo; // Entity 조인 상세 정보 } /**