fix: 필터 select 옵션에서 카테고리/엔티티 라벨이 올바르게 표시되도록 수정

- 백엔드: entityJoinService에서 _label 필드를 SELECT에 추가
- 백엔드: tableManagementService에 멀티테넌시 필터링 추가 (company_code)
- 백엔드: categorizeJoins에서 table_column_category_values를 명시적으로 dbJoins로 분류
- 백엔드: executeCachedLookup와 getTableData에 companyCode 파라미터 추가
- 프론트엔드: getColumnUniqueValues가 백엔드 조인 결과의 _name 필드를 사용하도록 수정
- 프론트엔드: TableSearchWidget에서 select 옵션 로드 로직 개선

이제 필터 select 박스에서 코드 대신 실제 이름(라벨)이 표시됩니다.
예: CATEGORY_148700 → 정상, topseal_admin → 탑씰 관리자 계정
This commit is contained in:
kjs 2025-11-12 14:02:58 +09:00
parent 58870237b6
commit 71fd3f5ee7
4 changed files with 203 additions and 73 deletions

View File

@ -24,20 +24,19 @@ export class EntityJoinService {
try {
logger.info(`Entity 컬럼 감지 시작: ${tableName}`);
// column_labels에서 entity 타입인 컬럼들 조회
// column_labels에서 entity 및 category 타입인 컬럼들 조회 (input_type 사용)
const entityColumns = await query<{
column_name: string;
input_type: string;
reference_table: string;
reference_column: string;
display_column: string | null;
}>(
`SELECT column_name, reference_table, reference_column, display_column
`SELECT column_name, input_type, reference_table, reference_column, display_column
FROM column_labels
WHERE table_name = $1
AND web_type = $2
AND reference_table IS NOT NULL
AND reference_column IS NOT NULL`,
[tableName, "entity"]
AND input_type IN ('entity', 'category')`,
[tableName]
);
logger.info(`🔍 Entity 컬럼 조회 결과: ${entityColumns.length}개 발견`);
@ -77,18 +76,34 @@ export class EntityJoinService {
}
for (const column of entityColumns) {
// 카테고리 타입인 경우 자동으로 category_values 테이블 참조 설정
let referenceTable = column.reference_table;
let referenceColumn = column.reference_column;
let displayColumn = column.display_column;
if (column.input_type === 'category') {
// 카테고리 타입: reference 정보가 비어있어도 자동 설정
referenceTable = referenceTable || 'table_column_category_values';
referenceColumn = referenceColumn || 'value_code';
displayColumn = displayColumn || 'value_label';
logger.info(`🏷️ 카테고리 타입 자동 설정: ${column.column_name}`, {
referenceTable,
referenceColumn,
displayColumn,
});
}
logger.info(`🔍 Entity 컬럼 상세 정보:`, {
column_name: column.column_name,
reference_table: column.reference_table,
reference_column: column.reference_column,
display_column: column.display_column,
input_type: column.input_type,
reference_table: referenceTable,
reference_column: referenceColumn,
display_column: displayColumn,
});
if (
!column.column_name ||
!column.reference_table ||
!column.reference_column
) {
if (!column.column_name || !referenceTable || !referenceColumn) {
logger.warn(`⚠️ 필수 정보 누락으로 스킵: ${column.column_name}`);
continue;
}
@ -112,27 +127,28 @@ export class EntityJoinService {
separator,
screenConfig,
});
} else if (column.display_column && column.display_column !== "none") {
} else if (displayColumn && displayColumn !== "none") {
// 기존 설정된 단일 표시 컬럼 사용 (none이 아닌 경우만)
displayColumns = [column.display_column];
displayColumns = [displayColumn];
logger.info(
`🔧 기존 display_column 사용: ${column.column_name}${column.display_column}`
`🔧 기존 display_column 사용: ${column.column_name}${displayColumn}`
);
} else {
// display_column이 "none"이거나 없는 경우 기본 표시 컬럼 설정
// 🚨 display_column이 항상 "none"이므로 이 로직을 기본으로 사용
let defaultDisplayColumn = column.reference_column;
if (column.reference_table === "dept_info") {
let defaultDisplayColumn = referenceColumn;
if (referenceTable === "dept_info") {
defaultDisplayColumn = "dept_name";
} else if (column.reference_table === "company_info") {
} else if (referenceTable === "company_info") {
defaultDisplayColumn = "company_name";
} else if (column.reference_table === "user_info") {
} else if (referenceTable === "user_info") {
defaultDisplayColumn = "user_name";
} else if (referenceTable === "category_values") {
defaultDisplayColumn = "category_name";
}
displayColumns = [defaultDisplayColumn];
logger.info(
`🔧 Entity 조인 기본 표시 컬럼 설정: ${column.column_name}${defaultDisplayColumn} (${column.reference_table})`
`🔧 Entity 조인 기본 표시 컬럼 설정: ${column.column_name}${defaultDisplayColumn} (${referenceTable})`
);
logger.info(`🔍 생성된 displayColumns 배열:`, displayColumns);
}
@ -143,8 +159,8 @@ export class EntityJoinService {
const joinConfig: EntityJoinConfig = {
sourceTable: tableName,
sourceColumn: column.column_name,
referenceTable: column.reference_table,
referenceColumn: column.reference_column,
referenceTable: referenceTable, // 카테고리의 경우 자동 설정된 값 사용
referenceColumn: referenceColumn, // 카테고리의 경우 자동 설정된 값 사용
displayColumns: displayColumns,
displayColumn: displayColumns[0], // 하위 호환성
aliasColumn: aliasColumn,
@ -245,11 +261,14 @@ export class EntityJoinService {
config.displayColumn,
];
const separator = config.separator || " - ";
// 결과 컬럼 배열 (aliasColumn + _label 필드)
const resultColumns: string[] = [];
if (displayColumns.length === 0 || !displayColumns[0]) {
// displayColumns가 빈 배열이거나 첫 번째 값이 null/undefined인 경우
// 조인 테이블의 referenceColumn을 기본값으로 사용
return `COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}`;
resultColumns.push(`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}`);
} else if (displayColumns.length === 1) {
// 단일 컬럼인 경우
const col = displayColumns[0];
@ -265,12 +284,18 @@ export class EntityJoinService {
"company_name",
"sales_yn",
"status",
"value_label", // table_column_category_values
"user_name", // user_info
].includes(col);
if (isJoinTableColumn) {
return `COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}`;
resultColumns.push(`COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}`);
// _label 필드도 함께 SELECT (프론트엔드 getColumnUniqueValues용)
// sourceColumn_label 형식으로 추가
resultColumns.push(`COALESCE(${alias}.${col}::TEXT, '') AS ${config.sourceColumn}_label`);
} else {
return `COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`;
resultColumns.push(`COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`);
}
} else {
// 여러 컬럼인 경우 CONCAT으로 연결
@ -291,6 +316,8 @@ export class EntityJoinService {
"company_name",
"sales_yn",
"status",
"value_label", // table_column_category_values
"user_name", // user_info
].includes(col);
if (isJoinTableColumn) {
@ -303,8 +330,11 @@ export class EntityJoinService {
})
.join(` || '${separator}' || `);
return `(${concatParts}) AS ${config.aliasColumn}`;
resultColumns.push(`(${concatParts}) AS ${config.aliasColumn}`);
}
// 모든 resultColumns를 반환
return resultColumns.join(", ");
})
.join(", ");
@ -320,6 +350,12 @@ export class EntityJoinService {
const joinClauses = uniqueReferenceTableConfigs
.map((config) => {
const alias = aliasMap.get(config.referenceTable);
// table_column_category_values는 특별한 조인 조건 필요
if (config.referenceTable === 'table_column_category_values') {
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}'`;
}
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
})
.join("\n");
@ -380,6 +416,14 @@ export class EntityJoinService {
return "join";
}
// table_column_category_values는 특수 조인 조건이 필요하므로 캐시 불가
if (config.referenceTable === 'table_column_category_values') {
logger.info(
`🎯 table_column_category_values는 캐시 전략 불가: ${config.sourceColumn}`
);
return "join";
}
// 참조 테이블의 캐시 가능성 확인
const displayCol =
config.displayColumn ||

View File

@ -1494,6 +1494,7 @@ export class TableManagementService {
search?: Record<string, any>;
sortBy?: string;
sortOrder?: string;
companyCode?: string;
}
): Promise<{
data: any[];
@ -1503,7 +1504,7 @@ export class TableManagementService {
totalPages: number;
}> {
try {
const { page, size, search = {}, sortBy, sortOrder = "asc" } = options;
const { page, size, search = {}, sortBy, sortOrder = "asc", companyCode } = options;
const offset = (page - 1) * size;
logger.info(`테이블 데이터 조회: ${tableName}`, options);
@ -1517,6 +1518,14 @@ export class TableManagementService {
let searchValues: any[] = [];
let paramIndex = 1;
// 멀티테넌시 필터 추가 (company_code)
if (companyCode) {
whereConditions.push(`company_code = $${paramIndex}`);
searchValues.push(companyCode);
paramIndex++;
logger.info(`🔒 멀티테넌시 필터 추가 (기본 조회): company_code = ${companyCode}`);
}
if (search && Object.keys(search).length > 0) {
for (const [column, value] of Object.entries(search)) {
if (value !== null && value !== undefined && value !== "") {
@ -2213,11 +2222,20 @@ export class TableManagementService {
const selectColumns = columns.data.map((col: any) => col.column_name);
// WHERE 절 구성
const whereClause = await this.buildWhereClause(
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}`
: companyFilter;
logger.info(`🔒 멀티테넌시 필터 추가 (Entity 조인): company_code = ${options.companyCode}`);
}
// ORDER BY 절 구성
const orderBy = options.sortBy
? `main.${options.sortBy} ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
@ -2343,6 +2361,7 @@ export class TableManagementService {
search?: Record<string, any>;
sortBy?: string;
sortOrder?: string;
companyCode?: string;
},
startTime: number
): Promise<EntityJoinResponse> {
@ -2530,11 +2549,11 @@ export class TableManagementService {
);
}
basicResult = await this.getTableData(tableName, fallbackOptions);
basicResult = await this.getTableData(tableName, { ...fallbackOptions, companyCode: options.companyCode });
}
} else {
// Entity 조인 컬럼 검색이 없는 경우 기존 캐시 방식 사용
basicResult = await this.getTableData(tableName, options);
basicResult = await this.getTableData(tableName, { ...options, companyCode: options.companyCode });
}
// Entity 값들을 캐시에서 룩업하여 변환
@ -2807,10 +2826,14 @@ export class TableManagementService {
}
// 모든 조인이 캐시 가능한 경우: 기본 쿼리 + 캐시 룩업
else {
// whereClause에서 company_code 추출 (멀티테넌시 필터)
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: {} },
{ page: Math.floor(offset / limit) + 1, size: limit, search: {}, companyCode },
startTime
);
}
@ -2831,6 +2854,13 @@ export class TableManagementService {
const dbJoins: EntityJoinConfig[] = [];
for (const config of joinConfigs) {
// table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인
if (config.referenceTable === 'table_column_category_values') {
dbJoins.push(config);
console.log(`🔗 DB 조인 (특수 조건): ${config.referenceTable}`);
continue;
}
// 캐시 가능성 확인
const cachedData = await referenceCacheService.getCachedReference(
config.referenceTable,

View File

@ -348,22 +348,60 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 컬럼의 고유 값 조회 함수
const getColumnUniqueValues = async (columnName: string) => {
console.log("🔍 [getColumnUniqueValues] 호출됨:", {
columnName,
dataLength: data.length,
columnMeta: columnMeta[columnName],
sampleData: data[0],
});
const meta = columnMeta[columnName];
const inputType = meta?.inputType || "text";
// 카테고리, 엔티티, 코드 타입인 경우 _name 필드 사용 (백엔드 조인 결과)
const isLabelType = ["category", "entity", "code"].includes(inputType);
const labelField = isLabelType ? `${columnName}_name` : columnName;
console.log("🔍 [getColumnUniqueValues] 필드 선택:", {
columnName,
inputType,
isLabelType,
labelField,
hasLabelField: data[0] && labelField in data[0],
sampleLabelValue: data[0] ? data[0][labelField] : undefined,
});
// 현재 로드된 데이터에서 고유 값 추출
const uniqueValues = new Set<string>();
const uniqueValuesMap = new Map<string, string>(); // value -> label
data.forEach((row) => {
const value = row[columnName];
if (value !== null && value !== undefined && value !== "") {
uniqueValues.add(String(value));
// 백엔드 조인된 _name 필드 사용 (없으면 원본 값)
const label = isLabelType && row[labelField] ? row[labelField] : String(value);
uniqueValuesMap.set(String(value), label);
}
});
// Set을 배열로 변환하고 정렬
const sortedValues = Array.from(uniqueValues).sort();
return sortedValues.map((value) => ({
label: value,
value: value,
}));
// Map을 배열로 변환하고 라벨 기준으로 정렬
const result = Array.from(uniqueValuesMap.entries())
.map(([value, label]) => ({
value: value,
label: label,
}))
.sort((a, b) => a.label.localeCompare(b.label));
console.log("✅ [getColumnUniqueValues] 결과:", {
columnName,
inputType,
isLabelType,
labelField,
uniqueCount: result.length,
values: result, // 전체 값 출력
allKeys: data[0] ? Object.keys(data[0]) : [], // 모든 키 출력
});
return result;
};
const registration = {
@ -396,7 +434,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
tableConfig.selectedTable,
tableConfig.columns,
columnLabels,
columnMeta,
columnMeta, // columnMeta가 변경되면 재등록 (inputType 정보 필요)
columnWidths,
tableLabel,
data, // 데이터 자체가 변경되면 재등록 (고유 값 조회용)

View File

@ -62,7 +62,7 @@ export function TableSearchWidget({ component }: TableSearchWidgetProps) {
}
}, [registeredTables, selectedTableId, autoSelectFirstTable, setSelectedTableId]);
// 현재 테이블의 저장된 필터 불러오기 및 select 옵션 로드
// 현재 테이블의 저장된 필터 불러오기
useEffect(() => {
if (currentTable?.tableName) {
const storageKey = `table_filters_${currentTable.tableName}`;
@ -89,36 +89,54 @@ export function TableSearchWidget({ component }: TableSearchWidgetProps) {
}));
setActiveFilters(activeFiltersList);
// select 타입 필터들의 옵션 로드
const loadSelectOptions = async () => {
const newOptions: Record<string, Array<{ label: string; value: string }>> = {};
for (const filter of activeFiltersList) {
if (filter.filterType === "select" && currentTable.getColumnUniqueValues) {
try {
const options = await currentTable.getColumnUniqueValues(filter.columnName);
newOptions[filter.columnName] = options;
console.log("✅ [TableSearchWidget] select 옵션 로드:", {
columnName: filter.columnName,
optionCount: options.length,
});
} catch (error) {
console.error("select 옵션 로드 실패:", filter.columnName, error);
}
}
}
setSelectOptions(newOptions);
};
loadSelectOptions();
} catch (error) {
console.error("저장된 필터 불러오기 실패:", error);
}
}
}
}, [currentTable?.tableName, currentTable?.getColumnUniqueValues]);
}, [currentTable?.tableName]);
// select 옵션 로드 (activeFilters 또는 dataCount 변경 시)
useEffect(() => {
if (!currentTable?.getColumnUniqueValues || activeFilters.length === 0) {
return;
}
const loadSelectOptions = async () => {
const selectFilters = activeFilters.filter(f => f.filterType === "select");
if (selectFilters.length === 0) {
return;
}
console.log("🔄 [TableSearchWidget] select 옵션 로드 시작:", {
activeFiltersCount: activeFilters.length,
selectFiltersCount: selectFilters.length,
dataCount: currentTable.dataCount,
});
const newOptions: Record<string, Array<{ label: string; value: string }>> = {};
for (const filter of selectFilters) {
try {
const options = await currentTable.getColumnUniqueValues(filter.columnName);
newOptions[filter.columnName] = options;
console.log("✅ [TableSearchWidget] select 옵션 로드:", {
columnName: filter.columnName,
optionCount: options.length,
options: options.slice(0, 5),
});
} catch (error) {
console.error("❌ [TableSearchWidget] select 옵션 로드 실패:", filter.columnName, error);
}
}
console.log("✅ [TableSearchWidget] 최종 selectOptions:", newOptions);
setSelectOptions(newOptions);
};
loadSelectOptions();
}, [activeFilters, currentTable?.dataCount, currentTable?.getColumnUniqueValues]);
// 디버깅: 현재 테이블 정보 로깅
useEffect(() => {
@ -193,13 +211,13 @@ export function TableSearchWidget({ component }: TableSearchWidgetProps) {
onValueChange={(val) => handleFilterChange(filter.columnName, val)}
>
<SelectTrigger className="h-8 text-xs sm:h-9 sm:text-sm">
<SelectValue placeholder={column?.columnLabel} />
<SelectValue placeholder={column?.columnLabel || "선택"} />
</SelectTrigger>
<SelectContent>
{options.length === 0 ? (
<SelectItem value="" disabled>
<div className="px-2 py-1.5 text-xs text-muted-foreground">
</SelectItem>
</div>
) : (
options.map((option) => (
<SelectItem key={option.value} value={option.value}>