feat: 테이블 검색 필터 개선 - 필터 너비 설정 및 자동 wrap 기능

- FilterPanel: 필터별 너비(width) 설정 기능 추가 (50-500px)
- TableSearchWidget: 필터가 여러 줄로 자동 wrap되도록 flex-wrap 적용
- TableSearchWidget: 필터 너비 설정을 localStorage에 저장/복원
- InteractiveScreenViewerDynamic: TableSearchWidget의 높이를 auto로 설정하여 콘텐츠에 맞게 자동 조정
- globals.css: 입력 필드 포커스 시 검정 테두리 제거 (combobox, input)

주요 변경사항:
1. 필터 설정에서 각 필터의 표시 너비를 개별 설정 가능
2. 필터가 많을 때 자동으로 여러 줄로 배치 (overflow 방지)
3. 설정된 필터 너비가 새로고침 후에도 유지됨
4. TableSearchWidget 높이가 콘텐츠에 맞게 자동 조정

TODO: TableSearchWidget 높이 변화 시 아래 컴포넌트 자동 재배치 기능 구현 예정
This commit is contained in:
kjs 2025-11-12 14:50:06 +09:00
parent 5c205753e2
commit 6d1743c524
6 changed files with 131 additions and 19 deletions

View File

@ -217,6 +217,18 @@ select:focus-visible {
outline-offset: 2px;
}
/* TableSearchWidget의 SelectTrigger 포커스 스타일 제거 */
[role="combobox"]:focus-visible {
outline: none !important;
box-shadow: none !important;
}
button[role="combobox"]:focus-visible {
outline: none !important;
box-shadow: none !important;
border-color: hsl(var(--input)) !important;
}
/* ===== Scrollbar Styles (Optional) ===== */
/* Webkit 기반 브라우저 (Chrome, Safari, Edge) */
::-webkit-scrollbar {

View File

@ -683,6 +683,9 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
// ✅ 격자 시스템 잔재 제거: style.width, style.height 무시
const { width: styleWidth, height: styleHeight, ...styleWithoutSize } = style;
// TableSearchWidget의 경우 높이를 자동으로 설정
const isTableSearchWidget = (component as any).componentId === "table-search-widget";
const componentStyle = {
position: "absolute" as const,
left: position?.x || 0,
@ -690,7 +693,8 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
zIndex: position?.z || 1,
...styleWithoutSize, // width/height 제외한 스타일만 먼저 적용
width: size?.width || 200, // size의 픽셀 값이 최종 우선순위
height: size?.height || 10,
height: isTableSearchWidget ? "auto" : (size?.height || 10),
minHeight: isTableSearchWidget ? "48px" : undefined,
};
return (

View File

@ -65,6 +65,7 @@ interface ColumnFilterConfig {
inputType: string;
enabled: boolean;
filterType: "text" | "number" | "date" | "select";
width?: number; // 필터 입력 필드 너비 (px)
selectOptions?: Array<{ label: string; value: string }>;
}
@ -188,6 +189,7 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
operator: "contains", // 기본 연산자
value: "",
filterType: cf.filterType,
width: cf.width || 200, // 너비 포함 (기본 200px)
}));
// localStorage에 저장
@ -285,6 +287,28 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
<SelectItem value="select"></SelectItem>
</SelectContent>
</Select>
{/* 너비 입력 */}
<Input
type="number"
value={filter.width || 200}
onChange={(e) => {
const newWidth = parseInt(e.target.value) || 200;
setColumnFilters((prev) =>
prev.map((f) =>
f.columnName === filter.columnName
? { ...f, width: newWidth }
: f
)
);
}}
disabled={!filter.enabled}
placeholder="너비"
className="h-8 w-[80px] text-xs sm:h-9 sm:text-sm"
min={50}
max={500}
/>
<span className="text-xs text-muted-foreground">px</span>
</div>
))}
</div>

View File

@ -376,11 +376,53 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const meta = columnMeta[columnName];
const inputType = meta?.inputType || "text";
// 카테고리, 엔티티, 코드 타입인 경우 _name 필드 사용 (백엔드 조인 결과)
// 카테고리 타입인 경우 전체 정의된 값 조회 (백엔드 API)
if (inputType === "category") {
try {
console.log("🔍 [getColumnUniqueValues] 카테고리 전체 값 조회:", {
tableName: tableConfig.selectedTable,
columnName,
});
// API 클라이언트 사용 (쿠키 인증 자동 처리)
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(
`/table-categories/${tableConfig.selectedTable}/${columnName}/values`
);
if (response.data.success && response.data.data) {
const categoryOptions = response.data.data.map((item: any) => ({
value: item.valueCode, // 카멜케이스
label: item.valueLabel, // 카멜케이스
}));
console.log("✅ [getColumnUniqueValues] 카테고리 전체 값:", {
columnName,
count: categoryOptions.length,
options: categoryOptions,
});
return categoryOptions;
} else {
console.warn("⚠️ [getColumnUniqueValues] 응답 형식 오류:", response.data);
}
} catch (error: any) {
console.error("❌ [getColumnUniqueValues] 카테고리 조회 실패:", {
error: error.message,
response: error.response?.data,
status: error.response?.status,
columnName,
tableName: tableConfig.selectedTable,
});
// 에러 시 현재 데이터 기반으로 fallback
}
}
// 일반 타입 또는 카테고리 조회 실패 시: 현재 데이터 기반
const isLabelType = ["category", "entity", "code"].includes(inputType);
const labelField = isLabelType ? `${columnName}_name` : columnName;
console.log("🔍 [getColumnUniqueValues] 필드 선택:", {
console.log("🔍 [getColumnUniqueValues] 데이터 기반 조회:", {
columnName,
inputType,
isLabelType,
@ -409,14 +451,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}))
.sort((a, b) => a.label.localeCompare(b.label));
console.log("✅ [getColumnUniqueValues] 결과:", {
console.log("✅ [getColumnUniqueValues] 데이터 기반 결과:", {
columnName,
inputType,
isLabelType,
labelField,
uniqueCount: result.length,
values: result, // 전체 값 출력
allKeys: data[0] ? Object.keys(data[0]) : [], // 모든 키 출력
values: result,
});
return result;

View File

@ -78,6 +78,7 @@ export function TableSearchWidget({ component }: TableSearchWidgetProps) {
inputType: string;
enabled: boolean;
filterType: "text" | "number" | "date" | "select";
width?: number;
}>;
// enabled된 필터들만 activeFilters로 설정
@ -88,6 +89,7 @@ export function TableSearchWidget({ component }: TableSearchWidgetProps) {
operator: "contains",
value: "",
filterType: f.filterType,
width: f.width || 200, // 저장된 너비 포함
}));
setActiveFilters(activeFiltersList);
@ -212,6 +214,7 @@ export function TableSearchWidget({ component }: TableSearchWidgetProps) {
const renderFilterInput = (filter: TableFilter) => {
const column = currentTable?.columns.find((c) => c.columnName === filter.columnName);
const value = filterValues[filter.columnName] || "";
const width = filter.width || 200; // 기본 너비 200px
switch (filter.filterType) {
case "date":
@ -220,8 +223,8 @@ export function TableSearchWidget({ component }: TableSearchWidgetProps) {
type="date"
value={value}
onChange={(e) => handleFilterChange(filter.columnName, e.target.value)}
className="h-9 text-xs sm:text-sm"
style={{ height: '36px', minHeight: '36px' }}
className="h-9 text-xs sm:text-sm focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0"
style={{ width: `${width}px`, height: '36px', minHeight: '36px', outline: 'none', boxShadow: 'none' }}
placeholder={column?.columnLabel}
/>
);
@ -232,8 +235,8 @@ export function TableSearchWidget({ component }: TableSearchWidgetProps) {
type="number"
value={value}
onChange={(e) => handleFilterChange(filter.columnName, e.target.value)}
className="h-9 text-xs sm:text-sm"
style={{ height: '36px', minHeight: '36px' }}
className="h-9 text-xs sm:text-sm focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0"
style={{ width: `${width}px`, height: '36px', minHeight: '36px', outline: 'none', boxShadow: 'none' }}
placeholder={column?.columnLabel}
/>
);
@ -241,18 +244,40 @@ export function TableSearchWidget({ component }: TableSearchWidgetProps) {
case "select": {
let options = selectOptions[filter.columnName] || [];
console.log("🔍 [renderFilterInput] select 렌더링:", {
columnName: filter.columnName,
selectOptions: selectOptions[filter.columnName],
optionsLength: options.length,
});
// 현재 선택된 값이 옵션 목록에 없으면 추가 (데이터 없을 때도 선택값 유지)
if (value && !options.find(opt => opt.value === value)) {
const savedLabel = selectedLabels[filter.columnName] || value;
options = [{ value, label: savedLabel }, ...options];
}
// 중복 제거 (value 기준)
const uniqueOptions = options.reduce((acc, option) => {
if (!acc.find(opt => opt.value === option.value)) {
acc.push(option);
}
return acc;
}, [] as Array<{ value: string; label: string }>);
console.log("✅ [renderFilterInput] uniqueOptions:", {
columnName: filter.columnName,
originalOptionsLength: options.length,
uniqueOptionsLength: uniqueOptions.length,
originalOptions: options,
uniqueOptions: uniqueOptions,
});
return (
<Select
value={value}
onValueChange={(val) => {
// 선택한 값의 라벨 저장
const selectedOption = options.find(opt => opt.value === val);
const selectedOption = uniqueOptions.find(opt => opt.value === val);
if (selectedOption) {
setSelectedLabels(prev => ({
...prev,
@ -262,17 +287,20 @@ export function TableSearchWidget({ component }: TableSearchWidgetProps) {
handleFilterChange(filter.columnName, val);
}}
>
<SelectTrigger className="h-9 min-h-9 text-xs sm:text-sm" style={{ height: '36px', minHeight: '36px' }}>
<SelectTrigger
className="h-9 min-h-9 text-xs sm:text-sm focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0"
style={{ width: `${width}px`, height: '36px', minHeight: '36px', outline: 'none', boxShadow: 'none' }}
>
<SelectValue placeholder={column?.columnLabel || "선택"} />
</SelectTrigger>
<SelectContent>
{options.length === 0 ? (
{uniqueOptions.length === 0 ? (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
</div>
) : (
options.map((option) => (
<SelectItem key={option.value} value={option.value}>
uniqueOptions.map((option, index) => (
<SelectItem key={`${filter.columnName}-${option.value}-${index}`} value={option.value}>
{option.label}
</SelectItem>
))
@ -288,7 +316,8 @@ export function TableSearchWidget({ component }: TableSearchWidgetProps) {
type="text"
value={value}
onChange={(e) => handleFilterChange(filter.columnName, e.target.value)}
className="h-9 text-xs sm:text-sm"
className="h-9 text-xs sm:text-sm focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0"
style={{ width: `${width}px`, height: '36px', minHeight: '36px', outline: 'none', boxShadow: 'none' }}
placeholder={column?.columnLabel}
/>
);
@ -297,17 +326,18 @@ export function TableSearchWidget({ component }: TableSearchWidgetProps) {
return (
<div
className="flex h-full w-full items-center gap-2 border-b bg-card"
className="flex w-full flex-wrap items-center gap-2 border-b bg-card"
style={{
padding: component.style?.padding || "0.75rem",
backgroundColor: component.style?.backgroundColor,
minHeight: "48px",
}}
>
{/* 필터 입력 필드들 */}
{activeFilters.length > 0 && (
<div className="flex flex-1 items-center gap-2 overflow-x-auto">
<div className="flex flex-1 flex-wrap items-center gap-2">
{activeFilters.map((filter) => (
<div key={filter.columnName} className="min-w-[150px]">
<div key={filter.columnName}>
{renderFilterInput(filter)}
</div>
))}

View File

@ -19,6 +19,7 @@ export interface TableFilter {
| "notEquals";
value: string | number | boolean;
filterType?: "text" | "number" | "date" | "select"; // 필터 입력 타입
width?: number; // 필터 입력 필드 너비 (px)
}
/**