diff --git a/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx b/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx index a84ee354..f8bf39c7 100644 --- a/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx +++ b/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useState, useMemo, useCallback } from "react"; +import React, { useEffect, useState, useMemo, useCallback, useRef } from "react"; import { ComponentRendererProps } from "@/types/component"; import { CardDisplayConfig } from "./types"; import { tableTypeApi } from "@/lib/api/screen"; @@ -13,6 +13,8 @@ import { Badge } from "@/components/ui/badge"; import { useScreenContextOptional } from "@/contexts/ScreenContext"; import { useSplitPanelContext } from "@/contexts/SplitPanelContext"; import { useModalDataStore } from "@/stores/modalDataStore"; +import { useTableOptions } from "@/contexts/TableOptionsContext"; +import { TableFilter, ColumnVisibility, TableColumn } from "@/types/table-options"; export interface CardDisplayComponentProps extends ComponentRendererProps { config?: CardDisplayConfig; @@ -48,11 +50,32 @@ export const CardDisplayComponent: React.FC = ({ const splitPanelContext = useSplitPanelContext(); const splitPanelPosition = screenContext?.splitPanelPosition; + // TableOptions Context (검색 필터 위젯 연동용) + let tableOptionsContext: ReturnType | null = null; + try { + tableOptionsContext = useTableOptions(); + } catch (e) { + // Context가 없으면 (디자이너 모드) 무시 + } + // 테이블 데이터 상태 관리 const [loadedTableData, setLoadedTableData] = useState([]); const [loadedTableColumns, setLoadedTableColumns] = useState([]); const [loading, setLoading] = useState(false); + // 필터 상태 (검색 필터 위젯에서 전달받은 필터) + const [filters, setFiltersInternal] = useState([]); + + // 필터 상태 변경 래퍼 (로깅용) + const setFilters = useCallback((newFilters: TableFilter[]) => { + console.log("🎴 [CardDisplay] setFilters 호출됨:", { + componentId: component.id, + filtersCount: newFilters.length, + filters: newFilters, + }); + setFiltersInternal(newFilters); + }, [component.id]); + // 카테고리 매핑 상태 (카테고리 코드 -> 라벨/색상) const [columnMeta, setColumnMeta] = useState< Record @@ -380,6 +403,195 @@ export const CardDisplayComponent: React.FC = ({ } }, [screenContext, component.id, dataProvider]); + // TableOptionsContext에 테이블 등록 (검색 필터 위젯 연동용) + const tableId = `card-display-${component.id}`; + const tableNameToUse = tableName || component.componentConfig?.tableName || ''; + const tableLabel = component.componentConfig?.title || component.label || "카드 디스플레이"; + + // ref로 최신 데이터 참조 (useCallback 의존성 문제 해결) + const loadedTableDataRef = useRef(loadedTableData); + const categoryMappingsRef = useRef(categoryMappings); + + useEffect(() => { + loadedTableDataRef.current = loadedTableData; + }, [loadedTableData]); + + useEffect(() => { + categoryMappingsRef.current = categoryMappings; + }, [categoryMappings]); + + // 필터가 변경되면 데이터 다시 로드 (테이블 리스트와 동일한 패턴) + // 초기 로드 여부 추적 + const isInitialLoadRef = useRef(true); + + useEffect(() => { + if (!tableNameToUse || isDesignMode) return; + + // 초기 로드는 별도 useEffect에서 처리하므로 스킵 + if (isInitialLoadRef.current) { + isInitialLoadRef.current = false; + return; + } + + const loadFilteredData = async () => { + try { + setLoading(true); + + // 필터 값을 검색 파라미터로 변환 + const searchParams: Record = {}; + filters.forEach(filter => { + if (filter.value !== undefined && filter.value !== null && filter.value !== '') { + searchParams[filter.columnName] = filter.value; + } + }); + + console.log("🔍 [CardDisplay] 필터 적용 데이터 로드:", { + tableName: tableNameToUse, + filtersCount: filters.length, + searchParams, + }); + + // search 파라미터로 검색 조건 전달 (API 스펙에 맞게) + const dataResponse = await tableTypeApi.getTableData(tableNameToUse, { + page: 1, + size: 50, + search: searchParams, + }); + + setLoadedTableData(dataResponse.data); + + // 데이터 건수 업데이트 + if (tableOptionsContext) { + tableOptionsContext.updateTableDataCount(tableId, dataResponse.data?.length || 0); + } + } catch (error) { + console.error("❌ [CardDisplay] 필터 적용 실패:", error); + } finally { + setLoading(false); + } + }; + + // 필터 변경 시 항상 데이터 다시 로드 (빈 필터 = 전체 데이터) + loadFilteredData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filters, tableNameToUse, isDesignMode, tableId]); + + // 컬럼 고유 값 조회 함수 (select 타입 필터용) + const getColumnUniqueValues = useCallback(async (columnName: string): Promise> => { + if (!tableNameToUse) return []; + + try { + // 현재 로드된 데이터에서 고유 값 추출 + const uniqueValues = new Set(); + loadedTableDataRef.current.forEach(row => { + const value = row[columnName]; + if (value !== null && value !== undefined && value !== '') { + uniqueValues.add(String(value)); + } + }); + + // 카테고리 매핑이 있으면 라벨 적용 + const mapping = categoryMappingsRef.current[columnName]; + return Array.from(uniqueValues).map(value => ({ + value, + label: mapping?.[value]?.label || value, + })); + } catch (error) { + console.error(`❌ [CardDisplay] 고유 값 조회 실패: ${columnName}`, error); + return []; + } + }, [tableNameToUse]); + + // TableOptionsContext에 등록 + // registerTable과 unregisterTable 함수 참조 저장 (의존성 안정화) + const registerTableRef = useRef(tableOptionsContext?.registerTable); + const unregisterTableRef = useRef(tableOptionsContext?.unregisterTable); + + // setFiltersInternal을 ref로 저장 (등록 시 최신 함수 사용) + const setFiltersRef = useRef(setFiltersInternal); + const getColumnUniqueValuesRef = useRef(getColumnUniqueValues); + + useEffect(() => { + registerTableRef.current = tableOptionsContext?.registerTable; + unregisterTableRef.current = tableOptionsContext?.unregisterTable; + }, [tableOptionsContext]); + + useEffect(() => { + setFiltersRef.current = setFiltersInternal; + }, [setFiltersInternal]); + + useEffect(() => { + getColumnUniqueValuesRef.current = getColumnUniqueValues; + }, [getColumnUniqueValues]); + + // 테이블 등록 (한 번만 실행, 컬럼 변경 시에만 재등록) + const columnsKey = JSON.stringify(loadedTableColumns.map((col: any) => col.columnName || col.column_name)); + + useEffect(() => { + if (!registerTableRef.current || !unregisterTableRef.current) return; + if (isDesignMode || !tableNameToUse || loadedTableColumns.length === 0) return; + + // 컬럼 정보를 TableColumn 형식으로 변환 + const columns: TableColumn[] = loadedTableColumns.map((col: any) => ({ + columnName: col.columnName || col.column_name, + columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name, + inputType: columnMeta[col.columnName || col.column_name]?.inputType || 'text', + visible: true, + width: 200, + sortable: true, + filterable: true, + })); + + // onFilterChange는 ref를 통해 최신 함수를 호출하는 래퍼 사용 + const onFilterChangeWrapper = (newFilters: TableFilter[]) => { + console.log("🎴 [CardDisplay] onFilterChange 래퍼 호출:", { + tableId, + filtersCount: newFilters.length, + }); + setFiltersRef.current(newFilters); + }; + + const getColumnUniqueValuesWrapper = async (columnName: string) => { + return getColumnUniqueValuesRef.current(columnName); + }; + + const registration = { + tableId, + label: tableLabel, + tableName: tableNameToUse, + columns, + dataCount: loadedTableData.length, + onFilterChange: onFilterChangeWrapper, + onGroupChange: () => {}, // 카드 디스플레이는 그룹핑 미지원 + onColumnVisibilityChange: () => {}, // 카드 디스플레이는 컬럼 가시성 미지원 + getColumnUniqueValues: getColumnUniqueValuesWrapper, + }; + + console.log("📋 [CardDisplay] TableOptionsContext에 등록:", { + tableId, + tableName: tableNameToUse, + columnsCount: columns.length, + dataCount: loadedTableData.length, + }); + + registerTableRef.current(registration); + + const unregister = unregisterTableRef.current; + const currentTableId = tableId; + + return () => { + console.log("📋 [CardDisplay] TableOptionsContext에서 해제:", currentTableId); + unregister(currentTableId); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + isDesignMode, + tableId, + tableNameToUse, + tableLabel, + columnsKey, // 컬럼 변경 시에만 재등록 + ]); + // 로딩 중인 경우 로딩 표시 if (loading) { return ( diff --git a/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx b/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx index 6d513976..af16bcea 100644 --- a/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx +++ b/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useRef } from "react"; +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"; @@ -41,6 +41,7 @@ interface TableSearchWidgetProps { showTableSelector?: boolean; // 테이블 선택 드롭다운 표시 여부 filterMode?: "dynamic" | "preset"; // 필터 모드 presetFilters?: PresetFilter[]; // 고정 필터 목록 + targetPanelPosition?: "left" | "right" | "auto"; // 분할 패널에서 대상 패널 위치 (기본: "left") }; }; screenId?: number; // 화면 ID @@ -82,19 +83,90 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table 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 tableList = Array.from(registeredTables.values()); - const currentTable = selectedTableId ? getTable(selectedTableId) : undefined; - - // 첫 번째 테이블 자동 선택 - useEffect(() => { - const tables = Array.from(registeredTables.values()); - - if (autoSelectFirstTable && tables.length > 0 && !selectedTableId) { - setSelectedTableId(tables[0].tableId); + const allTableList = Array.from(registeredTables.values()); + + // 대상 패널 위치에 따라 테이블 필터링 (tableId 패턴 기반) + const tableList = useMemo(() => { + // "auto"면 모든 테이블 반환 + if (targetPanelPosition === "auto") { + return allTableList; } - }, [registeredTables, selectedTableId, autoSelectFirstTable, setSelectedTableId]); + + // 테이블 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(() => { @@ -302,6 +374,12 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table return true; }); + console.log("🔍 [TableSearchWidget] 필터 적용:", { + currentTableId: currentTable?.tableId, + currentTableName: currentTable?.tableName, + filtersCount: filtersWithValues.length, + filtersWithValues, + }); currentTable?.onFilterChange(filtersWithValues); }; diff --git a/frontend/lib/registry/components/table-search-widget/TableSearchWidgetConfigPanel.tsx b/frontend/lib/registry/components/table-search-widget/TableSearchWidgetConfigPanel.tsx index 3424abb9..00a9766f 100644 --- a/frontend/lib/registry/components/table-search-widget/TableSearchWidgetConfigPanel.tsx +++ b/frontend/lib/registry/components/table-search-widget/TableSearchWidgetConfigPanel.tsx @@ -76,12 +76,16 @@ export function TableSearchWidgetConfigPanel({ const [localPresetFilters, setLocalPresetFilters] = useState( currentConfig.presetFilters ?? [] ); + const [localTargetPanelPosition, setLocalTargetPanelPosition] = useState<"left" | "right" | "auto">( + currentConfig.targetPanelPosition ?? "left" + ); useEffect(() => { setLocalAutoSelect(currentConfig.autoSelectFirstTable ?? true); setLocalShowSelector(currentConfig.showTableSelector ?? true); setLocalFilterMode(currentConfig.filterMode ?? "dynamic"); setLocalPresetFilters(currentConfig.presetFilters ?? []); + setLocalTargetPanelPosition(currentConfig.targetPanelPosition ?? "left"); }, [currentConfig]); // 설정 업데이트 헬퍼 @@ -164,6 +168,40 @@ export function TableSearchWidgetConfigPanel({ + {/* 대상 패널 위치 (분할 패널용) */} +
+ +

+ 분할 패널이 있는 화면에서 검색 필터가 어떤 패널의 컴포넌트를 대상으로 할지 선택합니다. +

+ { + setLocalTargetPanelPosition(value); + handleUpdate("targetPanelPosition", value); + }} + > +
+ + +
+
+ + +
+
+ + +
+
+
+ {/* 필터 모드 선택 */}