feat: enhance v2 components and entity join functionality

- Add entity join controller/routes enhancements
- Improve table management and category value services
- Update v2 table list, card display, search widget components
- Improve split panel layout responsiveness

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
wace
2026-03-25 09:54:18 +09:00
parent 08ad2abdd1
commit 0fd0a43370
17 changed files with 495 additions and 178 deletions
@@ -372,22 +372,27 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
if (response.data.success && response.data.data) {
const mapping: Record<string, { label: string; color?: string }> = {};
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) {
@@ -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}
</SelectItem>
))}
</SelectContent>
@@ -258,7 +258,9 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
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);
}
@@ -1613,12 +1613,20 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
if (response.data.success && response.data.data) {
const valueMap: Record<string, { label: string; color?: string }> = {};
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<SplitPanelLayoutComponentProps>
if (response.data.success && response.data.data) {
const valueMap: Record<string, { label: string; color?: string }> = {};
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}`;
@@ -1337,7 +1337,8 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
if (result.success && Array.isArray(result.data) && result.data.length > 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;
}
}
}
@@ -1287,22 +1287,25 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const apiClient = (await import("@/lib/api/client")).apiClient;
// 트리 구조를 평탄화하는 헬퍼 함수 (메인 테이블 + 엔티티 조인 공통 사용)
const flattenTree = (items: any[], mapping: Record<string, { label: string; color?: string }>) => {
const flattenTree = (items: any[], mapping: Record<string, { label: string; color?: string }>, 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);
}
});
};
@@ -372,22 +372,27 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
if (response.data.success && response.data.data) {
const mapping: Record<string, { label: string; color?: string }> = {};
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) {
@@ -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<TableListComponentProps> = ({
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
const [openFilterColumn, setOpenFilterColumn] = useState<string | null>(null);
// 🆕 서버에서 가져온 컬럼별 고유값 캐시 (헤더 필터 드롭다운용)
const [asyncColumnUniqueValues, setAsyncColumnUniqueValues] = useState<
Record<string, { value: string; label: string }[]>
>({});
const [loadingFilterColumn, setLoadingFilterColumn] = useState<string | null>(null);
const [filterSearchTerm, setFilterSearchTerm] = useState("");
// 🆕 Filter Builder (고급 필터) 관련 상태 - filteredData보다 먼저 정의해야 함
const [filterGroups, setFilterGroups] = useState<FilterGroup[]>([]);
// 🆕 joinColumnMapping - filteredData에서 사용하므로 먼저 정의해야 함
const [joinColumnMapping, setJoinColumnMapping] = useState<Record<string, string>>({});
// 🆕 분할 패널에서 우측에 이미 추가된 항목 필터링 (좌측 테이블에만 적용) + 헤더 필터
// 분할 패널에서 우측에 이미 추가된 항목 필터링 (좌측 테이블에만 적용)
// 헤더 필터와 필터 빌더는 서버사이드에서 처리됨 (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<TableListComponentProps> = ({
});
}
// 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<TableListComponentProps> = ({
// 트리 구조를 평탄화하는 헬퍼 함수 (메인 테이블 + 엔티티 조인 공통 사용)
// valueCode만 키로 사용 (valueId까지 넣으면 같은 라벨이 2번 나옴)
const flattenTree = (items: any[], mapping: Record<string, { label: string; color?: string }>) => {
const flattenTree = (items: any[], mapping: Record<string, { label: string; color?: string }>, 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<TableListComponentProps> = ({
};
}
// 검색 필터, 연결 필터, RelatedDataButtons 필터 병합
// 🆕 헤더 필터를 서버 필터 형식으로 변환
const headerFilterValues: Record<string, any> = {};
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<string, any> = {};
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<TableListComponentProps> = ({
isRelatedButtonTarget,
// 🆕 프리뷰용 회사 코드 오버라이드
companyCode,
// 🆕 서버사이드 헤더 필터 / 필터 빌더
headerFilters,
filterGroups,
joinColumnMapping,
]);
const fetchTableDataDebounced = useCallback(
@@ -2594,6 +2562,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
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<TableListComponentProps> = ({
<span className="text-primary">{sortDirection === "asc" ? "↑" : "↓"}</span>
)}
{/* 🆕 헤더 필터 버튼 */}
{tableConfig.headerFilter !== false &&
columnUniqueValues[column.columnName]?.length > 0 && (
{tableConfig.headerFilter !== false && (
<Popover
open={openFilterColumn === column.columnName}
onOpenChange={(open) => 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);
}
}}
>
<PopoverTrigger asChild>
<button
@@ -6146,7 +6148,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
</button>
</PopoverTrigger>
<PopoverContent
className="w-48 p-2"
className="w-56 p-2"
align="start"
onClick={(e) => e.stopPropagation()}
>
@@ -6164,35 +6166,66 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
</button>
)}
</div>
{/* 검색 입력 */}
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-2 h-3 w-3 -translate-y-1/2" />
<input
type="text"
value={filterSearchTerm}
onChange={(e) => 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()}
/>
</div>
<div className="max-h-48 space-y-1 overflow-y-auto">
{columnUniqueValues[column.columnName]?.slice(0, 50).map((val) => {
const isSelected = headerFilters[column.columnName]?.has(val);
return (
<div
key={val}
className={cn(
"hover:bg-muted flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-xs",
isSelected && "bg-primary/10",
)}
onClick={() => toggleHeaderFilter(column.columnName, val)}
>
<div
className={cn(
"flex h-4 w-4 items-center justify-center rounded border",
isSelected ? "bg-primary border-primary" : "border-input",
)}
>
{isSelected && <Check className="text-primary-foreground h-3 w-3" />}
</div>
<span className="truncate">{val || "(빈 값)"}</span>
</div>
);
})}
{(columnUniqueValues[column.columnName]?.length || 0) > 50 && (
<div className="text-muted-foreground px-2 py-1 text-xs">
... {(columnUniqueValues[column.columnName]?.length || 0) - 50}
{loadingFilterColumn === column.columnName ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
<span className="text-muted-foreground ml-2 text-xs">...</span>
</div>
)}
) : (asyncColumnUniqueValues[column.columnName] || []).length === 0 ? (
<div className="text-muted-foreground px-2 py-2 text-center text-xs">
</div>
) : (() => {
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 ? (
<div className="text-muted-foreground px-2 py-2 text-center text-xs">
</div>
) : (
<>
{filteredItems.map((item) => {
const isSelected = headerFilters[column.columnName]?.has(item.value);
return (
<div
key={item.value}
className={cn(
"hover:bg-muted flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-xs",
isSelected && "bg-primary/10",
)}
onClick={() => toggleHeaderFilter(column.columnName, item.value)}
>
<div
className={cn(
"flex h-4 w-4 items-center justify-center rounded border",
isSelected ? "bg-primary border-primary" : "border-input",
)}
>
{isSelected && <Check className="text-primary-foreground h-3 w-3" />}
</div>
<span className="truncate">{item.label || item.value || "(빈 값)"}</span>
</div>
);
})}
</>
);
})()}
</div>
</div>
</PopoverContent>
@@ -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<Record<string, Array<{ label: string; value: string }>>>({});
// 선택된 값의 라벨 저장 (데이터 없을 때도 라벨 유지)
const [selectedLabels, setSelectedLabels] = useState<Record<string, string>>({});
// select 필터 드롭다운 내 검색 텍스트
const [selectSearchTexts, setSelectSearchTexts] = useState<Record<string, string>>({});
// select 필터 Popover 열림 상태
const [selectPopoverOpen, setSelectPopoverOpen] = useState<Record<string, boolean>>({});
// 높이 감지를 위한 ref
const containerRef = useRef<HTMLDivElement>(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 (
<Popover>
<Popover
open={selectPopoverOpen[filter.columnName] || false}
onOpenChange={(open) => {
setSelectPopoverOpen((prev) => ({ ...prev, [filter.columnName]: open }));
if (!open) {
setSelectSearchTexts((prev) => ({ ...prev, [filter.columnName]: "" }));
}
}}
>
<PopoverTrigger asChild>
<Button
variant="outline"
@@ -735,12 +755,34 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<div className="border-b p-2">
<div className="relative">
<Search className="text-muted-foreground absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
type="text"
placeholder="검색..."
value={selectSearchTexts[filter.columnName] || ""}
onChange={(e) =>
setSelectSearchTexts((prev) => ({
...prev,
[filter.columnName]: e.target.value,
}))
}
onKeyDown={(e) => { if (e.key === "Enter") e.preventDefault(); }}
className="h-8 pl-8 text-xs focus-visible:ring-0 focus-visible:ring-offset-0 sm:text-sm"
style={{ outline: "none", boxShadow: "none" }}
autoFocus
/>
</div>
</div>
<div className="max-h-60 overflow-auto">
{uniqueOptions.length === 0 ? (
<div className="text-muted-foreground px-3 py-2 text-xs"> </div>
{filteredOptions.length === 0 ? (
<div className="text-muted-foreground px-3 py-2 text-xs">
{searchText ? "검색 결과 없음" : "옵션 없음"}
</div>
) : (
<div className="p-1">
{uniqueOptions.map((option, index) => (
{filteredOptions.map((option, index) => (
<div
key={`${filter.columnName}-multi-${option.value}-${index}`}
className="hover:bg-accent flex cursor-pointer items-center space-x-2 rounded-sm px-2 py-1.5"