2025-11-12 10:48:24 +09:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import React, { useState, useEffect } from "react";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
2025-11-12 12:06:58 +09:00
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
import { Settings, Filter, Layers, Search } from "lucide-react";
|
2025-11-12 10:48:24 +09:00
|
|
|
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
|
|
|
|
import { ColumnVisibilityPanel } from "@/components/screen/table-options/ColumnVisibilityPanel";
|
|
|
|
|
import { FilterPanel } from "@/components/screen/table-options/FilterPanel";
|
|
|
|
|
import { GroupingPanel } from "@/components/screen/table-options/GroupingPanel";
|
2025-11-12 12:06:58 +09:00
|
|
|
import { TableFilter } from "@/types/table-options";
|
2025-11-12 10:48:24 +09:00
|
|
|
import {
|
|
|
|
|
Select,
|
|
|
|
|
SelectContent,
|
|
|
|
|
SelectItem,
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
SelectValue,
|
|
|
|
|
} from "@/components/ui/select";
|
|
|
|
|
|
|
|
|
|
interface TableSearchWidgetProps {
|
|
|
|
|
component: {
|
|
|
|
|
id: string;
|
|
|
|
|
title?: string;
|
|
|
|
|
style?: {
|
|
|
|
|
width?: string;
|
|
|
|
|
height?: string;
|
|
|
|
|
padding?: string;
|
|
|
|
|
backgroundColor?: string;
|
|
|
|
|
};
|
|
|
|
|
componentConfig?: {
|
|
|
|
|
autoSelectFirstTable?: boolean; // 첫 번째 테이블 자동 선택 여부
|
|
|
|
|
showTableSelector?: boolean; // 테이블 선택 드롭다운 표시 여부
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function TableSearchWidget({ component }: TableSearchWidgetProps) {
|
2025-11-12 12:06:58 +09:00
|
|
|
const { registeredTables, selectedTableId, setSelectedTableId, getTable } = useTableOptions();
|
2025-11-12 10:48:24 +09:00
|
|
|
const [columnVisibilityOpen, setColumnVisibilityOpen] = useState(false);
|
|
|
|
|
const [filterOpen, setFilterOpen] = useState(false);
|
|
|
|
|
const [groupingOpen, setGroupingOpen] = useState(false);
|
2025-11-12 12:06:58 +09:00
|
|
|
|
|
|
|
|
// 활성화된 필터 목록
|
|
|
|
|
const [activeFilters, setActiveFilters] = useState<TableFilter[]>([]);
|
|
|
|
|
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
|
|
|
|
// select 타입 필터의 옵션들
|
|
|
|
|
const [selectOptions, setSelectOptions] = useState<Record<string, Array<{ label: string; value: string }>>>({});
|
2025-11-12 10:48:24 +09:00
|
|
|
|
|
|
|
|
const autoSelectFirstTable = component.componentConfig?.autoSelectFirstTable ?? true;
|
|
|
|
|
const showTableSelector = component.componentConfig?.showTableSelector ?? true;
|
|
|
|
|
|
|
|
|
|
// Map을 배열로 변환
|
|
|
|
|
const tableList = Array.from(registeredTables.values());
|
2025-11-12 12:06:58 +09:00
|
|
|
const currentTable = selectedTableId ? getTable(selectedTableId) : undefined;
|
2025-11-12 10:48:24 +09:00
|
|
|
|
|
|
|
|
// 첫 번째 테이블 자동 선택
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const tables = Array.from(registeredTables.values());
|
2025-11-12 10:58:21 +09:00
|
|
|
|
2025-11-12 10:48:24 +09:00
|
|
|
if (autoSelectFirstTable && tables.length > 0 && !selectedTableId) {
|
|
|
|
|
setSelectedTableId(tables[0].tableId);
|
|
|
|
|
}
|
|
|
|
|
}, [registeredTables, selectedTableId, autoSelectFirstTable, setSelectedTableId]);
|
|
|
|
|
|
2025-11-12 14:02:58 +09:00
|
|
|
// 현재 테이블의 저장된 필터 불러오기
|
2025-11-12 12:06:58 +09:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (currentTable?.tableName) {
|
|
|
|
|
const storageKey = `table_filters_${currentTable.tableName}`;
|
|
|
|
|
const savedFilters = localStorage.getItem(storageKey);
|
|
|
|
|
|
|
|
|
|
if (savedFilters) {
|
|
|
|
|
try {
|
|
|
|
|
const parsed = JSON.parse(savedFilters) as Array<{
|
|
|
|
|
columnName: string;
|
|
|
|
|
columnLabel: string;
|
|
|
|
|
inputType: string;
|
|
|
|
|
enabled: boolean;
|
|
|
|
|
filterType: "text" | "number" | "date" | "select";
|
|
|
|
|
}>;
|
|
|
|
|
|
|
|
|
|
// enabled된 필터들만 activeFilters로 설정
|
|
|
|
|
const activeFiltersList: TableFilter[] = parsed
|
|
|
|
|
.filter((f) => f.enabled)
|
|
|
|
|
.map((f) => ({
|
|
|
|
|
columnName: f.columnName,
|
|
|
|
|
operator: "contains",
|
|
|
|
|
value: "",
|
|
|
|
|
filterType: f.filterType,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
setActiveFilters(activeFiltersList);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("저장된 필터 불러오기 실패:", error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-11-12 14:02:58 +09:00
|
|
|
}, [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]);
|
2025-11-12 12:06:58 +09:00
|
|
|
|
|
|
|
|
// 디버깅: 현재 테이블 정보 로깅
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (currentTable) {
|
|
|
|
|
console.log("🔍 [TableSearchWidget] 현재 테이블 정보:", {
|
|
|
|
|
selectedTableId,
|
|
|
|
|
tableId: currentTable.tableId,
|
|
|
|
|
tableName: currentTable.tableName,
|
|
|
|
|
label: currentTable.label,
|
|
|
|
|
dataCount: currentTable.dataCount,
|
|
|
|
|
hasDataCount: currentTable.dataCount !== undefined,
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
console.log("🔍 [TableSearchWidget] 테이블 없음:", { selectedTableId });
|
|
|
|
|
}
|
|
|
|
|
}, [selectedTableId, currentTable?.dataCount]);
|
|
|
|
|
|
2025-11-12 10:48:24 +09:00
|
|
|
const hasMultipleTables = tableList.length > 1;
|
|
|
|
|
|
2025-11-12 12:06:58 +09:00
|
|
|
// 필터 값 변경 핸들러
|
|
|
|
|
const handleFilterChange = (columnName: string, value: string) => {
|
|
|
|
|
setFilterValues((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
[columnName]: value,
|
|
|
|
|
}));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 검색 실행
|
|
|
|
|
const handleSearch = () => {
|
|
|
|
|
// 빈 값이 아닌 필터만 적용
|
|
|
|
|
const filtersWithValues = activeFilters.map((filter) => ({
|
|
|
|
|
...filter,
|
|
|
|
|
value: filterValues[filter.columnName] || "",
|
|
|
|
|
})).filter((f) => f.value !== "");
|
|
|
|
|
|
|
|
|
|
currentTable?.onFilterChange(filtersWithValues);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 필터 입력 필드 렌더링
|
|
|
|
|
const renderFilterInput = (filter: TableFilter) => {
|
|
|
|
|
const column = currentTable?.columns.find((c) => c.columnName === filter.columnName);
|
|
|
|
|
const value = filterValues[filter.columnName] || "";
|
|
|
|
|
|
|
|
|
|
switch (filter.filterType) {
|
|
|
|
|
case "date":
|
|
|
|
|
return (
|
|
|
|
|
<Input
|
|
|
|
|
type="date"
|
|
|
|
|
value={value}
|
|
|
|
|
onChange={(e) => handleFilterChange(filter.columnName, e.target.value)}
|
|
|
|
|
className="h-8 text-xs sm:h-9 sm:text-sm"
|
|
|
|
|
placeholder={column?.columnLabel}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "number":
|
|
|
|
|
return (
|
|
|
|
|
<Input
|
|
|
|
|
type="number"
|
|
|
|
|
value={value}
|
|
|
|
|
onChange={(e) => handleFilterChange(filter.columnName, e.target.value)}
|
|
|
|
|
className="h-8 text-xs sm:h-9 sm:text-sm"
|
|
|
|
|
placeholder={column?.columnLabel}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "select": {
|
|
|
|
|
const options = selectOptions[filter.columnName] || [];
|
|
|
|
|
return (
|
|
|
|
|
<Select
|
|
|
|
|
value={value}
|
|
|
|
|
onValueChange={(val) => handleFilterChange(filter.columnName, val)}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-8 text-xs sm:h-9 sm:text-sm">
|
2025-11-12 14:02:58 +09:00
|
|
|
<SelectValue placeholder={column?.columnLabel || "선택"} />
|
2025-11-12 12:06:58 +09:00
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{options.length === 0 ? (
|
2025-11-12 14:02:58 +09:00
|
|
|
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
2025-11-12 12:06:58 +09:00
|
|
|
옵션 없음
|
2025-11-12 14:02:58 +09:00
|
|
|
</div>
|
2025-11-12 12:06:58 +09:00
|
|
|
) : (
|
|
|
|
|
options.map((option) => (
|
|
|
|
|
<SelectItem key={option.value} value={option.value}>
|
|
|
|
|
{option.label}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))
|
|
|
|
|
)}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
default: // text
|
|
|
|
|
return (
|
|
|
|
|
<Input
|
|
|
|
|
type="text"
|
|
|
|
|
value={value}
|
|
|
|
|
onChange={(e) => handleFilterChange(filter.columnName, e.target.value)}
|
|
|
|
|
className="h-8 text-xs sm:h-9 sm:text-sm"
|
|
|
|
|
placeholder={column?.columnLabel}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-12 10:48:24 +09:00
|
|
|
return (
|
|
|
|
|
<div
|
2025-11-12 12:06:58 +09:00
|
|
|
className="flex h-full w-full items-center gap-2 border-b bg-card"
|
2025-11-12 10:48:24 +09:00
|
|
|
style={{
|
|
|
|
|
padding: component.style?.padding || "0.75rem",
|
|
|
|
|
backgroundColor: component.style?.backgroundColor,
|
|
|
|
|
}}
|
|
|
|
|
>
|
2025-11-12 12:06:58 +09:00
|
|
|
{/* 필터 입력 필드들 */}
|
|
|
|
|
{activeFilters.length > 0 && (
|
|
|
|
|
<div className="flex flex-1 items-center gap-2 overflow-x-auto">
|
|
|
|
|
{activeFilters.map((filter) => (
|
|
|
|
|
<div key={filter.columnName} className="min-w-[150px]">
|
|
|
|
|
{renderFilterInput(filter)}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
{/* 검색 버튼 */}
|
|
|
|
|
<Button
|
|
|
|
|
variant="default"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={handleSearch}
|
|
|
|
|
className="h-8 shrink-0 text-xs sm:h-9 sm:text-sm"
|
|
|
|
|
>
|
|
|
|
|
<Search className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
|
|
|
|
|
검색
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-11-12 10:48:24 +09:00
|
|
|
|
2025-11-12 12:06:58 +09:00
|
|
|
{/* 필터가 없을 때는 빈 공간 */}
|
|
|
|
|
{activeFilters.length === 0 && <div className="flex-1" />}
|
2025-11-12 10:48:24 +09:00
|
|
|
|
2025-11-12 12:06:58 +09:00
|
|
|
{/* 오른쪽: 데이터 건수 + 설정 버튼들 */}
|
|
|
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
|
|
|
{/* 데이터 건수 표시 */}
|
|
|
|
|
{currentTable?.dataCount !== undefined && (
|
|
|
|
|
<div className="rounded-md bg-muted px-3 py-1.5 text-xs font-medium text-muted-foreground sm:text-sm">
|
|
|
|
|
{currentTable.dataCount.toLocaleString()}건
|
2025-11-12 10:48:24 +09:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
2025-11-12 10:58:21 +09:00
|
|
|
onClick={() => {
|
|
|
|
|
console.log("🔘 [TableSearchWidget] 테이블 옵션 버튼 클릭");
|
|
|
|
|
setColumnVisibilityOpen(true);
|
|
|
|
|
}}
|
2025-11-12 10:48:24 +09:00
|
|
|
disabled={!selectedTableId}
|
|
|
|
|
className="h-8 text-xs sm:h-9 sm:text-sm"
|
|
|
|
|
>
|
|
|
|
|
<Settings className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
|
|
|
|
|
테이블 옵션
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
2025-11-12 10:58:21 +09:00
|
|
|
onClick={() => {
|
|
|
|
|
console.log("🔘 [TableSearchWidget] 필터 설정 버튼 클릭");
|
|
|
|
|
setFilterOpen(true);
|
|
|
|
|
}}
|
2025-11-12 10:48:24 +09:00
|
|
|
disabled={!selectedTableId}
|
|
|
|
|
className="h-8 text-xs sm:h-9 sm:text-sm"
|
|
|
|
|
>
|
|
|
|
|
<Filter className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
|
|
|
|
|
필터 설정
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
2025-11-12 10:58:21 +09:00
|
|
|
onClick={() => {
|
|
|
|
|
console.log("🔘 [TableSearchWidget] 그룹 설정 버튼 클릭");
|
|
|
|
|
setGroupingOpen(true);
|
|
|
|
|
}}
|
2025-11-12 10:48:24 +09:00
|
|
|
disabled={!selectedTableId}
|
|
|
|
|
className="h-8 text-xs sm:h-9 sm:text-sm"
|
|
|
|
|
>
|
|
|
|
|
<Layers className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
|
|
|
|
|
그룹 설정
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 패널들 */}
|
|
|
|
|
<ColumnVisibilityPanel
|
|
|
|
|
isOpen={columnVisibilityOpen}
|
|
|
|
|
onClose={() => setColumnVisibilityOpen(false)}
|
|
|
|
|
/>
|
2025-11-12 12:06:58 +09:00
|
|
|
<FilterPanel
|
|
|
|
|
isOpen={filterOpen}
|
|
|
|
|
onClose={() => setFilterOpen(false)}
|
|
|
|
|
onFiltersApplied={(filters) => setActiveFilters(filters)}
|
|
|
|
|
/>
|
2025-11-12 10:48:24 +09:00
|
|
|
<GroupingPanel isOpen={groupingOpen} onClose={() => setGroupingOpen(false)} />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|