diff --git a/backend-node/src/controllers/entityJoinController.ts b/backend-node/src/controllers/entityJoinController.ts index 8649cea5..66e20ccd 100644 --- a/backend-node/src/controllers/entityJoinController.ts +++ b/backend-node/src/controllers/entityJoinController.ts @@ -28,6 +28,7 @@ export class EntityJoinController { additionalJoinColumns, // 추가 조인 컬럼 정보 (JSON 문자열) screenEntityConfigs, // 화면별 엔티티 설정 (JSON 문자열) autoFilter, // 🔒 멀티테넌시 자동 필터 + dataFilter, // 🆕 데이터 필터 (JSON 문자열) userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함 ...otherParams } = req.query; @@ -111,6 +112,19 @@ export class EntityJoinController { } } + // 🆕 데이터 필터 처리 + let parsedDataFilter: any = undefined; + if (dataFilter) { + try { + parsedDataFilter = + typeof dataFilter === "string" ? JSON.parse(dataFilter) : dataFilter; + logger.info("데이터 필터 파싱 완료:", parsedDataFilter); + } catch (error) { + logger.warn("데이터 필터 파싱 오류:", error); + parsedDataFilter = undefined; + } + } + const result = await tableManagementService.getTableDataWithEntityJoins( tableName, { @@ -126,6 +140,7 @@ export class EntityJoinController { enableEntityJoin === "true" || enableEntityJoin === true, additionalJoinColumns: parsedAdditionalJoinColumns, screenEntityConfigs: parsedScreenEntityConfigs, + dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달 } ); diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 4f6af0b9..beade4e6 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -742,6 +742,7 @@ export async function getTableData( sortBy, sortOrder = "asc", autoFilter, // 🆕 자동 필터 설정 추가 (컴포넌트에서 직접 전달) + dataFilter, // 🆕 컬럼 값 기반 데이터 필터링 } = req.body; logger.info(`=== 테이블 데이터 조회 시작: ${tableName} ===`); @@ -749,6 +750,7 @@ export async function getTableData( logger.info(`검색 조건:`, search); logger.info(`정렬: ${sortBy} ${sortOrder}`); logger.info(`자동 필터:`, autoFilter); // 🆕 + logger.info(`데이터 필터:`, dataFilter); // 🆕 if (!tableName) { const response: ApiResponse = { @@ -796,6 +798,7 @@ export async function getTableData( search: enhancedSearch, // 🆕 필터가 적용된 search 사용 sortBy, sortOrder, + dataFilter, // 🆕 데이터 필터 전달 }); logger.info( diff --git a/backend-node/src/routes/dataRoutes.ts b/backend-node/src/routes/dataRoutes.ts index 7d1f0a88..5193977a 100644 --- a/backend-node/src/routes/dataRoutes.ts +++ b/backend-node/src/routes/dataRoutes.ts @@ -14,7 +14,7 @@ router.get( authenticateToken, async (req: AuthenticatedRequest, res) => { try { - const { leftTable, rightTable, leftColumn, rightColumn, leftValue } = + const { leftTable, rightTable, leftColumn, rightColumn, leftValue, dataFilter } = req.query; // 입력값 검증 @@ -27,6 +27,16 @@ router.get( }); } + // dataFilter 파싱 (JSON 문자열로 전달됨) + let parsedDataFilter = undefined; + if (dataFilter && typeof dataFilter === "string") { + try { + parsedDataFilter = JSON.parse(dataFilter); + } catch (error) { + console.error("dataFilter 파싱 오류:", error); + } + } + // SQL 인젝션 방지를 위한 검증 const tables = [leftTable as string, rightTable as string]; const columns = [leftColumn as string, rightColumn as string]; @@ -61,16 +71,18 @@ router.get( rightColumn, leftValue, userCompany, + dataFilter: parsedDataFilter, // 🆕 데이터 필터 로그 }); - // 조인 데이터 조회 (회사 코드 전달) + // 조인 데이터 조회 (회사 코드 + 데이터 필터 전달) const result = await dataService.getJoinedData( leftTable as string, rightTable as string, leftColumn as string, rightColumn as string, leftValue as string, - userCompany + userCompany, + parsedDataFilter // 🆕 데이터 필터 전달 ); if (!result.success) { diff --git a/backend-node/src/services/dataService.ts b/backend-node/src/services/dataService.ts index 0cf7ad6b..bd7f74e1 100644 --- a/backend-node/src/services/dataService.ts +++ b/backend-node/src/services/dataService.ts @@ -14,6 +14,7 @@ * - 최고 관리자(company_code = "*")만 전체 데이터 조회 가능 */ import { query, queryOne } from "../database/db"; +import { buildDataFilterWhereClause } from "../utils/dataFilterUtil"; // 🆕 데이터 필터 유틸 interface GetTableDataParams { tableName: string; @@ -434,7 +435,8 @@ class DataService { leftColumn: string, rightColumn: string, leftValue?: string | number, - userCompany?: string + userCompany?: string, + dataFilter?: any // 🆕 데이터 필터 ): Promise> { try { // 왼쪽 테이블 접근 검증 @@ -478,6 +480,17 @@ class DataService { } } + // 🆕 데이터 필터 적용 (우측 패널에 대해, 테이블 별칭 "r" 사용) + if (dataFilter && dataFilter.enabled && dataFilter.filters && dataFilter.filters.length > 0) { + const filterResult = buildDataFilterWhereClause(dataFilter, "r", paramIndex); + if (filterResult.whereClause) { + whereConditions.push(filterResult.whereClause); + values.push(...filterResult.params); + paramIndex += filterResult.params.length; + console.log(`🔍 데이터 필터 적용 (${rightTable}):`, filterResult.whereClause); + } + } + // WHERE 절 추가 if (whereConditions.length > 0) { queryText += ` WHERE ${whereConditions.join(" AND ")}`; diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 112106bd..8ce3c9d4 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -128,7 +128,8 @@ export class TableManagementService { ); // 캐시 키 생성 (companyCode 포함) - const cacheKey = CacheKeys.TABLE_COLUMNS(tableName, page, size) + `_${companyCode}`; + const cacheKey = + CacheKeys.TABLE_COLUMNS(tableName, page, size) + `_${companyCode}`; const countCacheKey = CacheKeys.TABLE_COLUMN_COUNT(tableName); // 캐시에서 먼저 확인 @@ -162,9 +163,9 @@ export class TableManagementService { // 페이지네이션 적용한 컬럼 조회 const offset = (page - 1) * size; - + // 🔥 company_code가 있으면 table_type_columns 조인하여 회사별 inputType 가져오기 - const rawColumns = companyCode + const rawColumns = companyCode ? await query( `SELECT c.column_name as "columnName", @@ -260,8 +261,11 @@ export class TableManagementService { let categoryMappings: Map = new Map(); if (mappingTableExists && companyCode) { - logger.info("📥 getColumnList: 카테고리 매핑 조회 시작", { tableName, companyCode }); - + logger.info("📥 getColumnList: 카테고리 매핑 조회 시작", { + tableName, + companyCode, + }); + const mappings = await query( `SELECT logical_column_name as "columnName", @@ -272,11 +276,11 @@ export class TableManagementService { [tableName, companyCode] ); - logger.info("✅ getColumnList: 카테고리 매핑 조회 완료", { - tableName, - companyCode, + logger.info("✅ getColumnList: 카테고리 매핑 조회 완료", { + tableName, + companyCode, mappingCount: mappings.length, - mappings: mappings + mappings: mappings, }); mappings.forEach((m: any) => { @@ -288,7 +292,7 @@ export class TableManagementService { logger.info("✅ getColumnList: categoryMappings Map 생성 완료", { size: categoryMappings.size, - entries: Array.from(categoryMappings.entries()) + entries: Array.from(categoryMappings.entries()), }); } @@ -300,19 +304,27 @@ export class TableManagementService { numericPrecision: column.numericPrecision ? Number(column.numericPrecision) : null, - numericScale: column.numericScale ? Number(column.numericScale) : null, - displayOrder: column.displayOrder ? Number(column.displayOrder) : null, - // 자동 매핑: webType이 기본값('text')인 경우 DB 타입에 따라 자동 추론 - webType: - column.webType === "text" - ? this.inferWebType(column.dataType) - : column.webType, + numericScale: column.numericScale + ? Number(column.numericScale) + : null, + displayOrder: column.displayOrder + ? Number(column.displayOrder) + : null, + // webType은 사용자가 명시적으로 설정한 값을 그대로 사용 + // (자동 추론은 column_labels에 없는 경우에만 SQL 쿼리의 COALESCE에서 처리됨) + webType: column.webType, }; // 카테고리 타입인 경우 categoryMenus 추가 - if (column.inputType === "category" && categoryMappings.has(column.columnName)) { + if ( + column.inputType === "category" && + categoryMappings.has(column.columnName) + ) { const menus = categoryMappings.get(column.columnName); - logger.info(`✅ getColumnList: 컬럼 ${column.columnName}에 카테고리 메뉴 추가`, { menus }); + logger.info( + `✅ getColumnList: 컬럼 ${column.columnName}에 카테고리 메뉴 추가`, + { menus } + ); return { ...baseColumn, categoryMenus: menus, @@ -417,7 +429,9 @@ export class TableManagementService { companyCode: string // 🔥 회사 코드 추가 ): Promise { try { - logger.info(`컬럼 설정 업데이트 시작: ${tableName}.${columnName}, company: ${companyCode}`); + logger.info( + `컬럼 설정 업데이트 시작: ${tableName}.${columnName}, company: ${companyCode}` + ); // 테이블이 table_labels에 없으면 자동 추가 await this.insertTableIfNotExists(tableName); @@ -463,17 +477,22 @@ export class TableManagementService { // detailSettings가 문자열이면 파싱, 객체면 그대로 사용 let parsedDetailSettings: Record | undefined = undefined; if (settings.detailSettings) { - if (typeof settings.detailSettings === 'string') { + if (typeof settings.detailSettings === "string") { try { parsedDetailSettings = JSON.parse(settings.detailSettings); } catch (e) { - logger.warn(`detailSettings 파싱 실패, 그대로 사용: ${settings.detailSettings}`); + logger.warn( + `detailSettings 파싱 실패, 그대로 사용: ${settings.detailSettings}` + ); } - } else if (typeof settings.detailSettings === 'object') { - parsedDetailSettings = settings.detailSettings as Record; + } else if (typeof settings.detailSettings === "object") { + parsedDetailSettings = settings.detailSettings as Record< + string, + any + >; } } - + await this.updateColumnInputType( tableName, columnName, @@ -486,7 +505,7 @@ export class TableManagementService { // 캐시 무효화 - 해당 테이블의 컬럼 캐시 삭제 cache.deleteByPattern(`table_columns:${tableName}:`); cache.delete(CacheKeys.TABLE_COLUMN_COUNT(tableName)); - + logger.info(`컬럼 설정 업데이트 완료: ${tableName}.${columnName}`); } catch (error) { logger.error( @@ -1133,7 +1152,7 @@ export class TableManagementService { if (typeof value === "object" && value !== null && "value" in value) { actualValue = value.value; operator = value.operator || "contains"; - + logger.info("🔍 필터 객체 처리:", { columnName, originalValue: value, @@ -1180,11 +1199,19 @@ export class TableManagementService { switch (webType) { case "date": case "datetime": - return this.buildDateRangeCondition(columnName, actualValue, paramIndex); + return this.buildDateRangeCondition( + columnName, + actualValue, + paramIndex + ); case "number": case "decimal": - return this.buildNumberRangeCondition(columnName, actualValue, paramIndex); + return this.buildNumberRangeCondition( + columnName, + actualValue, + paramIndex + ); case "code": return await this.buildCodeSearchCondition( @@ -1220,7 +1247,7 @@ export class TableManagementService { if (typeof value === "object" && value !== null && "value" in value) { fallbackValue = value.value; } - + return { whereClause: `${columnName}::text ILIKE $${paramIndex}`, values: [`%${fallbackValue}%`], @@ -1583,6 +1610,7 @@ export class TableManagementService { sortBy?: string; sortOrder?: string; companyCode?: string; + dataFilter?: any; // 🆕 DataFilterConfig } ): Promise<{ data: any[]; @@ -1592,7 +1620,15 @@ export class TableManagementService { totalPages: number; }> { try { - const { page, size, search = {}, sortBy, sortOrder = "asc", companyCode } = options; + const { + page, + size, + search = {}, + sortBy, + sortOrder = "asc", + companyCode, + dataFilter, + } = options; const offset = (page - 1) * size; logger.info(`테이블 데이터 조회: ${tableName}`, options); @@ -1611,7 +1647,9 @@ export class TableManagementService { whereConditions.push(`company_code = $${paramIndex}`); searchValues.push(companyCode); paramIndex++; - logger.info(`🔒 멀티테넌시 필터 추가 (기본 조회): company_code = ${companyCode}`); + logger.info( + `🔒 멀티테넌시 필터 추가 (기본 조회): company_code = ${companyCode}` + ); } if (search && Object.keys(search).length > 0) { @@ -1649,6 +1687,29 @@ export class TableManagementService { } } + // 🆕 데이터 필터 적용 + if ( + dataFilter && + dataFilter.enabled && + dataFilter.filters && + dataFilter.filters.length > 0 + ) { + const { + buildDataFilterWhereClause, + } = require("../utils/dataFilterUtil"); + const { whereClause: filterWhere, params: filterParams } = + buildDataFilterWhereClause(dataFilter, paramIndex); + + if (filterWhere) { + whereConditions.push(filterWhere); + searchValues.push(...filterParams); + paramIndex += filterParams.length; + + logger.info(`🔍 데이터 필터 적용: ${filterWhere}`); + logger.info(`🔍 필터 파라미터:`, filterParams); + } + } + const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` @@ -1680,7 +1741,9 @@ export class TableManagementService { `; logger.info(`🔍 실행할 SQL: ${dataQuery}`); - logger.info(`🔍 파라미터: ${JSON.stringify([...searchValues, size, offset])}`); + logger.info( + `🔍 파라미터: ${JSON.stringify([...searchValues, size, offset])}` + ); let data = await query(dataQuery, [...searchValues, size, offset]); @@ -2152,6 +2215,7 @@ export class TableManagementService { joinAlias: string; }>; screenEntityConfigs?: Record; // 화면별 엔티티 설정 + dataFilter?: any; // 🆕 데이터 필터 } ): Promise { const startTime = Date.now(); @@ -2311,18 +2375,99 @@ export class TableManagementService { const selectColumns = columns.data.map((col: any) => col.column_name); // WHERE 절 구성 - let whereClause = await this.buildWhereClause( - tableName, - options.search - ); + 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}` + whereClause = whereClause + ? `${whereClause} AND ${companyFilter}` : companyFilter; - logger.info(`🔒 멀티테넌시 필터 추가 (Entity 조인): company_code = ${options.companyCode}`); + logger.info( + `🔒 멀티테넌시 필터 추가 (Entity 조인): company_code = ${options.companyCode}` + ); + } + + // 🆕 데이터 필터 적용 (Entity 조인) - 파라미터 바인딩 없이 직접 값 삽입 + if ( + options.dataFilter && + options.dataFilter.enabled && + options.dataFilter.filters && + options.dataFilter.filters.length > 0 + ) { + const filterConditions: string[] = []; + + for (const filter of options.dataFilter.filters) { + const { columnName, operator, value } = filter; + + if (!columnName || value === undefined || value === null) { + continue; + } + + const safeColumn = `main."${columnName}"`; + + switch (operator) { + case "equals": + filterConditions.push( + `${safeColumn} = '${String(value).replace(/'/g, "''")}'` + ); + break; + case "not_equals": + filterConditions.push( + `${safeColumn} != '${String(value).replace(/'/g, "''")}'` + ); + break; + case "in": + if (Array.isArray(value) && value.length > 0) { + const values = value + .map((v) => `'${String(v).replace(/'/g, "''")}'`) + .join(", "); + filterConditions.push(`${safeColumn} IN (${values})`); + } + break; + case "not_in": + if (Array.isArray(value) && value.length > 0) { + const values = value + .map((v) => `'${String(v).replace(/'/g, "''")}'`) + .join(", "); + filterConditions.push(`${safeColumn} NOT IN (${values})`); + } + break; + case "contains": + filterConditions.push( + `${safeColumn} LIKE '%${String(value).replace(/'/g, "''")}%'` + ); + break; + case "starts_with": + filterConditions.push( + `${safeColumn} LIKE '${String(value).replace(/'/g, "''")}%'` + ); + break; + case "ends_with": + filterConditions.push( + `${safeColumn} LIKE '%${String(value).replace(/'/g, "''")}'` + ); + break; + case "is_null": + filterConditions.push(`${safeColumn} IS NULL`); + break; + case "is_not_null": + filterConditions.push(`${safeColumn} IS NOT NULL`); + break; + } + } + + if (filterConditions.length > 0) { + const logicalOperator = + options.dataFilter.matchType === "any" ? " OR " : " AND "; + const filterWhere = `(${filterConditions.join(logicalOperator)})`; + + whereClause = whereClause + ? `${whereClause} AND ${filterWhere}` + : filterWhere; + + logger.info(`🔍 데이터 필터 적용 (Entity 조인): ${filterWhere}`); + } } // ORDER BY 절 구성 @@ -2412,8 +2557,10 @@ export class TableManagementService { query(dataQuery), query(countQuery), ]); - - logger.info(`✅ [executeJoinQuery] 조회 완료: ${dataResult?.length}개 행`); + + logger.info( + `✅ [executeJoinQuery] 조회 완료: ${dataResult?.length}개 행` + ); const data = Array.isArray(dataResult) ? dataResult : []; const total = @@ -2643,11 +2790,17 @@ export class TableManagementService { ); } - basicResult = await this.getTableData(tableName, { ...fallbackOptions, companyCode: options.companyCode }); + basicResult = await this.getTableData(tableName, { + ...fallbackOptions, + companyCode: options.companyCode, + }); } } else { // Entity 조인 컬럼 검색이 없는 경우 기존 캐시 방식 사용 - basicResult = await this.getTableData(tableName, { ...options, companyCode: options.companyCode }); + basicResult = await this.getTableData(tableName, { + ...options, + companyCode: options.companyCode, + }); } // Entity 값들을 캐시에서 룩업하여 변환 @@ -2921,13 +3074,20 @@ export class TableManagementService { // 모든 조인이 캐시 가능한 경우: 기본 쿼리 + 캐시 룩업 else { // whereClause에서 company_code 추출 (멀티테넌시 필터) - const companyCodeMatch = whereClause.match(/main\.company_code\s*=\s*'([^']+)'/); + 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: {}, companyCode }, + { + page: Math.floor(offset / limit) + 1, + size: limit, + search: {}, + companyCode, + }, startTime ); } @@ -2949,7 +3109,7 @@ export class TableManagementService { for (const config of joinConfigs) { // table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인 - if (config.referenceTable === 'table_column_category_values') { + if (config.referenceTable === "table_column_category_values") { dbJoins.push(config); console.log(`🔗 DB 조인 (특수 조건): ${config.referenceTable}`); continue; @@ -3227,7 +3387,7 @@ export class TableManagementService { let categoryMappings: Map = new Map(); if (mappingTableExists) { logger.info("카테고리 매핑 조회 시작", { tableName, companyCode }); - + const mappings = await query( `SELECT logical_column_name as "columnName", @@ -3238,11 +3398,11 @@ export class TableManagementService { [tableName, companyCode] ); - logger.info("카테고리 매핑 조회 완료", { - tableName, - companyCode, + logger.info("카테고리 매핑 조회 완료", { + tableName, + companyCode, mappingCount: mappings.length, - mappings: mappings + mappings: mappings, }); mappings.forEach((m: any) => { @@ -3254,7 +3414,7 @@ export class TableManagementService { logger.info("categoryMappings Map 생성 완료", { size: categoryMappings.size, - entries: Array.from(categoryMappings.entries()) + entries: Array.from(categoryMappings.entries()), }); } else { logger.warn("category_column_mapping 테이블이 존재하지 않음"); @@ -3276,9 +3436,14 @@ export class TableManagementService { }; // 카테고리 타입인 경우 categoryMenus 추가 - if (col.inputType === "category" && categoryMappings.has(col.columnName)) { + if ( + col.inputType === "category" && + categoryMappings.has(col.columnName) + ) { const menus = categoryMappings.get(col.columnName); - logger.info(`✅ 컬럼 ${col.columnName}에 카테고리 메뉴 추가`, { menus }); + logger.info(`✅ 컬럼 ${col.columnName}에 카테고리 메뉴 추가`, { + menus, + }); return { ...baseInfo, categoryMenus: menus, @@ -3309,7 +3474,10 @@ export class TableManagementService { * 레거시 지원: 컬럼 웹타입 정보 조회 * @deprecated getColumnInputTypes 사용 권장 */ - async getColumnWebTypes(tableName: string, companyCode: string): Promise { + async getColumnWebTypes( + tableName: string, + companyCode: string + ): Promise { logger.warn( `레거시 메서드 사용: getColumnWebTypes → getColumnInputTypes 사용 권장` ); diff --git a/backend-node/src/utils/dataFilterUtil.ts b/backend-node/src/utils/dataFilterUtil.ts new file mode 100644 index 00000000..8c2732fb --- /dev/null +++ b/backend-node/src/utils/dataFilterUtil.ts @@ -0,0 +1,154 @@ +/** + * 데이터 필터 유틸리티 + * 프론트엔드의 DataFilterConfig를 SQL WHERE 절로 변환 + */ + +export interface ColumnFilter { + id: string; + columnName: string; + operator: "equals" | "not_equals" | "in" | "not_in" | "contains" | "starts_with" | "ends_with" | "is_null" | "is_not_null"; + value: string | string[]; + valueType: "static" | "category" | "code"; +} + +export interface DataFilterConfig { + enabled: boolean; + filters: ColumnFilter[]; + matchType: "all" | "any"; // AND / OR +} + +/** + * DataFilterConfig를 SQL WHERE 조건과 파라미터로 변환 + * @param dataFilter 필터 설정 + * @param tableAlias 테이블 별칭 (예: "r", "t1") - 조인 쿼리에서 사용 + * @param startParamIndex 시작 파라미터 인덱스 (예: 1이면 $1부터 시작) + * @returns { whereClause: string, params: any[] } + */ +export function buildDataFilterWhereClause( + dataFilter: DataFilterConfig | undefined, + tableAlias?: string, + startParamIndex: number = 1 +): { whereClause: string; params: any[] } { + if (!dataFilter || !dataFilter.enabled || !dataFilter.filters || dataFilter.filters.length === 0) { + return { whereClause: "", params: [] }; + } + + const conditions: string[] = []; + const params: any[] = []; + let paramIndex = startParamIndex; + + // 테이블 별칭이 있으면 "alias."를 붙이고, 없으면 그냥 컬럼명만 + const getColumnRef = (colName: string) => { + return tableAlias ? `${tableAlias}."${colName}"` : `"${colName}"`; + }; + + for (const filter of dataFilter.filters) { + const { columnName, operator, value } = filter; + + if (!columnName) { + continue; // 컬럼명이 없으면 스킵 + } + + const columnRef = getColumnRef(columnName); + + switch (operator) { + case "equals": + conditions.push(`${columnRef} = $${paramIndex}`); + params.push(value); + paramIndex++; + break; + + case "not_equals": + conditions.push(`${columnRef} != $${paramIndex}`); + params.push(value); + paramIndex++; + break; + + case "in": + if (Array.isArray(value) && value.length > 0) { + const placeholders = value.map((_, idx) => `$${paramIndex + idx}`).join(", "); + conditions.push(`${columnRef} IN (${placeholders})`); + params.push(...value); + paramIndex += value.length; + } + break; + + case "not_in": + if (Array.isArray(value) && value.length > 0) { + const placeholders = value.map((_, idx) => `$${paramIndex + idx}`).join(", "); + conditions.push(`${columnRef} NOT IN (${placeholders})`); + params.push(...value); + paramIndex += value.length; + } + break; + + case "contains": + conditions.push(`${columnRef} LIKE $${paramIndex}`); + params.push(`%${value}%`); + paramIndex++; + break; + + case "starts_with": + conditions.push(`${columnRef} LIKE $${paramIndex}`); + params.push(`${value}%`); + paramIndex++; + break; + + case "ends_with": + conditions.push(`${columnRef} LIKE $${paramIndex}`); + params.push(`%${value}`); + paramIndex++; + break; + + case "is_null": + conditions.push(`${columnRef} IS NULL`); + break; + + case "is_not_null": + conditions.push(`${columnRef} IS NOT NULL`); + break; + + default: + // 알 수 없는 연산자는 무시 + break; + } + } + + if (conditions.length === 0) { + return { whereClause: "", params: [] }; + } + + // matchType에 따라 AND / OR 조합 + const logicalOperator = dataFilter.matchType === "any" ? " OR " : " AND "; + const whereClause = `(${conditions.join(logicalOperator)})`; + + return { whereClause, params }; +} + +/** + * 기존 WHERE 절에 dataFilter 조건을 추가 + * @param existingWhere 기존 WHERE 절 (예: "company_code = $1") + * @param existingParams 기존 파라미터 배열 + * @param dataFilter 필터 설정 + * @returns { whereClause: string, params: any[] } + */ +export function appendDataFilterToWhere( + existingWhere: string, + existingParams: any[], + dataFilter: DataFilterConfig | undefined +): { whereClause: string; params: any[] } { + const { whereClause: filterWhere, params: filterParams } = buildDataFilterWhereClause( + dataFilter, + existingParams.length + 1 + ); + + if (!filterWhere) { + return { whereClause: existingWhere, params: existingParams }; + } + + const newWhere = existingWhere ? `${existingWhere} AND ${filterWhere}` : filterWhere; + const newParams = [...existingParams, ...filterParams]; + + return { whereClause: newWhere, params: newParams }; +} + diff --git a/frontend/components/screen/config-panels/DataFilterConfigPanel.tsx b/frontend/components/screen/config-panels/DataFilterConfigPanel.tsx new file mode 100644 index 00000000..f3fed8bf --- /dev/null +++ b/frontend/components/screen/config-panels/DataFilterConfigPanel.tsx @@ -0,0 +1,374 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Trash2, Plus } from "lucide-react"; +import { ColumnFilter, DataFilterConfig } from "@/types/screen-management"; +import { UnifiedColumnInfo } from "@/types/table-management"; +import { apiClient } from "@/lib/api/client"; + +interface DataFilterConfigPanelProps { + tableName?: string; + columns?: UnifiedColumnInfo[]; + config?: DataFilterConfig; + onConfigChange: (config: DataFilterConfig) => void; +} + +/** + * 데이터 필터 설정 패널 + * 테이블 리스트, 분할 패널, 플로우 위젯 등에서 사용 + */ +export function DataFilterConfigPanel({ + tableName, + columns = [], + config, + onConfigChange, +}: DataFilterConfigPanelProps) { + const [localConfig, setLocalConfig] = useState( + config || { + enabled: false, + filters: [], + matchType: "all", + } + ); + + // 카테고리 값 캐시 (컬럼명 -> 카테고리 값 목록) + const [categoryValues, setCategoryValues] = useState>>({}); + const [loadingCategories, setLoadingCategories] = useState>({}); + + useEffect(() => { + if (config) { + setLocalConfig(config); + } + }, [config]); + + // 카테고리 값 로드 + const loadCategoryValues = async (columnName: string) => { + if (!tableName || categoryValues[columnName] || loadingCategories[columnName]) { + return; // 이미 로드되었거나 로딩 중이면 스킵 + } + + setLoadingCategories(prev => ({ ...prev, [columnName]: true })); + + try { + const response = await apiClient.get( + `/table-categories/${tableName}/${columnName}/values` + ); + + if (response.data.success && response.data.data) { + const values = response.data.data.map((item: any) => ({ + value: item.valueCode, + label: item.valueLabel, + })); + + setCategoryValues(prev => ({ ...prev, [columnName]: values })); + } + } catch (error) { + console.error(`카테고리 값 로드 실패 (${columnName}):`, error); + } finally { + setLoadingCategories(prev => ({ ...prev, [columnName]: false })); + } + }; + + const handleEnabledChange = (enabled: boolean) => { + const newConfig = { ...localConfig, enabled }; + setLocalConfig(newConfig); + onConfigChange(newConfig); + }; + + const handleMatchTypeChange = (matchType: "all" | "any") => { + const newConfig = { ...localConfig, matchType }; + setLocalConfig(newConfig); + onConfigChange(newConfig); + }; + + const handleAddFilter = () => { + const newFilter: ColumnFilter = { + id: `filter-${Date.now()}`, + columnName: columns[0]?.columnName || "", + operator: "equals", + value: "", + valueType: "static", + }; + const newConfig = { + ...localConfig, + filters: [...localConfig.filters, newFilter], + }; + setLocalConfig(newConfig); + onConfigChange(newConfig); + }; + + const handleRemoveFilter = (filterId: string) => { + const newConfig = { + ...localConfig, + filters: localConfig.filters.filter((f) => f.id !== filterId), + }; + setLocalConfig(newConfig); + onConfigChange(newConfig); + }; + + const handleFilterChange = (filterId: string, field: keyof ColumnFilter, value: any) => { + const newConfig = { + ...localConfig, + filters: localConfig.filters.map((filter) => + filter.id === filterId ? { ...filter, [field]: value } : filter + ), + }; + setLocalConfig(newConfig); + onConfigChange(newConfig); + }; + + // 선택된 컬럼의 input_type 찾기 (데이터베이스의 실제 input_type) + const getColumnInputType = (columnName: string) => { + const column = columns.find((col) => col.columnName === columnName); + // input_type (소문자) 필드 사용 - 이것이 실제 카테고리/엔티티 타입 정보 + return column?.input_type || column?.webType || "text"; + }; + + // 카테고리/코드 타입인지 확인 + const isCategoryOrCodeColumn = (columnName: string) => { + const inputType = getColumnInputType(columnName); + return inputType === "category" || inputType === "code"; + }; + + return ( +
+ {/* 필터 활성화 */} +
+ + +
+ + {localConfig.enabled && ( + <> + {/* 테이블명 표시 */} + {tableName && ( +
+ 테이블: {tableName} +
+ )} + + {/* 매칭 타입 */} + {localConfig.filters.length > 1 && ( +
+ + +
+ )} + + {/* 필터 목록 */} +
+ {localConfig.filters.map((filter, index) => ( +
+
+ + 필터 {index + 1} + + +
+ + {/* 컬럼 선택 */} +
+ + +
+ + {/* 연산자 선택 */} +
+ + +
+ + {/* 값 타입 선택 (카테고리/코드 컬럼만) */} + {isCategoryOrCodeColumn(filter.columnName) && ( +
+ + +
+ )} + + {/* 값 입력 (NULL 체크 제외) */} + {filter.operator !== "is_null" && filter.operator !== "is_not_null" && ( +
+ + {/* 카테고리 타입이고 값 타입이 category인 경우 셀렉트박스 */} + {filter.valueType === "category" && categoryValues[filter.columnName] ? ( + + ) : filter.operator === "in" || filter.operator === "not_in" ? ( + { + const values = e.target.value.split(",").map((v) => v.trim()); + handleFilterChange(filter.id, "value", values); + }} + placeholder="쉼표로 구분 (예: 값1, 값2, 값3)" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> + ) : ( + handleFilterChange(filter.id, "value", e.target.value)} + placeholder="필터 값 입력" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> + )} +

+ {filter.valueType === "category" && categoryValues[filter.columnName] + ? "카테고리 값을 선택하세요" + : filter.operator === "in" || filter.operator === "not_in" + ? "여러 값은 쉼표(,)로 구분하세요" + : "필터링할 값을 입력하세요"} +

+
+ )} +
+ ))} +
+ + {/* 필터 추가 버튼 */} + + + {columns.length === 0 && ( +

+ 테이블을 먼저 선택해주세요 +

+ )} + + )} +
+ ); +} + diff --git a/frontend/components/screen/config-panels/FlowWidgetConfigPanel.tsx b/frontend/components/screen/config-panels/FlowWidgetConfigPanel.tsx index 11ec5dd5..f35cc728 100644 --- a/frontend/components/screen/config-panels/FlowWidgetConfigPanel.tsx +++ b/frontend/components/screen/config-panels/FlowWidgetConfigPanel.tsx @@ -11,6 +11,7 @@ import { getFlowDefinitions } from "@/lib/api/flow"; import type { FlowDefinition } from "@/types/flow"; import { Loader2, Check, ChevronsUpDown } from "lucide-react"; import { cn } from "@/lib/utils"; +import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel"; interface FlowWidgetConfigPanelProps { config: Record; @@ -153,6 +154,24 @@ export function FlowWidgetConfigPanel({ config = {}, onChange }: FlowWidgetConfi + + {/* 🆕 데이터 필터링 설정 */} + {config.flowId && ( +
+
+

데이터 필터링

+

+ 특정 컬럼 값으로 플로우 데이터를 필터링합니다 +

+
+ onChange({ ...config, dataFilter })} + /> +
+ )} ); } diff --git a/frontend/lib/api/data.ts b/frontend/lib/api/data.ts index 208308ff..b3c023bf 100644 --- a/frontend/lib/api/data.ts +++ b/frontend/lib/api/data.ts @@ -62,6 +62,7 @@ export const dataApi = { leftColumn: string, rightColumn: string, leftValue?: any, + dataFilter?: any, // 🆕 데이터 필터 ): Promise => { const response = await apiClient.get(`/data/join`, { params: { @@ -70,6 +71,7 @@ export const dataApi = { leftColumn, rightColumn, leftValue, + dataFilter: dataFilter ? JSON.stringify(dataFilter) : undefined, // 🆕 데이터 필터 전달 }, }); const raw = response.data || {}; diff --git a/frontend/lib/api/entityJoin.ts b/frontend/lib/api/entityJoin.ts index d26e42b9..4402b557 100644 --- a/frontend/lib/api/entityJoin.ts +++ b/frontend/lib/api/entityJoin.ts @@ -68,6 +68,7 @@ export const entityJoinApi = { joinAlias: string; }>; screenEntityConfigs?: Record; // 🎯 화면별 엔티티 설정 + dataFilter?: any; // 🆕 데이터 필터 } = {}, ): Promise => { const searchParams = new URLSearchParams(); @@ -103,6 +104,7 @@ export const entityJoinApi = { additionalJoinColumns: params.additionalJoinColumns ? JSON.stringify(params.additionalJoinColumns) : undefined, screenEntityConfigs: params.screenEntityConfigs ? JSON.stringify(params.screenEntityConfigs) : undefined, // 🎯 화면별 엔티티 설정 autoFilter: JSON.stringify(autoFilter), // 🔒 멀티테넌시 필터링 + dataFilter: params.dataFilter ? JSON.stringify(params.dataFilter) : undefined, // 🆕 데이터 필터 }, }); return response.data.data; diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index 91947094..ced60e9e 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -257,6 +257,7 @@ export const SplitPanelLayoutComponent: React.FC size: 100, search: filters, // 필터 조건 전달 enableEntityJoin: true, // 엔티티 조인 활성화 + dataFilter: componentConfig.leftPanel?.dataFilter, // 🆕 데이터 필터 전달 }); @@ -314,6 +315,7 @@ export const SplitPanelLayoutComponent: React.FC leftColumn, rightColumn, leftValue, + componentConfig.rightPanel?.dataFilter, // 🆕 데이터 필터 전달 ); setRightData(joinedData || []); // 모든 관련 레코드 (배열) } diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx index 0b37ee26..57ab7c33 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx @@ -9,12 +9,13 @@ import { Slider } from "@/components/ui/slider"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; +// Accordion 제거 - 단순 섹션으로 변경 import { Check, ChevronsUpDown, ArrowRight, Plus, X } from "lucide-react"; import { cn } from "@/lib/utils"; import { SplitPanelLayoutConfig } from "./types"; import { TableInfo, ColumnInfo } from "@/types/screen"; import { tableTypeApi } from "@/lib/api/screen"; +import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel"; interface SplitPanelLayoutConfigPanelProps { config: SplitPanelLayoutConfig; @@ -325,14 +326,9 @@ export const SplitPanelLayoutConfigPanel: React.FC - {/* 좌측 패널 설정 (Accordion) */} - - - - 좌측 패널 설정 (마스터) - - -
+ {/* 좌측 패널 설정 */} +
+

좌측 패널 설정 (마스터)

@@ -1018,19 +1014,30 @@ export const SplitPanelLayoutConfigPanel: React.FC
)} -
- - - +
- {/* 우측 패널 설정 (Accordion) */} - - - - 우측 패널 설정 ({relationshipType === "detail" ? "상세" : "조인"}) - - -
+ {/* 좌측 패널 데이터 필터링 */} +
+

