feature/screen-management #206
|
|
@ -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, // 🆕 데이터 필터 전달
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 ")}`;
|
||||
|
|
|
|||
|
|
@ -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 사용 권장`
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 || {};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 || []); // 모든 관련 레코드 (배열)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
// 레이아웃 설정
|
||||
|
|
|
|||
|
|
@ -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만 추출하여 중복 확인
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(하나 이상 만족)
|
||||
}
|
||||
|
||||
// ===== 파일 업로드 관련 =====
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue