diff --git a/backend-node/src/controllers/entityJoinController.ts b/backend-node/src/controllers/entityJoinController.ts index de9ee95f..766c6a02 100644 --- a/backend-node/src/controllers/entityJoinController.ts +++ b/backend-node/src/controllers/entityJoinController.ts @@ -561,6 +561,34 @@ export class EntityJoinController { }); } } + /** + * 테이블 컬럼의 고유값 목록 조회 (헤더 필터 드롭다운용) + * GET /api/table-management/tables/:tableName/column-values/:columnName + */ + async getColumnUniqueValues(req: Request, res: Response): Promise { + try { + const { tableName, columnName } = req.params; + const companyCode = (req as any).user?.companyCode; + + const data = await tableManagementService.getColumnDistinctValues( + tableName, + columnName, + companyCode + ); + + res.status(200).json({ + success: true, + data, + }); + } catch (error) { + logger.error(`컬럼 고유값 조회 실패: ${req.params.tableName}.${req.params.columnName}`, error); + res.status(500).json({ + success: false, + message: "컬럼 고유값 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } } export const entityJoinController = new EntityJoinController(); diff --git a/backend-node/src/routes/entityJoinRoutes.ts b/backend-node/src/routes/entityJoinRoutes.ts index 0e023770..89c1ccd8 100644 --- a/backend-node/src/routes/entityJoinRoutes.ts +++ b/backend-node/src/routes/entityJoinRoutes.ts @@ -55,6 +55,15 @@ router.get( entityJoinController.getTableDataWithJoins.bind(entityJoinController) ); +/** + * 테이블 컬럼의 고유값 목록 조회 (헤더 필터 드롭다운용) + * GET /api/table-management/tables/:tableName/column-values/:columnName + */ +router.get( + "/tables/:tableName/column-values/:columnName", + entityJoinController.getColumnUniqueValues.bind(entityJoinController) +); + // ======================================== // 🎯 Entity 조인 설정 관리 // ======================================== diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts index a8b12605..6fc07d39 100644 --- a/backend-node/src/services/tableCategoryValueService.ts +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -211,7 +211,8 @@ class TableCategoryValueService { created_at AS "createdAt", updated_at AS "updatedAt", created_by AS "createdBy", - updated_by AS "updatedBy" + updated_by AS "updatedBy", + path FROM category_values WHERE table_name = $1 AND column_name = $2 @@ -1441,7 +1442,7 @@ class TableCategoryValueService { // is_active 필터 제거: 비활성화된 카테고리도 라벨로 표시되어야 함 if (companyCode === "*") { query = ` - SELECT DISTINCT value_code, value_label + SELECT DISTINCT value_code, value_label, path FROM category_values WHERE value_code IN (${placeholders1}) `; @@ -1449,7 +1450,7 @@ class TableCategoryValueService { } else { const companyIdx = n + 1; query = ` - SELECT DISTINCT value_code, value_label + SELECT DISTINCT value_code, value_label, path FROM category_values WHERE value_code IN (${placeholders1}) AND (company_code = $${companyIdx} OR company_code = '*') @@ -1460,10 +1461,15 @@ class TableCategoryValueService { const result = await pool.query(query, params); // { [code]: label } 형태로 변환 (중복 시 첫 번째 결과 우선) + // path가 있고 '/'를 포함하면(depth>1) 전체 경로를 ' > ' 구분자로 표시 const labels: Record = {}; for (const row of result.rows) { if (!labels[row.value_code]) { - labels[row.value_code] = row.value_label; + if (row.path && row.path.includes('/')) { + labels[row.value_code] = row.path.replace(/\//g, ' > '); + } else { + labels[row.value_code] = row.value_label; + } } } diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 82b66438..1f21d61b 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -3408,6 +3408,31 @@ export class TableManagementService { case "is_not_null": filterConditions.push(`${safeColumn} IS NOT NULL`); break; + case "not_contains": + filterConditions.push( + `${safeColumn}::text NOT LIKE '%${String(value).replace(/'/g, "''")}%'` + ); + break; + case "greater_than": + filterConditions.push( + `(${safeColumn})::numeric > ${parseFloat(String(value))}` + ); + break; + case "less_than": + filterConditions.push( + `(${safeColumn})::numeric < ${parseFloat(String(value))}` + ); + break; + case "greater_or_equal": + filterConditions.push( + `(${safeColumn})::numeric >= ${parseFloat(String(value))}` + ); + break; + case "less_or_equal": + filterConditions.push( + `(${safeColumn})::numeric <= ${parseFloat(String(value))}` + ); + break; } } @@ -3424,6 +3449,89 @@ export class TableManagementService { } } + // 🆕 filterGroups 처리 (런타임 필터 빌더 - 그룹별 AND/OR 지원) + if ( + options.dataFilter && + options.dataFilter.filterGroups && + options.dataFilter.filterGroups.length > 0 + ) { + const groupConditions: string[] = []; + + for (const group of options.dataFilter.filterGroups) { + if (!group.conditions || group.conditions.length === 0) continue; + + const conditions: string[] = []; + + for (const condition of group.conditions) { + const { columnName, operator, value } = condition; + if (!columnName) continue; + + const safeCol = `main."${columnName}"`; + + switch (operator) { + case "equals": + conditions.push(`${safeCol}::text = '${String(value).replace(/'/g, "''")}'`); + break; + case "not_equals": + conditions.push(`${safeCol}::text != '${String(value).replace(/'/g, "''")}'`); + break; + case "contains": + conditions.push(`${safeCol}::text LIKE '%${String(value).replace(/'/g, "''")}%'`); + break; + case "not_contains": + conditions.push(`${safeCol}::text NOT LIKE '%${String(value).replace(/'/g, "''")}%'`); + break; + case "starts_with": + conditions.push(`${safeCol}::text LIKE '${String(value).replace(/'/g, "''")}%'`); + break; + case "ends_with": + conditions.push(`${safeCol}::text LIKE '%${String(value).replace(/'/g, "''")}'`); + break; + case "greater_than": + conditions.push(`(${safeCol})::numeric > ${parseFloat(String(value))}`); + break; + case "less_than": + conditions.push(`(${safeCol})::numeric < ${parseFloat(String(value))}`); + break; + case "greater_or_equal": + conditions.push(`(${safeCol})::numeric >= ${parseFloat(String(value))}`); + break; + case "less_or_equal": + conditions.push(`(${safeCol})::numeric <= ${parseFloat(String(value))}`); + break; + case "is_null": + conditions.push(`(${safeCol} IS NULL OR ${safeCol}::text = '')`); + break; + case "is_not_null": + conditions.push(`(${safeCol} IS NOT NULL AND ${safeCol}::text != '')`); + break; + case "in": { + const inArr = Array.isArray(value) ? value : [String(value)]; + if (inArr.length > 0) { + const vals = inArr.map((v) => `'${String(v).replace(/'/g, "''")}'`).join(", "); + conditions.push(`${safeCol}::text IN (${vals})`); + } + break; + } + } + } + + if (conditions.length > 0) { + const logic = group.logic === "OR" ? " OR " : " AND "; + groupConditions.push(`(${conditions.join(logic)})`); + } + } + + if (groupConditions.length > 0) { + const groupWhere = groupConditions.join(" AND "); + whereClause = whereClause + ? `${whereClause} AND ${groupWhere}` + : groupWhere; + + logger.info(`🔍 필터 그룹 적용 (Entity 조인): ${groupWhere}`); + } + } + // 🆕 제외 필터 적용 (다른 테이블에 이미 존재하는 데이터 제외) if (options.excludeFilter && options.excludeFilter.enabled) { const { @@ -5387,4 +5495,40 @@ export class TableManagementService { return []; } } + + /** + * 테이블 컬럼의 고유값 목록 조회 (헤더 필터 드롭다운용) + */ + async getColumnDistinctValues( + tableName: string, + columnName: string, + companyCode?: string + ): Promise<{ value: string; label: string }[]> { + try { + // 테이블명/컬럼명 안전성 검증 (영문, 숫자, 언더스코어만 허용) + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName) || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(columnName)) { + logger.warn(`잘못된 테이블/컬럼명: ${tableName}.${columnName}`); + return []; + } + + let sql = `SELECT DISTINCT "${columnName}"::text as value FROM "${tableName}" WHERE "${columnName}" IS NOT NULL AND "${columnName}"::text != ''`; + const params: any[] = []; + + if (companyCode) { + params.push(companyCode); + sql += ` AND "company_code" = $${params.length}`; + } + + sql += ` ORDER BY value LIMIT 500`; + + const rows = await query<{ value: string }>(sql, params); + return rows.map((row) => ({ + value: row.value, + label: row.value, + })); + } catch (error) { + logger.error(`컬럼 고유값 조회 실패: ${tableName}.${columnName}`, error); + return []; + } + } } diff --git a/frontend/components/screen/InteractiveDataTable.tsx b/frontend/components/screen/InteractiveDataTable.tsx index 177b83e2..a0a60104 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -648,24 +648,31 @@ export const InteractiveDataTable: React.FC = ({ ); if (response.data.success && response.data.data) { - // valueCode 및 valueId -> {label, color} 매핑 생성 + // valueCode 및 valueId -> {label, color} 매핑 생성 (트리 재귀 평탄화) const mapping: Record = {}; - response.data.data.forEach((item: any) => { - // valueCode로 매핑 - if (item.valueCode) { - mapping[item.valueCode] = { - label: item.valueLabel, - color: item.color, - }; - } - // valueId로도 매핑 (숫자 ID 저장 시 라벨 표시용) - if (item.valueId !== undefined && item.valueId !== null) { - mapping[String(item.valueId)] = { - label: item.valueLabel, - color: item.color, - }; - } - }); + const flattenCategoryTree = (items: any[], parentLabel: string = "") => { + items.forEach((item: any) => { + const displayLabel = parentLabel + ? `${parentLabel} / ${item.valueLabel}` + : item.valueLabel; + if (item.valueCode) { + mapping[item.valueCode] = { + label: displayLabel, + color: item.color, + }; + } + if (item.valueId !== undefined && item.valueId !== null) { + mapping[String(item.valueId)] = { + label: displayLabel, + color: item.color, + }; + } + if (item.children && Array.isArray(item.children) && item.children.length > 0) { + flattenCategoryTree(item.children, item.valueLabel); + } + }); + }; + flattenCategoryTree(response.data.data); mappings[col.columnName] = mapping; console.log(`✅ 카테고리 매핑 로드 성공 [${col.columnName}]:`, mapping, { menuObjid }); } diff --git a/frontend/components/v2/V2Select.tsx b/frontend/components/v2/V2Select.tsx index a078ea67..98840d8f 100644 --- a/frontend/components/v2/V2Select.tsx +++ b/frontend/components/v2/V2Select.tsx @@ -92,7 +92,14 @@ const DropdownSelect = forwardRef< className={cn("w-full", allowClear && hasValue ? "pr-8" : "", className)} style={style} > - + + {(() => { + const val = typeof value === "string" ? value : (value?.[0] ?? ""); + const opt = options.find(o => o.value === val); + if (!opt || !val) return placeholder; + return opt.displayLabel || opt.label; + })()} + {options @@ -139,7 +146,7 @@ const DropdownSelect = forwardRef< const selectedLabels = useMemo(() => { return safeOptions .filter((o) => selectedValues.includes(o.value)) - .map((o) => o.label) + .map((o) => o.displayLabel || o.label) .filter(Boolean) as string[]; }, [selectedValues, safeOptions]); @@ -896,18 +903,23 @@ export const V2Select = forwardRef((props, ref) = // 트리 구조를 평탄화하여 옵션으로 변환 // 🔧 value로 valueCode를 사용 (커스텀 테이블 저장/조회 호환) const flattenTree = ( - items: { valueId: number; valueCode: string; valueLabel: string; children?: any[] }[], + items: { valueId: number; valueCode: string; valueLabel: string; path?: string; children?: any[] }[], depth: number = 0, + parentLabel: string = "", ): SelectOption[] => { const result: SelectOption[] = []; for (const item of items) { const prefix = depth > 0 ? "\u00A0\u00A0\u00A0".repeat(depth) + "└ " : ""; + const displayLabel = parentLabel + ? `${parentLabel} / ${item.valueLabel}` + : item.valueLabel; result.push({ value: item.valueCode, // 🔧 valueCode를 value로 사용 label: prefix + item.valueLabel, + displayLabel, }); if (item.children && item.children.length > 0) { - result.push(...flattenTree(item.children, depth + 1)); + result.push(...flattenTree(item.children, depth + 1, item.valueLabel)); } } return result; diff --git a/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx b/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx index fe8244c4..d9dc9dd6 100644 --- a/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx +++ b/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx @@ -372,22 +372,27 @@ export const CardDisplayComponent: React.FC = ({ if (response.data.success && response.data.data) { const mapping: Record = {}; - response.data.data.forEach((item: any) => { - // API 응답 형식: valueCode, valueLabel (camelCase) - const code = item.valueCode || item.value_code || item.category_code || item.code || item.value; - const label = item.valueLabel || item.value_label || item.category_name || item.name || item.label || code; - // color가 null/undefined/"none"이면 undefined로 유지 (배지 없음) - const rawColor = item.color ?? item.badge_color; - const color = (rawColor && rawColor !== "none") ? rawColor : undefined; - mapping[code] = { label, color }; - }); + const flattenCategoryTree = (items: any[], parentLabel: string = "") => { + items.forEach((item: any) => { + const code = item.valueCode || item.value_code || item.category_code || item.code || item.value; + const rawLabel = item.valueLabel || item.value_label || item.category_name || item.name || item.label || code; + const displayLabel = parentLabel ? `${parentLabel} / ${rawLabel}` : rawLabel; + const rawColor = item.color ?? item.badge_color; + const color = (rawColor && rawColor !== "none") ? rawColor : undefined; + mapping[code] = { label: displayLabel, color }; + if (item.children && Array.isArray(item.children) && item.children.length > 0) { + flattenCategoryTree(item.children, rawLabel); + } + }); + }; + flattenCategoryTree(response.data.data); mappings[columnName] = mapping; } } catch (error) { // 카테고리 매핑 로드 실패 시 무시 } } - + setCategoryMappings(mappings); } } catch (error) { diff --git a/frontend/lib/registry/components/category-select/CategorySelectComponent.tsx b/frontend/lib/registry/components/category-select/CategorySelectComponent.tsx index ea83e6ca..cc209a11 100644 --- a/frontend/lib/registry/components/category-select/CategorySelectComponent.tsx +++ b/frontend/lib/registry/components/category-select/CategorySelectComponent.tsx @@ -223,7 +223,9 @@ export const CategorySelectComponent: React.FC< key={categoryValue.valueId} value={categoryValue.valueCode} > - {categoryValue.valueLabel} + {categoryValue.path && categoryValue.path.includes('/') + ? categoryValue.path.replace(/\//g, ' / ') + : categoryValue.valueLabel} ))} diff --git a/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx b/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx index 4fe6b1f5..267f2d31 100644 --- a/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx +++ b/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx @@ -258,7 +258,9 @@ const SelectBasicComponent: React.FC = ({ const activeValues = response.data.filter((v: any) => v.isActive !== false); const options = activeValues.map((v: any) => ({ value: v.valueCode, - label: v.valueLabel || v.valueCode, + label: (v.path && v.path.includes('/')) + ? v.path.replace(/\//g, ' / ') + : (v.valueLabel || v.valueCode), })); setCategoryOptions(options); } diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index 597c759a..e61b4c5e 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -1613,12 +1613,20 @@ export const SplitPanelLayoutComponent: React.FC if (response.data.success && response.data.data) { const valueMap: Record = {}; - response.data.data.forEach((item: any) => { - valueMap[item.value_code || item.valueCode] = { - label: item.value_label || item.valueLabel, - color: item.color, - }; - }); + const flattenCategoryTree = (items: any[], parentLabel: string = "") => { + items.forEach((item: any) => { + const rawLabel = item.value_label || item.valueLabel; + const displayLabel = parentLabel ? `${parentLabel} / ${rawLabel}` : rawLabel; + valueMap[item.value_code || item.valueCode] = { + label: displayLabel, + color: item.color, + }; + if (item.children && Array.isArray(item.children) && item.children.length > 0) { + flattenCategoryTree(item.children, rawLabel); + } + }); + }; + flattenCategoryTree(response.data.data); mappings[columnName] = valueMap; console.log(`✅ 좌측 카테고리 매핑 로드 [${columnName}]:`, valueMap); } @@ -1675,12 +1683,20 @@ export const SplitPanelLayoutComponent: React.FC if (response.data.success && response.data.data) { const valueMap: Record = {}; - response.data.data.forEach((item: any) => { - valueMap[item.value_code || item.valueCode] = { - label: item.value_label || item.valueLabel, - color: item.color, - }; - }); + const flattenCategoryTree = (items: any[], parentLabel: string = "") => { + items.forEach((item: any) => { + const rawLabel = item.value_label || item.valueLabel; + const displayLabel = parentLabel ? `${parentLabel} / ${rawLabel}` : rawLabel; + valueMap[item.value_code || item.valueCode] = { + label: displayLabel, + color: item.color, + }; + if (item.children && Array.isArray(item.children) && item.children.length > 0) { + flattenCategoryTree(item.children, rawLabel); + } + }); + }; + flattenCategoryTree(response.data.data); // 조인된 테이블의 경우 "테이블명.컬럼명" 형태로 저장 const mappingKey = tableName === rightTableName ? columnName : `${tableName}.${columnName}`; diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx index d0fd3a5c..f7af6732 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx @@ -1337,7 +1337,8 @@ export const SplitPanelLayout2Component: React.FC 0) { for (const item of result.data) { if (item.valueCode && item.valueLabel) { - labelMap[item.valueCode] = item.valueLabel; + // 계층 경로 표시: path가 있고 '/'를 포함하면 전체 경로를 ' > ' 구분자로 표시 + labelMap[item.valueCode] = item.path && item.path.includes('/') ? item.path.replace(/\//g, ' > ') : item.valueLabel; } } } diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 80d84b7a..c8858855 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -1287,22 +1287,25 @@ export const TableListComponent: React.FC = ({ const apiClient = (await import("@/lib/api/client")).apiClient; // 트리 구조를 평탄화하는 헬퍼 함수 (메인 테이블 + 엔티티 조인 공통 사용) - const flattenTree = (items: any[], mapping: Record) => { + const flattenTree = (items: any[], mapping: Record, parentLabel: string = "") => { items.forEach((item: any) => { + const displayLabel = parentLabel + ? `${parentLabel} / ${item.valueLabel}` + : item.valueLabel; if (item.valueCode) { mapping[String(item.valueCode)] = { - label: item.valueLabel, + label: displayLabel, color: item.color, }; } if (item.valueId !== undefined && item.valueId !== null) { mapping[String(item.valueId)] = { - label: item.valueLabel, + label: displayLabel, color: item.color, }; } if (item.children && Array.isArray(item.children) && item.children.length > 0) { - flattenTree(item.children, mapping); + flattenTree(item.children, mapping, item.valueLabel); } }); }; diff --git a/frontend/lib/registry/components/v2-card-display/CardDisplayComponent.tsx b/frontend/lib/registry/components/v2-card-display/CardDisplayComponent.tsx index 3f83186c..eda7c900 100644 --- a/frontend/lib/registry/components/v2-card-display/CardDisplayComponent.tsx +++ b/frontend/lib/registry/components/v2-card-display/CardDisplayComponent.tsx @@ -372,22 +372,27 @@ export const CardDisplayComponent: React.FC = ({ if (response.data.success && response.data.data) { const mapping: Record = {}; - response.data.data.forEach((item: any) => { - // API 응답 형식: valueCode, valueLabel (camelCase) - const code = item.valueCode || item.value_code || item.category_code || item.code || item.value; - const label = item.valueLabel || item.value_label || item.category_name || item.name || item.label || code; - // color가 null/undefined/"none"이면 undefined로 유지 (배지 없음) - const rawColor = item.color ?? item.badge_color; - const color = (rawColor && rawColor !== "none") ? rawColor : undefined; - mapping[code] = { label, color }; - }); + const flattenCategoryTree = (items: any[], parentLabel: string = "") => { + items.forEach((item: any) => { + const code = item.valueCode || item.value_code || item.category_code || item.code || item.value; + const rawLabel = item.valueLabel || item.value_label || item.category_name || item.name || item.label || code; + const displayLabel = parentLabel ? `${parentLabel} / ${rawLabel}` : rawLabel; + const rawColor = item.color ?? item.badge_color; + const color = (rawColor && rawColor !== "none") ? rawColor : undefined; + mapping[code] = { label: displayLabel, color }; + if (item.children && Array.isArray(item.children) && item.children.length > 0) { + flattenCategoryTree(item.children, rawLabel); + } + }); + }; + flattenCategoryTree(response.data.data); mappings[columnName] = mapping; } } catch (error) { // 카테고리 매핑 로드 실패 시 무시 } } - + setCategoryMappings(mappings); } } catch (error) { diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index 5ef54bc3..9399fd15 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -369,6 +369,8 @@ import { Trash2, Lock, GripVertical, + Loader2, + Search, } from "lucide-react"; import * as XLSX from "xlsx"; import { FileText, ChevronRightIcon } from "lucide-react"; @@ -810,17 +812,25 @@ export const TableListComponent: React.FC = ({ const [headerFilters, setHeaderFilters] = useState>>({}); const [openFilterColumn, setOpenFilterColumn] = useState(null); + // 🆕 서버에서 가져온 컬럼별 고유값 캐시 (헤더 필터 드롭다운용) + const [asyncColumnUniqueValues, setAsyncColumnUniqueValues] = useState< + Record + >({}); + const [loadingFilterColumn, setLoadingFilterColumn] = useState(null); + const [filterSearchTerm, setFilterSearchTerm] = useState(""); + // 🆕 Filter Builder (고급 필터) 관련 상태 - filteredData보다 먼저 정의해야 함 const [filterGroups, setFilterGroups] = useState([]); - + // 🆕 joinColumnMapping - filteredData에서 사용하므로 먼저 정의해야 함 const [joinColumnMapping, setJoinColumnMapping] = useState>({}); - // 🆕 분할 패널에서 우측에 이미 추가된 항목 필터링 (좌측 테이블에만 적용) + 헤더 필터 + // 분할 패널에서 우측에 이미 추가된 항목 필터링 (좌측 테이블에만 적용) + // 헤더 필터와 필터 빌더는 서버사이드에서 처리됨 (fetchTableDataInternal에서 API 파라미터로 전달) const filteredData = useMemo(() => { let result = data; - // 1. 분할 패널 좌측에 있고, 우측에 추가된 항목이 있는 경우 필터링 + // 분할 패널 좌측에 있고, 우측에 추가된 항목이 있는 경우 필터링 if (splitPanelPosition === "left" && splitPanelContext?.addedItemIds && splitPanelContext.addedItemIds.size > 0) { const addedIds = splitPanelContext.addedItemIds; result = result.filter((row) => { @@ -829,78 +839,8 @@ export const TableListComponent: React.FC = ({ }); } - // 2. 헤더 필터 적용 (joinColumnMapping 사용 - 조인된 컬럼과 일치해야 함) - if (Object.keys(headerFilters).length > 0) { - result = result.filter((row) => { - return Object.entries(headerFilters).every(([columnName, values]) => { - if (values.size === 0) return true; - - // joinColumnMapping을 사용하여 조인된 컬럼명 확인 - const mappedColumnName = joinColumnMapping[columnName] || columnName; - - // 여러 가능한 컬럼명 시도 (mappedColumnName 우선) - const cellValue = row[mappedColumnName] ?? row[columnName] ?? row[columnName.toLowerCase()] ?? row[columnName.toUpperCase()]; - const cellStr = cellValue !== null && cellValue !== undefined ? String(cellValue) : ""; - - return values.has(cellStr); - }); - }); - } - - // 3. 🆕 Filter Builder 적용 - if (filterGroups.length > 0) { - result = result.filter((row) => { - return filterGroups.every((group) => { - const validConditions = group.conditions.filter( - (c) => c.column && (c.operator === "isEmpty" || c.operator === "isNotEmpty" || c.value), - ); - if (validConditions.length === 0) return true; - - const evaluateCondition = (value: any, condition: (typeof group.conditions)[0]): boolean => { - const strValue = value !== null && value !== undefined ? String(value).toLowerCase() : ""; - const condValue = condition.value.toLowerCase(); - - switch (condition.operator) { - case "equals": - return strValue === condValue; - case "notEquals": - return strValue !== condValue; - case "contains": - return strValue.includes(condValue); - case "notContains": - return !strValue.includes(condValue); - case "startsWith": - return strValue.startsWith(condValue); - case "endsWith": - return strValue.endsWith(condValue); - case "greaterThan": - return parseFloat(strValue) > parseFloat(condValue); - case "lessThan": - return parseFloat(strValue) < parseFloat(condValue); - case "greaterOrEqual": - return parseFloat(strValue) >= parseFloat(condValue); - case "lessOrEqual": - return parseFloat(strValue) <= parseFloat(condValue); - case "isEmpty": - return strValue === "" || value === null || value === undefined; - case "isNotEmpty": - return strValue !== "" && value !== null && value !== undefined; - default: - return true; - } - }; - - if (group.logic === "AND") { - return validConditions.every((cond) => evaluateCondition(row[cond.column], cond)); - } else { - return validConditions.some((cond) => evaluateCondition(row[cond.column], cond)); - } - }); - }); - } - return result; - }, [data, splitPanelPosition, splitPanelContext?.addedItemIds, headerFilters, filterGroups, joinColumnMapping]); + }, [data, splitPanelPosition, splitPanelContext?.addedItemIds]); const [currentPage, setCurrentPage] = useState(1); const [totalPages, setTotalPages] = useState(0); @@ -1650,16 +1590,19 @@ export const TableListComponent: React.FC = ({ // 트리 구조를 평탄화하는 헬퍼 함수 (메인 테이블 + 엔티티 조인 공통 사용) // valueCode만 키로 사용 (valueId까지 넣으면 같은 라벨이 2번 나옴) - const flattenTree = (items: any[], mapping: Record) => { + const flattenTree = (items: any[], mapping: Record, parentLabel: string = "") => { items.forEach((item: any) => { if (item.valueCode) { + const displayLabel = parentLabel + ? `${parentLabel} / ${item.valueLabel}` + : item.valueLabel; mapping[String(item.valueCode)] = { - label: item.valueLabel, + label: displayLabel, color: item.color, }; } if (item.children && Array.isArray(item.children) && item.children.length > 0) { - flattenTree(item.children, mapping); + flattenTree(item.children, mapping, item.valueLabel); } }); }; @@ -1956,11 +1899,32 @@ export const TableListComponent: React.FC = ({ }; } - // 검색 필터, 연결 필터, RelatedDataButtons 필터 병합 + // 🆕 헤더 필터를 서버 필터 형식으로 변환 + const headerFilterValues: Record = {}; + Object.entries(headerFilters).forEach(([columnName, values]) => { + if (values.size > 0) { + const mappedCol = joinColumnMapping[columnName] || columnName; + headerFilterValues[mappedCol] = { value: Array.from(values), operator: "in" }; + } + }); + + // 🆕 필터 빌더를 서버 필터 형식으로 변환 + const filterBuilderValues: Record = {}; + filterGroups.forEach((group) => { + group.conditions.forEach((cond) => { + if (cond.column && (cond.operator === "isEmpty" || cond.operator === "isNotEmpty" || cond.value)) { + filterBuilderValues[cond.column] = { value: cond.value, operator: cond.operator }; + } + }); + }); + + // 검색 필터, 연결 필터, RelatedDataButtons 필터, 헤더 필터, 필터 빌더 병합 const filters = { ...(Object.keys(searchValues).length > 0 ? searchValues : {}), ...linkedFilterValues, ...relatedButtonFilterValues, // 🆕 RelatedDataButtons 필터 추가 + ...headerFilterValues, // 🆕 헤더 필터 추가 + ...filterBuilderValues, // 🆕 필터 빌더 추가 }; const hasFilters = Object.keys(filters).length > 0; @@ -2137,6 +2101,10 @@ export const TableListComponent: React.FC = ({ isRelatedButtonTarget, // 🆕 프리뷰용 회사 코드 오버라이드 companyCode, + // 🆕 서버사이드 헤더 필터 / 필터 빌더 + headerFilters, + filterGroups, + joinColumnMapping, ]); const fetchTableDataDebounced = useCallback( @@ -2594,6 +2562,11 @@ export const TableListComponent: React.FC = ({ return result; }, [data, tableConfig.columns, joinColumnMapping]); + // 데이터 변경 시 헤더 필터 드롭다운 캐시 초기화 + useEffect(() => { + setAsyncColumnUniqueValues({}); + }, [data]); + // 🆕 헤더 필터 토글 const toggleHeaderFilter = useCallback((columnName: string, value: string) => { setHeaderFilters((prev) => { @@ -6122,11 +6095,40 @@ export const TableListComponent: React.FC = ({ {sortDirection === "asc" ? "↑" : "↓"} )} {/* 🆕 헤더 필터 버튼 */} - {tableConfig.headerFilter !== false && - columnUniqueValues[column.columnName]?.length > 0 && ( + {tableConfig.headerFilter !== false && ( setOpenFilterColumn(open ? column.columnName : null)} + onOpenChange={(open) => { + if (open) { + setOpenFilterColumn(column.columnName); + setFilterSearchTerm(""); + // 서버에서 고유값 가져오기 + if (!asyncColumnUniqueValues[column.columnName]) { + setLoadingFilterColumn(column.columnName); + const mappedCol = joinColumnMapping[column.columnName] || column.columnName; + const tableName = tableConfig.selectedTable; + if (tableName) { + import("@/lib/api/client").then(({ apiClient }) => { + apiClient + .get(`/table-management/tables/${tableName}/column-values/${mappedCol}`) + .then((res) => { + const values = (res.data?.data || []).map((v: any) => ({ + value: String(v.value ?? ""), + label: String(v.label ?? v.value ?? ""), + })); + setAsyncColumnUniqueValues((prev) => ({ ...prev, [column.columnName]: values })); + }) + .catch(() => { + setAsyncColumnUniqueValues((prev) => ({ ...prev, [column.columnName]: [] })); + }) + .finally(() => setLoadingFilterColumn(null)); + }); + } + } + } else { + setOpenFilterColumn(null); + } + }} > e.stopPropagation()} > @@ -6164,35 +6166,66 @@ export const TableListComponent: React.FC = ({ )} + {/* 검색 입력 */} +
+ + setFilterSearchTerm(e.target.value)} + placeholder="검색..." + className="border-input bg-background w-full rounded border py-1 pr-2 pl-7 text-xs" + onClick={(e) => e.stopPropagation()} + /> +
- {columnUniqueValues[column.columnName]?.slice(0, 50).map((val) => { - const isSelected = headerFilters[column.columnName]?.has(val); - return ( -
toggleHeaderFilter(column.columnName, val)} - > -
- {isSelected && } -
- {val || "(빈 값)"} -
- ); - })} - {(columnUniqueValues[column.columnName]?.length || 0) > 50 && ( -
- ...외 {(columnUniqueValues[column.columnName]?.length || 0) - 50}개 + {loadingFilterColumn === column.columnName ? ( +
+ + 로딩중...
- )} + ) : (asyncColumnUniqueValues[column.columnName] || []).length === 0 ? ( +
+ 필터 값이 없습니다 +
+ ) : (() => { + const filteredItems = (asyncColumnUniqueValues[column.columnName] || []).filter((item) => { + if (!filterSearchTerm) return true; + const term = filterSearchTerm.toLowerCase(); + return item.value.toLowerCase().includes(term) || item.label.toLowerCase().includes(term); + }); + return filteredItems.length === 0 ? ( +
+ 검색 결과가 없습니다 +
+ ) : ( + <> + {filteredItems.map((item) => { + const isSelected = headerFilters[column.columnName]?.has(item.value); + return ( +
toggleHeaderFilter(column.columnName, item.value)} + > +
+ {isSelected && } +
+ {item.label || item.value || "(빈 값)"} +
+ ); + })} + + ); + })()}
diff --git a/frontend/lib/registry/components/v2-table-search-widget/TableSearchWidget.tsx b/frontend/lib/registry/components/v2-table-search-widget/TableSearchWidget.tsx index a786cd49..5ba91079 100644 --- a/frontend/lib/registry/components/v2-table-search-widget/TableSearchWidget.tsx +++ b/frontend/lib/registry/components/v2-table-search-widget/TableSearchWidget.tsx @@ -3,7 +3,7 @@ import React, { useState, useEffect, useRef, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Settings, X, ChevronsUpDown } from "lucide-react"; +import { Settings, X, ChevronsUpDown, Search } from "lucide-react"; import { useTableOptions } from "@/contexts/TableOptionsContext"; import { useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext"; import { useActiveTab } from "@/contexts/ActiveTabContext"; @@ -77,6 +77,10 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table const [selectOptions, setSelectOptions] = useState>>({}); // 선택된 값의 라벨 저장 (데이터 없을 때도 라벨 유지) const [selectedLabels, setSelectedLabels] = useState>({}); + // select 필터 드롭다운 내 검색 텍스트 + const [selectSearchTexts, setSelectSearchTexts] = useState>({}); + // select 필터 Popover 열림 상태 + const [selectPopoverOpen, setSelectPopoverOpen] = useState>({}); // 높이 감지를 위한 ref const containerRef = useRef(null); @@ -695,6 +699,14 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table [] as Array<{ value: string; label: string }>, ); + // 검색 텍스트로 필터링 + const searchText = selectSearchTexts[filter.columnName] || ""; + const filteredOptions = searchText + ? uniqueOptions.filter((option) => + option.label.toLowerCase().includes(searchText.toLowerCase()) + ) + : uniqueOptions; + // 항상 다중선택 모드 const selectedValues: string[] = Array.isArray(value) ? value : value ? [value] : []; @@ -719,7 +731,15 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table }; return ( - + { + setSelectPopoverOpen((prev) => ({ ...prev, [filter.columnName]: open })); + if (!open) { + setSelectSearchTexts((prev) => ({ ...prev, [filter.columnName]: "" })); + } + }} + >