feat: 선택(select) 타입 필터 동적 옵션 로드 기능 추가
- TableRegistration에 getColumnUniqueValues 콜백 함수 추가 - TableListComponent에서 현재 데이터의 고유 값 추출 함수 구현 - TableSearchWidget에서 select 타입 필터의 옵션을 자동으로 로드 - 테이블 데이터가 변경되면 필터 옵션도 자동 업데이트 - 데이터 건수 표시 기능도 함께 수정 (등록 순서 문제 해결)
This commit is contained in:
parent
33ba13b070
commit
58870237b6
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -18,197 +18,302 @@ import {
|
|||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { TableFilter } from "@/types/table-options";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onFiltersApplied?: (filters: TableFilter[]) => void; // 필터 적용 시 콜백
|
||||
}
|
||||
|
||||
export const FilterPanel: React.FC<Props> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
}) => {
|
||||
const { getTable, selectedTableId } = useTableOptions();
|
||||
const table = selectedTableId ? getTable(selectedTableId) : undefined;
|
||||
|
||||
const [activeFilters, setActiveFilters] = useState<TableFilter[]>([]);
|
||||
|
||||
const addFilter = () => {
|
||||
setActiveFilters([
|
||||
...activeFilters,
|
||||
{ columnName: "", operator: "contains", value: "" },
|
||||
]);
|
||||
};
|
||||
|
||||
const removeFilter = (index: number) => {
|
||||
setActiveFilters(activeFilters.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateFilter = (
|
||||
index: number,
|
||||
field: keyof TableFilter,
|
||||
value: any
|
||||
) => {
|
||||
setActiveFilters(
|
||||
activeFilters.map((filter, i) =>
|
||||
i === index ? { ...filter, [field]: value } : filter
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const applyFilters = () => {
|
||||
// 빈 필터 제거
|
||||
const validFilters = activeFilters.filter(
|
||||
(f) => f.columnName && f.value !== ""
|
||||
);
|
||||
table?.onFilterChange(validFilters);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setActiveFilters([]);
|
||||
table?.onFilterChange([]);
|
||||
};
|
||||
|
||||
const operatorLabels: Record<string, string> = {
|
||||
equals: "같음",
|
||||
// 필터 타입별 연산자
|
||||
const operatorsByType: Record<string, Record<string, string>> = {
|
||||
text: {
|
||||
contains: "포함",
|
||||
equals: "같음",
|
||||
startsWith: "시작",
|
||||
endsWith: "끝",
|
||||
notEquals: "같지 않음",
|
||||
},
|
||||
number: {
|
||||
equals: "같음",
|
||||
gt: "보다 큼",
|
||||
lt: "보다 작음",
|
||||
gte: "이상",
|
||||
lte: "이하",
|
||||
notEquals: "같지 않음",
|
||||
},
|
||||
date: {
|
||||
equals: "같음",
|
||||
gt: "이후",
|
||||
lt: "이전",
|
||||
gte: "이후 포함",
|
||||
lte: "이전 포함",
|
||||
},
|
||||
select: {
|
||||
equals: "같음",
|
||||
notEquals: "같지 않음",
|
||||
},
|
||||
};
|
||||
|
||||
// 컬럼 필터 설정 인터페이스
|
||||
interface ColumnFilterConfig {
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
inputType: string;
|
||||
enabled: boolean;
|
||||
filterType: "text" | "number" | "date" | "select";
|
||||
selectOptions?: Array<{ label: string; value: string }>;
|
||||
}
|
||||
|
||||
export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied }) => {
|
||||
const { getTable, selectedTableId } = useTableOptions();
|
||||
const table = selectedTableId ? getTable(selectedTableId) : undefined;
|
||||
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFilterConfig[]>([]);
|
||||
const [selectAll, setSelectAll] = useState(false);
|
||||
|
||||
// localStorage에서 저장된 필터 설정 불러오기
|
||||
useEffect(() => {
|
||||
if (table?.columns && table?.tableName) {
|
||||
const storageKey = `table_filters_${table.tableName}`;
|
||||
const savedFilters = localStorage.getItem(storageKey);
|
||||
|
||||
let filters: ColumnFilterConfig[];
|
||||
|
||||
if (savedFilters) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedFilters) as ColumnFilterConfig[];
|
||||
// 저장된 설정과 현재 컬럼 병합
|
||||
filters = table.columns
|
||||
.filter((col) => col.filterable !== false)
|
||||
.map((col) => {
|
||||
const saved = parsed.find((f) => f.columnName === col.columnName);
|
||||
return saved || {
|
||||
columnName: col.columnName,
|
||||
columnLabel: col.columnLabel,
|
||||
inputType: col.inputType || "text",
|
||||
enabled: false,
|
||||
filterType: mapInputTypeToFilterType(col.inputType || "text"),
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("저장된 필터 설정 불러오기 실패:", error);
|
||||
filters = table.columns
|
||||
.filter((col) => col.filterable !== false)
|
||||
.map((col) => ({
|
||||
columnName: col.columnName,
|
||||
columnLabel: col.columnLabel,
|
||||
inputType: col.inputType || "text",
|
||||
enabled: false,
|
||||
filterType: mapInputTypeToFilterType(col.inputType || "text"),
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
filters = table.columns
|
||||
.filter((col) => col.filterable !== false)
|
||||
.map((col) => ({
|
||||
columnName: col.columnName,
|
||||
columnLabel: col.columnLabel,
|
||||
inputType: col.inputType || "text",
|
||||
enabled: false,
|
||||
filterType: mapInputTypeToFilterType(col.inputType || "text"),
|
||||
}));
|
||||
}
|
||||
|
||||
setColumnFilters(filters);
|
||||
}
|
||||
}, [table?.columns, table?.tableName]);
|
||||
|
||||
// inputType을 filterType으로 매핑
|
||||
const mapInputTypeToFilterType = (
|
||||
inputType: string
|
||||
): "text" | "number" | "date" | "select" => {
|
||||
if (inputType.includes("number") || inputType.includes("decimal")) {
|
||||
return "number";
|
||||
}
|
||||
if (inputType.includes("date") || inputType.includes("time")) {
|
||||
return "date";
|
||||
}
|
||||
if (
|
||||
inputType.includes("select") ||
|
||||
inputType.includes("code") ||
|
||||
inputType.includes("category")
|
||||
) {
|
||||
return "select";
|
||||
}
|
||||
return "text";
|
||||
};
|
||||
|
||||
// 전체 선택/해제
|
||||
const toggleSelectAll = (checked: boolean) => {
|
||||
setSelectAll(checked);
|
||||
setColumnFilters((prev) =>
|
||||
prev.map((filter) => ({ ...filter, enabled: checked }))
|
||||
);
|
||||
};
|
||||
|
||||
// 개별 필터 토글
|
||||
const toggleFilter = (columnName: string) => {
|
||||
setColumnFilters((prev) =>
|
||||
prev.map((filter) =>
|
||||
filter.columnName === columnName
|
||||
? { ...filter, enabled: !filter.enabled }
|
||||
: filter
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// 필터 타입 변경
|
||||
const updateFilterType = (
|
||||
columnName: string,
|
||||
filterType: "text" | "number" | "date" | "select"
|
||||
) => {
|
||||
setColumnFilters((prev) =>
|
||||
prev.map((filter) =>
|
||||
filter.columnName === columnName ? { ...filter, filterType } : filter
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// 저장
|
||||
const applyFilters = () => {
|
||||
// enabled된 필터들만 TableFilter로 변환
|
||||
const activeFilters: TableFilter[] = columnFilters
|
||||
.filter((cf) => cf.enabled)
|
||||
.map((cf) => ({
|
||||
columnName: cf.columnName,
|
||||
operator: "contains", // 기본 연산자
|
||||
value: "",
|
||||
filterType: cf.filterType,
|
||||
}));
|
||||
|
||||
// localStorage에 저장
|
||||
if (table?.tableName) {
|
||||
const storageKey = `table_filters_${table.tableName}`;
|
||||
localStorage.setItem(storageKey, JSON.stringify(columnFilters));
|
||||
}
|
||||
|
||||
table?.onFilterChange(activeFilters);
|
||||
|
||||
// 콜백으로 활성화된 필터 정보 전달
|
||||
onFiltersApplied?.(activeFilters);
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
// 초기화
|
||||
const clearFilters = () => {
|
||||
setColumnFilters((prev) =>
|
||||
prev.map((filter) => ({ ...filter, enabled: false }))
|
||||
);
|
||||
setSelectAll(false);
|
||||
};
|
||||
|
||||
const enabledCount = columnFilters.filter((f) => f.enabled).length;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-2xl">
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
검색 필터 설정
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
검색 필터로 사용할 컬럼을 선택하세요. 선택한 컬럼의 검색 입력 필드가
|
||||
표시됩니다.
|
||||
검색 필터로 사용할 컬럼을 선택하세요. 선택한 컬럼의 검색 입력 필드가 표시됩니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{/* 전체 선택/해제 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs text-muted-foreground sm:text-sm">
|
||||
총 {activeFilters.length}개의 검색 필터가 표시됩니다
|
||||
<div className="flex items-center justify-between rounded-lg border bg-muted/30 p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
checked={selectAll}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleSelectAll(checked as boolean)
|
||||
}
|
||||
/>
|
||||
<span className="text-sm font-medium">전체 선택/해제</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{enabledCount} / {columnFilters.length}개
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearFilters}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 필터 리스트 */}
|
||||
<ScrollArea className="h-[300px] sm:h-[400px]">
|
||||
<div className="space-y-3 pr-4">
|
||||
{activeFilters.map((filter, index) => (
|
||||
{/* 컬럼 필터 리스트 */}
|
||||
<ScrollArea className="h-[400px] sm:h-[450px]">
|
||||
<div className="space-y-2 pr-4">
|
||||
{columnFilters.map((filter) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col gap-2 rounded-lg border bg-background p-3 sm:flex-row sm:items-center"
|
||||
key={filter.columnName}
|
||||
className="flex items-center gap-3 rounded-lg border bg-background p-3 transition-colors hover:bg-muted/50"
|
||||
>
|
||||
{/* 컬럼 선택 */}
|
||||
<Select
|
||||
value={filter.columnName}
|
||||
onValueChange={(val) =>
|
||||
updateFilter(index, "columnName", val)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-9 sm:w-40 sm:text-sm">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{table?.columns
|
||||
.filter((col) => col.filterable !== false)
|
||||
.map((col) => (
|
||||
<SelectItem
|
||||
key={col.columnName}
|
||||
value={col.columnName}
|
||||
>
|
||||
{col.columnLabel}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* 체크박스 */}
|
||||
<Checkbox
|
||||
checked={filter.enabled}
|
||||
onCheckedChange={() => toggleFilter(filter.columnName)}
|
||||
/>
|
||||
|
||||
{/* 연산자 선택 */}
|
||||
{/* 컬럼 정보 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">
|
||||
{filter.columnLabel}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{filter.columnName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필터 타입 선택 */}
|
||||
<Select
|
||||
value={filter.operator}
|
||||
onValueChange={(val) =>
|
||||
updateFilter(index, "operator", val)
|
||||
value={filter.filterType}
|
||||
onValueChange={(val: any) =>
|
||||
updateFilterType(filter.columnName, val)
|
||||
}
|
||||
disabled={!filter.enabled}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-9 sm:w-32 sm:text-sm">
|
||||
<SelectTrigger className="h-8 w-[110px] text-xs sm:h-9 sm:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(operatorLabels).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="text">텍스트</SelectItem>
|
||||
<SelectItem value="number">숫자</SelectItem>
|
||||
<SelectItem value="date">날짜</SelectItem>
|
||||
<SelectItem value="select">선택</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 값 입력 */}
|
||||
<Input
|
||||
value={filter.value as string}
|
||||
onChange={(e) =>
|
||||
updateFilter(index, "value", e.target.value)
|
||||
}
|
||||
placeholder="값 입력"
|
||||
className="h-8 flex-1 text-xs sm:h-9 sm:text-sm"
|
||||
/>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeFilter(index)}
|
||||
className="h-8 w-8 shrink-0 sm:h-9 sm:w-9"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* 필터 추가 버튼 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={addFilter}
|
||||
className="h-8 w-full text-xs sm:h-9 sm:text-sm"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
필터 추가
|
||||
</Button>
|
||||
{/* 안내 메시지 */}
|
||||
<div className="rounded-lg bg-muted/50 p-3 text-center text-xs text-muted-foreground">
|
||||
검색 필터를 사용하려면 최소 1개 이상의 컬럼을 선택하세요
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={clearFilters}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
onClick={onClose}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={applyFilters}
|
||||
disabled={enabledCount === 0}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
저장
|
||||
|
|
|
|||
|
|
@ -72,6 +72,29 @@ export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({
|
|||
[registeredTables]
|
||||
);
|
||||
|
||||
/**
|
||||
* 테이블 데이터 건수 업데이트
|
||||
*/
|
||||
const updateTableDataCount = useCallback((tableId: string, count: number) => {
|
||||
setRegisteredTables((prev) => {
|
||||
const table = prev.get(tableId);
|
||||
if (table) {
|
||||
// 기존 테이블 정보에 dataCount만 업데이트
|
||||
const updatedTable = { ...table, dataCount: count };
|
||||
const newMap = new Map(prev);
|
||||
newMap.set(tableId, updatedTable);
|
||||
console.log("🔄 [TableOptionsContext] 데이터 건수 업데이트:", {
|
||||
tableId,
|
||||
count,
|
||||
updated: true,
|
||||
});
|
||||
return newMap;
|
||||
}
|
||||
console.warn("⚠️ [TableOptionsContext] 테이블을 찾을 수 없음:", tableId);
|
||||
return prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<TableOptionsContext.Provider
|
||||
value={{
|
||||
|
|
@ -79,6 +102,7 @@ export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({
|
|||
registerTable,
|
||||
unregisterTable,
|
||||
getTable,
|
||||
updateTableDataCount,
|
||||
selectedTableId,
|
||||
setSelectedTableId,
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -246,19 +246,20 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
// 상태 관리
|
||||
// ========================================
|
||||
|
||||
// 사용자 정보
|
||||
const { userId } = useAuth();
|
||||
// 사용자 정보 (props에서 받거나 useAuth에서 가져오기)
|
||||
const { userId: authUserId } = useAuth();
|
||||
const currentUserId = userId || authUserId;
|
||||
|
||||
// TableOptions Context
|
||||
const { registerTable, unregisterTable } = useTableOptions();
|
||||
const { registerTable, unregisterTable, updateTableDataCount } = useTableOptions();
|
||||
const [filters, setFilters] = useState<TableFilter[]>([]);
|
||||
const [grouping, setGrouping] = useState<string[]>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<ColumnVisibility[]>([]);
|
||||
|
||||
// 초기 로드 시 localStorage에서 저장된 설정 불러오기
|
||||
useEffect(() => {
|
||||
if (tableConfig.selectedTable && userId) {
|
||||
const storageKey = `table_column_visibility_${tableConfig.selectedTable}_${userId}`;
|
||||
if (tableConfig.selectedTable && currentUserId) {
|
||||
const storageKey = `table_column_visibility_${tableConfig.selectedTable}_${currentUserId}`;
|
||||
const savedSettings = localStorage.getItem(storageKey);
|
||||
|
||||
if (savedSettings) {
|
||||
|
|
@ -270,7 +271,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
}
|
||||
}
|
||||
}
|
||||
}, [tableConfig.selectedTable, userId]);
|
||||
}, [tableConfig.selectedTable, currentUserId]);
|
||||
|
||||
// columnVisibility 변경 시 컬럼 순서 및 가시성 적용
|
||||
useEffect(() => {
|
||||
|
|
@ -281,12 +282,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
setColumnOrder(newOrder);
|
||||
|
||||
// localStorage에 저장 (사용자별)
|
||||
if (tableConfig.selectedTable && userId) {
|
||||
const storageKey = `table_column_visibility_${tableConfig.selectedTable}_${userId}`;
|
||||
if (tableConfig.selectedTable && currentUserId) {
|
||||
const storageKey = `table_column_visibility_${tableConfig.selectedTable}_${currentUserId}`;
|
||||
localStorage.setItem(storageKey, JSON.stringify(columnVisibility));
|
||||
}
|
||||
}
|
||||
}, [columnVisibility, tableConfig.selectedTable, userId]);
|
||||
}, [columnVisibility, tableConfig.selectedTable, currentUserId]);
|
||||
|
||||
const [data, setData] = useState<Record<string, any>[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
@ -345,10 +346,31 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
// 컬럼의 고유 값 조회 함수
|
||||
const getColumnUniqueValues = async (columnName: string) => {
|
||||
// 현재 로드된 데이터에서 고유 값 추출
|
||||
const uniqueValues = new Set<string>();
|
||||
data.forEach((row) => {
|
||||
const value = row[columnName];
|
||||
if (value !== null && value !== undefined && value !== "") {
|
||||
uniqueValues.add(String(value));
|
||||
}
|
||||
});
|
||||
|
||||
// Set을 배열로 변환하고 정렬
|
||||
const sortedValues = Array.from(uniqueValues).sort();
|
||||
|
||||
return sortedValues.map((value) => ({
|
||||
label: value,
|
||||
value: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const registration = {
|
||||
tableId,
|
||||
label: tableLabel || tableConfig.selectedTable,
|
||||
tableName: tableConfig.selectedTable,
|
||||
dataCount: totalItems || data.length, // 초기 데이터 건수 포함
|
||||
columns: columnsToRegister.map((col) => ({
|
||||
columnName: col.columnName || col.field,
|
||||
columnLabel: columnLabels[col.columnName] || col.displayName || col.label || col.columnName || col.field,
|
||||
|
|
@ -361,6 +383,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
onFilterChange: setFilters,
|
||||
onGroupChange: setGrouping,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
getColumnUniqueValues, // 고유 값 조회 함수 등록
|
||||
};
|
||||
|
||||
registerTable(registration);
|
||||
|
|
@ -376,6 +399,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
columnMeta,
|
||||
columnWidths,
|
||||
tableLabel,
|
||||
data, // 데이터 자체가 변경되면 재등록 (고유 값 조회용)
|
||||
totalItems, // 전체 항목 수가 변경되면 재등록
|
||||
registerTable,
|
||||
unregisterTable,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@
|
|||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Settings, Filter, Layers } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Settings, Filter, Layers, Search } from "lucide-react";
|
||||
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";
|
||||
import { TableFilter } from "@/types/table-options";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -33,16 +35,23 @@ interface TableSearchWidgetProps {
|
|||
}
|
||||
|
||||
export function TableSearchWidget({ component }: TableSearchWidgetProps) {
|
||||
const { registeredTables, selectedTableId, setSelectedTableId } = useTableOptions();
|
||||
const { registeredTables, selectedTableId, setSelectedTableId, getTable } = useTableOptions();
|
||||
const [columnVisibilityOpen, setColumnVisibilityOpen] = useState(false);
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const [groupingOpen, setGroupingOpen] = useState(false);
|
||||
|
||||
// 활성화된 필터 목록
|
||||
const [activeFilters, setActiveFilters] = useState<TableFilter[]>([]);
|
||||
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
||||
// select 타입 필터의 옵션들
|
||||
const [selectOptions, setSelectOptions] = useState<Record<string, Array<{ label: string; value: string }>>>({});
|
||||
|
||||
const autoSelectFirstTable = component.componentConfig?.autoSelectFirstTable ?? true;
|
||||
const showTableSelector = component.componentConfig?.showTableSelector ?? true;
|
||||
|
||||
// Map을 배열로 변환
|
||||
const tableList = Array.from(registeredTables.values());
|
||||
const currentTable = selectedTableId ? getTable(selectedTableId) : undefined;
|
||||
|
||||
// 첫 번째 테이블 자동 선택
|
||||
useEffect(() => {
|
||||
|
|
@ -53,58 +62,211 @@ export function TableSearchWidget({ component }: TableSearchWidgetProps) {
|
|||
}
|
||||
}, [registeredTables, selectedTableId, autoSelectFirstTable, setSelectedTableId]);
|
||||
|
||||
// 현재 테이블의 저장된 필터 불러오기 및 select 옵션 로드
|
||||
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);
|
||||
|
||||
// select 타입 필터들의 옵션 로드
|
||||
const loadSelectOptions = async () => {
|
||||
const newOptions: Record<string, Array<{ label: string; value: string }>> = {};
|
||||
|
||||
for (const filter of activeFiltersList) {
|
||||
if (filter.filterType === "select" && currentTable.getColumnUniqueValues) {
|
||||
try {
|
||||
const options = await currentTable.getColumnUniqueValues(filter.columnName);
|
||||
newOptions[filter.columnName] = options;
|
||||
console.log("✅ [TableSearchWidget] select 옵션 로드:", {
|
||||
columnName: filter.columnName,
|
||||
optionCount: options.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("select 옵션 로드 실패:", filter.columnName, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setSelectOptions(newOptions);
|
||||
};
|
||||
|
||||
loadSelectOptions();
|
||||
} catch (error) {
|
||||
console.error("저장된 필터 불러오기 실패:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [currentTable?.tableName, currentTable?.getColumnUniqueValues]);
|
||||
|
||||
// 디버깅: 현재 테이블 정보 로깅
|
||||
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]);
|
||||
|
||||
const hasMultipleTables = tableList.length > 1;
|
||||
|
||||
// 필터 값 변경 핸들러
|
||||
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">
|
||||
<SelectValue placeholder={column?.columnLabel} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.length === 0 ? (
|
||||
<SelectItem value="" disabled>
|
||||
옵션 없음
|
||||
</SelectItem>
|
||||
) : (
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full w-full items-center justify-between gap-2 border-b bg-card"
|
||||
className="flex h-full w-full items-center gap-2 border-b bg-card"
|
||||
style={{
|
||||
padding: component.style?.padding || "0.75rem",
|
||||
backgroundColor: component.style?.backgroundColor,
|
||||
}}
|
||||
>
|
||||
{/* 왼쪽: 제목 + 테이블 정보 */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 제목 */}
|
||||
{component.title && (
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{component.title}
|
||||
</div>
|
||||
)}
|
||||
{/* 필터 입력 필드들 */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* 테이블 선택 드롭다운 (여러 테이블이 있고, showTableSelector가 true일 때만) */}
|
||||
{showTableSelector && hasMultipleTables && (
|
||||
<Select value={selectedTableId || ""} onValueChange={setSelectedTableId}>
|
||||
<SelectTrigger className="h-8 w-[200px] text-xs sm:h-9 sm:text-sm">
|
||||
<SelectValue placeholder="테이블 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableList.map((table) => (
|
||||
<SelectItem key={table.tableId} value={table.tableId} className="text-xs sm:text-sm">
|
||||
{table.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{/* 필터가 없을 때는 빈 공간 */}
|
||||
{activeFilters.length === 0 && <div className="flex-1" />}
|
||||
|
||||
{/* 테이블이 하나만 있을 때는 라벨만 표시 */}
|
||||
{!hasMultipleTables && tableList.length === 1 && (
|
||||
<div className="text-xs text-muted-foreground sm:text-sm">
|
||||
{tableList[0].label}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 테이블이 없을 때 */}
|
||||
{tableList.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground sm:text-sm">
|
||||
화면에 테이블 컴포넌트를 추가하면 자동으로 감지됩니다
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 버튼들 */}
|
||||
{/* 오른쪽: 데이터 건수 + 설정 버튼들 */}
|
||||
<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()}건
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
|
@ -153,7 +315,11 @@ export function TableSearchWidget({ component }: TableSearchWidgetProps) {
|
|||
isOpen={columnVisibilityOpen}
|
||||
onClose={() => setColumnVisibilityOpen(false)}
|
||||
/>
|
||||
<FilterPanel isOpen={filterOpen} onClose={() => setFilterOpen(false)} />
|
||||
<FilterPanel
|
||||
isOpen={filterOpen}
|
||||
onClose={() => setFilterOpen(false)}
|
||||
onFiltersApplied={(filters) => setActiveFilters(filters)}
|
||||
/>
|
||||
<GroupingPanel isOpen={groupingOpen} onClose={() => setGroupingOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export interface TableFilter {
|
|||
| "lte"
|
||||
| "notEquals";
|
||||
value: string | number | boolean;
|
||||
filterType?: "text" | "number" | "date" | "select"; // 필터 입력 타입
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -52,11 +53,15 @@ export interface TableRegistration {
|
|||
label: string; // 사용자에게 보이는 이름 (예: "품목 관리")
|
||||
tableName: string; // 실제 DB 테이블명 (예: "item_info")
|
||||
columns: TableColumn[];
|
||||
dataCount?: number; // 현재 표시된 데이터 건수
|
||||
|
||||
// 콜백 함수들
|
||||
onFilterChange: (filters: TableFilter[]) => void;
|
||||
onGroupChange: (groups: string[]) => void;
|
||||
onColumnVisibilityChange: (columns: ColumnVisibility[]) => void;
|
||||
|
||||
// 데이터 조회 함수 (선택 타입 필터용)
|
||||
getColumnUniqueValues?: (columnName: string) => Promise<Array<{ label: string; value: string }>>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -67,6 +72,7 @@ export interface TableOptionsContextValue {
|
|||
registerTable: (registration: TableRegistration) => void;
|
||||
unregisterTable: (tableId: string) => void;
|
||||
getTable: (tableId: string) => TableRegistration | undefined;
|
||||
updateTableDataCount: (tableId: string, count: number) => void; // 데이터 건수 업데이트
|
||||
selectedTableId: string | null;
|
||||
setSelectedTableId: (tableId: string | null) => void;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue