- Integrated `TableManagementService` to validate unique constraints before insert, update, and upsert actions in various controllers, including `dataflowExecutionController`, `dynamicFormController`, and `tableManagementController`. - Improved error handling in `errorHandler` to provide detailed messages indicating which field has a unique constraint violation. - Updated the `formatPgError` utility to extract and display specific column labels for unique constraint violations, enhancing user feedback. - Adjusted the table schema retrieval to include company-specific nullable and unique constraints, ensuring accurate representation of database rules. These changes improve data integrity by preventing duplicate entries and enhance user experience through clearer error messages related to unique constraints.
847 lines
32 KiB
TypeScript
847 lines
32 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, X, ChevronsUpDown } from "lucide-react";
|
|
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
|
import { useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext";
|
|
import { useActiveTab } from "@/contexts/ActiveTabContext";
|
|
import { TableSettingsModal } from "@/components/screen/table-options/TableSettingsModal";
|
|
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 tableOptionsContext = useTableOptions();
|
|
const { registeredTables, selectedTableId, setSelectedTableId, getTable, getActiveTabTables } = tableOptionsContext;
|
|
const { isPreviewMode } = useScreenPreview(); // 미리보기 모드 확인
|
|
const { getAllActiveTabIds, activeTabs } = useActiveTab(); // 활성 탭 정보
|
|
|
|
// 높이 관리 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;
|
|
}
|
|
|
|
// 탭별 필터 값 저장 (탭 ID -> 필터 값)
|
|
const [tabFilterValues, setTabFilterValues] = useState<Record<string, Record<string, any>>>({});
|
|
|
|
const [settingsOpen, setSettingsOpen] = 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());
|
|
|
|
// 현재 활성 탭 ID 목록
|
|
const activeTabIds = useMemo(() => getAllActiveTabIds(), [activeTabs]);
|
|
|
|
// 대상 패널 위치 + 활성 탭에 따라 테이블 필터링
|
|
const tableList = useMemo(() => {
|
|
// 1단계: 활성 탭 기반 필터링
|
|
// - 활성 탭에 속한 테이블만 표시
|
|
// - 탭에 속하지 않은 테이블(parentTabId가 없는)도 포함
|
|
let filteredByTab = allTableList.filter((table) => {
|
|
// 탭에 속하지 않는 테이블은 항상 표시
|
|
if (!table.parentTabId) return true;
|
|
// 활성 탭에 속한 테이블만 표시
|
|
return activeTabIds.includes(table.parentTabId);
|
|
});
|
|
|
|
// 2단계: 대상 패널 위치에 따라 추가 필터링
|
|
if (targetPanelPosition !== "auto") {
|
|
filteredByTab = filteredByTab.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 (filteredByTab.length === 0) {
|
|
return allTableList.filter((table) => !table.parentTabId || activeTabIds.includes(table.parentTabId));
|
|
}
|
|
|
|
return filteredByTab;
|
|
}, [allTableList, targetPanelPosition, activeTabIds]);
|
|
|
|
// currentTable은 tableList(필터링된 목록)에서 가져와야 함
|
|
const currentTable = useMemo(() => {
|
|
if (!selectedTableId) return undefined;
|
|
|
|
// 먼저 tableList(필터링된 목록)에서 찾기
|
|
const tableFromList = tableList.find((t) => t.tableId === selectedTableId);
|
|
if (tableFromList) {
|
|
return tableFromList;
|
|
}
|
|
|
|
// tableList에 없으면 전체에서 찾기 (폴백)
|
|
const tableFromAll = getTable(selectedTableId);
|
|
return tableFromAll;
|
|
}, [selectedTableId, tableList, getTable]);
|
|
|
|
// 🆕 활성 탭 ID 문자열 (변경 감지용)
|
|
const activeTabIdsStr = useMemo(() => activeTabIds.join(","), [activeTabIds]);
|
|
|
|
// 🆕 이전 활성 탭 ID 추적 (탭 전환 감지용)
|
|
const prevActiveTabIdsRef = useRef<string>(activeTabIdsStr);
|
|
|
|
// 대상 패널의 첫 번째 테이블 자동 선택
|
|
useEffect(() => {
|
|
if (!autoSelectFirstTable) {
|
|
return;
|
|
}
|
|
|
|
// 탭 전환 감지: 활성 탭이 변경되었는지 확인
|
|
const tabChanged = prevActiveTabIdsRef.current !== activeTabIdsStr;
|
|
if (tabChanged) {
|
|
// 탭이 변경되면 항상 ref를 갱신 (tableList가 비어 있어도)
|
|
// 이렇게 해야 비동기로 tableList가 나중에 채워질 때 중복 감지하지 않음
|
|
prevActiveTabIdsRef.current = activeTabIdsStr;
|
|
|
|
if (tableList.length === 0) {
|
|
// 테이블이 아직 등록되지 않은 상태 (비동기 로드 중)
|
|
// tableList가 나중에 채워지면 아래 폴백 로직에서 처리됨
|
|
return;
|
|
}
|
|
|
|
// 탭 전환 시: 해당 탭에 속한 테이블 중 첫 번째 강제 선택
|
|
const activeTabTable = tableList.find((t) => t.parentTabId && activeTabIds.includes(t.parentTabId));
|
|
const targetTable = activeTabTable || tableList[0];
|
|
|
|
if (targetTable) {
|
|
setSelectedTableId(targetTable.tableId);
|
|
}
|
|
return; // 탭 전환 시에는 여기서 종료
|
|
}
|
|
|
|
// tableList가 비어있으면 아래 로직 스킵
|
|
if (tableList.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// 현재 선택된 테이블이 활성 탭에 속하는지 확인
|
|
const isCurrentTableInActiveTab = selectedTableId && tableList.some((t) => {
|
|
if (t.tableId !== selectedTableId) return false;
|
|
// parentTabId가 있는 테이블이면 활성 탭에 속하는지 확인
|
|
if (t.parentTabId) return activeTabIds.includes(t.parentTabId);
|
|
return true; // parentTabId 없는 전역 테이블은 항상 유효
|
|
});
|
|
|
|
// 현재 선택된 테이블이 활성 탭에 없거나 미선택이면 첫 번째 테이블 선택
|
|
if (!selectedTableId || !isCurrentTableInActiveTab) {
|
|
const activeTabTable = tableList.find((t) => t.parentTabId && activeTabIds.includes(t.parentTabId));
|
|
const targetTable = activeTabTable || tableList[0];
|
|
|
|
if (targetTable && targetTable.tableId !== selectedTableId) {
|
|
setSelectedTableId(targetTable.tableId);
|
|
}
|
|
}
|
|
}, [
|
|
tableList,
|
|
selectedTableId,
|
|
autoSelectFirstTable,
|
|
setSelectedTableId,
|
|
targetPanelPosition,
|
|
activeTabIdsStr,
|
|
activeTabIds,
|
|
]);
|
|
|
|
// 현재 선택된 테이블의 탭 ID (탭별 필터 저장용)
|
|
const currentTableTabId = currentTable?.parentTabId;
|
|
|
|
// 탭별 필터 값 저장 키 생성
|
|
const getTabFilterStorageKey = (tableName: string, tabId?: string) => {
|
|
const baseKey = screenId
|
|
? `table_filter_values_${tableName}_screen_${screenId}`
|
|
: `table_filter_values_${tableName}`;
|
|
return tabId ? `${baseKey}_tab_${tabId}` : baseKey;
|
|
};
|
|
|
|
// 탭 변경 시 이전 탭의 필터 값 저장 + 새 탭의 필터 값 복원
|
|
useEffect(() => {
|
|
if (!currentTable?.tableName) return;
|
|
|
|
// 현재 필터 값이 있으면 탭별로 저장
|
|
if (Object.keys(filterValues).length > 0 && currentTableTabId) {
|
|
const storageKey = getTabFilterStorageKey(currentTable.tableName, currentTableTabId);
|
|
localStorage.setItem(storageKey, JSON.stringify(filterValues));
|
|
|
|
// 메모리 캐시에도 저장
|
|
setTabFilterValues((prev) => ({
|
|
...prev,
|
|
[currentTableTabId]: filterValues,
|
|
}));
|
|
}
|
|
}, [currentTableTabId, currentTable?.tableName]);
|
|
|
|
// 탭 전환 플래그 (탭 복귀 시 필터 재적용을 위해)
|
|
const needsFilterReapplyRef = useRef(false);
|
|
const prevActiveTabIdsForReapplyRef = useRef<string>(activeTabIdsStr);
|
|
|
|
// 탭 전환 감지: 플래그만 설정 (실제 적용은 currentTable이 준비된 후)
|
|
useEffect(() => {
|
|
if (prevActiveTabIdsForReapplyRef.current !== activeTabIdsStr) {
|
|
prevActiveTabIdsForReapplyRef.current = activeTabIdsStr;
|
|
needsFilterReapplyRef.current = true;
|
|
}
|
|
}, [activeTabIdsStr]);
|
|
|
|
// 탭 복귀 시 기존 필터값 재적용
|
|
// currentTable이 준비되고 필터값이 있을 때 실행
|
|
useEffect(() => {
|
|
if (!needsFilterReapplyRef.current) return;
|
|
if (!currentTable?.onFilterChange) return;
|
|
|
|
// 플래그 즉시 해제 (중복 실행 방지)
|
|
needsFilterReapplyRef.current = false;
|
|
|
|
// activeFilters와 filterValues가 있으면 직접 onFilterChange 호출
|
|
// applyFilters 클로저 의존성을 피하고 직접 계산
|
|
if (activeFilters.length === 0) return;
|
|
|
|
const hasValues = Object.values(filterValues).some(
|
|
(v) => v !== "" && v !== undefined && v !== null,
|
|
);
|
|
if (!hasValues) return;
|
|
|
|
const filtersWithValues = activeFilters
|
|
.map((filter) => {
|
|
let filterValue = filterValues[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}`;
|
|
};
|
|
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 (Array.isArray(filterValue)) {
|
|
filterValue = filterValue.join("|");
|
|
}
|
|
|
|
let operator = "contains";
|
|
if (filter.filterType === "select") operator = "equals";
|
|
else if (filter.filterType === "number") operator = "equals";
|
|
|
|
return {
|
|
...filter,
|
|
value: filterValue || "",
|
|
operator,
|
|
};
|
|
})
|
|
.filter((f) => {
|
|
if (!f.value) return false;
|
|
if (typeof f.value === "string" && f.value === "") return false;
|
|
return true;
|
|
});
|
|
|
|
// 직접 onFilterChange 호출 (applyFilters 클로저 우회)
|
|
currentTable.onFilterChange(filtersWithValues);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [currentTable?.onFilterChange, currentTable?.tableName, activeFilters, filterValues]);
|
|
|
|
// 필터 적용을 다음 렌더 사이클로 지연 (activeFilters 업데이트 후 적용 보장)
|
|
const pendingFilterApplyRef = useRef<{ values: Record<string, any>; tableName: string } | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (pendingFilterApplyRef.current) {
|
|
const { values, tableName } = pendingFilterApplyRef.current;
|
|
// 현재 테이블이 요청된 테이블과 일치하는지 확인 (탭이 빠르게 전환된 경우 방지)
|
|
if (currentTable?.tableName === tableName) {
|
|
applyFilters(values);
|
|
}
|
|
pendingFilterApplyRef.current = null;
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [activeFilters, currentTable?.tableName]);
|
|
|
|
// 현재 테이블의 저장된 필터 불러오기 (동적 모드) 또는 고정 필터 적용 (고정 모드)
|
|
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);
|
|
|
|
// 탭별 저장된 필터 값 복원
|
|
if (currentTableTabId) {
|
|
const storageKey = getTabFilterStorageKey(currentTable.tableName, currentTableTabId);
|
|
const savedValues = localStorage.getItem(storageKey);
|
|
if (savedValues) {
|
|
try {
|
|
const parsedValues = JSON.parse(savedValues);
|
|
setFilterValues(parsedValues);
|
|
// 다음 렌더 사이클에서 필터 적용 (activeFilters 업데이트 후)
|
|
pendingFilterApplyRef.current = { values: parsedValues, tableName: currentTable.tableName };
|
|
} catch {
|
|
setFilterValues({});
|
|
}
|
|
} else {
|
|
setFilterValues({});
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 동적 모드: 화면별로 독립적인 필터 설정 불러오기
|
|
// 참고: FilterPanel.tsx에서도 screenId만 사용하여 저장하므로 키가 일치해야 함
|
|
const filterConfigKey = screenId
|
|
? `table_filters_${currentTable.tableName}_screen_${screenId}`
|
|
: `table_filters_${currentTable.tableName}`;
|
|
const savedFilters = localStorage.getItem(filterConfigKey);
|
|
|
|
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);
|
|
|
|
// 탭별 저장된 필터 값 복원
|
|
if (currentTableTabId) {
|
|
const valuesStorageKey = getTabFilterStorageKey(currentTable.tableName, currentTableTabId);
|
|
const savedValues = localStorage.getItem(valuesStorageKey);
|
|
if (savedValues) {
|
|
try {
|
|
const parsedValues = JSON.parse(savedValues);
|
|
setFilterValues(parsedValues);
|
|
// 다음 렌더 사이클에서 필터 적용 (activeFilters 업데이트 후)
|
|
pendingFilterApplyRef.current = { values: parsedValues, tableName: currentTable.tableName };
|
|
} catch {
|
|
setFilterValues({});
|
|
}
|
|
} else {
|
|
setFilterValues({});
|
|
}
|
|
} else {
|
|
setFilterValues({});
|
|
}
|
|
} catch (error) {
|
|
console.error("저장된 필터 불러오기 실패:", error);
|
|
// 파싱 에러 시 필터 초기화
|
|
setActiveFilters([]);
|
|
setFilterValues({});
|
|
}
|
|
} else {
|
|
// 필터 설정이 없으면 activeFilters와 filterValues 모두 초기화
|
|
setActiveFilters([]);
|
|
setFilterValues({});
|
|
setSelectOptions({});
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [currentTable?.tableName, filterMode, screenId, currentTableTabId, 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 loadedOptions: Record<string, Array<{ label: string; value: string }>> = {};
|
|
let hasNewOptions = false;
|
|
|
|
for (const filter of selectFilters) {
|
|
try {
|
|
const options = await currentTable.getColumnUniqueValues(filter.columnName);
|
|
if (options && options.length > 0) {
|
|
loadedOptions[filter.columnName] = options;
|
|
hasNewOptions = true;
|
|
}
|
|
} catch (error) {
|
|
console.error("❌ [TableSearchWidget] select 옵션 로드 실패:", filter.columnName, error);
|
|
}
|
|
}
|
|
|
|
if (hasNewOptions) {
|
|
setSelectOptions((prev) => {
|
|
// 새로 로드된 옵션으로 항상 갱신 (카테고리 label 정보가 나중에 로드될 수 있으므로)
|
|
// 로드 실패한 컬럼의 기존 옵션은 유지
|
|
return { ...prev, ...loadedOptions };
|
|
});
|
|
}
|
|
};
|
|
|
|
loadSelectOptions();
|
|
}, [activeFilters, currentTable?.tableName, currentTable?.getColumnUniqueValues, currentTable?.dataCount]);
|
|
|
|
// 높이 변화 감지 및 알림 (실제 화면에서만)
|
|
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);
|
|
|
|
// 탭별 필터 값 저장
|
|
if (currentTable?.tableName && currentTableTabId) {
|
|
const storageKey = getTabFilterStorageKey(currentTable.tableName, currentTableTabId);
|
|
localStorage.setItem(storageKey, JSON.stringify(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 = "";
|
|
}
|
|
}
|
|
|
|
// 다중선택 배열을 처리 (파이프로 연결된 문자열로 변환)
|
|
// filterType에 관계없이 배열이면 파이프로 연결
|
|
if (Array.isArray(filterValue)) {
|
|
filterValue = filterValue.join("|");
|
|
}
|
|
|
|
// 🔧 filterType에 따라 operator 설정
|
|
// - "select" 유형: 정확히 일치 (equals)
|
|
// - "text" 유형: 부분 일치 (contains)
|
|
// - "date", "number": 각각 적절한 처리
|
|
let operator = "contains"; // 기본값
|
|
if (filter.filterType === "select") {
|
|
operator = "equals"; // 선택 필터는 정확히 일치
|
|
} else if (filter.filterType === "number") {
|
|
operator = "equals"; // 숫자도 정확히 일치
|
|
}
|
|
|
|
return {
|
|
...filter,
|
|
value: filterValue || "",
|
|
operator, // operator 추가
|
|
};
|
|
})
|
|
.filter((f) => {
|
|
// 빈 값 체크
|
|
if (!f.value) return false;
|
|
if (typeof f.value === "string" && f.value === "") return false;
|
|
return true;
|
|
});
|
|
|
|
if (currentTable?.onFilterChange) {
|
|
currentTable.onFilterChange(filtersWithValues);
|
|
}
|
|
};
|
|
|
|
// 필터 초기화
|
|
const handleResetFilters = () => {
|
|
setFilterValues({});
|
|
setSelectedLabels({});
|
|
currentTable?.onFilterChange([]);
|
|
|
|
// 탭별 저장된 필터 값도 초기화
|
|
if (currentTable?.tableName && currentTableTabId) {
|
|
const storageKey = getTabFilterStorageKey(currentTable.tableName, currentTableTabId);
|
|
localStorage.removeItem(storageKey);
|
|
}
|
|
};
|
|
|
|
// 필터 입력 필드 렌더링
|
|
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": {
|
|
const 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="w-auto p-0" style={{ width: "var(--radix-popover-trigger-width)" }} 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="hover:bg-accent flex cursor-pointer items-center space-x-2 rounded-sm px-2 py-1.5"
|
|
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="truncate 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="h-7 w-full 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 && setSettingsOpen(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>
|
|
)}
|
|
</div>
|
|
|
|
{/* 통합 설정 모달 */}
|
|
<TableSettingsModal
|
|
isOpen={settingsOpen}
|
|
onClose={() => setSettingsOpen(false)}
|
|
onFiltersApplied={(filters) => setActiveFilters(filters)}
|
|
screenId={screenId}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|