좌측 패널 데이터 필터링

+

+ 특정 컬럼 값으로 좌측 패널 데이터를 필터링합니다 +

+ ({ + columnName: col.columnName, + columnLabel: col.columnLabel || col.columnName, + dataType: col.dataType || "text", + input_type: (col as any).input_type, + } as any))} + config={config.leftPanel?.dataFilter} + onConfigChange={(dataFilter) => updateLeftPanel({ dataFilter })} + /> +
+ + {/* 우측 패널 설정 */} +
+

우측 패널 설정 ({relationshipType === "detail" ? "상세" : "조인"})

@@ -1672,19 +1679,29 @@ export const SplitPanelLayoutConfigPanel: React.FC
)} -
- - - +
- {/* 레이아웃 설정 (Accordion) */} - - - - 레이아웃 설정 - - -
+ {/* 우측 패널 데이터 필터링 */} +
+

우측 패널 데이터 필터링

+

+ 특정 컬럼 값으로 우측 패널 데이터를 필터링합니다 +

+ ({ + columnName: col.columnName, + columnLabel: col.columnLabel || col.columnName, + dataType: col.dataType || "text", + input_type: (col as any).input_type, + } as any))} + config={config.rightPanel?.dataFilter} + onConfigChange={(dataFilter) => updateRightPanel({ dataFilter })} + /> +
+ + {/* 레이아웃 설정 */} +
@@ -1712,10 +1729,7 @@ export const SplitPanelLayoutConfigPanel: React.FC updateConfig({ autoLoad: checked })} />
-
- - - +
); }; diff --git a/frontend/lib/registry/components/split-panel-layout/types.ts b/frontend/lib/registry/components/split-panel-layout/types.ts index 6f6421e5..95d98085 100644 --- a/frontend/lib/registry/components/split-panel-layout/types.ts +++ b/frontend/lib/registry/components/split-panel-layout/types.ts @@ -2,6 +2,8 @@ * SplitPanelLayout 컴포넌트 타입 정의 */ +import { DataFilterConfig } from "@/types/screen-management"; + export interface SplitPanelLayoutConfig { // 좌측 패널 설정 leftPanel: { @@ -52,6 +54,9 @@ export interface SplitPanelLayoutConfig { hoverable?: boolean; // 호버 효과 stickyHeader?: boolean; // 헤더 고정 }; + + // 🆕 컬럼 값 기반 데이터 필터링 + dataFilter?: DataFilterConfig; }; // 우측 패널 설정 @@ -105,6 +110,9 @@ export interface SplitPanelLayoutConfig { hoverable?: boolean; // 호버 효과 stickyHeader?: boolean; // 헤더 고정 }; + + // 🆕 컬럼 값 기반 데이터 필터링 + dataFilter?: DataFilterConfig; }; // 레이아웃 설정 diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 6d5a64b4..16734059 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -852,6 +852,7 @@ export const TableListComponent: React.FC = ({ search: filters, enableEntityJoin: true, additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined, + dataFilter: tableConfig.dataFilter, // 🆕 데이터 필터 전달 }); // 실제 데이터의 item_number만 추출하여 중복 확인 diff --git a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx index d1136f80..6d76cfc8 100644 --- a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx +++ b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx @@ -13,6 +13,7 @@ import { Plus, Trash2, ArrowUp, ArrowDown, ChevronsUpDown, Check } from "lucide- import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { cn } from "@/lib/utils"; +import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel"; export interface TableListConfigPanelProps { config: TableListConfig; @@ -47,7 +48,7 @@ export const TableListConfigPanel: React.FC = ({ const [availableTables, setAvailableTables] = useState>([]); const [loadingTables, setLoadingTables] = useState(false); const [availableColumns, setAvailableColumns] = useState< - Array<{ columnName: string; dataType: string; label?: string }> + Array<{ columnName: string; dataType: string; label?: string; input_type?: string }> >([]); const [entityJoinColumns, setEntityJoinColumns] = useState<{ availableColumns: Array<{ @@ -157,6 +158,7 @@ export const TableListConfigPanel: React.FC = ({ columnName: column.columnName || column.name, dataType: column.dataType || column.type || "text", label: column.label || column.displayName || column.columnLabel || column.columnName || column.name, + input_type: column.input_type || column.inputType, // 🆕 input_type 추가 })); setAvailableColumns(mappedColumns); @@ -189,6 +191,7 @@ export const TableListConfigPanel: React.FC = ({ columnName: col.columnName, dataType: col.dataType, label: col.displayName || col.columnName, + input_type: col.input_type || col.inputType, // 🆕 input_type 추가 })), ); } @@ -1140,6 +1143,28 @@ export const TableListConfigPanel: React.FC = ({ )} + + {/* 🆕 데이터 필터링 설정 */} +
+
+

데이터 필터링

+

+ 특정 컬럼 값으로 데이터를 필터링합니다 +

+
+
+ ({ + columnName: col.columnName, + columnLabel: col.label || col.columnName, + dataType: col.dataType, + input_type: col.input_type, // 🆕 실제 input_type 전달 + } as any))} + config={config.dataFilter} + onConfigChange={(dataFilter) => handleChange("dataFilter", dataFilter)} + /> +
); diff --git a/frontend/lib/registry/components/table-list/types.ts b/frontend/lib/registry/components/table-list/types.ts index 053d6fb1..04cbfae2 100644 --- a/frontend/lib/registry/components/table-list/types.ts +++ b/frontend/lib/registry/components/table-list/types.ts @@ -173,6 +173,9 @@ export interface CheckboxConfig { /** * TableList 컴포넌트 설정 타입 */ + +import { DataFilterConfig } from "@/types/screen-management"; + export interface TableListConfig extends ComponentConfig { // 표시 모드 설정 displayMode?: "table" | "card"; // 기본: "table" @@ -225,6 +228,9 @@ export interface TableListConfig extends ComponentConfig { autoLoad: boolean; refreshInterval?: number; // 초 단위 + // 🆕 컬럼 값 기반 데이터 필터링 + dataFilter?: DataFilterConfig; + // 이벤트 핸들러 onRowClick?: (row: any) => void; onRowDoubleClick?: (row: any) => void; diff --git a/frontend/types/screen-management.ts b/frontend/types/screen-management.ts index d83a6354..a1dbd99a 100644 --- a/frontend/types/screen-management.ts +++ b/frontend/types/screen-management.ts @@ -137,6 +137,9 @@ export interface DataTableComponent extends BaseComponent { filterColumn: string; // 필터링할 테이블 컬럼 (예: company_code, dept_code) userField: 'companyCode' | 'userId' | 'deptCode'; // 사용자 정보에서 가져올 필드 }; + + // 🆕 컬럼 값 기반 데이터 필터링 + dataFilter?: DataFilterConfig; } /** @@ -173,6 +176,8 @@ export interface FlowComponent extends BaseComponent { stepColumnConfig?: { [stepId: number]: FlowStepColumnConfig; }; + // 🆕 컬럼 값 기반 데이터 필터링 + dataFilter?: DataFilterConfig; } /** @@ -435,6 +440,26 @@ export interface DataTableFilter { logicalOperator?: "AND" | "OR"; } +/** + * 컬럼 필터 조건 (단일 필터) + */ +export interface ColumnFilter { + id: string; + columnName: string; // 필터링할 컬럼명 + operator: "equals" | "not_equals" | "in" | "not_in" | "contains" | "starts_with" | "ends_with" | "is_null" | "is_not_null"; + value: string | string[]; // 필터 값 (in/not_in은 배열) + valueType: "static" | "category" | "code"; // 값 타입 +} + +/** + * 데이터 필터 설정 (여러 필터의 조합) + */ +export interface DataFilterConfig { + enabled: boolean; // 필터 활성화 여부 + filters: ColumnFilter[]; // 필터 조건 목록 + matchType: "all" | "any"; // AND(모두 만족) / OR(하나 이상 만족) +} + // ===== 파일 업로드 관련 ===== /**