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:
parent
5c205753e2
commit
6d1743c524
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export interface TableFilter {
|
|||
| "notEquals";
|
||||
value: string | number | boolean;
|
||||
filterType?: "text" | "number" | "date" | "select"; // 필터 입력 타입
|
||||
width?: number; // 필터 입력 필드 너비 (px)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue