feature/screen-management #206

Merged
kjs merged 3 commits from feature/screen-management into main 2025-11-13 17:07:02 +09:00
17 changed files with 941 additions and 98 deletions

View File

@ -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, // 🆕 데이터 필터 전달
}
);

View File

@ -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<null> = {
@ -796,6 +798,7 @@ export async function getTableData(
search: enhancedSearch, // 🆕 필터가 적용된 search 사용
sortBy,
sortOrder,
dataFilter, // 🆕 데이터 필터 전달
});
logger.info(

View File

@ -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) {

View File

@ -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<ServiceResponse<any[]>> {
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 ")}`;

View File

@ -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<any>(
`SELECT
c.column_name as "columnName",
@ -260,8 +261,11 @@ export class TableManagementService {
let categoryMappings: Map<string, number[]> = new Map();
if (mappingTableExists && companyCode) {
logger.info("📥 getColumnList: 카테고리 매핑 조회 시작", { tableName, companyCode });
logger.info("📥 getColumnList: 카테고리 매핑 조회 시작", {
tableName,
companyCode,
});
const mappings = await query<any>(
`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<void> {
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<string, any> | 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<string, any>;
} 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<any>(dataQuery, [...searchValues, size, offset]);
@ -2152,6 +2215,7 @@ export class TableManagementService {
joinAlias: string;
}>;
screenEntityConfigs?: Record<string, any>; // 화면별 엔티티 설정
dataFilter?: any; // 🆕 데이터 필터
}
): Promise<EntityJoinResponse> {
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<string, number[]> = new Map();
if (mappingTableExists) {
logger.info("카테고리 매핑 조회 시작", { tableName, companyCode });
const mappings = await query<any>(
`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<ColumnTypeInfo[]> {
async getColumnWebTypes(
tableName: string,
companyCode: string
): Promise<ColumnTypeInfo[]> {
logger.warn(
`레거시 메서드 사용: 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 { Loader2, Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel";
interface FlowWidgetConfigPanelProps {
config: Record<string, any>;
@ -153,6 +154,24 @@ export function FlowWidgetConfigPanel({ config = {}, onChange }: FlowWidgetConfi
</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>
);
}

View File

@ -62,6 +62,7 @@ export const dataApi = {
leftColumn: string,
rightColumn: string,
leftValue?: any,
dataFilter?: any, // 🆕 데이터 필터
): Promise<any[]> => {
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 || {};

View File

@ -68,6 +68,7 @@ export const entityJoinApi = {
joinAlias: string;
}>;
screenEntityConfigs?: Record<string, any>; // 🎯 화면별 엔티티 설정
dataFilter?: any; // 🆕 데이터 필터
} = {},
): Promise<EntityJoinResponse> => {
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;

View File

@ -257,6 +257,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
size: 100,
search: filters, // 필터 조건 전달
enableEntityJoin: true, // 엔티티 조인 활성화
dataFilter: componentConfig.leftPanel?.dataFilter, // 🆕 데이터 필터 전달
});
@ -314,6 +315,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
leftColumn,
rightColumn,
leftValue,
componentConfig.rightPanel?.dataFilter, // 🆕 데이터 필터 전달
);
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 { 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<SplitPanelLayoutConfigPanelPr
</Select>
</div>
{/* 좌측 패널 설정 (Accordion) */}
<Accordion type="single" collapsible defaultValue="left-panel" className="w-full">
<AccordionItem value="left-panel" className="border rounded-lg px-4">
<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-4 border-t pt-4 mt-4">
<h3 className="text-sm font-semibold"> ()</h3>
<div className="space-y-2">
<Label> </Label>
@ -1018,19 +1014,30 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
</div>
</div>
)}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
{/* 우측 패널 설정 (Accordion) */}
<Accordion type="single" collapsible defaultValue="right-panel" className="w-full">
<AccordionItem value="right-panel" className="border rounded-lg px-4">
<AccordionTrigger className="text-sm font-semibold hover:no-underline">
({relationshipType === "detail" ? "상세" : "조인"})
</AccordionTrigger>
<AccordionContent className="overflow-visible">
<div className="space-y-4 pt-2">
{/* 좌측 패널 데이터 필터링 */}
<div className="space-y-4 border-t pt-4 mt-4">
<h3 className="text-sm font-semibold"> </h3>
<p className="text-xs text-muted-foreground">
</p>
<DataFilterConfigPanel
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">
<Label> </Label>
@ -1672,19 +1679,29 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
</div>
</div>
)}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
{/* 레이아웃 설정 (Accordion) */}
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="layout" className="border rounded-lg px-4">
<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-4 border-t pt-4 mt-4">
<h3 className="text-sm font-semibold"> </h3>
<p className="text-xs text-muted-foreground">
</p>
<DataFilterConfigPanel
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">
<Label> : {config.splitRatio || 30}%</Label>
@ -1712,10 +1729,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
onCheckedChange={(checked) => updateConfig({ autoLoad: checked })}
/>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
);
};

View File

@ -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;
};
// 레이아웃 설정

View File

@ -852,6 +852,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
search: filters,
enableEntityJoin: true,
additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined,
dataFilter: tableConfig.dataFilter, // 🆕 데이터 필터 전달
});
// 실제 데이터의 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 { 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<TableListConfigPanelProps> = ({
const [availableTables, setAvailableTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
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<TableListConfigPanelProps> = ({
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<TableListConfigPanelProps> = ({
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<TableListConfigPanelProps> = ({
</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>
);

View File

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

View File

@ -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(하나 이상 만족)
}
// ===== 파일 업로드 관련 =====
/**