Compare commits

...

2 Commits

17 changed files with 941 additions and 98 deletions

View File

@ -28,6 +28,7 @@ export class EntityJoinController {
additionalJoinColumns, // 추가 조인 컬럼 정보 (JSON 문자열) additionalJoinColumns, // 추가 조인 컬럼 정보 (JSON 문자열)
screenEntityConfigs, // 화면별 엔티티 설정 (JSON 문자열) screenEntityConfigs, // 화면별 엔티티 설정 (JSON 문자열)
autoFilter, // 🔒 멀티테넌시 자동 필터 autoFilter, // 🔒 멀티테넌시 자동 필터
dataFilter, // 🆕 데이터 필터 (JSON 문자열)
userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함 userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함
...otherParams ...otherParams
} = req.query; } = 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( const result = await tableManagementService.getTableDataWithEntityJoins(
tableName, tableName,
{ {
@ -126,6 +140,7 @@ export class EntityJoinController {
enableEntityJoin === "true" || enableEntityJoin === true, enableEntityJoin === "true" || enableEntityJoin === true,
additionalJoinColumns: parsedAdditionalJoinColumns, additionalJoinColumns: parsedAdditionalJoinColumns,
screenEntityConfigs: parsedScreenEntityConfigs, screenEntityConfigs: parsedScreenEntityConfigs,
dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달
} }
); );

View File

@ -742,6 +742,7 @@ export async function getTableData(
sortBy, sortBy,
sortOrder = "asc", sortOrder = "asc",
autoFilter, // 🆕 자동 필터 설정 추가 (컴포넌트에서 직접 전달) autoFilter, // 🆕 자동 필터 설정 추가 (컴포넌트에서 직접 전달)
dataFilter, // 🆕 컬럼 값 기반 데이터 필터링
} = req.body; } = req.body;
logger.info(`=== 테이블 데이터 조회 시작: ${tableName} ===`); logger.info(`=== 테이블 데이터 조회 시작: ${tableName} ===`);
@ -749,6 +750,7 @@ export async function getTableData(
logger.info(`검색 조건:`, search); logger.info(`검색 조건:`, search);
logger.info(`정렬: ${sortBy} ${sortOrder}`); logger.info(`정렬: ${sortBy} ${sortOrder}`);
logger.info(`자동 필터:`, autoFilter); // 🆕 logger.info(`자동 필터:`, autoFilter); // 🆕
logger.info(`데이터 필터:`, dataFilter); // 🆕
if (!tableName) { if (!tableName) {
const response: ApiResponse<null> = { const response: ApiResponse<null> = {
@ -796,6 +798,7 @@ export async function getTableData(
search: enhancedSearch, // 🆕 필터가 적용된 search 사용 search: enhancedSearch, // 🆕 필터가 적용된 search 사용
sortBy, sortBy,
sortOrder, sortOrder,
dataFilter, // 🆕 데이터 필터 전달
}); });
logger.info( logger.info(

View File

@ -14,7 +14,7 @@ router.get(
authenticateToken, authenticateToken,
async (req: AuthenticatedRequest, res) => { async (req: AuthenticatedRequest, res) => {
try { try {
const { leftTable, rightTable, leftColumn, rightColumn, leftValue } = const { leftTable, rightTable, leftColumn, rightColumn, leftValue, dataFilter } =
req.query; 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 인젝션 방지를 위한 검증 // SQL 인젝션 방지를 위한 검증
const tables = [leftTable as string, rightTable as string]; const tables = [leftTable as string, rightTable as string];
const columns = [leftColumn as string, rightColumn as string]; const columns = [leftColumn as string, rightColumn as string];
@ -61,16 +71,18 @@ router.get(
rightColumn, rightColumn,
leftValue, leftValue,
userCompany, userCompany,
dataFilter: parsedDataFilter, // 🆕 데이터 필터 로그
}); });
// 조인 데이터 조회 (회사 코드 전달) // 조인 데이터 조회 (회사 코드 + 데이터 필터 전달)
const result = await dataService.getJoinedData( const result = await dataService.getJoinedData(
leftTable as string, leftTable as string,
rightTable as string, rightTable as string,
leftColumn as string, leftColumn as string,
rightColumn as string, rightColumn as string,
leftValue as string, leftValue as string,
userCompany userCompany,
parsedDataFilter // 🆕 데이터 필터 전달
); );
if (!result.success) { if (!result.success) {

View File

@ -14,6 +14,7 @@
* - (company_code = "*") * - (company_code = "*")
*/ */
import { query, queryOne } from "../database/db"; import { query, queryOne } from "../database/db";
import { buildDataFilterWhereClause } from "../utils/dataFilterUtil"; // 🆕 데이터 필터 유틸
interface GetTableDataParams { interface GetTableDataParams {
tableName: string; tableName: string;
@ -434,7 +435,8 @@ class DataService {
leftColumn: string, leftColumn: string,
rightColumn: string, rightColumn: string,
leftValue?: string | number, leftValue?: string | number,
userCompany?: string userCompany?: string,
dataFilter?: any // 🆕 데이터 필터
): Promise<ServiceResponse<any[]>> { ): Promise<ServiceResponse<any[]>> {
try { 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 절 추가 // WHERE 절 추가
if (whereConditions.length > 0) { if (whereConditions.length > 0) {
queryText += ` WHERE ${whereConditions.join(" AND ")}`; queryText += ` WHERE ${whereConditions.join(" AND ")}`;

View File

@ -128,7 +128,8 @@ export class TableManagementService {
); );
// 캐시 키 생성 (companyCode 포함) // 캐시 키 생성 (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); const countCacheKey = CacheKeys.TABLE_COLUMN_COUNT(tableName);
// 캐시에서 먼저 확인 // 캐시에서 먼저 확인
@ -162,9 +163,9 @@ export class TableManagementService {
// 페이지네이션 적용한 컬럼 조회 // 페이지네이션 적용한 컬럼 조회
const offset = (page - 1) * size; const offset = (page - 1) * size;
// 🔥 company_code가 있으면 table_type_columns 조인하여 회사별 inputType 가져오기 // 🔥 company_code가 있으면 table_type_columns 조인하여 회사별 inputType 가져오기
const rawColumns = companyCode const rawColumns = companyCode
? await query<any>( ? await query<any>(
`SELECT `SELECT
c.column_name as "columnName", c.column_name as "columnName",
@ -260,8 +261,11 @@ export class TableManagementService {
let categoryMappings: Map<string, number[]> = new Map(); let categoryMappings: Map<string, number[]> = new Map();
if (mappingTableExists && companyCode) { if (mappingTableExists && companyCode) {
logger.info("📥 getColumnList: 카테고리 매핑 조회 시작", { tableName, companyCode }); logger.info("📥 getColumnList: 카테고리 매핑 조회 시작", {
tableName,
companyCode,
});
const mappings = await query<any>( const mappings = await query<any>(
`SELECT `SELECT
logical_column_name as "columnName", logical_column_name as "columnName",
@ -272,11 +276,11 @@ export class TableManagementService {
[tableName, companyCode] [tableName, companyCode]
); );
logger.info("✅ getColumnList: 카테고리 매핑 조회 완료", { logger.info("✅ getColumnList: 카테고리 매핑 조회 완료", {
tableName, tableName,
companyCode, companyCode,
mappingCount: mappings.length, mappingCount: mappings.length,
mappings: mappings mappings: mappings,
}); });
mappings.forEach((m: any) => { mappings.forEach((m: any) => {
@ -288,7 +292,7 @@ export class TableManagementService {
logger.info("✅ getColumnList: categoryMappings Map 생성 완료", { logger.info("✅ getColumnList: categoryMappings Map 생성 완료", {
size: categoryMappings.size, size: categoryMappings.size,
entries: Array.from(categoryMappings.entries()) entries: Array.from(categoryMappings.entries()),
}); });
} }
@ -300,19 +304,27 @@ export class TableManagementService {
numericPrecision: column.numericPrecision numericPrecision: column.numericPrecision
? Number(column.numericPrecision) ? Number(column.numericPrecision)
: null, : null,
numericScale: column.numericScale ? Number(column.numericScale) : null, numericScale: column.numericScale
displayOrder: column.displayOrder ? Number(column.displayOrder) : null, ? Number(column.numericScale)
// 자동 매핑: webType이 기본값('text')인 경우 DB 타입에 따라 자동 추론 : null,
webType: displayOrder: column.displayOrder
column.webType === "text" ? Number(column.displayOrder)
? this.inferWebType(column.dataType) : null,
: column.webType, // webType은 사용자가 명시적으로 설정한 값을 그대로 사용
// (자동 추론은 column_labels에 없는 경우에만 SQL 쿼리의 COALESCE에서 처리됨)
webType: column.webType,
}; };
// 카테고리 타입인 경우 categoryMenus 추가 // 카테고리 타입인 경우 categoryMenus 추가
if (column.inputType === "category" && categoryMappings.has(column.columnName)) { if (
column.inputType === "category" &&
categoryMappings.has(column.columnName)
) {
const menus = categoryMappings.get(column.columnName); const menus = categoryMappings.get(column.columnName);
logger.info(`✅ getColumnList: 컬럼 ${column.columnName}에 카테고리 메뉴 추가`, { menus }); logger.info(
`✅ getColumnList: 컬럼 ${column.columnName}에 카테고리 메뉴 추가`,
{ menus }
);
return { return {
...baseColumn, ...baseColumn,
categoryMenus: menus, categoryMenus: menus,
@ -417,7 +429,9 @@ export class TableManagementService {
companyCode: string // 🔥 회사 코드 추가 companyCode: string // 🔥 회사 코드 추가
): Promise<void> { ): Promise<void> {
try { try {
logger.info(`컬럼 설정 업데이트 시작: ${tableName}.${columnName}, company: ${companyCode}`); logger.info(
`컬럼 설정 업데이트 시작: ${tableName}.${columnName}, company: ${companyCode}`
);
// 테이블이 table_labels에 없으면 자동 추가 // 테이블이 table_labels에 없으면 자동 추가
await this.insertTableIfNotExists(tableName); await this.insertTableIfNotExists(tableName);
@ -463,17 +477,22 @@ export class TableManagementService {
// detailSettings가 문자열이면 파싱, 객체면 그대로 사용 // detailSettings가 문자열이면 파싱, 객체면 그대로 사용
let parsedDetailSettings: Record<string, any> | undefined = undefined; let parsedDetailSettings: Record<string, any> | undefined = undefined;
if (settings.detailSettings) { if (settings.detailSettings) {
if (typeof settings.detailSettings === 'string') { if (typeof settings.detailSettings === "string") {
try { try {
parsedDetailSettings = JSON.parse(settings.detailSettings); parsedDetailSettings = JSON.parse(settings.detailSettings);
} catch (e) { } catch (e) {
logger.warn(`detailSettings 파싱 실패, 그대로 사용: ${settings.detailSettings}`); logger.warn(
`detailSettings 파싱 실패, 그대로 사용: ${settings.detailSettings}`
);
} }
} else if (typeof settings.detailSettings === 'object') { } else if (typeof settings.detailSettings === "object") {
parsedDetailSettings = settings.detailSettings as Record<string, any>; parsedDetailSettings = settings.detailSettings as Record<
string,
any
>;
} }
} }
await this.updateColumnInputType( await this.updateColumnInputType(
tableName, tableName,
columnName, columnName,
@ -486,7 +505,7 @@ export class TableManagementService {
// 캐시 무효화 - 해당 테이블의 컬럼 캐시 삭제 // 캐시 무효화 - 해당 테이블의 컬럼 캐시 삭제
cache.deleteByPattern(`table_columns:${tableName}:`); cache.deleteByPattern(`table_columns:${tableName}:`);
cache.delete(CacheKeys.TABLE_COLUMN_COUNT(tableName)); cache.delete(CacheKeys.TABLE_COLUMN_COUNT(tableName));
logger.info(`컬럼 설정 업데이트 완료: ${tableName}.${columnName}`); logger.info(`컬럼 설정 업데이트 완료: ${tableName}.${columnName}`);
} catch (error) { } catch (error) {
logger.error( logger.error(
@ -1133,7 +1152,7 @@ export class TableManagementService {
if (typeof value === "object" && value !== null && "value" in value) { if (typeof value === "object" && value !== null && "value" in value) {
actualValue = value.value; actualValue = value.value;
operator = value.operator || "contains"; operator = value.operator || "contains";
logger.info("🔍 필터 객체 처리:", { logger.info("🔍 필터 객체 처리:", {
columnName, columnName,
originalValue: value, originalValue: value,
@ -1180,11 +1199,19 @@ export class TableManagementService {
switch (webType) { switch (webType) {
case "date": case "date":
case "datetime": case "datetime":
return this.buildDateRangeCondition(columnName, actualValue, paramIndex); return this.buildDateRangeCondition(
columnName,
actualValue,
paramIndex
);
case "number": case "number":
case "decimal": case "decimal":
return this.buildNumberRangeCondition(columnName, actualValue, paramIndex); return this.buildNumberRangeCondition(
columnName,
actualValue,
paramIndex
);
case "code": case "code":
return await this.buildCodeSearchCondition( return await this.buildCodeSearchCondition(
@ -1220,7 +1247,7 @@ export class TableManagementService {
if (typeof value === "object" && value !== null && "value" in value) { if (typeof value === "object" && value !== null && "value" in value) {
fallbackValue = value.value; fallbackValue = value.value;
} }
return { return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`, whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${fallbackValue}%`], values: [`%${fallbackValue}%`],
@ -1583,6 +1610,7 @@ export class TableManagementService {
sortBy?: string; sortBy?: string;
sortOrder?: string; sortOrder?: string;
companyCode?: string; companyCode?: string;
dataFilter?: any; // 🆕 DataFilterConfig
} }
): Promise<{ ): Promise<{
data: any[]; data: any[];
@ -1592,7 +1620,15 @@ export class TableManagementService {
totalPages: number; totalPages: number;
}> { }> {
try { 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; const offset = (page - 1) * size;
logger.info(`테이블 데이터 조회: ${tableName}`, options); logger.info(`테이블 데이터 조회: ${tableName}`, options);
@ -1611,7 +1647,9 @@ export class TableManagementService {
whereConditions.push(`company_code = $${paramIndex}`); whereConditions.push(`company_code = $${paramIndex}`);
searchValues.push(companyCode); searchValues.push(companyCode);
paramIndex++; paramIndex++;
logger.info(`🔒 멀티테넌시 필터 추가 (기본 조회): company_code = ${companyCode}`); logger.info(
`🔒 멀티테넌시 필터 추가 (기본 조회): company_code = ${companyCode}`
);
} }
if (search && Object.keys(search).length > 0) { 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 = const whereClause =
whereConditions.length > 0 whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}` ? `WHERE ${whereConditions.join(" AND ")}`
@ -1680,7 +1741,9 @@ export class TableManagementService {
`; `;
logger.info(`🔍 실행할 SQL: ${dataQuery}`); logger.info(`🔍 실행할 SQL: ${dataQuery}`);
logger.info(`🔍 파라미터: ${JSON.stringify([...searchValues, size, offset])}`); logger.info(
`🔍 파라미터: ${JSON.stringify([...searchValues, size, offset])}`
);
let data = await query<any>(dataQuery, [...searchValues, size, offset]); let data = await query<any>(dataQuery, [...searchValues, size, offset]);
@ -2152,6 +2215,7 @@ export class TableManagementService {
joinAlias: string; joinAlias: string;
}>; }>;
screenEntityConfigs?: Record<string, any>; // 화면별 엔티티 설정 screenEntityConfigs?: Record<string, any>; // 화면별 엔티티 설정
dataFilter?: any; // 🆕 데이터 필터
} }
): Promise<EntityJoinResponse> { ): Promise<EntityJoinResponse> {
const startTime = Date.now(); const startTime = Date.now();
@ -2311,18 +2375,99 @@ export class TableManagementService {
const selectColumns = columns.data.map((col: any) => col.column_name); const selectColumns = columns.data.map((col: any) => col.column_name);
// WHERE 절 구성 // WHERE 절 구성
let whereClause = await this.buildWhereClause( let whereClause = await this.buildWhereClause(tableName, options.search);
tableName,
options.search
);
// 멀티테넌시 필터 추가 (company_code) // 멀티테넌시 필터 추가 (company_code)
if (options.companyCode) { if (options.companyCode) {
const companyFilter = `main.company_code = '${options.companyCode.replace(/'/g, "''")}'`; const companyFilter = `main.company_code = '${options.companyCode.replace(/'/g, "''")}'`;
whereClause = whereClause whereClause = whereClause
? `${whereClause} AND ${companyFilter}` ? `${whereClause} AND ${companyFilter}`
: 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 절 구성 // ORDER BY 절 구성
@ -2412,8 +2557,10 @@ export class TableManagementService {
query(dataQuery), query(dataQuery),
query(countQuery), query(countQuery),
]); ]);
logger.info(`✅ [executeJoinQuery] 조회 완료: ${dataResult?.length}개 행`); logger.info(
`✅ [executeJoinQuery] 조회 완료: ${dataResult?.length}개 행`
);
const data = Array.isArray(dataResult) ? dataResult : []; const data = Array.isArray(dataResult) ? dataResult : [];
const total = 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 { } else {
// Entity 조인 컬럼 검색이 없는 경우 기존 캐시 방식 사용 // Entity 조인 컬럼 검색이 없는 경우 기존 캐시 방식 사용
basicResult = await this.getTableData(tableName, { ...options, companyCode: options.companyCode }); basicResult = await this.getTableData(tableName, {
...options,
companyCode: options.companyCode,
});
} }
// Entity 값들을 캐시에서 룩업하여 변환 // Entity 값들을 캐시에서 룩업하여 변환
@ -2921,13 +3074,20 @@ export class TableManagementService {
// 모든 조인이 캐시 가능한 경우: 기본 쿼리 + 캐시 룩업 // 모든 조인이 캐시 가능한 경우: 기본 쿼리 + 캐시 룩업
else { else {
// whereClause에서 company_code 추출 (멀티테넌시 필터) // 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; const companyCode = companyCodeMatch ? companyCodeMatch[1] : undefined;
return await this.executeCachedLookup( return await this.executeCachedLookup(
tableName, tableName,
cacheableJoins, cacheableJoins,
{ page: Math.floor(offset / limit) + 1, size: limit, search: {}, companyCode }, {
page: Math.floor(offset / limit) + 1,
size: limit,
search: {},
companyCode,
},
startTime startTime
); );
} }
@ -2949,7 +3109,7 @@ export class TableManagementService {
for (const config of joinConfigs) { for (const config of joinConfigs) {
// table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인 // table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인
if (config.referenceTable === 'table_column_category_values') { if (config.referenceTable === "table_column_category_values") {
dbJoins.push(config); dbJoins.push(config);
console.log(`🔗 DB 조인 (특수 조건): ${config.referenceTable}`); console.log(`🔗 DB 조인 (특수 조건): ${config.referenceTable}`);
continue; continue;
@ -3227,7 +3387,7 @@ export class TableManagementService {
let categoryMappings: Map<string, number[]> = new Map(); let categoryMappings: Map<string, number[]> = new Map();
if (mappingTableExists) { if (mappingTableExists) {
logger.info("카테고리 매핑 조회 시작", { tableName, companyCode }); logger.info("카테고리 매핑 조회 시작", { tableName, companyCode });
const mappings = await query<any>( const mappings = await query<any>(
`SELECT `SELECT
logical_column_name as "columnName", logical_column_name as "columnName",
@ -3238,11 +3398,11 @@ export class TableManagementService {
[tableName, companyCode] [tableName, companyCode]
); );
logger.info("카테고리 매핑 조회 완료", { logger.info("카테고리 매핑 조회 완료", {
tableName, tableName,
companyCode, companyCode,
mappingCount: mappings.length, mappingCount: mappings.length,
mappings: mappings mappings: mappings,
}); });
mappings.forEach((m: any) => { mappings.forEach((m: any) => {
@ -3254,7 +3414,7 @@ export class TableManagementService {
logger.info("categoryMappings Map 생성 완료", { logger.info("categoryMappings Map 생성 완료", {
size: categoryMappings.size, size: categoryMappings.size,
entries: Array.from(categoryMappings.entries()) entries: Array.from(categoryMappings.entries()),
}); });
} else { } else {
logger.warn("category_column_mapping 테이블이 존재하지 않음"); logger.warn("category_column_mapping 테이블이 존재하지 않음");
@ -3276,9 +3436,14 @@ export class TableManagementService {
}; };
// 카테고리 타입인 경우 categoryMenus 추가 // 카테고리 타입인 경우 categoryMenus 추가
if (col.inputType === "category" && categoryMappings.has(col.columnName)) { if (
col.inputType === "category" &&
categoryMappings.has(col.columnName)
) {
const menus = categoryMappings.get(col.columnName); const menus = categoryMappings.get(col.columnName);
logger.info(`✅ 컬럼 ${col.columnName}에 카테고리 메뉴 추가`, { menus }); logger.info(`✅ 컬럼 ${col.columnName}에 카테고리 메뉴 추가`, {
menus,
});
return { return {
...baseInfo, ...baseInfo,
categoryMenus: menus, categoryMenus: menus,
@ -3309,7 +3474,10 @@ export class TableManagementService {
* 지원: 컬럼 * 지원: 컬럼
* @deprecated getColumnInputTypes * @deprecated getColumnInputTypes
*/ */
async getColumnWebTypes(tableName: string, companyCode: string): Promise<ColumnTypeInfo[]> { async getColumnWebTypes(
tableName: string,
companyCode: string
): Promise<ColumnTypeInfo[]> {
logger.warn( logger.warn(
`레거시 메서드 사용: getColumnWebTypes → getColumnInputTypes 사용 권장` `레거시 메서드 사용: getColumnWebTypes → getColumnInputTypes 사용 권장`
); );

View File

@ -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 };
}

View File

@ -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<DataFilterConfig>(
config || {
enabled: false,
filters: [],
matchType: "all",
}
);
// 카테고리 값 캐시 (컬럼명 -> 카테고리 값 목록)
const [categoryValues, setCategoryValues] = useState<Record<string, Array<{ value: string; label: string }>>>({});
const [loadingCategories, setLoadingCategories] = useState<Record<string, boolean>>({});
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 (
<div className="space-y-4">
{/* 필터 활성화 */}
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Switch checked={localConfig.enabled} onCheckedChange={handleEnabledChange} />
</div>
{localConfig.enabled && (
<>
{/* 테이블명 표시 */}
{tableName && (
<div className="text-xs text-muted-foreground">
: <span className="font-medium">{tableName}</span>
</div>
)}
{/* 매칭 타입 */}
{localConfig.filters.length > 1 && (
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Select value={localConfig.matchType} onValueChange={handleMatchTypeChange}>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> (AND)</SelectItem>
<SelectItem value="any"> (OR)</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* 필터 목록 */}
<div className="space-y-3 max-h-[600px] overflow-y-auto pr-2">
{localConfig.filters.map((filter, index) => (
<div key={filter.id} className="rounded-lg border p-3 space-y-2">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-muted-foreground">
{index + 1}
</span>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => handleRemoveFilter(filter.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* 컬럼 선택 */}
<div>
<Label className="text-xs"></Label>
<Select
value={filter.columnName}
onValueChange={(value) => {
const column = columns.find((col) => col.columnName === value);
console.log("🔍 컬럼 선택:", {
columnName: value,
input_type: column?.input_type,
column,
});
// 컬럼 타입에 따라 valueType 자동 설정
let valueType: "static" | "category" | "code" = "static";
if (column?.input_type === "category") {
valueType = "category";
console.log("📦 카테고리 컬럼 감지, 값 로딩 시작:", value);
loadCategoryValues(value); // 카테고리 값 로드
} else if (column?.input_type === "code") {
valueType = "code";
}
// 한 번에 모든 변경사항 적용
const newConfig = {
...localConfig,
filters: localConfig.filters.map((f) =>
f.id === filter.id
? { ...f, columnName: value, valueType, value: "" }
: f
),
};
console.log("✅ 필터 설정 업데이트:", {
filterId: filter.id,
columnName: value,
valueType,
newConfig,
});
setLocalConfig(newConfig);
onConfigChange(newConfig);
}}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
{(col.input_type === "category" || col.input_type === "code") && (
<span className="ml-2 text-xs text-muted-foreground">
({col.input_type})
</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 연산자 선택 */}
<div>
<Label className="text-xs"></Label>
<Select
value={filter.operator}
onValueChange={(value: any) => handleFilterChange(filter.id, "operator", value)}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="equals"> (=)</SelectItem>
<SelectItem value="not_equals"> ()</SelectItem>
<SelectItem value="in"> (IN)</SelectItem>
<SelectItem value="not_in"> (NOT IN)</SelectItem>
<SelectItem value="contains"> (LIKE %value%)</SelectItem>
<SelectItem value="starts_with"> (LIKE value%)</SelectItem>
<SelectItem value="ends_with"> (LIKE %value)</SelectItem>
<SelectItem value="is_null">NULL</SelectItem>
<SelectItem value="is_not_null">NOT NULL</SelectItem>
</SelectContent>
</Select>
</div>
{/* 값 타입 선택 (카테고리/코드 컬럼만) */}
{isCategoryOrCodeColumn(filter.columnName) && (
<div>
<Label className="text-xs"> </Label>
<Select
value={filter.valueType}
onValueChange={(value: any) =>
handleFilterChange(filter.id, "valueType", value)
}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="static"> </SelectItem>
<SelectItem value="category"> </SelectItem>
<SelectItem value="code"> </SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* 값 입력 (NULL 체크 제외) */}
{filter.operator !== "is_null" && filter.operator !== "is_not_null" && (
<div>
<Label className="text-xs"></Label>
{/* 카테고리 타입이고 값 타입이 category인 경우 셀렉트박스 */}
{filter.valueType === "category" && categoryValues[filter.columnName] ? (
<Select
value={Array.isArray(filter.value) ? filter.value[0] : filter.value}
onValueChange={(value) => handleFilterChange(filter.id, "value", value)}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder={
loadingCategories[filter.columnName] ? "로딩 중..." : "값 선택"
} />
</SelectTrigger>
<SelectContent>
{categoryValues[filter.columnName].map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : filter.operator === "in" || filter.operator === "not_in" ? (
<Input
value={Array.isArray(filter.value) ? filter.value.join(", ") : filter.value}
onChange={(e) => {
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"
/>
) : (
<Input
value={Array.isArray(filter.value) ? filter.value[0] || "" : filter.value}
onChange={(e) => handleFilterChange(filter.id, "value", e.target.value)}
placeholder="필터 값 입력"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
)}
<p className="text-[10px] text-muted-foreground mt-1">
{filter.valueType === "category" && categoryValues[filter.columnName]
? "카테고리 값을 선택하세요"
: filter.operator === "in" || filter.operator === "not_in"
? "여러 값은 쉼표(,)로 구분하세요"
: "필터링할 값을 입력하세요"}
</p>
</div>
)}
</div>
))}
</div>
{/* 필터 추가 버튼 */}
<Button
variant="outline"
size="sm"
className="w-full h-8 text-xs sm:h-10 sm:text-sm"
onClick={handleAddFilter}
disabled={columns.length === 0}
>
<Plus className="mr-2 h-4 w-4" />
</Button>
{columns.length === 0 && (
<p className="text-xs text-muted-foreground text-center">
</p>
)}
</>
)}
</div>
);
}

View File

@ -11,6 +11,7 @@ import { getFlowDefinitions } from "@/lib/api/flow";
import type { FlowDefinition } from "@/types/flow"; import type { FlowDefinition } from "@/types/flow";
import { Loader2, Check, ChevronsUpDown } from "lucide-react"; import { Loader2, Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel";
interface FlowWidgetConfigPanelProps { interface FlowWidgetConfigPanelProps {
config: Record<string, any>; config: Record<string, any>;
@ -153,6 +154,24 @@ export function FlowWidgetConfigPanel({ config = {}, onChange }: FlowWidgetConfi
</div> </div>
</div> </div>
</div> </div>
{/* 🆕 데이터 필터링 설정 */}
{config.flowId && (
<div>
<div className="mb-2">
<h3 className="text-sm font-medium"> </h3>
<p className="text-xs text-muted-foreground mt-1">
</p>
</div>
<DataFilterConfigPanel
tableName={selectedFlow?.name}
columns={[]} // 플로우의 첫 번째 스텝 테이블 컬럼 정보 필요 (TODO: API 연동)
config={config.dataFilter}
onConfigChange={(dataFilter) => onChange({ ...config, dataFilter })}
/>
</div>
)}
</div> </div>
); );
} }

View File

@ -62,6 +62,7 @@ export const dataApi = {
leftColumn: string, leftColumn: string,
rightColumn: string, rightColumn: string,
leftValue?: any, leftValue?: any,
dataFilter?: any, // 🆕 데이터 필터
): Promise<any[]> => { ): Promise<any[]> => {
const response = await apiClient.get(`/data/join`, { const response = await apiClient.get(`/data/join`, {
params: { params: {
@ -70,6 +71,7 @@ export const dataApi = {
leftColumn, leftColumn,
rightColumn, rightColumn,
leftValue, leftValue,
dataFilter: dataFilter ? JSON.stringify(dataFilter) : undefined, // 🆕 데이터 필터 전달
}, },
}); });
const raw = response.data || {}; const raw = response.data || {};

View File

@ -68,6 +68,7 @@ export const entityJoinApi = {
joinAlias: string; joinAlias: string;
}>; }>;
screenEntityConfigs?: Record<string, any>; // 🎯 화면별 엔티티 설정 screenEntityConfigs?: Record<string, any>; // 🎯 화면별 엔티티 설정
dataFilter?: any; // 🆕 데이터 필터
} = {}, } = {},
): Promise<EntityJoinResponse> => { ): Promise<EntityJoinResponse> => {
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
@ -103,6 +104,7 @@ export const entityJoinApi = {
additionalJoinColumns: params.additionalJoinColumns ? JSON.stringify(params.additionalJoinColumns) : undefined, additionalJoinColumns: params.additionalJoinColumns ? JSON.stringify(params.additionalJoinColumns) : undefined,
screenEntityConfigs: params.screenEntityConfigs ? JSON.stringify(params.screenEntityConfigs) : undefined, // 🎯 화면별 엔티티 설정 screenEntityConfigs: params.screenEntityConfigs ? JSON.stringify(params.screenEntityConfigs) : undefined, // 🎯 화면별 엔티티 설정
autoFilter: JSON.stringify(autoFilter), // 🔒 멀티테넌시 필터링 autoFilter: JSON.stringify(autoFilter), // 🔒 멀티테넌시 필터링
dataFilter: params.dataFilter ? JSON.stringify(params.dataFilter) : undefined, // 🆕 데이터 필터
}, },
}); });
return response.data.data; return response.data.data;

View File

@ -257,6 +257,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
size: 100, size: 100,
search: filters, // 필터 조건 전달 search: filters, // 필터 조건 전달
enableEntityJoin: true, // 엔티티 조인 활성화 enableEntityJoin: true, // 엔티티 조인 활성화
dataFilter: componentConfig.leftPanel?.dataFilter, // 🆕 데이터 필터 전달
}); });
@ -314,6 +315,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
leftColumn, leftColumn,
rightColumn, rightColumn,
leftValue, leftValue,
componentConfig.rightPanel?.dataFilter, // 🆕 데이터 필터 전달
); );
setRightData(joinedData || []); // 모든 관련 레코드 (배열) setRightData(joinedData || []); // 모든 관련 레코드 (배열)
} }

View File

@ -9,12 +9,13 @@ import { Slider } from "@/components/ui/slider";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button"; 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 { Check, ChevronsUpDown, ArrowRight, Plus, X } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { SplitPanelLayoutConfig } from "./types"; import { SplitPanelLayoutConfig } from "./types";
import { TableInfo, ColumnInfo } from "@/types/screen"; import { TableInfo, ColumnInfo } from "@/types/screen";
import { tableTypeApi } from "@/lib/api/screen"; import { tableTypeApi } from "@/lib/api/screen";
import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel";
interface SplitPanelLayoutConfigPanelProps { interface SplitPanelLayoutConfigPanelProps {
config: SplitPanelLayoutConfig; config: SplitPanelLayoutConfig;
@ -325,14 +326,9 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
</Select> </Select>
</div> </div>
{/* 좌측 패널 설정 (Accordion) */} {/* 좌측 패널 설정 */}
<Accordion type="single" collapsible defaultValue="left-panel" className="w-full"> <div className="space-y-4 border-t pt-4 mt-4">
<AccordionItem value="left-panel" className="border rounded-lg px-4"> <h3 className="text-sm font-semibold"> ()</h3>
<AccordionTrigger className="text-sm font-semibold hover:no-underline">
()
</AccordionTrigger>
<AccordionContent className="overflow-visible">
<div className="space-y-4 pt-2">
<div className="space-y-2"> <div className="space-y-2">
<Label> </Label> <Label> </Label>
@ -1018,19 +1014,30 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
</div> </div>
</div> </div>
)} )}
</div> </div>
</AccordionContent>
</AccordionItem>
</Accordion>
{/* 우측 패널 설정 (Accordion) */} {/* 좌측 패널 데이터 필터링 */}
<Accordion type="single" collapsible defaultValue="right-panel" className="w-full"> <div className="space-y-4 border-t pt-4 mt-4">
<AccordionItem value="right-panel" className="border rounded-lg px-4"> <h3 className="text-sm font-semibold"> </h3>
<AccordionTrigger className="text-sm font-semibold hover:no-underline"> <p className="text-xs text-muted-foreground">
({relationshipType === "detail" ? "상세" : "조인"})
</AccordionTrigger> </p>
<AccordionContent className="overflow-visible"> <DataFilterConfigPanel
<div className="space-y-4 pt-2"> tableName={config.leftPanel?.tableName || screenTableName}
columns={leftTableColumns.map((col) => ({
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 })}
/>
</div>
{/* 우측 패널 설정 */}
<div className="space-y-4 border-t pt-4 mt-4">
<h3 className="text-sm font-semibold"> ({relationshipType === "detail" ? "상세" : "조인"})</h3>
<div className="space-y-2"> <div className="space-y-2">
<Label> </Label> <Label> </Label>
@ -1672,19 +1679,29 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
</div> </div>
</div> </div>
)} )}
</div> </div>
</AccordionContent>
</AccordionItem>
</Accordion>
{/* 레이아웃 설정 (Accordion) */} {/* 우측 패널 데이터 필터링 */}
<Accordion type="single" collapsible className="w-full"> <div className="space-y-4 border-t pt-4 mt-4">
<AccordionItem value="layout" className="border rounded-lg px-4"> <h3 className="text-sm font-semibold"> </h3>
<AccordionTrigger className="text-sm font-semibold hover:no-underline"> <p className="text-xs text-muted-foreground">
</AccordionTrigger> </p>
<AccordionContent className="overflow-visible"> <DataFilterConfigPanel
<div className="space-y-4 pt-2"> tableName={config.rightPanel?.tableName}
columns={rightTableColumns.map((col) => ({
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 })}
/>
</div>
{/* 레이아웃 설정 */}
<div className="space-y-4 border-t pt-4 mt-4">
<div className="space-y-2"> <div className="space-y-2">
<Label> : {config.splitRatio || 30}%</Label> <Label> : {config.splitRatio || 30}%</Label>
@ -1712,10 +1729,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
onCheckedChange={(checked) => updateConfig({ autoLoad: checked })} onCheckedChange={(checked) => updateConfig({ autoLoad: checked })}
/> />
</div> </div>
</div> </div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div> </div>
); );
}; };

View File

@ -2,6 +2,8 @@
* SplitPanelLayout * SplitPanelLayout
*/ */
import { DataFilterConfig } from "@/types/screen-management";
export interface SplitPanelLayoutConfig { export interface SplitPanelLayoutConfig {
// 좌측 패널 설정 // 좌측 패널 설정
leftPanel: { leftPanel: {
@ -52,6 +54,9 @@ export interface SplitPanelLayoutConfig {
hoverable?: boolean; // 호버 효과 hoverable?: boolean; // 호버 효과
stickyHeader?: boolean; // 헤더 고정 stickyHeader?: boolean; // 헤더 고정
}; };
// 🆕 컬럼 값 기반 데이터 필터링
dataFilter?: DataFilterConfig;
}; };
// 우측 패널 설정 // 우측 패널 설정
@ -105,6 +110,9 @@ export interface SplitPanelLayoutConfig {
hoverable?: boolean; // 호버 효과 hoverable?: boolean; // 호버 효과
stickyHeader?: boolean; // 헤더 고정 stickyHeader?: boolean; // 헤더 고정
}; };
// 🆕 컬럼 값 기반 데이터 필터링
dataFilter?: DataFilterConfig;
}; };
// 레이아웃 설정 // 레이아웃 설정

View File

@ -852,6 +852,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
search: filters, search: filters,
enableEntityJoin: true, enableEntityJoin: true,
additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined, additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined,
dataFilter: tableConfig.dataFilter, // 🆕 데이터 필터 전달
}); });
// 실제 데이터의 item_number만 추출하여 중복 확인 // 실제 데이터의 item_number만 추출하여 중복 확인

View File

@ -13,6 +13,7 @@ import { Plus, Trash2, ArrowUp, ArrowDown, ChevronsUpDown, Check } from "lucide-
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel";
export interface TableListConfigPanelProps { export interface TableListConfigPanelProps {
config: TableListConfig; config: TableListConfig;
@ -47,7 +48,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
const [availableTables, setAvailableTables] = useState<Array<{ tableName: string; displayName: string }>>([]); const [availableTables, setAvailableTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
const [loadingTables, setLoadingTables] = useState(false); const [loadingTables, setLoadingTables] = useState(false);
const [availableColumns, setAvailableColumns] = useState< 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<{ const [entityJoinColumns, setEntityJoinColumns] = useState<{
availableColumns: Array<{ availableColumns: Array<{
@ -157,6 +158,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
columnName: column.columnName || column.name, columnName: column.columnName || column.name,
dataType: column.dataType || column.type || "text", dataType: column.dataType || column.type || "text",
label: column.label || column.displayName || column.columnLabel || column.columnName || column.name, label: column.label || column.displayName || column.columnLabel || column.columnName || column.name,
input_type: column.input_type || column.inputType, // 🆕 input_type 추가
})); }));
setAvailableColumns(mappedColumns); setAvailableColumns(mappedColumns);
@ -189,6 +191,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
columnName: col.columnName, columnName: col.columnName,
dataType: col.dataType, dataType: col.dataType,
label: col.displayName || col.columnName, label: col.displayName || col.columnName,
input_type: col.input_type || col.inputType, // 🆕 input_type 추가
})), })),
); );
} }
@ -1140,6 +1143,28 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
</div> </div>
</div> </div>
)} )}
{/* 🆕 데이터 필터링 설정 */}
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"> </h3>
<p className="text-xs text-muted-foreground mt-1">
</p>
</div>
<hr className="border-border" />
<DataFilterConfigPanel
tableName={config.selectedTable || screenTableName}
columns={availableColumns.map((col) => ({
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)}
/>
</div>
</div> </div>
</div> </div>
); );

View File

@ -173,6 +173,9 @@ export interface CheckboxConfig {
/** /**
* TableList * TableList
*/ */
import { DataFilterConfig } from "@/types/screen-management";
export interface TableListConfig extends ComponentConfig { export interface TableListConfig extends ComponentConfig {
// 표시 모드 설정 // 표시 모드 설정
displayMode?: "table" | "card"; // 기본: "table" displayMode?: "table" | "card"; // 기본: "table"
@ -225,6 +228,9 @@ export interface TableListConfig extends ComponentConfig {
autoLoad: boolean; autoLoad: boolean;
refreshInterval?: number; // 초 단위 refreshInterval?: number; // 초 단위
// 🆕 컬럼 값 기반 데이터 필터링
dataFilter?: DataFilterConfig;
// 이벤트 핸들러 // 이벤트 핸들러
onRowClick?: (row: any) => void; onRowClick?: (row: any) => void;
onRowDoubleClick?: (row: any) => void; onRowDoubleClick?: (row: any) => void;

View File

@ -137,6 +137,9 @@ export interface DataTableComponent extends BaseComponent {
filterColumn: string; // 필터링할 테이블 컬럼 (예: company_code, dept_code) filterColumn: string; // 필터링할 테이블 컬럼 (예: company_code, dept_code)
userField: 'companyCode' | 'userId' | 'deptCode'; // 사용자 정보에서 가져올 필드 userField: 'companyCode' | 'userId' | 'deptCode'; // 사용자 정보에서 가져올 필드
}; };
// 🆕 컬럼 값 기반 데이터 필터링
dataFilter?: DataFilterConfig;
} }
/** /**
@ -173,6 +176,8 @@ export interface FlowComponent extends BaseComponent {
stepColumnConfig?: { stepColumnConfig?: {
[stepId: number]: FlowStepColumnConfig; [stepId: number]: FlowStepColumnConfig;
}; };
// 🆕 컬럼 값 기반 데이터 필터링
dataFilter?: DataFilterConfig;
} }
/** /**
@ -435,6 +440,26 @@ export interface DataTableFilter {
logicalOperator?: "AND" | "OR"; 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(하나 이상 만족)
}
// ===== 파일 업로드 관련 ===== // ===== 파일 업로드 관련 =====
/** /**