Compare commits
2 Commits
2663400e26
...
ab1cbd37b3
| Author | SHA1 | Date |
|---|---|---|
|
|
ab1cbd37b3 | |
|
|
296ee3e825 |
|
|
@ -28,6 +28,7 @@ export class EntityJoinController {
|
||||||
additionalJoinColumns, // 추가 조인 컬럼 정보 (JSON 문자열)
|
additionalJoinColumns, // 추가 조인 컬럼 정보 (JSON 문자열)
|
||||||
screenEntityConfigs, // 화면별 엔티티 설정 (JSON 문자열)
|
screenEntityConfigs, // 화면별 엔티티 설정 (JSON 문자열)
|
||||||
autoFilter, // 🔒 멀티테넌시 자동 필터
|
autoFilter, // 🔒 멀티테넌시 자동 필터
|
||||||
|
dataFilter, // 🆕 데이터 필터 (JSON 문자열)
|
||||||
userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함
|
userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함
|
||||||
...otherParams
|
...otherParams
|
||||||
} = req.query;
|
} = req.query;
|
||||||
|
|
@ -111,6 +112,19 @@ export class EntityJoinController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 데이터 필터 처리
|
||||||
|
let parsedDataFilter: any = undefined;
|
||||||
|
if (dataFilter) {
|
||||||
|
try {
|
||||||
|
parsedDataFilter =
|
||||||
|
typeof dataFilter === "string" ? JSON.parse(dataFilter) : dataFilter;
|
||||||
|
logger.info("데이터 필터 파싱 완료:", parsedDataFilter);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("데이터 필터 파싱 오류:", error);
|
||||||
|
parsedDataFilter = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result = await tableManagementService.getTableDataWithEntityJoins(
|
const result = await tableManagementService.getTableDataWithEntityJoins(
|
||||||
tableName,
|
tableName,
|
||||||
{
|
{
|
||||||
|
|
@ -126,6 +140,7 @@ export class EntityJoinController {
|
||||||
enableEntityJoin === "true" || enableEntityJoin === true,
|
enableEntityJoin === "true" || enableEntityJoin === true,
|
||||||
additionalJoinColumns: parsedAdditionalJoinColumns,
|
additionalJoinColumns: parsedAdditionalJoinColumns,
|
||||||
screenEntityConfigs: parsedScreenEntityConfigs,
|
screenEntityConfigs: parsedScreenEntityConfigs,
|
||||||
|
dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -742,6 +742,7 @@ export async function getTableData(
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder = "asc",
|
sortOrder = "asc",
|
||||||
autoFilter, // 🆕 자동 필터 설정 추가 (컴포넌트에서 직접 전달)
|
autoFilter, // 🆕 자동 필터 설정 추가 (컴포넌트에서 직접 전달)
|
||||||
|
dataFilter, // 🆕 컬럼 값 기반 데이터 필터링
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
logger.info(`=== 테이블 데이터 조회 시작: ${tableName} ===`);
|
logger.info(`=== 테이블 데이터 조회 시작: ${tableName} ===`);
|
||||||
|
|
@ -749,6 +750,7 @@ export async function getTableData(
|
||||||
logger.info(`검색 조건:`, search);
|
logger.info(`검색 조건:`, search);
|
||||||
logger.info(`정렬: ${sortBy} ${sortOrder}`);
|
logger.info(`정렬: ${sortBy} ${sortOrder}`);
|
||||||
logger.info(`자동 필터:`, autoFilter); // 🆕
|
logger.info(`자동 필터:`, autoFilter); // 🆕
|
||||||
|
logger.info(`데이터 필터:`, dataFilter); // 🆕
|
||||||
|
|
||||||
if (!tableName) {
|
if (!tableName) {
|
||||||
const response: ApiResponse<null> = {
|
const response: ApiResponse<null> = {
|
||||||
|
|
@ -796,6 +798,7 @@ export async function getTableData(
|
||||||
search: enhancedSearch, // 🆕 필터가 적용된 search 사용
|
search: enhancedSearch, // 🆕 필터가 적용된 search 사용
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
|
dataFilter, // 🆕 데이터 필터 전달
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ router.get(
|
||||||
authenticateToken,
|
authenticateToken,
|
||||||
async (req: AuthenticatedRequest, res) => {
|
async (req: AuthenticatedRequest, res) => {
|
||||||
try {
|
try {
|
||||||
const { leftTable, rightTable, leftColumn, rightColumn, leftValue } =
|
const { leftTable, rightTable, leftColumn, rightColumn, leftValue, dataFilter } =
|
||||||
req.query;
|
req.query;
|
||||||
|
|
||||||
// 입력값 검증
|
// 입력값 검증
|
||||||
|
|
@ -27,6 +27,16 @@ router.get(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dataFilter 파싱 (JSON 문자열로 전달됨)
|
||||||
|
let parsedDataFilter = undefined;
|
||||||
|
if (dataFilter && typeof dataFilter === "string") {
|
||||||
|
try {
|
||||||
|
parsedDataFilter = JSON.parse(dataFilter);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("dataFilter 파싱 오류:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// SQL 인젝션 방지를 위한 검증
|
// SQL 인젝션 방지를 위한 검증
|
||||||
const tables = [leftTable as string, rightTable as string];
|
const tables = [leftTable as string, rightTable as string];
|
||||||
const columns = [leftColumn as string, rightColumn as string];
|
const columns = [leftColumn as string, rightColumn as string];
|
||||||
|
|
@ -61,16 +71,18 @@ router.get(
|
||||||
rightColumn,
|
rightColumn,
|
||||||
leftValue,
|
leftValue,
|
||||||
userCompany,
|
userCompany,
|
||||||
|
dataFilter: parsedDataFilter, // 🆕 데이터 필터 로그
|
||||||
});
|
});
|
||||||
|
|
||||||
// 조인 데이터 조회 (회사 코드 전달)
|
// 조인 데이터 조회 (회사 코드 + 데이터 필터 전달)
|
||||||
const result = await dataService.getJoinedData(
|
const result = await dataService.getJoinedData(
|
||||||
leftTable as string,
|
leftTable as string,
|
||||||
rightTable as string,
|
rightTable as string,
|
||||||
leftColumn as string,
|
leftColumn as string,
|
||||||
rightColumn as string,
|
rightColumn as string,
|
||||||
leftValue as string,
|
leftValue as string,
|
||||||
userCompany
|
userCompany,
|
||||||
|
parsedDataFilter // 🆕 데이터 필터 전달
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
* - 최고 관리자(company_code = "*")만 전체 데이터 조회 가능
|
* - 최고 관리자(company_code = "*")만 전체 데이터 조회 가능
|
||||||
*/
|
*/
|
||||||
import { query, queryOne } from "../database/db";
|
import { query, queryOne } from "../database/db";
|
||||||
|
import { buildDataFilterWhereClause } from "../utils/dataFilterUtil"; // 🆕 데이터 필터 유틸
|
||||||
|
|
||||||
interface GetTableDataParams {
|
interface GetTableDataParams {
|
||||||
tableName: string;
|
tableName: string;
|
||||||
|
|
@ -434,7 +435,8 @@ class DataService {
|
||||||
leftColumn: string,
|
leftColumn: string,
|
||||||
rightColumn: string,
|
rightColumn: string,
|
||||||
leftValue?: string | number,
|
leftValue?: string | number,
|
||||||
userCompany?: string
|
userCompany?: string,
|
||||||
|
dataFilter?: any // 🆕 데이터 필터
|
||||||
): Promise<ServiceResponse<any[]>> {
|
): Promise<ServiceResponse<any[]>> {
|
||||||
try {
|
try {
|
||||||
// 왼쪽 테이블 접근 검증
|
// 왼쪽 테이블 접근 검증
|
||||||
|
|
@ -478,6 +480,17 @@ class DataService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 데이터 필터 적용 (우측 패널에 대해, 테이블 별칭 "r" 사용)
|
||||||
|
if (dataFilter && dataFilter.enabled && dataFilter.filters && dataFilter.filters.length > 0) {
|
||||||
|
const filterResult = buildDataFilterWhereClause(dataFilter, "r", paramIndex);
|
||||||
|
if (filterResult.whereClause) {
|
||||||
|
whereConditions.push(filterResult.whereClause);
|
||||||
|
values.push(...filterResult.params);
|
||||||
|
paramIndex += filterResult.params.length;
|
||||||
|
console.log(`🔍 데이터 필터 적용 (${rightTable}):`, filterResult.whereClause);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// WHERE 절 추가
|
// WHERE 절 추가
|
||||||
if (whereConditions.length > 0) {
|
if (whereConditions.length > 0) {
|
||||||
queryText += ` WHERE ${whereConditions.join(" AND ")}`;
|
queryText += ` WHERE ${whereConditions.join(" AND ")}`;
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,8 @@ export class TableManagementService {
|
||||||
);
|
);
|
||||||
|
|
||||||
// 캐시 키 생성 (companyCode 포함)
|
// 캐시 키 생성 (companyCode 포함)
|
||||||
const cacheKey = CacheKeys.TABLE_COLUMNS(tableName, page, size) + `_${companyCode}`;
|
const cacheKey =
|
||||||
|
CacheKeys.TABLE_COLUMNS(tableName, page, size) + `_${companyCode}`;
|
||||||
const countCacheKey = CacheKeys.TABLE_COLUMN_COUNT(tableName);
|
const countCacheKey = CacheKeys.TABLE_COLUMN_COUNT(tableName);
|
||||||
|
|
||||||
// 캐시에서 먼저 확인
|
// 캐시에서 먼저 확인
|
||||||
|
|
@ -162,9 +163,9 @@ export class TableManagementService {
|
||||||
|
|
||||||
// 페이지네이션 적용한 컬럼 조회
|
// 페이지네이션 적용한 컬럼 조회
|
||||||
const offset = (page - 1) * size;
|
const offset = (page - 1) * size;
|
||||||
|
|
||||||
// 🔥 company_code가 있으면 table_type_columns 조인하여 회사별 inputType 가져오기
|
// 🔥 company_code가 있으면 table_type_columns 조인하여 회사별 inputType 가져오기
|
||||||
const rawColumns = companyCode
|
const rawColumns = companyCode
|
||||||
? await query<any>(
|
? await query<any>(
|
||||||
`SELECT
|
`SELECT
|
||||||
c.column_name as "columnName",
|
c.column_name as "columnName",
|
||||||
|
|
@ -260,8 +261,11 @@ export class TableManagementService {
|
||||||
|
|
||||||
let categoryMappings: Map<string, number[]> = new Map();
|
let categoryMappings: Map<string, number[]> = new Map();
|
||||||
if (mappingTableExists && companyCode) {
|
if (mappingTableExists && companyCode) {
|
||||||
logger.info("📥 getColumnList: 카테고리 매핑 조회 시작", { tableName, companyCode });
|
logger.info("📥 getColumnList: 카테고리 매핑 조회 시작", {
|
||||||
|
tableName,
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
const mappings = await query<any>(
|
const mappings = await query<any>(
|
||||||
`SELECT
|
`SELECT
|
||||||
logical_column_name as "columnName",
|
logical_column_name as "columnName",
|
||||||
|
|
@ -272,11 +276,11 @@ export class TableManagementService {
|
||||||
[tableName, companyCode]
|
[tableName, companyCode]
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info("✅ getColumnList: 카테고리 매핑 조회 완료", {
|
logger.info("✅ getColumnList: 카테고리 매핑 조회 완료", {
|
||||||
tableName,
|
tableName,
|
||||||
companyCode,
|
companyCode,
|
||||||
mappingCount: mappings.length,
|
mappingCount: mappings.length,
|
||||||
mappings: mappings
|
mappings: mappings,
|
||||||
});
|
});
|
||||||
|
|
||||||
mappings.forEach((m: any) => {
|
mappings.forEach((m: any) => {
|
||||||
|
|
@ -288,7 +292,7 @@ export class TableManagementService {
|
||||||
|
|
||||||
logger.info("✅ getColumnList: categoryMappings Map 생성 완료", {
|
logger.info("✅ getColumnList: categoryMappings Map 생성 완료", {
|
||||||
size: categoryMappings.size,
|
size: categoryMappings.size,
|
||||||
entries: Array.from(categoryMappings.entries())
|
entries: Array.from(categoryMappings.entries()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -300,19 +304,27 @@ export class TableManagementService {
|
||||||
numericPrecision: column.numericPrecision
|
numericPrecision: column.numericPrecision
|
||||||
? Number(column.numericPrecision)
|
? Number(column.numericPrecision)
|
||||||
: null,
|
: null,
|
||||||
numericScale: column.numericScale ? Number(column.numericScale) : null,
|
numericScale: column.numericScale
|
||||||
displayOrder: column.displayOrder ? Number(column.displayOrder) : null,
|
? Number(column.numericScale)
|
||||||
// 자동 매핑: webType이 기본값('text')인 경우 DB 타입에 따라 자동 추론
|
: null,
|
||||||
webType:
|
displayOrder: column.displayOrder
|
||||||
column.webType === "text"
|
? Number(column.displayOrder)
|
||||||
? this.inferWebType(column.dataType)
|
: null,
|
||||||
: column.webType,
|
// webType은 사용자가 명시적으로 설정한 값을 그대로 사용
|
||||||
|
// (자동 추론은 column_labels에 없는 경우에만 SQL 쿼리의 COALESCE에서 처리됨)
|
||||||
|
webType: column.webType,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 카테고리 타입인 경우 categoryMenus 추가
|
// 카테고리 타입인 경우 categoryMenus 추가
|
||||||
if (column.inputType === "category" && categoryMappings.has(column.columnName)) {
|
if (
|
||||||
|
column.inputType === "category" &&
|
||||||
|
categoryMappings.has(column.columnName)
|
||||||
|
) {
|
||||||
const menus = categoryMappings.get(column.columnName);
|
const menus = categoryMappings.get(column.columnName);
|
||||||
logger.info(`✅ getColumnList: 컬럼 ${column.columnName}에 카테고리 메뉴 추가`, { menus });
|
logger.info(
|
||||||
|
`✅ getColumnList: 컬럼 ${column.columnName}에 카테고리 메뉴 추가`,
|
||||||
|
{ menus }
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
...baseColumn,
|
...baseColumn,
|
||||||
categoryMenus: menus,
|
categoryMenus: menus,
|
||||||
|
|
@ -417,7 +429,9 @@ export class TableManagementService {
|
||||||
companyCode: string // 🔥 회사 코드 추가
|
companyCode: string // 🔥 회사 코드 추가
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
logger.info(`컬럼 설정 업데이트 시작: ${tableName}.${columnName}, company: ${companyCode}`);
|
logger.info(
|
||||||
|
`컬럼 설정 업데이트 시작: ${tableName}.${columnName}, company: ${companyCode}`
|
||||||
|
);
|
||||||
|
|
||||||
// 테이블이 table_labels에 없으면 자동 추가
|
// 테이블이 table_labels에 없으면 자동 추가
|
||||||
await this.insertTableIfNotExists(tableName);
|
await this.insertTableIfNotExists(tableName);
|
||||||
|
|
@ -463,17 +477,22 @@ export class TableManagementService {
|
||||||
// detailSettings가 문자열이면 파싱, 객체면 그대로 사용
|
// detailSettings가 문자열이면 파싱, 객체면 그대로 사용
|
||||||
let parsedDetailSettings: Record<string, any> | undefined = undefined;
|
let parsedDetailSettings: Record<string, any> | undefined = undefined;
|
||||||
if (settings.detailSettings) {
|
if (settings.detailSettings) {
|
||||||
if (typeof settings.detailSettings === 'string') {
|
if (typeof settings.detailSettings === "string") {
|
||||||
try {
|
try {
|
||||||
parsedDetailSettings = JSON.parse(settings.detailSettings);
|
parsedDetailSettings = JSON.parse(settings.detailSettings);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn(`detailSettings 파싱 실패, 그대로 사용: ${settings.detailSettings}`);
|
logger.warn(
|
||||||
|
`detailSettings 파싱 실패, 그대로 사용: ${settings.detailSettings}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else if (typeof settings.detailSettings === 'object') {
|
} else if (typeof settings.detailSettings === "object") {
|
||||||
parsedDetailSettings = settings.detailSettings as Record<string, any>;
|
parsedDetailSettings = settings.detailSettings as Record<
|
||||||
|
string,
|
||||||
|
any
|
||||||
|
>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.updateColumnInputType(
|
await this.updateColumnInputType(
|
||||||
tableName,
|
tableName,
|
||||||
columnName,
|
columnName,
|
||||||
|
|
@ -486,7 +505,7 @@ export class TableManagementService {
|
||||||
// 캐시 무효화 - 해당 테이블의 컬럼 캐시 삭제
|
// 캐시 무효화 - 해당 테이블의 컬럼 캐시 삭제
|
||||||
cache.deleteByPattern(`table_columns:${tableName}:`);
|
cache.deleteByPattern(`table_columns:${tableName}:`);
|
||||||
cache.delete(CacheKeys.TABLE_COLUMN_COUNT(tableName));
|
cache.delete(CacheKeys.TABLE_COLUMN_COUNT(tableName));
|
||||||
|
|
||||||
logger.info(`컬럼 설정 업데이트 완료: ${tableName}.${columnName}`);
|
logger.info(`컬럼 설정 업데이트 완료: ${tableName}.${columnName}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|
@ -1133,7 +1152,7 @@ export class TableManagementService {
|
||||||
if (typeof value === "object" && value !== null && "value" in value) {
|
if (typeof value === "object" && value !== null && "value" in value) {
|
||||||
actualValue = value.value;
|
actualValue = value.value;
|
||||||
operator = value.operator || "contains";
|
operator = value.operator || "contains";
|
||||||
|
|
||||||
logger.info("🔍 필터 객체 처리:", {
|
logger.info("🔍 필터 객체 처리:", {
|
||||||
columnName,
|
columnName,
|
||||||
originalValue: value,
|
originalValue: value,
|
||||||
|
|
@ -1180,11 +1199,19 @@ export class TableManagementService {
|
||||||
switch (webType) {
|
switch (webType) {
|
||||||
case "date":
|
case "date":
|
||||||
case "datetime":
|
case "datetime":
|
||||||
return this.buildDateRangeCondition(columnName, actualValue, paramIndex);
|
return this.buildDateRangeCondition(
|
||||||
|
columnName,
|
||||||
|
actualValue,
|
||||||
|
paramIndex
|
||||||
|
);
|
||||||
|
|
||||||
case "number":
|
case "number":
|
||||||
case "decimal":
|
case "decimal":
|
||||||
return this.buildNumberRangeCondition(columnName, actualValue, paramIndex);
|
return this.buildNumberRangeCondition(
|
||||||
|
columnName,
|
||||||
|
actualValue,
|
||||||
|
paramIndex
|
||||||
|
);
|
||||||
|
|
||||||
case "code":
|
case "code":
|
||||||
return await this.buildCodeSearchCondition(
|
return await this.buildCodeSearchCondition(
|
||||||
|
|
@ -1220,7 +1247,7 @@ export class TableManagementService {
|
||||||
if (typeof value === "object" && value !== null && "value" in value) {
|
if (typeof value === "object" && value !== null && "value" in value) {
|
||||||
fallbackValue = value.value;
|
fallbackValue = value.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
||||||
values: [`%${fallbackValue}%`],
|
values: [`%${fallbackValue}%`],
|
||||||
|
|
@ -1583,6 +1610,7 @@ export class TableManagementService {
|
||||||
sortBy?: string;
|
sortBy?: string;
|
||||||
sortOrder?: string;
|
sortOrder?: string;
|
||||||
companyCode?: string;
|
companyCode?: string;
|
||||||
|
dataFilter?: any; // 🆕 DataFilterConfig
|
||||||
}
|
}
|
||||||
): Promise<{
|
): Promise<{
|
||||||
data: any[];
|
data: any[];
|
||||||
|
|
@ -1592,7 +1620,15 @@ export class TableManagementService {
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const { page, size, search = {}, sortBy, sortOrder = "asc", companyCode } = options;
|
const {
|
||||||
|
page,
|
||||||
|
size,
|
||||||
|
search = {},
|
||||||
|
sortBy,
|
||||||
|
sortOrder = "asc",
|
||||||
|
companyCode,
|
||||||
|
dataFilter,
|
||||||
|
} = options;
|
||||||
const offset = (page - 1) * size;
|
const offset = (page - 1) * size;
|
||||||
|
|
||||||
logger.info(`테이블 데이터 조회: ${tableName}`, options);
|
logger.info(`테이블 데이터 조회: ${tableName}`, options);
|
||||||
|
|
@ -1611,7 +1647,9 @@ export class TableManagementService {
|
||||||
whereConditions.push(`company_code = $${paramIndex}`);
|
whereConditions.push(`company_code = $${paramIndex}`);
|
||||||
searchValues.push(companyCode);
|
searchValues.push(companyCode);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
logger.info(`🔒 멀티테넌시 필터 추가 (기본 조회): company_code = ${companyCode}`);
|
logger.info(
|
||||||
|
`🔒 멀티테넌시 필터 추가 (기본 조회): company_code = ${companyCode}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (search && Object.keys(search).length > 0) {
|
if (search && Object.keys(search).length > 0) {
|
||||||
|
|
@ -1649,6 +1687,29 @@ export class TableManagementService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 데이터 필터 적용
|
||||||
|
if (
|
||||||
|
dataFilter &&
|
||||||
|
dataFilter.enabled &&
|
||||||
|
dataFilter.filters &&
|
||||||
|
dataFilter.filters.length > 0
|
||||||
|
) {
|
||||||
|
const {
|
||||||
|
buildDataFilterWhereClause,
|
||||||
|
} = require("../utils/dataFilterUtil");
|
||||||
|
const { whereClause: filterWhere, params: filterParams } =
|
||||||
|
buildDataFilterWhereClause(dataFilter, paramIndex);
|
||||||
|
|
||||||
|
if (filterWhere) {
|
||||||
|
whereConditions.push(filterWhere);
|
||||||
|
searchValues.push(...filterParams);
|
||||||
|
paramIndex += filterParams.length;
|
||||||
|
|
||||||
|
logger.info(`🔍 데이터 필터 적용: ${filterWhere}`);
|
||||||
|
logger.info(`🔍 필터 파라미터:`, filterParams);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const whereClause =
|
const whereClause =
|
||||||
whereConditions.length > 0
|
whereConditions.length > 0
|
||||||
? `WHERE ${whereConditions.join(" AND ")}`
|
? `WHERE ${whereConditions.join(" AND ")}`
|
||||||
|
|
@ -1680,7 +1741,9 @@ export class TableManagementService {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
logger.info(`🔍 실행할 SQL: ${dataQuery}`);
|
logger.info(`🔍 실행할 SQL: ${dataQuery}`);
|
||||||
logger.info(`🔍 파라미터: ${JSON.stringify([...searchValues, size, offset])}`);
|
logger.info(
|
||||||
|
`🔍 파라미터: ${JSON.stringify([...searchValues, size, offset])}`
|
||||||
|
);
|
||||||
|
|
||||||
let data = await query<any>(dataQuery, [...searchValues, size, offset]);
|
let data = await query<any>(dataQuery, [...searchValues, size, offset]);
|
||||||
|
|
||||||
|
|
@ -2152,6 +2215,7 @@ export class TableManagementService {
|
||||||
joinAlias: string;
|
joinAlias: string;
|
||||||
}>;
|
}>;
|
||||||
screenEntityConfigs?: Record<string, any>; // 화면별 엔티티 설정
|
screenEntityConfigs?: Record<string, any>; // 화면별 엔티티 설정
|
||||||
|
dataFilter?: any; // 🆕 데이터 필터
|
||||||
}
|
}
|
||||||
): Promise<EntityJoinResponse> {
|
): Promise<EntityJoinResponse> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
@ -2311,18 +2375,99 @@ export class TableManagementService {
|
||||||
const selectColumns = columns.data.map((col: any) => col.column_name);
|
const selectColumns = columns.data.map((col: any) => col.column_name);
|
||||||
|
|
||||||
// WHERE 절 구성
|
// WHERE 절 구성
|
||||||
let whereClause = await this.buildWhereClause(
|
let whereClause = await this.buildWhereClause(tableName, options.search);
|
||||||
tableName,
|
|
||||||
options.search
|
|
||||||
);
|
|
||||||
|
|
||||||
// 멀티테넌시 필터 추가 (company_code)
|
// 멀티테넌시 필터 추가 (company_code)
|
||||||
if (options.companyCode) {
|
if (options.companyCode) {
|
||||||
const companyFilter = `main.company_code = '${options.companyCode.replace(/'/g, "''")}'`;
|
const companyFilter = `main.company_code = '${options.companyCode.replace(/'/g, "''")}'`;
|
||||||
whereClause = whereClause
|
whereClause = whereClause
|
||||||
? `${whereClause} AND ${companyFilter}`
|
? `${whereClause} AND ${companyFilter}`
|
||||||
: companyFilter;
|
: companyFilter;
|
||||||
logger.info(`🔒 멀티테넌시 필터 추가 (Entity 조인): company_code = ${options.companyCode}`);
|
logger.info(
|
||||||
|
`🔒 멀티테넌시 필터 추가 (Entity 조인): company_code = ${options.companyCode}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 데이터 필터 적용 (Entity 조인) - 파라미터 바인딩 없이 직접 값 삽입
|
||||||
|
if (
|
||||||
|
options.dataFilter &&
|
||||||
|
options.dataFilter.enabled &&
|
||||||
|
options.dataFilter.filters &&
|
||||||
|
options.dataFilter.filters.length > 0
|
||||||
|
) {
|
||||||
|
const filterConditions: string[] = [];
|
||||||
|
|
||||||
|
for (const filter of options.dataFilter.filters) {
|
||||||
|
const { columnName, operator, value } = filter;
|
||||||
|
|
||||||
|
if (!columnName || value === undefined || value === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeColumn = `main."${columnName}"`;
|
||||||
|
|
||||||
|
switch (operator) {
|
||||||
|
case "equals":
|
||||||
|
filterConditions.push(
|
||||||
|
`${safeColumn} = '${String(value).replace(/'/g, "''")}'`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "not_equals":
|
||||||
|
filterConditions.push(
|
||||||
|
`${safeColumn} != '${String(value).replace(/'/g, "''")}'`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "in":
|
||||||
|
if (Array.isArray(value) && value.length > 0) {
|
||||||
|
const values = value
|
||||||
|
.map((v) => `'${String(v).replace(/'/g, "''")}'`)
|
||||||
|
.join(", ");
|
||||||
|
filterConditions.push(`${safeColumn} IN (${values})`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "not_in":
|
||||||
|
if (Array.isArray(value) && value.length > 0) {
|
||||||
|
const values = value
|
||||||
|
.map((v) => `'${String(v).replace(/'/g, "''")}'`)
|
||||||
|
.join(", ");
|
||||||
|
filterConditions.push(`${safeColumn} NOT IN (${values})`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "contains":
|
||||||
|
filterConditions.push(
|
||||||
|
`${safeColumn} LIKE '%${String(value).replace(/'/g, "''")}%'`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "starts_with":
|
||||||
|
filterConditions.push(
|
||||||
|
`${safeColumn} LIKE '${String(value).replace(/'/g, "''")}%'`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "ends_with":
|
||||||
|
filterConditions.push(
|
||||||
|
`${safeColumn} LIKE '%${String(value).replace(/'/g, "''")}'`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "is_null":
|
||||||
|
filterConditions.push(`${safeColumn} IS NULL`);
|
||||||
|
break;
|
||||||
|
case "is_not_null":
|
||||||
|
filterConditions.push(`${safeColumn} IS NOT NULL`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterConditions.length > 0) {
|
||||||
|
const logicalOperator =
|
||||||
|
options.dataFilter.matchType === "any" ? " OR " : " AND ";
|
||||||
|
const filterWhere = `(${filterConditions.join(logicalOperator)})`;
|
||||||
|
|
||||||
|
whereClause = whereClause
|
||||||
|
? `${whereClause} AND ${filterWhere}`
|
||||||
|
: filterWhere;
|
||||||
|
|
||||||
|
logger.info(`🔍 데이터 필터 적용 (Entity 조인): ${filterWhere}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ORDER BY 절 구성
|
// ORDER BY 절 구성
|
||||||
|
|
@ -2412,8 +2557,10 @@ export class TableManagementService {
|
||||||
query(dataQuery),
|
query(dataQuery),
|
||||||
query(countQuery),
|
query(countQuery),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
logger.info(`✅ [executeJoinQuery] 조회 완료: ${dataResult?.length}개 행`);
|
logger.info(
|
||||||
|
`✅ [executeJoinQuery] 조회 완료: ${dataResult?.length}개 행`
|
||||||
|
);
|
||||||
|
|
||||||
const data = Array.isArray(dataResult) ? dataResult : [];
|
const data = Array.isArray(dataResult) ? dataResult : [];
|
||||||
const total =
|
const total =
|
||||||
|
|
@ -2643,11 +2790,17 @@ export class TableManagementService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
basicResult = await this.getTableData(tableName, { ...fallbackOptions, companyCode: options.companyCode });
|
basicResult = await this.getTableData(tableName, {
|
||||||
|
...fallbackOptions,
|
||||||
|
companyCode: options.companyCode,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Entity 조인 컬럼 검색이 없는 경우 기존 캐시 방식 사용
|
// Entity 조인 컬럼 검색이 없는 경우 기존 캐시 방식 사용
|
||||||
basicResult = await this.getTableData(tableName, { ...options, companyCode: options.companyCode });
|
basicResult = await this.getTableData(tableName, {
|
||||||
|
...options,
|
||||||
|
companyCode: options.companyCode,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Entity 값들을 캐시에서 룩업하여 변환
|
// Entity 값들을 캐시에서 룩업하여 변환
|
||||||
|
|
@ -2921,13 +3074,20 @@ export class TableManagementService {
|
||||||
// 모든 조인이 캐시 가능한 경우: 기본 쿼리 + 캐시 룩업
|
// 모든 조인이 캐시 가능한 경우: 기본 쿼리 + 캐시 룩업
|
||||||
else {
|
else {
|
||||||
// whereClause에서 company_code 추출 (멀티테넌시 필터)
|
// whereClause에서 company_code 추출 (멀티테넌시 필터)
|
||||||
const companyCodeMatch = whereClause.match(/main\.company_code\s*=\s*'([^']+)'/);
|
const companyCodeMatch = whereClause.match(
|
||||||
|
/main\.company_code\s*=\s*'([^']+)'/
|
||||||
|
);
|
||||||
const companyCode = companyCodeMatch ? companyCodeMatch[1] : undefined;
|
const companyCode = companyCodeMatch ? companyCodeMatch[1] : undefined;
|
||||||
|
|
||||||
return await this.executeCachedLookup(
|
return await this.executeCachedLookup(
|
||||||
tableName,
|
tableName,
|
||||||
cacheableJoins,
|
cacheableJoins,
|
||||||
{ page: Math.floor(offset / limit) + 1, size: limit, search: {}, companyCode },
|
{
|
||||||
|
page: Math.floor(offset / limit) + 1,
|
||||||
|
size: limit,
|
||||||
|
search: {},
|
||||||
|
companyCode,
|
||||||
|
},
|
||||||
startTime
|
startTime
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -2949,7 +3109,7 @@ export class TableManagementService {
|
||||||
|
|
||||||
for (const config of joinConfigs) {
|
for (const config of joinConfigs) {
|
||||||
// table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인
|
// table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인
|
||||||
if (config.referenceTable === 'table_column_category_values') {
|
if (config.referenceTable === "table_column_category_values") {
|
||||||
dbJoins.push(config);
|
dbJoins.push(config);
|
||||||
console.log(`🔗 DB 조인 (특수 조건): ${config.referenceTable}`);
|
console.log(`🔗 DB 조인 (특수 조건): ${config.referenceTable}`);
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -3227,7 +3387,7 @@ export class TableManagementService {
|
||||||
let categoryMappings: Map<string, number[]> = new Map();
|
let categoryMappings: Map<string, number[]> = new Map();
|
||||||
if (mappingTableExists) {
|
if (mappingTableExists) {
|
||||||
logger.info("카테고리 매핑 조회 시작", { tableName, companyCode });
|
logger.info("카테고리 매핑 조회 시작", { tableName, companyCode });
|
||||||
|
|
||||||
const mappings = await query<any>(
|
const mappings = await query<any>(
|
||||||
`SELECT
|
`SELECT
|
||||||
logical_column_name as "columnName",
|
logical_column_name as "columnName",
|
||||||
|
|
@ -3238,11 +3398,11 @@ export class TableManagementService {
|
||||||
[tableName, companyCode]
|
[tableName, companyCode]
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info("카테고리 매핑 조회 완료", {
|
logger.info("카테고리 매핑 조회 완료", {
|
||||||
tableName,
|
tableName,
|
||||||
companyCode,
|
companyCode,
|
||||||
mappingCount: mappings.length,
|
mappingCount: mappings.length,
|
||||||
mappings: mappings
|
mappings: mappings,
|
||||||
});
|
});
|
||||||
|
|
||||||
mappings.forEach((m: any) => {
|
mappings.forEach((m: any) => {
|
||||||
|
|
@ -3254,7 +3414,7 @@ export class TableManagementService {
|
||||||
|
|
||||||
logger.info("categoryMappings Map 생성 완료", {
|
logger.info("categoryMappings Map 생성 완료", {
|
||||||
size: categoryMappings.size,
|
size: categoryMappings.size,
|
||||||
entries: Array.from(categoryMappings.entries())
|
entries: Array.from(categoryMappings.entries()),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
logger.warn("category_column_mapping 테이블이 존재하지 않음");
|
logger.warn("category_column_mapping 테이블이 존재하지 않음");
|
||||||
|
|
@ -3276,9 +3436,14 @@ export class TableManagementService {
|
||||||
};
|
};
|
||||||
|
|
||||||
// 카테고리 타입인 경우 categoryMenus 추가
|
// 카테고리 타입인 경우 categoryMenus 추가
|
||||||
if (col.inputType === "category" && categoryMappings.has(col.columnName)) {
|
if (
|
||||||
|
col.inputType === "category" &&
|
||||||
|
categoryMappings.has(col.columnName)
|
||||||
|
) {
|
||||||
const menus = categoryMappings.get(col.columnName);
|
const menus = categoryMappings.get(col.columnName);
|
||||||
logger.info(`✅ 컬럼 ${col.columnName}에 카테고리 메뉴 추가`, { menus });
|
logger.info(`✅ 컬럼 ${col.columnName}에 카테고리 메뉴 추가`, {
|
||||||
|
menus,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
...baseInfo,
|
...baseInfo,
|
||||||
categoryMenus: menus,
|
categoryMenus: menus,
|
||||||
|
|
@ -3309,7 +3474,10 @@ export class TableManagementService {
|
||||||
* 레거시 지원: 컬럼 웹타입 정보 조회
|
* 레거시 지원: 컬럼 웹타입 정보 조회
|
||||||
* @deprecated getColumnInputTypes 사용 권장
|
* @deprecated getColumnInputTypes 사용 권장
|
||||||
*/
|
*/
|
||||||
async getColumnWebTypes(tableName: string, companyCode: string): Promise<ColumnTypeInfo[]> {
|
async getColumnWebTypes(
|
||||||
|
tableName: string,
|
||||||
|
companyCode: string
|
||||||
|
): Promise<ColumnTypeInfo[]> {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`레거시 메서드 사용: getColumnWebTypes → getColumnInputTypes 사용 권장`
|
`레거시 메서드 사용: getColumnWebTypes → getColumnInputTypes 사용 권장`
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 type { FlowDefinition } from "@/types/flow";
|
||||||
import { Loader2, Check, ChevronsUpDown } from "lucide-react";
|
import { Loader2, Check, ChevronsUpDown } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel";
|
||||||
|
|
||||||
interface FlowWidgetConfigPanelProps {
|
interface FlowWidgetConfigPanelProps {
|
||||||
config: Record<string, any>;
|
config: Record<string, any>;
|
||||||
|
|
@ -153,6 +154,24 @@ export function FlowWidgetConfigPanel({ config = {}, onChange }: FlowWidgetConfi
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 🆕 데이터 필터링 설정 */}
|
||||||
|
{config.flowId && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-2">
|
||||||
|
<h3 className="text-sm font-medium">데이터 필터링</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
특정 컬럼 값으로 플로우 데이터를 필터링합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DataFilterConfigPanel
|
||||||
|
tableName={selectedFlow?.name}
|
||||||
|
columns={[]} // 플로우의 첫 번째 스텝 테이블 컬럼 정보 필요 (TODO: API 연동)
|
||||||
|
config={config.dataFilter}
|
||||||
|
onConfigChange={(dataFilter) => onChange({ ...config, dataFilter })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ export const dataApi = {
|
||||||
leftColumn: string,
|
leftColumn: string,
|
||||||
rightColumn: string,
|
rightColumn: string,
|
||||||
leftValue?: any,
|
leftValue?: any,
|
||||||
|
dataFilter?: any, // 🆕 데이터 필터
|
||||||
): Promise<any[]> => {
|
): Promise<any[]> => {
|
||||||
const response = await apiClient.get(`/data/join`, {
|
const response = await apiClient.get(`/data/join`, {
|
||||||
params: {
|
params: {
|
||||||
|
|
@ -70,6 +71,7 @@ export const dataApi = {
|
||||||
leftColumn,
|
leftColumn,
|
||||||
rightColumn,
|
rightColumn,
|
||||||
leftValue,
|
leftValue,
|
||||||
|
dataFilter: dataFilter ? JSON.stringify(dataFilter) : undefined, // 🆕 데이터 필터 전달
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const raw = response.data || {};
|
const raw = response.data || {};
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ export const entityJoinApi = {
|
||||||
joinAlias: string;
|
joinAlias: string;
|
||||||
}>;
|
}>;
|
||||||
screenEntityConfigs?: Record<string, any>; // 🎯 화면별 엔티티 설정
|
screenEntityConfigs?: Record<string, any>; // 🎯 화면별 엔티티 설정
|
||||||
|
dataFilter?: any; // 🆕 데이터 필터
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<EntityJoinResponse> => {
|
): Promise<EntityJoinResponse> => {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
|
|
@ -103,6 +104,7 @@ export const entityJoinApi = {
|
||||||
additionalJoinColumns: params.additionalJoinColumns ? JSON.stringify(params.additionalJoinColumns) : undefined,
|
additionalJoinColumns: params.additionalJoinColumns ? JSON.stringify(params.additionalJoinColumns) : undefined,
|
||||||
screenEntityConfigs: params.screenEntityConfigs ? JSON.stringify(params.screenEntityConfigs) : undefined, // 🎯 화면별 엔티티 설정
|
screenEntityConfigs: params.screenEntityConfigs ? JSON.stringify(params.screenEntityConfigs) : undefined, // 🎯 화면별 엔티티 설정
|
||||||
autoFilter: JSON.stringify(autoFilter), // 🔒 멀티테넌시 필터링
|
autoFilter: JSON.stringify(autoFilter), // 🔒 멀티테넌시 필터링
|
||||||
|
dataFilter: params.dataFilter ? JSON.stringify(params.dataFilter) : undefined, // 🆕 데이터 필터
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
|
|
|
||||||
|
|
@ -257,6 +257,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
size: 100,
|
size: 100,
|
||||||
search: filters, // 필터 조건 전달
|
search: filters, // 필터 조건 전달
|
||||||
enableEntityJoin: true, // 엔티티 조인 활성화
|
enableEntityJoin: true, // 엔티티 조인 활성화
|
||||||
|
dataFilter: componentConfig.leftPanel?.dataFilter, // 🆕 데이터 필터 전달
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -314,6 +315,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
leftColumn,
|
leftColumn,
|
||||||
rightColumn,
|
rightColumn,
|
||||||
leftValue,
|
leftValue,
|
||||||
|
componentConfig.rightPanel?.dataFilter, // 🆕 데이터 필터 전달
|
||||||
);
|
);
|
||||||
setRightData(joinedData || []); // 모든 관련 레코드 (배열)
|
setRightData(joinedData || []); // 모든 관련 레코드 (배열)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,13 @@ import { Slider } from "@/components/ui/slider";
|
||||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command";
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
// Accordion 제거 - 단순 섹션으로 변경
|
||||||
import { Check, ChevronsUpDown, ArrowRight, Plus, X } from "lucide-react";
|
import { Check, ChevronsUpDown, ArrowRight, Plus, X } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { SplitPanelLayoutConfig } from "./types";
|
import { SplitPanelLayoutConfig } from "./types";
|
||||||
import { TableInfo, ColumnInfo } from "@/types/screen";
|
import { TableInfo, ColumnInfo } from "@/types/screen";
|
||||||
import { tableTypeApi } from "@/lib/api/screen";
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
|
import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel";
|
||||||
|
|
||||||
interface SplitPanelLayoutConfigPanelProps {
|
interface SplitPanelLayoutConfigPanelProps {
|
||||||
config: SplitPanelLayoutConfig;
|
config: SplitPanelLayoutConfig;
|
||||||
|
|
@ -325,14 +326,9 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 좌측 패널 설정 (Accordion) */}
|
{/* 좌측 패널 설정 */}
|
||||||
<Accordion type="single" collapsible defaultValue="left-panel" className="w-full">
|
<div className="space-y-4 border-t pt-4 mt-4">
|
||||||
<AccordionItem value="left-panel" className="border rounded-lg px-4">
|
<h3 className="text-sm font-semibold">좌측 패널 설정 (마스터)</h3>
|
||||||
<AccordionTrigger className="text-sm font-semibold hover:no-underline">
|
|
||||||
좌측 패널 설정 (마스터)
|
|
||||||
</AccordionTrigger>
|
|
||||||
<AccordionContent className="overflow-visible">
|
|
||||||
<div className="space-y-4 pt-2">
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>패널 제목</Label>
|
<Label>패널 제목</Label>
|
||||||
|
|
@ -1018,19 +1014,30 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
{/* 우측 패널 설정 (Accordion) */}
|
{/* 좌측 패널 데이터 필터링 */}
|
||||||
<Accordion type="single" collapsible defaultValue="right-panel" className="w-full">
|
<div className="space-y-4 border-t pt-4 mt-4">
|
||||||
<AccordionItem value="right-panel" className="border rounded-lg px-4">
|
<h3 className="text-sm font-semibold">좌측 패널 데이터 필터링</h3>
|
||||||
<AccordionTrigger className="text-sm font-semibold hover:no-underline">
|
<p className="text-xs text-muted-foreground">
|
||||||
우측 패널 설정 ({relationshipType === "detail" ? "상세" : "조인"})
|
특정 컬럼 값으로 좌측 패널 데이터를 필터링합니다
|
||||||
</AccordionTrigger>
|
</p>
|
||||||
<AccordionContent className="overflow-visible">
|
<DataFilterConfigPanel
|
||||||
<div className="space-y-4 pt-2">
|
tableName={config.leftPanel?.tableName || screenTableName}
|
||||||
|
columns={leftTableColumns.map((col) => ({
|
||||||
|
columnName: col.columnName,
|
||||||
|
columnLabel: col.columnLabel || col.columnName,
|
||||||
|
dataType: col.dataType || "text",
|
||||||
|
input_type: (col as any).input_type,
|
||||||
|
} as any))}
|
||||||
|
config={config.leftPanel?.dataFilter}
|
||||||
|
onConfigChange={(dataFilter) => updateLeftPanel({ dataFilter })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측 패널 설정 */}
|
||||||
|
<div className="space-y-4 border-t pt-4 mt-4">
|
||||||
|
<h3 className="text-sm font-semibold">우측 패널 설정 ({relationshipType === "detail" ? "상세" : "조인"})</h3>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>패널 제목</Label>
|
<Label>패널 제목</Label>
|
||||||
|
|
@ -1672,19 +1679,29 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
{/* 레이아웃 설정 (Accordion) */}
|
{/* 우측 패널 데이터 필터링 */}
|
||||||
<Accordion type="single" collapsible className="w-full">
|
<div className="space-y-4 border-t pt-4 mt-4">
|
||||||
<AccordionItem value="layout" className="border rounded-lg px-4">
|
<h3 className="text-sm font-semibold">우측 패널 데이터 필터링</h3>
|
||||||
<AccordionTrigger className="text-sm font-semibold hover:no-underline">
|
<p className="text-xs text-muted-foreground">
|
||||||
레이아웃 설정
|
특정 컬럼 값으로 우측 패널 데이터를 필터링합니다
|
||||||
</AccordionTrigger>
|
</p>
|
||||||
<AccordionContent className="overflow-visible">
|
<DataFilterConfigPanel
|
||||||
<div className="space-y-4 pt-2">
|
tableName={config.rightPanel?.tableName}
|
||||||
|
columns={rightTableColumns.map((col) => ({
|
||||||
|
columnName: col.columnName,
|
||||||
|
columnLabel: col.columnLabel || col.columnName,
|
||||||
|
dataType: col.dataType || "text",
|
||||||
|
input_type: (col as any).input_type,
|
||||||
|
} as any))}
|
||||||
|
config={config.rightPanel?.dataFilter}
|
||||||
|
onConfigChange={(dataFilter) => updateRightPanel({ dataFilter })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 레이아웃 설정 */}
|
||||||
|
<div className="space-y-4 border-t pt-4 mt-4">
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>좌측 패널 너비: {config.splitRatio || 30}%</Label>
|
<Label>좌측 패널 너비: {config.splitRatio || 30}%</Label>
|
||||||
|
|
@ -1712,10 +1729,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
onCheckedChange={(checked) => updateConfig({ autoLoad: checked })}
|
onCheckedChange={(checked) => updateConfig({ autoLoad: checked })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
</Accordion>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
* SplitPanelLayout 컴포넌트 타입 정의
|
* SplitPanelLayout 컴포넌트 타입 정의
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { DataFilterConfig } from "@/types/screen-management";
|
||||||
|
|
||||||
export interface SplitPanelLayoutConfig {
|
export interface SplitPanelLayoutConfig {
|
||||||
// 좌측 패널 설정
|
// 좌측 패널 설정
|
||||||
leftPanel: {
|
leftPanel: {
|
||||||
|
|
@ -52,6 +54,9 @@ export interface SplitPanelLayoutConfig {
|
||||||
hoverable?: boolean; // 호버 효과
|
hoverable?: boolean; // 호버 효과
|
||||||
stickyHeader?: boolean; // 헤더 고정
|
stickyHeader?: boolean; // 헤더 고정
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 🆕 컬럼 값 기반 데이터 필터링
|
||||||
|
dataFilter?: DataFilterConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 우측 패널 설정
|
// 우측 패널 설정
|
||||||
|
|
@ -105,6 +110,9 @@ export interface SplitPanelLayoutConfig {
|
||||||
hoverable?: boolean; // 호버 효과
|
hoverable?: boolean; // 호버 효과
|
||||||
stickyHeader?: boolean; // 헤더 고정
|
stickyHeader?: boolean; // 헤더 고정
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 🆕 컬럼 값 기반 데이터 필터링
|
||||||
|
dataFilter?: DataFilterConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 레이아웃 설정
|
// 레이아웃 설정
|
||||||
|
|
|
||||||
|
|
@ -852,6 +852,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
search: filters,
|
search: filters,
|
||||||
enableEntityJoin: true,
|
enableEntityJoin: true,
|
||||||
additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined,
|
additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined,
|
||||||
|
dataFilter: tableConfig.dataFilter, // 🆕 데이터 필터 전달
|
||||||
});
|
});
|
||||||
|
|
||||||
// 실제 데이터의 item_number만 추출하여 중복 확인
|
// 실제 데이터의 item_number만 추출하여 중복 확인
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { Plus, Trash2, ArrowUp, ArrowDown, ChevronsUpDown, Check } from "lucide-
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel";
|
||||||
|
|
||||||
export interface TableListConfigPanelProps {
|
export interface TableListConfigPanelProps {
|
||||||
config: TableListConfig;
|
config: TableListConfig;
|
||||||
|
|
@ -47,7 +48,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
||||||
const [availableTables, setAvailableTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
|
const [availableTables, setAvailableTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
|
||||||
const [loadingTables, setLoadingTables] = useState(false);
|
const [loadingTables, setLoadingTables] = useState(false);
|
||||||
const [availableColumns, setAvailableColumns] = useState<
|
const [availableColumns, setAvailableColumns] = useState<
|
||||||
Array<{ columnName: string; dataType: string; label?: string }>
|
Array<{ columnName: string; dataType: string; label?: string; input_type?: string }>
|
||||||
>([]);
|
>([]);
|
||||||
const [entityJoinColumns, setEntityJoinColumns] = useState<{
|
const [entityJoinColumns, setEntityJoinColumns] = useState<{
|
||||||
availableColumns: Array<{
|
availableColumns: Array<{
|
||||||
|
|
@ -157,6 +158,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
||||||
columnName: column.columnName || column.name,
|
columnName: column.columnName || column.name,
|
||||||
dataType: column.dataType || column.type || "text",
|
dataType: column.dataType || column.type || "text",
|
||||||
label: column.label || column.displayName || column.columnLabel || column.columnName || column.name,
|
label: column.label || column.displayName || column.columnLabel || column.columnName || column.name,
|
||||||
|
input_type: column.input_type || column.inputType, // 🆕 input_type 추가
|
||||||
}));
|
}));
|
||||||
setAvailableColumns(mappedColumns);
|
setAvailableColumns(mappedColumns);
|
||||||
|
|
||||||
|
|
@ -189,6 +191,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
||||||
columnName: col.columnName,
|
columnName: col.columnName,
|
||||||
dataType: col.dataType,
|
dataType: col.dataType,
|
||||||
label: col.displayName || col.columnName,
|
label: col.displayName || col.columnName,
|
||||||
|
input_type: col.input_type || col.inputType, // 🆕 input_type 추가
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1140,6 +1143,28 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 🆕 데이터 필터링 설정 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">데이터 필터링</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
특정 컬럼 값으로 데이터를 필터링합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<hr className="border-border" />
|
||||||
|
<DataFilterConfigPanel
|
||||||
|
tableName={config.selectedTable || screenTableName}
|
||||||
|
columns={availableColumns.map((col) => ({
|
||||||
|
columnName: col.columnName,
|
||||||
|
columnLabel: col.label || col.columnName,
|
||||||
|
dataType: col.dataType,
|
||||||
|
input_type: col.input_type, // 🆕 실제 input_type 전달
|
||||||
|
} as any))}
|
||||||
|
config={config.dataFilter}
|
||||||
|
onConfigChange={(dataFilter) => handleChange("dataFilter", dataFilter)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -173,6 +173,9 @@ export interface CheckboxConfig {
|
||||||
/**
|
/**
|
||||||
* TableList 컴포넌트 설정 타입
|
* TableList 컴포넌트 설정 타입
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { DataFilterConfig } from "@/types/screen-management";
|
||||||
|
|
||||||
export interface TableListConfig extends ComponentConfig {
|
export interface TableListConfig extends ComponentConfig {
|
||||||
// 표시 모드 설정
|
// 표시 모드 설정
|
||||||
displayMode?: "table" | "card"; // 기본: "table"
|
displayMode?: "table" | "card"; // 기본: "table"
|
||||||
|
|
@ -225,6 +228,9 @@ export interface TableListConfig extends ComponentConfig {
|
||||||
autoLoad: boolean;
|
autoLoad: boolean;
|
||||||
refreshInterval?: number; // 초 단위
|
refreshInterval?: number; // 초 단위
|
||||||
|
|
||||||
|
// 🆕 컬럼 값 기반 데이터 필터링
|
||||||
|
dataFilter?: DataFilterConfig;
|
||||||
|
|
||||||
// 이벤트 핸들러
|
// 이벤트 핸들러
|
||||||
onRowClick?: (row: any) => void;
|
onRowClick?: (row: any) => void;
|
||||||
onRowDoubleClick?: (row: any) => void;
|
onRowDoubleClick?: (row: any) => void;
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,9 @@ export interface DataTableComponent extends BaseComponent {
|
||||||
filterColumn: string; // 필터링할 테이블 컬럼 (예: company_code, dept_code)
|
filterColumn: string; // 필터링할 테이블 컬럼 (예: company_code, dept_code)
|
||||||
userField: 'companyCode' | 'userId' | 'deptCode'; // 사용자 정보에서 가져올 필드
|
userField: 'companyCode' | 'userId' | 'deptCode'; // 사용자 정보에서 가져올 필드
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 🆕 컬럼 값 기반 데이터 필터링
|
||||||
|
dataFilter?: DataFilterConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -173,6 +176,8 @@ export interface FlowComponent extends BaseComponent {
|
||||||
stepColumnConfig?: {
|
stepColumnConfig?: {
|
||||||
[stepId: number]: FlowStepColumnConfig;
|
[stepId: number]: FlowStepColumnConfig;
|
||||||
};
|
};
|
||||||
|
// 🆕 컬럼 값 기반 데이터 필터링
|
||||||
|
dataFilter?: DataFilterConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -435,6 +440,26 @@ export interface DataTableFilter {
|
||||||
logicalOperator?: "AND" | "OR";
|
logicalOperator?: "AND" | "OR";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컬럼 필터 조건 (단일 필터)
|
||||||
|
*/
|
||||||
|
export interface ColumnFilter {
|
||||||
|
id: string;
|
||||||
|
columnName: string; // 필터링할 컬럼명
|
||||||
|
operator: "equals" | "not_equals" | "in" | "not_in" | "contains" | "starts_with" | "ends_with" | "is_null" | "is_not_null";
|
||||||
|
value: string | string[]; // 필터 값 (in/not_in은 배열)
|
||||||
|
valueType: "static" | "category" | "code"; // 값 타입
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 필터 설정 (여러 필터의 조합)
|
||||||
|
*/
|
||||||
|
export interface DataFilterConfig {
|
||||||
|
enabled: boolean; // 필터 활성화 여부
|
||||||
|
filters: ColumnFilter[]; // 필터 조건 목록
|
||||||
|
matchType: "all" | "any"; // AND(모두 만족) / OR(하나 이상 만족)
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 파일 업로드 관련 =====
|
// ===== 파일 업로드 관련 =====
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue