ERP-node/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx

630 lines
24 KiB
TypeScript

"use client";
import React, { useState, useEffect, useRef, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Settings, Filter, Layers, X, Check, ChevronsUpDown } from "lucide-react";
import { useTableOptions } from "@/contexts/TableOptionsContext";
import { useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext";
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, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ModernDatePicker } from "@/components/screen/filters/ModernDatePicker";
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
interface PresetFilter {
id: string;
columnName: string;
columnLabel: string;
filterType: "text" | "number" | "date" | "select";
width?: number;
multiSelect?: boolean; // 다중선택 여부 (select 타입에서만 사용)
}
interface TableSearchWidgetProps {
component: {
id: string;
title?: string;
style?: {
width?: string;
height?: string;
padding?: string;
backgroundColor?: string;
};
componentConfig?: {
autoSelectFirstTable?: boolean; // 첫 번째 테이블 자동 선택 여부
showTableSelector?: boolean; // 테이블 선택 드롭다운 표시 여부
filterMode?: "dynamic" | "preset"; // 필터 모드
presetFilters?: PresetFilter[]; // 고정 필터 목록
targetPanelPosition?: "left" | "right" | "auto"; // 분할 패널에서 대상 패널 위치 (기본: "left")
};
};
screenId?: number; // 화면 ID
onHeightChange?: (height: number) => void; // 높이 변화 콜백
}
export function TableSearchWidget({ component, screenId, onHeightChange }: TableSearchWidgetProps) {
const { registeredTables, selectedTableId, setSelectedTableId, getTable } = useTableOptions();
const { isPreviewMode } = useScreenPreview(); // 미리보기 모드 확인
// 높이 관리 context (실제 화면에서만 사용)
let setWidgetHeight:
| ((screenId: number, componentId: string, height: number, originalHeight: number) => void)
| undefined;
try {
const heightContext = useTableSearchWidgetHeight();
setWidgetHeight = heightContext.setWidgetHeight;
} catch (e) {
// Context가 없으면 (디자이너 모드) 무시
setWidgetHeight = undefined;
}
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, any>>({});
// select 타입 필터의 옵션들
const [selectOptions, setSelectOptions] = useState<Record<string, Array<{ label: string; value: string }>>>({});
// 선택된 값의 라벨 저장 (데이터 없을 때도 라벨 유지)
const [selectedLabels, setSelectedLabels] = useState<Record<string, string>>({});
// 높이 감지를 위한 ref
const containerRef = useRef<HTMLDivElement>(null);
const autoSelectFirstTable = component.componentConfig?.autoSelectFirstTable ?? true;
const showTableSelector = component.componentConfig?.showTableSelector ?? true;
const filterMode = component.componentConfig?.filterMode ?? "dynamic";
const presetFilters = component.componentConfig?.presetFilters ?? [];
const targetPanelPosition = component.componentConfig?.targetPanelPosition ?? "left"; // 기본값: 좌측 패널
// Map을 배열로 변환
const allTableList = Array.from(registeredTables.values());
// 대상 패널 위치에 따라 테이블 필터링 (tableId 패턴 기반)
const tableList = useMemo(() => {
// "auto"면 모든 테이블 반환
if (targetPanelPosition === "auto") {
return allTableList;
}
// 테이블 ID 패턴으로 필터링
// card-display-XXX: 좌측 패널 (카드 디스플레이)
// datatable-XXX, table-list-XXX: 우측 패널 (테이블 리스트)
const filteredTables = allTableList.filter(table => {
const tableId = table.tableId.toLowerCase();
if (targetPanelPosition === "left") {
// 좌측 패널 대상: card-display만
return tableId.includes("card-display") || tableId.includes("card");
} else if (targetPanelPosition === "right") {
// 우측 패널 대상: datatable, table-list 등 (card-display 제외)
const isCardDisplay = tableId.includes("card-display") || tableId.includes("card");
return !isCardDisplay;
}
return true;
});
// 필터링된 결과가 없으면 모든 테이블 반환 (폴백)
if (filteredTables.length === 0) {
console.log("🔍 [TableSearchWidget] 대상 패널에 테이블 없음, 전체 테이블 사용:", {
targetPanelPosition,
allTablesCount: allTableList.length,
allTableIds: allTableList.map(t => t.tableId),
});
return allTableList;
}
console.log("🔍 [TableSearchWidget] 테이블 필터링:", {
targetPanelPosition,
allTablesCount: allTableList.length,
filteredCount: filteredTables.length,
filteredTableIds: filteredTables.map(t => t.tableId),
});
return filteredTables;
}, [allTableList, targetPanelPosition]);
// currentTable은 tableList(필터링된 목록)에서 가져와야 함
const currentTable = useMemo(() => {
if (!selectedTableId) return undefined;
// 먼저 tableList(필터링된 목록)에서 찾기
const tableFromList = tableList.find(t => t.tableId === selectedTableId);
if (tableFromList) {
return tableFromList;
}
// tableList에 없으면 전체에서 찾기 (폴백)
return getTable(selectedTableId);
}, [selectedTableId, tableList, getTable]);
// 대상 패널의 첫 번째 테이블 자동 선택
useEffect(() => {
if (!autoSelectFirstTable || tableList.length === 0) {
return;
}
// 현재 선택된 테이블이 대상 패널에 있는지 확인
const isCurrentTableInTarget = selectedTableId && tableList.some(t => t.tableId === selectedTableId);
// 현재 선택된 테이블이 대상 패널에 없으면 대상 패널의 첫 번째 테이블 선택
if (!selectedTableId || !isCurrentTableInTarget) {
const targetTable = tableList[0];
console.log("🔍 [TableSearchWidget] 대상 패널 테이블 자동 선택:", {
targetPanelPosition,
selectedTableId: targetTable.tableId,
tableName: targetTable.tableName,
});
setSelectedTableId(targetTable.tableId);
}
}, [tableList, selectedTableId, autoSelectFirstTable, setSelectedTableId, targetPanelPosition]);
// 현재 테이블의 저장된 필터 불러오기 (동적 모드) 또는 고정 필터 적용 (고정 모드)
useEffect(() => {
if (!currentTable?.tableName) return;
// 고정 모드: presetFilters를 activeFilters로 설정
if (filterMode === "preset") {
const activeFiltersList: TableFilter[] = presetFilters.map((f) => ({
columnName: f.columnName,
operator: "contains",
value: "",
filterType: f.filterType,
width: f.width || 200,
}));
setActiveFilters(activeFiltersList);
return;
}
// 동적 모드: 화면별로 독립적인 필터 설정 불러오기
const storageKey = screenId
? `table_filters_${currentTable.tableName}_screen_${screenId}`
: `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";
width?: number;
}>;
// enabled된 필터들만 activeFilters로 설정
const activeFiltersList: TableFilter[] = parsed
.filter((f) => f.enabled)
.map((f) => ({
columnName: f.columnName,
operator: "contains",
value: "",
filterType: f.filterType,
width: f.width || 200, // 저장된 너비 포함
}));
setActiveFilters(activeFiltersList);
} catch (error) {
console.error("저장된 필터 불러오기 실패:", error);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentTable?.tableName, filterMode, screenId, JSON.stringify(presetFilters)]);
// select 옵션 초기 로드 (한 번만 실행, 이후 유지)
useEffect(() => {
if (!currentTable?.getColumnUniqueValues || activeFilters.length === 0) {
return;
}
const loadSelectOptions = async () => {
const selectFilters = activeFilters.filter((f) => f.filterType === "select");
if (selectFilters.length === 0) {
return;
}
const newOptions: Record<string, Array<{ label: string; value: string }>> = { ...selectOptions };
for (const filter of selectFilters) {
// 이미 로드된 옵션이 있으면 스킵 (초기값 유지)
if (newOptions[filter.columnName] && newOptions[filter.columnName].length > 0) {
continue;
}
try {
const options = await currentTable.getColumnUniqueValues(filter.columnName);
newOptions[filter.columnName] = options;
} catch (error) {
console.error("❌ [TableSearchWidget] select 옵션 로드 실패:", filter.columnName, error);
}
}
setSelectOptions(newOptions);
};
loadSelectOptions();
}, [activeFilters, currentTable?.tableName, currentTable?.getColumnUniqueValues]); // dataCount 제거, tableName으로 변경
// 높이 변화 감지 및 알림 (실제 화면에서만)
useEffect(() => {
if (!containerRef.current || !screenId || !setWidgetHeight) return;
// 컴포넌트의 원래 높이 (디자이너에서 설정한 높이)
const originalHeight = (component as any).size?.height || 50;
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const newHeight = entry.contentRect.height;
// Context에 높이 저장 (다른 컴포넌트 위치 조정에 사용)
setWidgetHeight(screenId, component.id, newHeight, originalHeight);
// localStorage에 높이 저장 (새로고침 시 복원용)
localStorage.setItem(
`table_search_widget_height_screen_${screenId}_${component.id}`,
JSON.stringify({ height: newHeight, originalHeight }),
);
// 콜백이 있으면 호출
if (onHeightChange) {
onHeightChange(newHeight);
}
}
});
resizeObserver.observe(containerRef.current);
return () => {
resizeObserver.disconnect();
};
}, [screenId, component.id, setWidgetHeight, onHeightChange]);
// 화면 로딩 시 저장된 높이 복원
useEffect(() => {
if (!screenId || !setWidgetHeight) return;
const storageKey = `table_search_widget_height_screen_${screenId}_${component.id}`;
const savedData = localStorage.getItem(storageKey);
if (savedData) {
try {
const { height, originalHeight } = JSON.parse(savedData);
setWidgetHeight(screenId, component.id, height, originalHeight);
} catch (error) {
console.error("저장된 높이 복원 실패:", error);
}
}
}, [screenId, component.id, setWidgetHeight]);
const hasMultipleTables = tableList.length > 1;
// 필터 값 변경 핸들러
const handleFilterChange = (columnName: string, value: any) => {
const newValues = {
...filterValues,
[columnName]: value,
};
setFilterValues(newValues);
// 실시간 검색: 값 변경 시 즉시 필터 적용
applyFilters(newValues);
};
// 필터 적용 함수
const applyFilters = (values: Record<string, any> = filterValues) => {
// 빈 값이 아닌 필터만 적용
const filtersWithValues = activeFilters
.map((filter) => {
let filterValue = values[filter.columnName];
// 날짜 범위 객체를 처리
if (filter.filterType === "date" && filterValue && typeof filterValue === "object" && (filterValue.from || filterValue.to)) {
// 날짜 범위 객체를 문자열 형식으로 변환 (백엔드 재시작 불필요)
const formatDate = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
// "YYYY-MM-DD|YYYY-MM-DD" 형식으로 변환
const fromStr = filterValue.from ? formatDate(filterValue.from) : "";
const toStr = filterValue.to ? formatDate(filterValue.to) : "";
if (fromStr && toStr) {
// 둘 다 있으면 파이프로 연결
filterValue = `${fromStr}|${toStr}`;
} else if (fromStr) {
// 시작일만 있으면
filterValue = `${fromStr}|`;
} else if (toStr) {
// 종료일만 있으면
filterValue = `|${toStr}`;
} else {
filterValue = "";
}
}
// 다중선택 배열을 처리 (파이프로 연결된 문자열로 변환)
if (filter.filterType === "select" && Array.isArray(filterValue)) {
filterValue = filterValue.join("|");
}
return {
...filter,
value: filterValue || "",
};
})
.filter((f) => {
// 빈 값 체크
if (!f.value) return false;
if (typeof f.value === "string" && f.value === "") return false;
if (Array.isArray(f.value) && f.value.length === 0) return false;
return true;
});
console.log("🔍 [TableSearchWidget] 필터 적용:", {
currentTableId: currentTable?.tableId,
currentTableName: currentTable?.tableName,
filtersCount: filtersWithValues.length,
filtersWithValues,
});
currentTable?.onFilterChange(filtersWithValues);
};
// 필터 초기화
const handleResetFilters = () => {
setFilterValues({});
setSelectedLabels({});
currentTable?.onFilterChange([]);
};
// 필터 입력 필드 렌더링
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":
return (
<div style={{ width: `${width}px` }}>
<ModernDatePicker
label={column?.columnLabel || filter.columnName}
value={value ? (typeof value === 'string' ? { from: new Date(value), to: new Date(value) } : value) : {}}
onChange={(dateRange) => {
if (dateRange.from && dateRange.to) {
// 기간이 선택되면 from과 to를 모두 저장
handleFilterChange(filter.columnName, dateRange);
} else {
handleFilterChange(filter.columnName, "");
}
}}
includeTime={false}
/>
</div>
);
case "number":
return (
<Input
type="number"
value={value}
onChange={(e) => handleFilterChange(filter.columnName, e.target.value)}
className="h-9 text-xs focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none sm:text-sm"
style={{ width: `${width}px`, height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }}
placeholder={column?.columnLabel}
/>
);
case "select": {
let options = selectOptions[filter.columnName] || [];
// 중복 제거 (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 }>,
);
// 항상 다중선택 모드
const selectedValues: string[] = Array.isArray(value) ? value : (value ? [value] : []);
// 선택된 값들의 라벨 표시
const getDisplayText = () => {
if (selectedValues.length === 0) return column?.columnLabel || "선택";
if (selectedValues.length === 1) {
const opt = uniqueOptions.find(o => o.value === selectedValues[0]);
return opt?.label || selectedValues[0];
}
return `${selectedValues.length}개 선택됨`;
};
const handleMultiSelectChange = (optionValue: string, checked: boolean) => {
let newValues: string[];
if (checked) {
newValues = [...selectedValues, optionValue];
} else {
newValues = selectedValues.filter(v => v !== optionValue);
}
handleFilterChange(filter.columnName, newValues.length > 0 ? newValues : "");
};
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className={cn(
"h-9 min-h-9 justify-between text-xs font-normal focus:ring-0 focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 sm:text-sm",
selectedValues.length === 0 && "text-muted-foreground"
)}
style={{ width: `${width}px`, height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }}
>
<span className="truncate">{getDisplayText()}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: `${width}px` }}
align="start"
>
<div className="max-h-60 overflow-auto">
{uniqueOptions.length === 0 ? (
<div className="text-muted-foreground px-3 py-2 text-xs"> </div>
) : (
<div className="p-1">
{uniqueOptions.map((option, index) => (
<div
key={`${filter.columnName}-multi-${option.value}-${index}`}
className="flex items-center space-x-2 rounded-sm px-2 py-1.5 hover:bg-accent cursor-pointer"
onClick={() => handleMultiSelectChange(option.value, !selectedValues.includes(option.value))}
>
<Checkbox
checked={selectedValues.includes(option.value)}
onCheckedChange={(checked) => handleMultiSelectChange(option.value, checked as boolean)}
onClick={(e) => e.stopPropagation()}
/>
<span className="text-xs sm:text-sm">{option.label}</span>
</div>
))}
</div>
)}
</div>
{selectedValues.length > 0 && (
<div className="border-t p-1">
<Button
variant="ghost"
size="sm"
className="w-full h-7 text-xs"
onClick={() => handleFilterChange(filter.columnName, "")}
>
</Button>
</div>
)}
</PopoverContent>
</Popover>
);
}
default: // text
return (
<Input
type="text"
value={value}
onChange={(e) => handleFilterChange(filter.columnName, e.target.value)}
className="h-9 text-xs focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none sm:text-sm"
style={{ width: `${width}px`, height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }}
placeholder={column?.columnLabel}
/>
);
}
};
return (
<div
ref={containerRef}
className="bg-card flex w-full flex-wrap items-center gap-2 border-b"
style={{
padding: component.style?.padding || "0.75rem",
backgroundColor: component.style?.backgroundColor,
minHeight: "48px",
}}
>
{/* 필터 입력 필드들 */}
{activeFilters.length > 0 && (
<div className="flex flex-1 flex-wrap items-center gap-2">
{activeFilters.map((filter) => (
<div key={filter.columnName}>{renderFilterInput(filter)}</div>
))}
{/* 초기화 버튼 */}
<Button variant="outline" size="sm" onClick={handleResetFilters} className="h-9 shrink-0 text-xs sm:text-sm">
<X className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
</Button>
</div>
)}
{/* 필터가 없을 때는 빈 공간 */}
{activeFilters.length === 0 && <div className="flex-1" />}
{/* 오른쪽: 데이터 건수 + 설정 버튼들 (고정 모드에서는 숨김) */}
<div className="flex flex-shrink-0 items-center gap-2">
{/* 데이터 건수 표시 */}
{currentTable?.dataCount !== undefined && (
<div className="bg-muted text-muted-foreground rounded-md px-3 py-1.5 text-xs font-medium sm:text-sm">
{currentTable.dataCount.toLocaleString()}
</div>
)}
{/* 동적 모드일 때만 설정 버튼들 표시 (미리보기에서는 비활성화) */}
{filterMode === "dynamic" && (
<>
<Button
variant="outline"
size="sm"
onClick={() => !isPreviewMode && setColumnVisibilityOpen(true)}
disabled={!selectedTableId || isPreviewMode}
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"
onClick={() => !isPreviewMode && setFilterOpen(true)}
disabled={!selectedTableId || isPreviewMode}
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"
onClick={() => !isPreviewMode && setGroupingOpen(true)}
disabled={!selectedTableId || isPreviewMode}
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)} />
<FilterPanel
isOpen={filterOpen}
onClose={() => setFilterOpen(false)}
onFiltersApplied={(filters) => setActiveFilters(filters)}
screenId={screenId}
/>
<GroupingPanel isOpen={groupingOpen} onClose={() => setGroupingOpen(false)} />
</div>
);
}