"use client"; import React, { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { TableListConfig, ColumnConfig } from "./types"; import { WebType } from "@/types/common"; import { tableTypeApi } from "@/lib/api/screen"; import { entityJoinApi } from "@/lib/api/entityJoin"; import { codeCache } from "@/lib/caching/codeCache"; import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization"; import { getFullImageUrl } from "@/lib/api/client"; import { Button } from "@/components/ui/button"; import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, RefreshCw, ArrowUp, ArrowDown, TableIcon, Settings, X, Layers, ChevronDown, Filter, Check, Download, FileSpreadsheet, Copy, ClipboardCopy, Edit, CheckSquare, Trash2, Lock, } from "lucide-react"; import * as XLSX from "xlsx"; import { FileText, ChevronRightIcon } from "lucide-react"; import { Checkbox } from "@/components/ui/checkbox"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; import { tableDisplayStore } from "@/stores/tableDisplayStore"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Label } from "@/components/ui/label"; import { AdvancedSearchFilters } from "@/components/screen/filters/AdvancedSearchFilters"; import { SingleTableWithSticky } from "./SingleTableWithSticky"; import { CardModeRenderer } from "./CardModeRenderer"; import { TableOptionsModal } from "@/components/common/TableOptionsModal"; import { useTableOptions } from "@/contexts/TableOptionsContext"; import { TableFilter, ColumnVisibility } from "@/types/table-options"; import { useAuth } from "@/hooks/useAuth"; import { useScreenContextOptional } from "@/contexts/ScreenContext"; import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext"; import type { DataProvidable, DataReceivable, DataReceiverConfig } from "@/types/data-transfer"; // ======================================== // 인터페이스 // ======================================== // 그룹화된 데이터 인터페이스 interface GroupedData { groupKey: string; groupValues: Record; items: any[]; count: number; summary?: Record; // 🆕 그룹별 소계 } // ======================================== // 캐시 및 유틸리티 // ======================================== const tableColumnCache = new Map(); const tableInfoCache = new Map(); const TABLE_CACHE_TTL = 5 * 60 * 1000; // 5분 const cleanupTableCache = () => { const now = Date.now(); for (const [key, entry] of tableColumnCache.entries()) { if (now - entry.timestamp > TABLE_CACHE_TTL) { tableColumnCache.delete(key); } } for (const [key, entry] of tableInfoCache.entries()) { if (now - entry.timestamp > TABLE_CACHE_TTL) { tableInfoCache.delete(key); } } }; if (typeof window !== "undefined") { setInterval(cleanupTableCache, 10 * 60 * 1000); } const debounceTimers = new Map(); const activeRequests = new Map>(); const debouncedApiCall = (key: string, fn: (...args: T) => Promise, delay: number = 300) => { return (...args: T): Promise => { const activeRequest = activeRequests.get(key); if (activeRequest) { return activeRequest as Promise; } return new Promise((resolve, reject) => { const existingTimer = debounceTimers.get(key); if (existingTimer) { clearTimeout(existingTimer); } const timer = setTimeout(async () => { try { const requestPromise = fn(...args); activeRequests.set(key, requestPromise); const result = await requestPromise; resolve(result); } catch (error) { reject(error); } finally { debounceTimers.delete(key); activeRequests.delete(key); } }, delay); debounceTimers.set(key, timer); }); }; }; // ======================================== // Filter Builder 인터페이스 // ======================================== interface FilterCondition { id: string; column: string; operator: | "equals" | "notEquals" | "contains" | "notContains" | "startsWith" | "endsWith" | "greaterThan" | "lessThan" | "greaterOrEqual" | "lessOrEqual" | "isEmpty" | "isNotEmpty"; value: string; } interface FilterGroup { id: string; logic: "AND" | "OR"; conditions: FilterCondition[]; } // ======================================== // Props 인터페이스 // ======================================== export interface TableListComponentProps { component: any; isDesignMode?: boolean; isSelected?: boolean; isInteractive?: boolean; onClick?: () => void; onDragStart?: (e: React.DragEvent) => void; onDragEnd?: (e: React.DragEvent) => void; className?: string; style?: React.CSSProperties; formData?: Record; onFormDataChange?: (data: any) => void; config?: TableListConfig; size?: { width: number; height: number }; position?: { x: number; y: number; z?: number }; componentConfig?: any; selectedScreen?: any; onZoneComponentDrop?: any; onZoneClick?: any; tableName?: string; onRefresh?: () => void; onClose?: () => void; screenId?: number | string; // 화면 ID (필터 설정 저장용) userId?: string; // 사용자 ID (컬럼 순서 저장용) onSelectedRowsChange?: ( selectedRows: any[], selectedRowsData: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[], tableDisplayData?: any[], ) => void; onConfigChange?: (config: any) => void; refreshKey?: number; } // ======================================== // 메인 컴포넌트 // ======================================== export const TableListComponent: React.FC = ({ component, isDesignMode = false, isSelected = false, onClick, onDragStart, onDragEnd, config, className, style, formData: propFormData, // 🆕 부모에서 전달받은 formData onFormDataChange, componentConfig, onSelectedRowsChange, onConfigChange, refreshKey, tableName, userId, screenId, // 화면 ID 추출 }) => { // ======================================== // 설정 및 스타일 // ======================================== const tableConfig = { ...config, ...component.config, ...componentConfig, } as TableListConfig; // selectedTable 안전하게 추출 (문자열인지 확인) let finalSelectedTable = componentConfig?.selectedTable || component.config?.selectedTable || config?.selectedTable || tableName; // 디버그 로그 제거 (성능 최적화) // 객체인 경우 tableName 속성 추출 시도 if (typeof finalSelectedTable === "object" && finalSelectedTable !== null) { console.warn("⚠️ selectedTable이 객체입니다:", finalSelectedTable); finalSelectedTable = (finalSelectedTable as any).tableName || (finalSelectedTable as any).name || tableName; console.log("✅ 객체에서 추출한 테이블명:", finalSelectedTable); } tableConfig.selectedTable = finalSelectedTable; // 디버그 로그 제거 (성능 최적화) const buttonColor = component.style?.labelColor || "#212121"; const buttonTextColor = component.config?.buttonTextColor || "#ffffff"; const gridColumns = component.gridColumns || 1; let calculatedWidth: string; if (isDesignMode) { if (gridColumns === 1) { calculatedWidth = "400px"; } else if (gridColumns === 2) { calculatedWidth = "800px"; } else { calculatedWidth = "100%"; } } else { calculatedWidth = "100%"; } const componentStyle: React.CSSProperties = { position: "relative", display: "flex", flexDirection: "column", backgroundColor: "hsl(var(--background))", overflow: "hidden", boxSizing: "border-box", width: "100%", height: "100%", minHeight: isDesignMode ? "300px" : "100%", ...style, // style prop이 위의 기본값들을 덮어씀 }; // ======================================== // 상태 관리 // ======================================== // 사용자 정보 (props에서 받거나 useAuth에서 가져오기) const { userId: authUserId } = useAuth(); const currentUserId = userId || authUserId; // 화면 컨텍스트 (데이터 제공자로 등록) const screenContext = useScreenContextOptional(); // 분할 패널 컨텍스트 (분할 패널 내부에서 데이터 수신자로 등록) const splitPanelContext = useSplitPanelContext(); // 🆕 ScreenContext에서 splitPanelPosition 가져오기 (중첩 화면에서도 작동) const splitPanelPosition = screenContext?.splitPanelPosition; // 🆕 연결된 필터 상태 (다른 컴포넌트 값으로 필터링) const [linkedFilterValues, setLinkedFilterValues] = useState>({}); // TableOptions Context const { registerTable, unregisterTable, updateTableDataCount } = useTableOptions(); const [filters, setFilters] = useState([]); const [grouping, setGrouping] = useState([]); const [columnVisibility, setColumnVisibility] = useState([]); // filters가 변경되면 searchValues 업데이트 (실시간 검색) useEffect(() => { const newSearchValues: Record = {}; filters.forEach((filter) => { if (filter.value) { newSearchValues[filter.columnName] = filter.value; } }); // console.log("🔍 [TableListComponent] filters → searchValues:", { // filters: filters.length, // searchValues: newSearchValues, // }); setSearchValues(newSearchValues); setCurrentPage(1); // 필터 변경 시 첫 페이지로 }, [filters]); // grouping이 변경되면 groupByColumns 업데이트 useEffect(() => { setGroupByColumns(grouping); }, [grouping]); // 초기 로드 시 localStorage에서 저장된 설정 불러오기 useEffect(() => { if (tableConfig.selectedTable && currentUserId) { const storageKey = `table_column_visibility_${tableConfig.selectedTable}_${currentUserId}`; const savedSettings = localStorage.getItem(storageKey); if (savedSettings) { try { const parsed = JSON.parse(savedSettings) as ColumnVisibility[]; setColumnVisibility(parsed); } catch (error) { console.error("저장된 컬럼 설정 불러오기 실패:", error); } } } }, [tableConfig.selectedTable, currentUserId]); // columnVisibility 변경 시 컬럼 순서 및 가시성 적용 useEffect(() => { if (columnVisibility.length > 0) { const newOrder = columnVisibility .map((cv) => cv.columnName) .filter((name) => name !== "__checkbox__"); // 체크박스 제외 setColumnOrder(newOrder); // localStorage에 저장 (사용자별) if (tableConfig.selectedTable && currentUserId) { const storageKey = `table_column_visibility_${tableConfig.selectedTable}_${currentUserId}`; localStorage.setItem(storageKey, JSON.stringify(columnVisibility)); } } }, [columnVisibility, tableConfig.selectedTable, currentUserId]); // 🆕 columnOrder를 visibleColumns 이전에 정의 (visibleColumns에서 사용) const [columnOrder, setColumnOrder] = useState([]); // 🆕 visibleColumns를 상단에서 정의 (다른 useCallback/useMemo에서 사용하기 위해) const visibleColumns = useMemo(() => { let cols = (tableConfig.columns || []).filter((col) => col.visible !== false); // columnVisibility가 있으면 가시성 적용 if (columnVisibility.length > 0) { cols = cols.filter((col) => { const visibilityConfig = columnVisibility.find((cv) => cv.columnName === col.columnName); return visibilityConfig ? visibilityConfig.visible : true; }); } // 체크박스 컬럼 (나중에 위치 결정) // 기본값: enabled가 undefined면 true로 처리 let checkboxCol: ColumnConfig | null = null; if (tableConfig.checkbox?.enabled ?? true) { checkboxCol = { columnName: "__checkbox__", displayName: "", visible: true, sortable: false, searchable: false, width: 40, align: "center" as const, order: -1, editable: false, // 체크박스는 편집 불가 }; } // columnOrder가 있으면 해당 순서로 정렬 if (columnOrder.length > 0) { const orderMap = new Map(columnOrder.map((name, idx) => [name, idx])); cols = [...cols].sort((a, b) => { const aIdx = orderMap.get(a.columnName) ?? 9999; const bIdx = orderMap.get(b.columnName) ?? 9999; return aIdx - bIdx; }); } // 체크박스 위치 결정 if (checkboxCol) { const checkboxPosition = tableConfig.checkbox?.position || "left"; if (checkboxPosition === "left") { return [checkboxCol, ...cols]; } else { return [...cols, checkboxCol]; } } return cols; }, [tableConfig.columns, tableConfig.checkbox, columnVisibility, columnOrder]); const [data, setData] = useState[]>([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); // 🆕 컬럼 헤더 필터 상태 (상단에서 선언) const [headerFilters, setHeaderFilters] = useState>>({}); const [openFilterColumn, setOpenFilterColumn] = useState(null); // 🆕 Filter Builder (고급 필터) 관련 상태 - filteredData보다 먼저 정의해야 함 const [filterGroups, setFilterGroups] = useState([]); // 🆕 분할 패널에서 우측에 이미 추가된 항목 필터링 (좌측 테이블에만 적용) + 헤더 필터 const filteredData = useMemo(() => { let result = data; // 1. 분할 패널 좌측에 있고, 우측에 추가된 항목이 있는 경우 필터링 if (splitPanelPosition === "left" && splitPanelContext?.addedItemIds && splitPanelContext.addedItemIds.size > 0) { const addedIds = splitPanelContext.addedItemIds; result = result.filter((row) => { const rowId = String(row.id || row.po_item_id || row.item_id || ""); return !addedIds.has(rowId); }); } // 2. 헤더 필터 적용 (joinColumnMapping 사용 안 함 - 직접 컬럼명 사용) if (Object.keys(headerFilters).length > 0) { result = result.filter((row) => { return Object.entries(headerFilters).every(([columnName, values]) => { if (values.size === 0) return true; // 여러 가능한 컬럼명 시도 const cellValue = row[columnName] ?? row[columnName.toLowerCase()] ?? row[columnName.toUpperCase()]; const cellStr = cellValue !== null && cellValue !== undefined ? String(cellValue) : ""; return values.has(cellStr); }); }); } // 3. 🆕 Filter Builder 적용 if (filterGroups.length > 0) { result = result.filter((row) => { return filterGroups.every((group) => { const validConditions = group.conditions.filter( (c) => c.column && (c.operator === "isEmpty" || c.operator === "isNotEmpty" || c.value) ); if (validConditions.length === 0) return true; const evaluateCondition = (value: any, condition: typeof group.conditions[0]): boolean => { const strValue = value !== null && value !== undefined ? String(value).toLowerCase() : ""; const condValue = condition.value.toLowerCase(); switch (condition.operator) { case "equals": return strValue === condValue; case "notEquals": return strValue !== condValue; case "contains": return strValue.includes(condValue); case "notContains": return !strValue.includes(condValue); case "startsWith": return strValue.startsWith(condValue); case "endsWith": return strValue.endsWith(condValue); case "greaterThan": return parseFloat(strValue) > parseFloat(condValue); case "lessThan": return parseFloat(strValue) < parseFloat(condValue); case "greaterOrEqual": return parseFloat(strValue) >= parseFloat(condValue); case "lessOrEqual": return parseFloat(strValue) <= parseFloat(condValue); case "isEmpty": return strValue === "" || value === null || value === undefined; case "isNotEmpty": return strValue !== "" && value !== null && value !== undefined; default: return true; } }; if (group.logic === "AND") { return validConditions.every((cond) => evaluateCondition(row[cond.column], cond)); } else { return validConditions.some((cond) => evaluateCondition(row[cond.column], cond)); } }); }); } return result; }, [data, splitPanelPosition, splitPanelContext?.addedItemIds, headerFilters, filterGroups]); const [currentPage, setCurrentPage] = useState(1); const [totalPages, setTotalPages] = useState(0); const [totalItems, setTotalItems] = useState(0); const [searchTerm, setSearchTerm] = useState(""); const [sortColumn, setSortColumn] = useState(null); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); const hasInitializedSort = useRef(false); const [columnLabels, setColumnLabels] = useState>({}); const [tableLabel, setTableLabel] = useState(""); const [localPageSize, setLocalPageSize] = useState(tableConfig.pagination?.pageSize || 20); const [displayColumns, setDisplayColumns] = useState([]); const [joinColumnMapping, setJoinColumnMapping] = useState>({}); const [columnMeta, setColumnMeta] = useState< Record >({}); // 🆕 엔티티 조인 테이블의 컬럼 메타데이터 (테이블명.컬럼명 → inputType) const [joinedColumnMeta, setJoinedColumnMeta] = useState< Record >({}); const [categoryMappings, setCategoryMappings] = useState< Record> >({}); const [categoryMappingsKey, setCategoryMappingsKey] = useState(0); // 강제 리렌더링용 const [searchValues, setSearchValues] = useState>({}); const [selectedRows, setSelectedRows] = useState>(new Set()); const [columnWidths, setColumnWidths] = useState>({}); const [refreshTrigger, setRefreshTrigger] = useState(0); // columnOrder는 상단에서 정의됨 (visibleColumns보다 먼저 필요) const columnRefs = useRef>({}); const [isAllSelected, setIsAllSelected] = useState(false); const hasInitializedWidths = useRef(false); const isResizing = useRef(false); // 필터 설정 관련 상태 const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false); const [visibleFilterColumns, setVisibleFilterColumns] = useState>(new Set()); // 🆕 키보드 네비게이션 관련 상태 const [focusedCell, setFocusedCell] = useState<{ rowIndex: number; colIndex: number } | null>(null); const tableContainerRef = useRef(null); // 🆕 인라인 셀 편집 관련 상태 const [editingCell, setEditingCell] = useState<{ rowIndex: number; colIndex: number; columnName: string; originalValue: any; } | null>(null); const [editingValue, setEditingValue] = useState(""); const editInputRef = useRef(null); // 🆕 배치 편집 관련 상태 const [editMode, setEditMode] = useState<"immediate" | "batch">("immediate"); // 편집 모드 const [pendingChanges, setPendingChanges] = useState>(new Map()); // key: `${rowIndex}-${columnName}` const [localEditedData, setLocalEditedData] = useState>>({}); // 로컬 수정 데이터 // 🆕 유효성 검사 관련 상태 const [validationErrors, setValidationErrors] = useState>(new Map()); // key: `${rowIndex}-${columnName}` // 🆕 유효성 검사 규칙 타입 type ValidationRule = { required?: boolean; min?: number; max?: number; minLength?: number; maxLength?: number; pattern?: RegExp; customMessage?: string; validate?: (value: any, row: any) => string | null; // 커스텀 검증 함수 (에러 메시지 또는 null) }; // 🆕 Cascading Lookups 관련 상태 const [cascadingOptions, setCascadingOptions] = useState>({}); const [loadingCascading, setLoadingCascading] = useState>({}); // 🆕 Multi-Level Headers (Column Bands) 타입 type ColumnBand = { caption: string; columns: string[]; // 포함될 컬럼명 배열 }; // 그룹 설정 관련 상태 const [groupByColumns, setGroupByColumns] = useState([]); const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); // 🆕 Master-Detail 관련 상태 const [expandedRows, setExpandedRows] = useState>(new Set()); // 확장된 행 키 목록 const [detailData, setDetailData] = useState>({}); // 상세 데이터 캐시 // 🆕 Drag & Drop 재정렬 관련 상태 const [draggedRowIndex, setDraggedRowIndex] = useState(null); const [dropTargetIndex, setDropTargetIndex] = useState(null); const [isDragEnabled, setIsDragEnabled] = useState((tableConfig as any).enableRowDrag ?? false); // 🆕 Virtual Scrolling 관련 상태 const [isVirtualScrollEnabled] = useState((tableConfig as any).virtualScroll ?? false); const [scrollTop, setScrollTop] = useState(0); const ROW_HEIGHT = 40; // 각 행의 높이 (픽셀) const OVERSCAN = 5; // 버퍼로 추가 렌더링할 행 수 const scrollContainerRef = useRef(null); // 🆕 Column Reordering 관련 상태 const [draggedColumnIndex, setDraggedColumnIndex] = useState(null); const [dropTargetColumnIndex, setDropTargetColumnIndex] = useState(null); const [isColumnDragEnabled] = useState((tableConfig as any).enableColumnDrag ?? true); // 🆕 State Persistence: 통합 상태 키 const tableStateKey = useMemo(() => { if (!tableConfig.selectedTable) return null; return `tableState_${tableConfig.selectedTable}`; }, [tableConfig.selectedTable]); // 🆕 Real-Time Updates 관련 상태 const [isRealTimeEnabled] = useState((tableConfig as any).realTimeUpdates ?? false); const [wsConnectionStatus, setWsConnectionStatus] = useState<"connecting" | "connected" | "disconnected">("disconnected"); const wsRef = useRef(null); const reconnectTimeoutRef = useRef(null); // 🆕 Context Menu 관련 상태 const [contextMenu, setContextMenu] = useState<{ x: number; y: number; rowIndex: number; colIndex: number; row: any; } | null>(null); // 사용자 옵션 모달 관련 상태 const [isTableOptionsOpen, setIsTableOptionsOpen] = useState(false); const [showGridLines, setShowGridLines] = useState(true); const [viewMode, setViewMode] = useState<"table" | "card" | "grouped-card">("table"); const [frozenColumns, setFrozenColumns] = useState([]); // 🆕 Search Panel (통합 검색) 관련 상태 const [globalSearchTerm, setGlobalSearchTerm] = useState(""); const [isSearchPanelOpen, setIsSearchPanelOpen] = useState(false); const [searchHighlights, setSearchHighlights] = useState>(new Set()); // "rowIndex-colIndex" 형식 // 🆕 Filter Builder (고급 필터) 관련 상태 추가 const [isFilterBuilderOpen, setIsFilterBuilderOpen] = useState(false); const [activeFilterCount, setActiveFilterCount] = useState(0); // 🆕 연결된 필터 처리 (셀렉트박스 등 다른 컴포넌트 값으로 필터링) useEffect(() => { const linkedFilters = tableConfig.linkedFilters; if (!linkedFilters || linkedFilters.length === 0 || !screenContext) { return; } // 연결된 소스 컴포넌트들의 값을 주기적으로 확인 const checkLinkedFilters = () => { const newFilterValues: Record = {}; let hasChanges = false; linkedFilters.forEach((filter) => { if (filter.enabled === false) return; const sourceProvider = screenContext.getDataProvider(filter.sourceComponentId); if (sourceProvider) { const selectedData = sourceProvider.getSelectedData(); if (selectedData && selectedData.length > 0) { const sourceField = filter.sourceField || "value"; const value = selectedData[0][sourceField]; if (value !== linkedFilterValues[filter.targetColumn]) { newFilterValues[filter.targetColumn] = value; hasChanges = true; } else { newFilterValues[filter.targetColumn] = linkedFilterValues[filter.targetColumn]; } } } }); if (hasChanges) { console.log("🔗 [TableList] 연결된 필터 값 변경:", newFilterValues); setLinkedFilterValues(newFilterValues); // searchValues에 연결된 필터 값 병합 setSearchValues(prev => ({ ...prev, ...newFilterValues })); // 첫 페이지로 이동 setCurrentPage(1); } }; // 초기 체크 checkLinkedFilters(); // 주기적으로 체크 (500ms마다) const intervalId = setInterval(checkLinkedFilters, 500); return () => { clearInterval(intervalId); }; }, [screenContext, tableConfig.linkedFilters, linkedFilterValues]); // DataProvidable 인터페이스 구현 const dataProvider: DataProvidable = { componentId: component.id, componentType: "table-list", getSelectedData: () => { // 🆕 필터링된 데이터에서 선택된 행만 반환 (우측에 추가된 항목 제외) const selectedData = filteredData.filter((row) => { const rowId = String(row.id || row[tableConfig.selectedTable + "_id"] || ""); return selectedRows.has(rowId); }); return selectedData; }, getAllData: () => { // 🆕 필터링된 데이터 반환 return filteredData; }, clearSelection: () => { setSelectedRows(new Set()); setIsAllSelected(false); }, }; // DataReceivable 인터페이스 구현 const dataReceiver: DataReceivable = { componentId: component.id, componentType: "table", receiveData: async (receivedData: any[], config: DataReceiverConfig) => { console.log("📥 TableList 데이터 수신:", { componentId: component.id, receivedDataCount: receivedData.length, mode: config.mode, currentDataCount: data.length, }); try { let newData: any[] = []; switch (config.mode) { case "append": // 기존 데이터에 추가 newData = [...data, ...receivedData]; console.log("✅ Append 모드: 기존 데이터에 추가", { newDataCount: newData.length }); break; case "replace": // 기존 데이터를 완전히 교체 newData = receivedData; console.log("✅ Replace 모드: 데이터 교체", { newDataCount: newData.length }); break; case "merge": // 기존 데이터와 병합 (ID 기반) const existingMap = new Map(data.map(item => [item.id, item])); receivedData.forEach(item => { if (item.id && existingMap.has(item.id)) { // 기존 데이터 업데이트 existingMap.set(item.id, { ...existingMap.get(item.id), ...item }); } else { // 새 데이터 추가 existingMap.set(item.id || Date.now() + Math.random(), item); } }); newData = Array.from(existingMap.values()); console.log("✅ Merge 모드: 데이터 병합", { newDataCount: newData.length }); break; } // 상태 업데이트 setData(newData); // 총 아이템 수 업데이트 setTotalItems(newData.length); console.log("✅ 데이터 수신 완료:", { finalDataCount: newData.length }); } catch (error) { console.error("❌ 데이터 수신 실패:", error); throw error; } }, getData: () => { return data; }, }; // 화면 컨텍스트에 데이터 제공자/수신자로 등록 useEffect(() => { if (screenContext && component.id) { screenContext.registerDataProvider(component.id, dataProvider); screenContext.registerDataReceiver(component.id, dataReceiver); return () => { screenContext.unregisterDataProvider(component.id); screenContext.unregisterDataReceiver(component.id); }; } }, [screenContext, component.id, data, selectedRows]); // 분할 패널 컨텍스트에 데이터 수신자로 등록 // useSplitPanelPosition 훅으로 위치 가져오기 (중첩된 화면에서도 작동) const currentSplitPosition = splitPanelPosition || splitPanelContext?.getPositionByScreenId(screenId as number) || null; useEffect(() => { if (splitPanelContext && component.id && currentSplitPosition) { const splitPanelReceiver = { componentId: component.id, componentType: "table-list", receiveData: async (incomingData: any[], mode: "append" | "replace" | "merge") => { console.log("📥 [TableListComponent] 분할 패널에서 데이터 수신:", { count: incomingData.length, mode, position: currentSplitPosition, }); await dataReceiver.receiveData(incomingData, { targetComponentId: component.id, targetComponentType: "table-list", mode, mappingRules: [], }); }, }; splitPanelContext.registerReceiver(currentSplitPosition, component.id, splitPanelReceiver); return () => { splitPanelContext.unregisterReceiver(currentSplitPosition, component.id); }; } }, [splitPanelContext, component.id, currentSplitPosition, dataReceiver]); // 테이블 등록 (Context에 등록) const tableId = `table-list-${component.id}`; useEffect(() => { // tableConfig.columns를 직접 사용 (displayColumns는 비어있을 수 있음) const columnsToRegister = (tableConfig.columns || []) .filter((col) => col.visible !== false && col.columnName !== "__checkbox__"); if (!tableConfig.selectedTable || !columnsToRegister || columnsToRegister.length === 0) { return; } // 컬럼의 고유 값 조회 함수 const getColumnUniqueValues = async (columnName: string) => { console.log("🔍 [getColumnUniqueValues] 호출됨:", { columnName, dataLength: data.length, columnMeta: columnMeta[columnName], sampleData: data[0], }); const meta = columnMeta[columnName]; const inputType = meta?.inputType || "text"; // 카테고리 타입인 경우 전체 정의된 값 조회 (백엔드 API) if (inputType === "category") { try { console.log("🔍 [getColumnUniqueValues] 카테고리 전체 값 조회:", { tableName: tableConfig.selectedTable, columnName, }); // API 클라이언트 사용 (쿠키 인증 자동 처리) const { apiClient } = await import("@/lib/api/client"); const response = await apiClient.get( `/table-categories/${tableConfig.selectedTable}/${columnName}/values` ); if (response.data.success && response.data.data) { const categoryOptions = response.data.data.map((item: any) => ({ value: item.valueCode, // 카멜케이스 label: item.valueLabel, // 카멜케이스 })); console.log("✅ [getColumnUniqueValues] 카테고리 전체 값:", { columnName, count: categoryOptions.length, options: categoryOptions, }); return categoryOptions; } else { console.warn("⚠️ [getColumnUniqueValues] 응답 형식 오류:", response.data); } } catch (error: any) { console.error("❌ [getColumnUniqueValues] 카테고리 조회 실패:", { error: error.message, response: error.response?.data, status: error.response?.status, columnName, tableName: tableConfig.selectedTable, }); // 에러 시 현재 데이터 기반으로 fallback } } // 일반 타입 또는 카테고리 조회 실패 시: 현재 데이터 기반 const isLabelType = ["category", "entity", "code"].includes(inputType); const labelField = isLabelType ? `${columnName}_name` : columnName; console.log("🔍 [getColumnUniqueValues] 데이터 기반 조회:", { columnName, inputType, isLabelType, labelField, hasLabelField: data[0] && labelField in data[0], sampleLabelValue: data[0] ? data[0][labelField] : undefined, }); // 현재 로드된 데이터에서 고유 값 추출 const uniqueValuesMap = new Map(); // value -> label data.forEach((row) => { const value = row[columnName]; if (value !== null && value !== undefined && value !== "") { // 백엔드 조인된 _name 필드 사용 (없으면 원본 값) const label = isLabelType && row[labelField] ? row[labelField] : String(value); uniqueValuesMap.set(String(value), label); } }); // Map을 배열로 변환하고 라벨 기준으로 정렬 const result = Array.from(uniqueValuesMap.entries()) .map(([value, label]) => ({ value: value, label: label, })) .sort((a, b) => a.label.localeCompare(b.label)); console.log("✅ [getColumnUniqueValues] 데이터 기반 결과:", { columnName, inputType, isLabelType, labelField, uniqueCount: result.length, values: result, }); return result; }; 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, inputType: columnMeta[col.columnName]?.inputType || "text", visible: col.visible !== false, width: columnWidths[col.columnName] || col.width || 150, sortable: col.sortable !== false, filterable: col.searchable !== false, })), onFilterChange: setFilters, onGroupChange: setGrouping, onColumnVisibilityChange: setColumnVisibility, getColumnUniqueValues, // 고유 값 조회 함수 등록 }; registerTable(registration); return () => { unregisterTable(tableId); }; }, [ tableId, tableConfig.selectedTable, tableConfig.columns, columnLabels, columnMeta, // columnMeta가 변경되면 재등록 (inputType 정보 필요) columnWidths, tableLabel, data, // 데이터 자체가 변경되면 재등록 (고유 값 조회용) totalItems, // 전체 항목 수가 변경되면 재등록 registerTable, unregisterTable, ]); // 🎯 초기 로드 시 localStorage에서 정렬 상태 불러오기 useEffect(() => { if (!tableConfig.selectedTable || !userId || hasInitializedSort.current) return; const storageKey = `table_sort_state_${tableConfig.selectedTable}_${userId}`; const savedSort = localStorage.getItem(storageKey); if (savedSort) { try { const { column, direction } = JSON.parse(savedSort); if (column && direction) { setSortColumn(column); setSortDirection(direction); hasInitializedSort.current = true; console.log("📂 localStorage에서 정렬 상태 복원:", { column, direction }); } } catch (error) { console.error("❌ 정렬 상태 복원 실패:", error); } } }, [tableConfig.selectedTable, userId]); // 🆕 초기 로드 시 localStorage에서 컬럼 순서 불러오기 useEffect(() => { if (!tableConfig.selectedTable || !userId) return; const userKey = userId || "guest"; const storageKey = `table_column_order_${tableConfig.selectedTable}_${userKey}`; const savedOrder = localStorage.getItem(storageKey); if (savedOrder) { try { const parsedOrder = JSON.parse(savedOrder); console.log("📂 localStorage에서 컬럼 순서 불러오기:", { storageKey, columnOrder: parsedOrder }); setColumnOrder(parsedOrder); // 부모 컴포넌트에 초기 컬럼 순서 전달 if (onSelectedRowsChange && parsedOrder.length > 0) { console.log("✅ 초기 컬럼 순서 전달:", parsedOrder); // 초기 데이터도 함께 전달 (컬럼 순서대로 재정렬) const initialData = data.map((row: any) => { const reordered: any = {}; parsedOrder.forEach((colName: string) => { if (colName in row) { reordered[colName] = row[colName]; } }); // 나머지 컬럼 추가 Object.keys(row).forEach((key) => { if (!(key in reordered)) { reordered[key] = row[key]; } }); return reordered; }); // 전역 저장소에 데이터 저장 if (tableConfig.selectedTable) { // 컬럼 라벨 매핑 생성 (tableConfig.columns 사용 - visibleColumns는 아직 정의되지 않음) const cols = (tableConfig.columns || []).filter((col) => col.visible !== false); const labels: Record = {}; cols.forEach((col) => { labels[col.columnName] = columnLabels[col.columnName] || col.columnName; }); tableDisplayStore.setTableData( tableConfig.selectedTable, initialData, parsedOrder.filter((col) => col !== "__checkbox__"), sortColumn, sortDirection, { filterConditions: Object.keys(searchValues).length > 0 ? searchValues : undefined, searchTerm: searchTerm || undefined, visibleColumns: cols.map((col) => col.columnName), columnLabels: labels, currentPage: currentPage, pageSize: localPageSize, totalItems: totalItems, }, ); } onSelectedRowsChange([], [], sortColumn, sortDirection, parsedOrder, initialData); } } catch (error) { console.error("❌ 컬럼 순서 파싱 실패:", error); } } }, [tableConfig.selectedTable, userId, data.length]); // data.length 추가 (데이터 로드 후 실행) const { optimizedConvertCode } = useEntityJoinOptimization(columnMeta, { enableBatchLoading: true, preloadCommonCodes: true, maxBatchSize: 5, }); // ======================================== // 컬럼 라벨 가져오기 // ======================================== const fetchColumnLabels = useCallback(async () => { if (!tableConfig.selectedTable) return; try { // 🔥 FIX: 캐시 키에 회사 코드 포함 (멀티테넌시 지원) const currentUser = JSON.parse(localStorage.getItem("currentUser") || "{}"); const companyCode = currentUser.companyCode || "UNKNOWN"; const cacheKey = `columns_${tableConfig.selectedTable}_${companyCode}`; const cached = tableColumnCache.get(cacheKey); if (cached && Date.now() - cached.timestamp < TABLE_CACHE_TTL) { const labels: Record = {}; const meta: Record = {}; // 캐시된 inputTypes 맵 생성 const inputTypeMap: Record = {}; if (cached.inputTypes) { cached.inputTypes.forEach((col: any) => { inputTypeMap[col.columnName] = col.inputType; }); } cached.columns.forEach((col: any) => { labels[col.columnName] = col.displayName || col.comment || col.columnName; meta[col.columnName] = { webType: col.webType, codeCategory: col.codeCategory, inputType: inputTypeMap[col.columnName], // 캐시된 inputType 사용! }; }); setColumnLabels(labels); setColumnMeta(meta); return; } const columns = await tableTypeApi.getColumns(tableConfig.selectedTable); // 컬럼 입력 타입 정보 가져오기 const inputTypes = await tableTypeApi.getColumnInputTypes(tableConfig.selectedTable); const inputTypeMap: Record = {}; inputTypes.forEach((col: any) => { inputTypeMap[col.columnName] = col.inputType; }); tableColumnCache.set(cacheKey, { columns, inputTypes, timestamp: Date.now(), }); const labels: Record = {}; const meta: Record = {}; columns.forEach((col: any) => { labels[col.columnName] = col.displayName || col.comment || col.columnName; meta[col.columnName] = { webType: col.webType, codeCategory: col.codeCategory, inputType: inputTypeMap[col.columnName], }; }); setColumnLabels(labels); setColumnMeta(meta); } catch (error) { console.error("컬럼 라벨 가져오기 실패:", error); } }, [tableConfig.selectedTable]); // ======================================== // 테이블 라벨 가져오기 // ======================================== const fetchTableLabel = useCallback(async () => { if (!tableConfig.selectedTable) return; try { const cacheKey = `table_info_${tableConfig.selectedTable}`; const cached = tableInfoCache.get(cacheKey); if (cached && Date.now() - cached.timestamp < TABLE_CACHE_TTL) { const tables = cached.tables || []; const tableInfo = tables.find((t: any) => t.tableName === tableConfig.selectedTable); const label = tableInfo?.displayName || (tableInfo as any)?.comment || tableInfo?.description || tableConfig.selectedTable; setTableLabel(label); return; } const tables = await tableTypeApi.getTables(); tableInfoCache.set(cacheKey, { tables, timestamp: Date.now(), }); const tableInfo = tables.find((t: any) => t.tableName === tableConfig.selectedTable); const label = tableInfo?.displayName || (tableInfo as any)?.comment || tableInfo?.description || tableConfig.selectedTable; setTableLabel(label); } catch (error) { console.error("테이블 라벨 가져오기 실패:", error); } }, [tableConfig.selectedTable]); // ======================================== // 카테고리 값 매핑 로드 // ======================================== // 카테고리 컬럼 목록 추출 (useMemo로 최적화) const categoryColumns = useMemo(() => { const cols = Object.entries(columnMeta) .filter(([_, meta]) => meta.inputType === "category") .map(([columnName, _]) => columnName); return cols; }, [columnMeta]); // 카테고리 매핑 로드 (columnMeta 변경 시 즉시 실행) useEffect(() => { const loadCategoryMappings = async () => { if (!tableConfig.selectedTable) { return; } if (categoryColumns.length === 0) { setCategoryMappings({}); return; } try { const mappings: Record> = {}; const apiClient = (await import("@/lib/api/client")).apiClient; for (const columnName of categoryColumns) { try { // 🆕 엔티티 조인 컬럼 처리: "테이블명.컬럼명" 형태인지 확인 let targetTable = tableConfig.selectedTable; let targetColumn = columnName; if (columnName.includes(".")) { const parts = columnName.split("."); targetTable = parts[0]; // 조인된 테이블명 (예: item_info) targetColumn = parts[1]; // 실제 컬럼명 (예: material) console.log(`🔗 [TableList] 엔티티 조인 컬럼 감지:`, { originalColumn: columnName, targetTable, targetColumn, }); } console.log(`📡 [TableList] API 호출 시작 [${columnName}]:`, { url: `/table-categories/${targetTable}/${targetColumn}/values`, }); const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values`); console.log(`📡 [TableList] API 응답 [${columnName}]:`, { success: response.data.success, dataLength: response.data.data?.length, rawData: response.data, items: response.data.data, }); if (response.data.success && response.data.data && Array.isArray(response.data.data)) { const mapping: Record = {}; response.data.data.forEach((item: any) => { // valueCode를 문자열로 변환하여 키로 사용 const key = String(item.valueCode); mapping[key] = { label: item.valueLabel, color: item.color, }; console.log(` 🔑 [${columnName}] "${key}" => "${item.valueLabel}" (색상: ${item.color})`); }); if (Object.keys(mapping).length > 0) { // 🆕 원래 컬럼명(item_info.material)으로 매핑 저장 mappings[columnName] = mapping; console.log(`✅ [TableList] 카테고리 매핑 로드 완료 [${columnName}]:`, { columnName, mappingCount: Object.keys(mapping).length, mappingKeys: Object.keys(mapping), mapping, }); } else { console.warn(`⚠️ [TableList] 매핑 데이터가 비어있음 [${columnName}]`); } } else { console.warn(`⚠️ [TableList] 카테고리 값 없음 [${columnName}]:`, { success: response.data.success, hasData: !!response.data.data, isArray: Array.isArray(response.data.data), response: response.data, }); } } catch (error: any) { console.error(`❌ [TableList] 카테고리 값 로드 실패 [${columnName}]:`, { error: error.message, stack: error.stack, response: error.response?.data, status: error.response?.status, }); } } // 🆕 엔티티 조인 컬럼의 inputType 정보 가져오기 및 카테고리 매핑 로드 // 1. "테이블명.컬럼명" 형태의 조인 컬럼 추출 const joinedColumns = tableConfig.columns ?.filter((col) => col.columnName?.includes(".")) .map((col) => col.columnName) || []; // 2. additionalJoinInfo가 있는 컬럼도 추출 (예: item_code_material → item_info.material) const additionalJoinColumns = tableConfig.columns ?.filter((col: any) => col.additionalJoinInfo?.referenceTable) .map((col: any) => ({ columnName: col.columnName, // 예: item_code_material referenceTable: col.additionalJoinInfo.referenceTable, // 예: item_info // joinAlias에서 실제 컬럼명 추출 (item_code_material → material) actualColumn: col.additionalJoinInfo.joinAlias?.replace(`${col.additionalJoinInfo.sourceColumn}_`, '') || col.columnName, })) || []; console.log("🔍 [TableList] additionalJoinInfo 컬럼:", additionalJoinColumns); // 조인 테이블별로 그룹화 const joinedTableColumns: Record = {}; // "테이블명.컬럼명" 형태 처리 for (const joinedColumn of joinedColumns) { const parts = joinedColumn.split("."); if (parts.length !== 2) continue; const joinedTable = parts[0]; const joinedColumnName = parts[1]; if (!joinedTableColumns[joinedTable]) { joinedTableColumns[joinedTable] = []; } joinedTableColumns[joinedTable].push({ columnName: joinedColumn, actualColumn: joinedColumnName, }); } // additionalJoinInfo 형태 처리 for (const col of additionalJoinColumns) { if (!joinedTableColumns[col.referenceTable]) { joinedTableColumns[col.referenceTable] = []; } joinedTableColumns[col.referenceTable].push({ columnName: col.columnName, // 예: item_code_material actualColumn: col.actualColumn, // 예: material }); } console.log("🔍 [TableList] 조인 테이블별 컬럼:", joinedTableColumns); // 조인된 테이블별로 inputType 정보 가져오기 const newJoinedColumnMeta: Record = {}; for (const [joinedTable, columns] of Object.entries(joinedTableColumns)) { try { // 조인 테이블의 컬럼 inputType 정보 가져오기 (이미 import된 tableTypeApi 사용) const inputTypes = await tableTypeApi.getColumnInputTypes(joinedTable); console.log(`📡 [TableList] 조인 테이블 inputType 로드 [${joinedTable}]:`, inputTypes); for (const col of columns) { const inputTypeInfo = inputTypes.find((it: any) => it.columnName === col.actualColumn); // 컬럼명 그대로 저장 (item_code_material 또는 item_info.material) newJoinedColumnMeta[col.columnName] = { inputType: inputTypeInfo?.inputType, }; console.log(` 🔗 [${col.columnName}] (실제: ${col.actualColumn}) inputType: ${inputTypeInfo?.inputType || "unknown"}`); // inputType이 category인 경우 카테고리 매핑 로드 if (inputTypeInfo?.inputType === "category" && !mappings[col.columnName]) { try { console.log(`📡 [TableList] 조인 테이블 카테고리 로드 시도 [${col.columnName}]:`, { url: `/table-categories/${joinedTable}/${col.actualColumn}/values`, }); const response = await apiClient.get(`/table-categories/${joinedTable}/${col.actualColumn}/values`); if (response.data.success && response.data.data && Array.isArray(response.data.data)) { const mapping: Record = {}; response.data.data.forEach((item: any) => { const key = String(item.valueCode); mapping[key] = { label: item.valueLabel, color: item.color, }; }); if (Object.keys(mapping).length > 0) { mappings[col.columnName] = mapping; console.log(`✅ [TableList] 조인 테이블 카테고리 매핑 로드 완료 [${col.columnName}]:`, { mappingCount: Object.keys(mapping).length, }); } } } catch (error) { console.log(`ℹ️ [TableList] 조인 테이블 카테고리 없음 (${col.columnName})`); } } } } catch (error) { console.error(`❌ [TableList] 조인 테이블 inputType 로드 실패 [${joinedTable}]:`, error); } } // 조인 컬럼 메타데이터 상태 업데이트 if (Object.keys(newJoinedColumnMeta).length > 0) { setJoinedColumnMeta(newJoinedColumnMeta); console.log("✅ [TableList] 조인 컬럼 메타데이터 설정:", newJoinedColumnMeta); } console.log("📊 [TableList] 전체 카테고리 매핑 설정:", { mappingsCount: Object.keys(mappings).length, mappingsKeys: Object.keys(mappings), mappings, }); if (Object.keys(mappings).length > 0) { setCategoryMappings(mappings); setCategoryMappingsKey((prev) => prev + 1); console.log("✅ [TableList] setCategoryMappings 호출 완료"); } else { console.warn("⚠️ [TableList] 매핑이 비어있어 상태 업데이트 스킵"); } } catch (error) { console.error("❌ [TableList] 카테고리 매핑 로드 실패:", error); } }; loadCategoryMappings(); }, [tableConfig.selectedTable, categoryColumns.length, JSON.stringify(categoryColumns), JSON.stringify(tableConfig.columns)]); // 더 명확한 의존성 // ======================================== // 데이터 가져오기 // ======================================== const fetchTableDataInternal = useCallback(async () => { console.log("📡 [TableList] fetchTableDataInternal 호출됨", { tableName: tableConfig.selectedTable, isDesignMode, currentPage, }); if (!tableConfig.selectedTable || isDesignMode) { setData([]); setTotalPages(0); setTotalItems(0); return; } setLoading(true); setError(null); try { const page = tableConfig.pagination?.currentPage || currentPage; const pageSize = localPageSize; const sortBy = sortColumn || undefined; const sortOrder = sortDirection; const search = searchTerm || undefined; // 🆕 연결 필터 값 가져오기 (분할 패널 내부일 때) let linkedFilterValues: Record = {}; let hasLinkedFiltersConfigured = false; // 연결 필터가 설정되어 있는지 여부 let hasSelectedLeftData = false; // 좌측에서 데이터가 선택되었는지 여부 console.log("🔍 [TableList] 분할 패널 컨텍스트 확인:", { hasSplitPanelContext: !!splitPanelContext, tableName: tableConfig.selectedTable, selectedLeftData: splitPanelContext?.selectedLeftData, linkedFilters: splitPanelContext?.linkedFilters, }); if (splitPanelContext) { // 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지) const linkedFiltersConfig = splitPanelContext.linkedFilters || []; hasLinkedFiltersConfigured = linkedFiltersConfig.some( (filter) => filter.targetColumn?.startsWith(tableConfig.selectedTable + ".") || filter.targetColumn === tableConfig.selectedTable ); // 좌측 데이터 선택 여부 확인 hasSelectedLeftData = splitPanelContext.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0; const allLinkedFilters = splitPanelContext.getLinkedFilterValues(); console.log("🔗 [TableList] 연결 필터 원본:", allLinkedFilters); // 현재 테이블에 해당하는 필터만 추출 (테이블명.컬럼명 형식에서) for (const [key, value] of Object.entries(allLinkedFilters)) { if (key.includes(".")) { const [tableName, columnName] = key.split("."); if (tableName === tableConfig.selectedTable) { linkedFilterValues[columnName] = value; hasLinkedFiltersConfigured = true; // 이 테이블에 대한 필터가 있음 } } else { // 테이블명 없이 컬럼명만 있는 경우 그대로 사용 linkedFilterValues[key] = value; } } if (Object.keys(linkedFilterValues).length > 0) { console.log("🔗 [TableList] 연결 필터 적용:", linkedFilterValues); } } // 🆕 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우 // → 빈 데이터 표시 (모든 데이터를 보여주지 않음) if (hasLinkedFiltersConfigured && !hasSelectedLeftData) { console.log("⚠️ [TableList] 연결 필터 설정됨 but 좌측 데이터 미선택 → 빈 데이터 표시"); setData([]); setTotalItems(0); setLoading(false); return; } // 검색 필터와 연결 필터 병합 const filters = { ...(Object.keys(searchValues).length > 0 ? searchValues : {}), ...linkedFilterValues, }; const hasFilters = Object.keys(filters).length > 0; // 🆕 REST API 데이터 소스 처리 const isRestApiTable = tableConfig.selectedTable.startsWith("restapi_") || tableConfig.selectedTable.startsWith("_restapi_"); let response: any; if (isRestApiTable) { // REST API 데이터 소스인 경우 const connectionIdMatch = tableConfig.selectedTable.match(/restapi_(\d+)/); const connectionId = connectionIdMatch ? parseInt(connectionIdMatch[1]) : null; if (connectionId) { console.log("🌐 [TableList] REST API 데이터 소스 호출", { connectionId }); // REST API 연결 정보 가져오기 및 데이터 조회 const { ExternalRestApiConnectionAPI } = await import("@/lib/api/externalRestApiConnection"); const restApiData = await ExternalRestApiConnectionAPI.fetchData( connectionId, undefined, // endpoint - 연결 정보에서 가져옴 "response", // jsonPath - 기본값 response ); response = { data: restApiData.rows || [], total: restApiData.total || restApiData.rows?.length || 0, totalPages: Math.ceil((restApiData.total || restApiData.rows?.length || 0) / pageSize), }; console.log("✅ [TableList] REST API 응답:", { dataLength: response.data.length, total: response.total }); } else { throw new Error("REST API 연결 ID를 찾을 수 없습니다."); } } else { // 일반 DB 테이블인 경우 (기존 로직) const entityJoinColumns = (tableConfig.columns || []) .filter((col) => col.additionalJoinInfo) .map((col) => ({ sourceTable: col.additionalJoinInfo!.sourceTable, sourceColumn: col.additionalJoinInfo!.sourceColumn, joinAlias: col.additionalJoinInfo!.joinAlias, referenceTable: col.additionalJoinInfo!.referenceTable, })); // 🎯 화면별 엔티티 표시 설정 수집 const screenEntityConfigs: Record = {}; (tableConfig.columns || []) .filter((col) => col.entityDisplayConfig && col.entityDisplayConfig.displayColumns?.length > 0) .forEach((col) => { screenEntityConfigs[col.columnName] = { displayColumns: col.entityDisplayConfig!.displayColumns, separator: col.entityDisplayConfig!.separator || " - ", sourceTable: col.entityDisplayConfig!.sourceTable || tableConfig.selectedTable, joinTable: col.entityDisplayConfig!.joinTable, }; }); console.log("🎯 [TableList] 화면별 엔티티 설정:", screenEntityConfigs); // 🆕 제외 필터 처리 (다른 테이블에 이미 존재하는 데이터 제외) let excludeFilterParam: any = undefined; if (tableConfig.excludeFilter?.enabled) { const excludeConfig = tableConfig.excludeFilter; let filterValue: any = undefined; // 필터 값 소스에 따라 값 가져오기 (우선순위: formData > URL > 분할패널) if (excludeConfig.filterColumn && excludeConfig.filterValueField) { const fieldName = excludeConfig.filterValueField; // 1순위: props로 전달받은 formData에서 값 가져오기 (모달에서 사용) if (propFormData && propFormData[fieldName]) { filterValue = propFormData[fieldName]; console.log("🔗 [TableList] formData에서 excludeFilter 값 가져오기:", { field: fieldName, value: filterValue, }); } // 2순위: URL 파라미터에서 값 가져오기 else if (typeof window !== "undefined") { const urlParams = new URLSearchParams(window.location.search); filterValue = urlParams.get(fieldName); if (filterValue) { console.log("🔗 [TableList] URL에서 excludeFilter 값 가져오기:", { field: fieldName, value: filterValue, }); } } // 3순위: 분할 패널 부모 데이터에서 값 가져오기 if (!filterValue && splitPanelContext?.selectedLeftData) { filterValue = splitPanelContext.selectedLeftData[fieldName]; if (filterValue) { console.log("🔗 [TableList] 분할패널에서 excludeFilter 값 가져오기:", { field: fieldName, value: filterValue, }); } } } if (filterValue || !excludeConfig.filterColumn) { excludeFilterParam = { enabled: true, referenceTable: excludeConfig.referenceTable, referenceColumn: excludeConfig.referenceColumn, sourceColumn: excludeConfig.sourceColumn, filterColumn: excludeConfig.filterColumn, filterValue: filterValue, }; console.log("🚫 [TableList] 제외 필터 적용:", excludeFilterParam); } } // 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원) response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, { page, size: pageSize, sortBy, sortOrder, search: hasFilters ? filters : undefined, enableEntityJoin: true, additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined, screenEntityConfigs: Object.keys(screenEntityConfigs).length > 0 ? screenEntityConfigs : undefined, // 🎯 화면별 엔티티 설정 전달 dataFilter: tableConfig.dataFilter, // 🆕 데이터 필터 전달 excludeFilter: excludeFilterParam, // 🆕 제외 필터 전달 }); // 실제 데이터의 item_number만 추출하여 중복 확인 const itemNumbers = (response.data || []).map((item: any) => item.item_number); const uniqueItemNumbers = [...new Set(itemNumbers)]; // console.log("✅ [TableList] API 응답 받음"); // console.log(` - dataLength: ${response.data?.length || 0}`); // console.log(` - total: ${response.total}`); // console.log(` - itemNumbers: ${JSON.stringify(itemNumbers)}`); // console.log(` - uniqueItemNumbers: ${JSON.stringify(uniqueItemNumbers)}`); // console.log(` - isDuplicated: ${itemNumbers.length !== uniqueItemNumbers.length}`); setData(response.data || []); setTotalPages(response.totalPages || 0); setTotalItems(response.total || 0); setError(null); // 🎯 Store에 필터 조건 저장 (엑셀 다운로드용) // tableConfig.columns 사용 (visibleColumns는 이 시점에서 아직 정의되지 않을 수 있음) const cols = (tableConfig.columns || []).filter((col) => col.visible !== false); const labels: Record = {}; cols.forEach((col) => { labels[col.columnName] = columnLabels[col.columnName] || col.columnName; }); tableDisplayStore.setTableData( tableConfig.selectedTable, response.data || [], cols.map((col) => col.columnName), sortBy, sortOrder, { filterConditions: filters, searchTerm: search, visibleColumns: cols.map((col) => col.columnName), columnLabels: labels, currentPage: page, pageSize: pageSize, totalItems: response.total || 0, } ); } } catch (err: any) { console.error("데이터 가져오기 실패:", err); setData([]); setTotalPages(0); setTotalItems(0); setError(err.message || "데이터를 불러오지 못했습니다."); } finally { setLoading(false); } }, [ tableConfig.selectedTable, tableConfig.pagination?.currentPage, tableConfig.columns, currentPage, localPageSize, sortColumn, sortDirection, searchTerm, searchValues, isDesignMode, splitPanelContext?.selectedLeftData, // 🆕 연결 필터 변경 시 재조회 ]); const fetchTableDataDebounced = useCallback( (...args: Parameters) => { const key = `fetchData_${tableConfig.selectedTable}_${currentPage}_${sortColumn}_${sortDirection}`; return debouncedApiCall(key, fetchTableDataInternal, 300)(...args); }, [fetchTableDataInternal, tableConfig.selectedTable, currentPage, sortColumn, sortDirection], ); // ======================================== // 이벤트 핸들러 // ======================================== const handlePageChange = (newPage: number) => { if (newPage < 1 || newPage > totalPages) return; setCurrentPage(newPage); if (tableConfig.pagination) { tableConfig.pagination.currentPage = newPage; } if (onConfigChange) { onConfigChange({ ...tableConfig, pagination: { ...tableConfig.pagination, currentPage: newPage } }); } }; const handleSort = (column: string) => { console.log("🔄 정렬 클릭:", { column, currentSortColumn: sortColumn, currentSortDirection: sortDirection }); let newSortColumn = column; let newSortDirection: "asc" | "desc" = "asc"; if (sortColumn === column) { newSortDirection = sortDirection === "asc" ? "desc" : "asc"; setSortDirection(newSortDirection); } else { setSortColumn(column); setSortDirection("asc"); newSortColumn = column; newSortDirection = "asc"; } // 🎯 정렬 상태를 localStorage에 저장 (사용자별) if (tableConfig.selectedTable && userId) { const storageKey = `table_sort_state_${tableConfig.selectedTable}_${userId}`; try { localStorage.setItem(storageKey, JSON.stringify({ column: newSortColumn, direction: newSortDirection })); console.log("💾 정렬 상태 저장:", { column: newSortColumn, direction: newSortDirection }); } catch (error) { console.error("❌ 정렬 상태 저장 실패:", error); } } console.log("📊 새로운 정렬 정보:", { newSortColumn, newSortDirection }); console.log("🔍 onSelectedRowsChange 존재 여부:", !!onSelectedRowsChange); // 정렬 변경 시 선택 정보와 함께 정렬 정보도 전달 if (onSelectedRowsChange) { const selectedRowsData = data.filter((row, index) => selectedRows.has(getRowKey(row, index))); // 1단계: 데이터를 정렬 const sortedData = [...data].sort((a, b) => { const aVal = a[newSortColumn]; const bVal = b[newSortColumn]; // null/undefined 처리 if (aVal == null && bVal == null) return 0; if (aVal == null) return 1; if (bVal == null) return -1; // 숫자 비교 (문자열이어도 숫자로 변환 가능하면 숫자로 비교) const aNum = Number(aVal); const bNum = Number(bVal); // 둘 다 유효한 숫자이고, 원본 값이 빈 문자열이 아닌 경우 if (!isNaN(aNum) && !isNaN(bNum) && aVal !== "" && bVal !== "") { return newSortDirection === "desc" ? bNum - aNum : aNum - bNum; } // 문자열 비교 (대소문자 구분 없이, 숫자 포함 문자열도 자연스럽게 정렬) const aStr = String(aVal).toLowerCase(); const bStr = String(bVal).toLowerCase(); // 자연스러운 정렬 (숫자 포함 문자열) const comparison = aStr.localeCompare(bStr, undefined, { numeric: true, sensitivity: "base" }); return newSortDirection === "desc" ? -comparison : comparison; }); // 2단계: 정렬된 데이터를 컬럼 순서대로 재정렬 // tableConfig.columns 사용 (visibleColumns는 이 시점에서 아직 정의되지 않을 수 있음) const cols = (tableConfig.columns || []).filter((col) => col.visible !== false); const reorderedData = sortedData.map((row: any) => { const reordered: any = {}; cols.forEach((col) => { if (col.columnName in row) { reordered[col.columnName] = row[col.columnName]; } }); // 나머지 컬럼 추가 Object.keys(row).forEach((key) => { if (!(key in reordered)) { reordered[key] = row[key]; } }); return reordered; }); console.log("✅ 정렬 정보 전달:", { selectedRowsCount: selectedRows.size, selectedRowsDataCount: selectedRowsData.length, sortBy: newSortColumn, sortOrder: newSortDirection, columnOrder: columnOrder.length > 0 ? columnOrder : undefined, tableDisplayDataCount: reorderedData.length, firstRowAfterSort: reorderedData[0]?.[newSortColumn], lastRowAfterSort: reorderedData[reorderedData.length - 1]?.[newSortColumn], }); onSelectedRowsChange( Array.from(selectedRows), selectedRowsData, newSortColumn, newSortDirection, columnOrder.length > 0 ? columnOrder : undefined, reorderedData, ); // 전역 저장소에 정렬된 데이터 저장 if (tableConfig.selectedTable) { const cleanColumnOrder = ( columnOrder.length > 0 ? columnOrder : cols.map((c) => c.columnName) ).filter((col) => col !== "__checkbox__"); // 컬럼 라벨 정보도 함께 저장 const labels: Record = {}; cols.forEach((col) => { labels[col.columnName] = columnLabels[col.columnName] || col.columnName; }); tableDisplayStore.setTableData( tableConfig.selectedTable, reorderedData, cleanColumnOrder, newSortColumn, newSortDirection, { filterConditions: Object.keys(searchValues).length > 0 ? searchValues : undefined, searchTerm: searchTerm || undefined, visibleColumns: cols.map((col) => col.columnName), columnLabels: labels, currentPage: currentPage, pageSize: localPageSize, totalItems: totalItems, }, ); } } else { console.warn("⚠️ onSelectedRowsChange 콜백이 없습니다!"); } }; const handleSearchValueChange = (columnName: string, value: any) => { setSearchValues((prev) => ({ ...prev, [columnName]: value })); }; const handleAdvancedSearch = () => { setCurrentPage(1); fetchTableDataDebounced(); }; const handleClearAdvancedFilters = useCallback(() => { console.log("🔄 필터 초기화 시작", { 이전searchValues: searchValues }); // 상태를 초기화하고 useEffect로 데이터 새로고침 setSearchValues({}); setCurrentPage(1); // 강제로 데이터 새로고침 트리거 setRefreshTrigger((prev) => prev + 1); }, [searchValues]); const handleRefresh = () => { fetchTableDataDebounced(); }; const getRowKey = (row: any, index: number) => { return row.id || row.uuid || `row-${index}`; }; const handleRowSelection = (rowKey: string, checked: boolean) => { const newSelectedRows = new Set(selectedRows); if (checked) { newSelectedRows.add(rowKey); } else { newSelectedRows.delete(rowKey); } setSelectedRows(newSelectedRows); const selectedRowsData = data.filter((row, index) => newSelectedRows.has(getRowKey(row, index))); if (onSelectedRowsChange) { onSelectedRowsChange(Array.from(newSelectedRows), selectedRowsData, sortColumn || undefined, sortDirection); } if (onFormDataChange) { onFormDataChange({ selectedRows: Array.from(newSelectedRows), selectedRowsData, }); } // 🆕 modalDataStore에 선택된 데이터 자동 저장 (테이블명 기반 dataSourceId) if (tableConfig.selectedTable && selectedRowsData.length > 0) { import("@/stores/modalDataStore").then(({ useModalDataStore }) => { const modalItems = selectedRowsData.map((row, idx) => ({ id: getRowKey(row, idx), originalData: row, additionalData: {}, })); useModalDataStore.getState().setData(tableConfig.selectedTable!, modalItems); console.log("✅ [TableList] modalDataStore에 데이터 저장:", { dataSourceId: tableConfig.selectedTable, count: modalItems.length, }); }); } else if (tableConfig.selectedTable && selectedRowsData.length === 0) { // 선택 해제 시 데이터 제거 import("@/stores/modalDataStore").then(({ useModalDataStore }) => { useModalDataStore.getState().clearData(tableConfig.selectedTable!); console.log("🗑️ [TableList] modalDataStore 데이터 제거:", tableConfig.selectedTable); }); } const allRowsSelected = filteredData.every((row, index) => newSelectedRows.has(getRowKey(row, index))); setIsAllSelected(allRowsSelected && filteredData.length > 0); }; const handleSelectAll = (checked: boolean) => { if (checked) { const allKeys = filteredData.map((row, index) => getRowKey(row, index)); const newSelectedRows = new Set(allKeys); setSelectedRows(newSelectedRows); setIsAllSelected(true); if (onSelectedRowsChange) { onSelectedRowsChange(Array.from(newSelectedRows), filteredData, sortColumn || undefined, sortDirection); } if (onFormDataChange) { onFormDataChange({ selectedRows: Array.from(newSelectedRows), selectedRowsData: filteredData, }); } // 🆕 modalDataStore에 전체 데이터 저장 if (tableConfig.selectedTable && filteredData.length > 0) { import("@/stores/modalDataStore").then(({ useModalDataStore }) => { const modalItems = filteredData.map((row, idx) => ({ id: getRowKey(row, idx), originalData: row, additionalData: {}, })); useModalDataStore.getState().setData(tableConfig.selectedTable!, modalItems); console.log("✅ [TableList] modalDataStore에 전체 데이터 저장:", { dataSourceId: tableConfig.selectedTable, count: modalItems.length, }); }); } } else { setSelectedRows(new Set()); setIsAllSelected(false); if (onSelectedRowsChange) { onSelectedRowsChange([], [], sortColumn || undefined, sortDirection); } if (onFormDataChange) { onFormDataChange({ selectedRows: [], selectedRowsData: [] }); } // 🆕 modalDataStore 데이터 제거 if (tableConfig.selectedTable) { import("@/stores/modalDataStore").then(({ useModalDataStore }) => { useModalDataStore.getState().clearData(tableConfig.selectedTable!); console.log("🗑️ [TableList] modalDataStore 전체 데이터 제거:", tableConfig.selectedTable); }); } } }; const handleRowClick = (row: any, index: number, e: React.MouseEvent) => { // 체크박스 클릭은 무시 (이미 handleRowSelection에서 처리됨) const target = e.target as HTMLElement; if (target.closest('input[type="checkbox"]')) { return; } // 행 선택/해제 토글 const rowKey = getRowKey(row, index); const isCurrentlySelected = selectedRows.has(rowKey); handleRowSelection(rowKey, !isCurrentlySelected); // 🆕 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우) // disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달) if (splitPanelContext && splitPanelPosition === "left" && !splitPanelContext.disableAutoDataTransfer) { if (!isCurrentlySelected) { // 선택된 경우: 데이터 저장 splitPanelContext.setSelectedLeftData(row); console.log("🔗 [TableList] 분할 패널 좌측 데이터 저장:", { row, parentDataMapping: splitPanelContext.parentDataMapping, }); } else { // 선택 해제된 경우: 데이터 초기화 splitPanelContext.setSelectedLeftData(null); console.log("🔗 [TableList] 분할 패널 좌측 데이터 초기화"); } } console.log("행 클릭:", { row, index, isSelected: !isCurrentlySelected }); }; // 🆕 셀 클릭 핸들러 (포커스 설정) const handleCellClick = (rowIndex: number, colIndex: number, e: React.MouseEvent) => { e.stopPropagation(); setFocusedCell({ rowIndex, colIndex }); // 테이블 컨테이너에 포커스 설정 (키보드 이벤트 수신용) tableContainerRef.current?.focus(); }; // 🆕 셀 더블클릭 핸들러 (편집 모드 진입) - visibleColumns 정의 후 사용 const handleCellDoubleClick = useCallback((rowIndex: number, colIndex: number, columnName: string, value: any) => { // 체크박스 컬럼은 편집 불가 if (columnName === "__checkbox__") return; // 🆕 편집 불가 컬럼 체크 const column = visibleColumns.find((col) => col.columnName === columnName); if (column?.editable === false) { toast.warning(`'${column.displayName || columnName}' 컬럼은 편집할 수 없습니다.`); return; } setEditingCell({ rowIndex, colIndex, columnName, originalValue: value }); setEditingValue(value !== null && value !== undefined ? String(value) : ""); setFocusedCell({ rowIndex, colIndex }); }, [visibleColumns]); // 🆕 편집 모드 진입 placeholder (실제 구현은 visibleColumns 정의 후) const startEditingRef = useRef<() => void>(() => {}); // 🆕 각 컬럼의 고유값 목록 계산 const columnUniqueValues = useMemo(() => { const result: Record = {}; if (data.length === 0) return result; (tableConfig.columns || []).forEach((column: { columnName: string }) => { if (column.columnName === "__checkbox__") return; const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; const values = new Set(); data.forEach((row) => { const val = row[mappedColumnName]; if (val !== null && val !== undefined && val !== "") { values.add(String(val)); } }); result[column.columnName] = Array.from(values).sort(); }); return result; }, [data, tableConfig.columns, joinColumnMapping]); // 🆕 헤더 필터 토글 const toggleHeaderFilter = useCallback((columnName: string, value: string) => { setHeaderFilters((prev) => { const current = prev[columnName] || new Set(); const newSet = new Set(current); if (newSet.has(value)) { newSet.delete(value); } else { newSet.add(value); } return { ...prev, [columnName]: newSet }; }); }, []); // 🆕 헤더 필터 초기화 const clearHeaderFilter = useCallback((columnName: string) => { setHeaderFilters((prev) => { const newFilters = { ...prev }; delete newFilters[columnName]; return newFilters; }); setOpenFilterColumn(null); }, []); // 🆕 모든 헤더 필터 초기화 const clearAllHeaderFilters = useCallback(() => { setHeaderFilters({}); setOpenFilterColumn(null); }, []); // 🆕 데이터 요약 (Total Summaries) 설정 // 형식: { columnName: { type: 'sum' | 'avg' | 'count' | 'min' | 'max', label?: string } } const summaryConfig = useMemo(() => { const config: Record = {}; // tableConfig에서 summary 설정 읽기 if (tableConfig.summaries) { tableConfig.summaries.forEach((summary: { columnName: string; type: string; label?: string }) => { config[summary.columnName] = { type: summary.type, label: summary.label }; }); } return config; }, [tableConfig.summaries]); // 🆕 요약 데이터 계산 const summaryData = useMemo(() => { if (Object.keys(summaryConfig).length === 0 || data.length === 0) { return null; } const result: Record = {}; Object.entries(summaryConfig).forEach(([columnName, config]) => { const values = data .map((row) => { const mappedColumnName = joinColumnMapping[columnName] || columnName; const val = row[mappedColumnName]; return typeof val === "number" ? val : parseFloat(val); }) .filter((v) => !isNaN(v)); let value: number | string = 0; let label = config.label || ""; switch (config.type) { case "sum": value = values.reduce((acc, v) => acc + v, 0); label = label || "합계"; break; case "avg": value = values.length > 0 ? values.reduce((acc, v) => acc + v, 0) / values.length : 0; label = label || "평균"; break; case "count": value = data.length; label = label || "개수"; break; case "min": value = values.length > 0 ? Math.min(...values) : 0; label = label || "최소"; break; case "max": value = values.length > 0 ? Math.max(...values) : 0; label = label || "최대"; break; default: value = 0; } result[columnName] = { value, label }; }); return result; }, [data, summaryConfig, joinColumnMapping]); // 🆕 편집 취소 const cancelEditing = useCallback(() => { setEditingCell(null); setEditingValue(""); tableContainerRef.current?.focus(); }, []); // 🆕 편집 저장 (즉시 저장 또는 배치 저장) const saveEditing = useCallback(async () => { if (!editingCell) return; const { rowIndex, columnName, originalValue } = editingCell; const newValue = editingValue; // 값이 변경되지 않았으면 그냥 닫기 if (String(originalValue ?? "") === newValue) { setCellValidationError(rowIndex, columnName, null); // 에러 초기화 cancelEditing(); return; } // 현재 행 데이터 가져오기 const row = data[rowIndex]; if (!row || !tableConfig.selectedTable) { cancelEditing(); return; } // 🆕 유효성 검사 실행 const validationError = validateValue(newValue === "" ? null : newValue, columnName, row); if (validationError) { setCellValidationError(rowIndex, columnName, validationError); toast.error(validationError); // 편집 상태 유지 (에러 수정 가능하도록) return; } // 유효성 통과 시 에러 초기화 setCellValidationError(rowIndex, columnName, null); // 기본 키 필드 찾기 (id 또는 첫 번째 컬럼) const primaryKeyField = tableConfig.primaryKey || "id"; const primaryKeyValue = row[primaryKeyField]; if (primaryKeyValue === undefined || primaryKeyValue === null) { console.error("기본 키 값을 찾을 수 없습니다:", primaryKeyField); cancelEditing(); return; } // 🆕 배치 모드: 변경사항을 pending에 저장 if (editMode === "batch") { const changeKey = `${rowIndex}-${columnName}`; setPendingChanges((prev) => { const newMap = new Map(prev); newMap.set(changeKey, { rowIndex, columnName, originalValue, newValue: newValue === "" ? null : newValue, primaryKeyValue, }); return newMap; }); // 로컬 수정 데이터 업데이트 (화면 표시용) setLocalEditedData((prev) => ({ ...prev, [rowIndex]: { ...(prev[rowIndex] || {}), [columnName]: newValue === "" ? null : newValue, }, })); console.log("📝 배치 편집 추가:", { columnName, newValue, pendingCount: pendingChanges.size + 1 }); cancelEditing(); return; } // 🆕 즉시 모드: 바로 저장 try { const { apiClient } = await import("@/lib/api/client"); await apiClient.put(`/dynamic-form/update-field`, { tableName: tableConfig.selectedTable, keyField: primaryKeyField, keyValue: primaryKeyValue, updateField: columnName, updateValue: newValue === "" ? null : newValue, }); // 데이터 새로고침 트리거 setRefreshTrigger((prev) => prev + 1); console.log("✅ 셀 편집 저장 완료:", { columnName, newValue }); } catch (error) { console.error("❌ 셀 편집 저장 실패:", error); } cancelEditing(); }, [editingCell, editingValue, data, tableConfig.selectedTable, tableConfig.primaryKey, cancelEditing, editMode, pendingChanges.size]); // 🆕 배치 저장: 모든 변경사항 한번에 저장 const saveBatchChanges = useCallback(async () => { if (pendingChanges.size === 0) { toast.info("저장할 변경사항이 없습니다."); return; } try { const { apiClient } = await import("@/lib/api/client"); const primaryKeyField = tableConfig.primaryKey || "id"; // 모든 변경사항 저장 const savePromises = Array.from(pendingChanges.values()).map((change) => apiClient.put(`/dynamic-form/update-field`, { tableName: tableConfig.selectedTable, keyField: primaryKeyField, keyValue: change.primaryKeyValue, updateField: change.columnName, updateValue: change.newValue, }) ); await Promise.all(savePromises); // 상태 초기화 setPendingChanges(new Map()); setLocalEditedData({}); setRefreshTrigger((prev) => prev + 1); toast.success(`${pendingChanges.size}개의 변경사항이 저장되었습니다.`); console.log("✅ 배치 저장 완료:", pendingChanges.size, "개"); } catch (error) { console.error("❌ 배치 저장 실패:", error); toast.error("저장 중 오류가 발생했습니다."); } }, [pendingChanges, tableConfig.selectedTable, tableConfig.primaryKey]); // 🆕 배치 취소: 모든 변경사항 롤백 const cancelBatchChanges = useCallback(() => { if (pendingChanges.size === 0) return; setPendingChanges(new Map()); setLocalEditedData({}); toast.info("변경사항이 취소되었습니다."); console.log("🔄 배치 편집 취소"); }, [pendingChanges.size]); // 🆕 특정 셀이 수정되었는지 확인 const isCellModified = useCallback((rowIndex: number, columnName: string) => { return pendingChanges.has(`${rowIndex}-${columnName}`); }, [pendingChanges]); // 🆕 수정된 셀 값 가져오기 (로컬 수정 데이터 우선) const getDisplayValue = useCallback((row: any, rowIndex: number, columnName: string) => { const localValue = localEditedData[rowIndex]?.[columnName]; if (localValue !== undefined) { return localValue; } return row[columnName]; }, [localEditedData]); // 🆕 유효성 검사 함수 const validateValue = useCallback(( value: any, columnName: string, row: any ): string | null => { // tableConfig.validation에서 컬럼별 규칙 가져오기 const rules = (tableConfig as any).validation?.[columnName] as ValidationRule | undefined; if (!rules) return null; const strValue = value !== null && value !== undefined ? String(value) : ""; const numValue = parseFloat(strValue); // 필수 검사 if (rules.required && (!strValue || strValue.trim() === "")) { return rules.customMessage || "필수 입력 항목입니다."; } // 값이 비어있으면 다른 검사 스킵 (required가 아닌 경우) if (!strValue || strValue.trim() === "") return null; // 최소값 검사 if (rules.min !== undefined && !isNaN(numValue) && numValue < rules.min) { return rules.customMessage || `최소값은 ${rules.min}입니다.`; } // 최대값 검사 if (rules.max !== undefined && !isNaN(numValue) && numValue > rules.max) { return rules.customMessage || `최대값은 ${rules.max}입니다.`; } // 최소 길이 검사 if (rules.minLength !== undefined && strValue.length < rules.minLength) { return rules.customMessage || `최소 ${rules.minLength}자 이상 입력해주세요.`; } // 최대 길이 검사 if (rules.maxLength !== undefined && strValue.length > rules.maxLength) { return rules.customMessage || `최대 ${rules.maxLength}자까지 입력 가능합니다.`; } // 패턴 검사 if (rules.pattern && !rules.pattern.test(strValue)) { return rules.customMessage || "입력 형식이 올바르지 않습니다."; } // 커스텀 검증 if (rules.validate) { const customError = rules.validate(value, row); if (customError) return customError; } return null; }, [tableConfig]); // 🆕 셀 유효성 에러 여부 확인 const getCellValidationError = useCallback((rowIndex: number, columnName: string): string | null => { return validationErrors.get(`${rowIndex}-${columnName}`) || null; }, [validationErrors]); // 🆕 유효성 검사 에러 설정 const setCellValidationError = useCallback((rowIndex: number, columnName: string, error: string | null) => { setValidationErrors((prev) => { const newMap = new Map(prev); const key = `${rowIndex}-${columnName}`; if (error) { newMap.set(key, error); } else { newMap.delete(key); } return newMap; }); }, []); // 🆕 모든 유효성 에러 초기화 const clearAllValidationErrors = useCallback(() => { setValidationErrors(new Map()); }, []); // 🆕 Excel 내보내기 함수 const exportToExcel = useCallback((exportAll: boolean = true) => { try { // 내보낼 데이터 선택 (선택된 행만 또는 전체) let exportData: any[]; if (exportAll) { exportData = filteredData; } else { // 선택된 행만 내보내기 exportData = filteredData.filter((row, index) => { const rowKey = getRowKey(row, index); return selectedRows.has(rowKey); }); } if (exportData.length === 0) { toast.error(exportAll ? "내보낼 데이터가 없습니다." : "선택된 행이 없습니다."); return; } // 컬럼 정보 가져오기 (체크박스 제외) const exportColumns = visibleColumns.filter((col) => col.columnName !== "__checkbox__"); // 헤더 행 생성 const headers = exportColumns.map((col) => columnLabels[col.columnName] || col.columnName); // 데이터 행 생성 const rows = exportData.map((row) => { return exportColumns.map((col) => { const mappedColumnName = joinColumnMapping[col.columnName] || col.columnName; const value = row[mappedColumnName]; // 카테고리 매핑된 값 처리 if (categoryMappings[col.columnName] && value !== null && value !== undefined) { const mapping = categoryMappings[col.columnName][String(value)]; if (mapping) { return mapping.label; } } // null/undefined 처리 if (value === null || value === undefined) { return ""; } return value; }); }); // 워크시트 생성 const wsData = [headers, ...rows]; const ws = XLSX.utils.aoa_to_sheet(wsData); // 컬럼 너비 자동 조정 const colWidths = exportColumns.map((col, idx) => { const headerLength = headers[idx]?.length || 10; const maxDataLength = Math.max( ...rows.map((row) => String(row[idx] ?? "").length) ); return { wch: Math.min(Math.max(headerLength, maxDataLength) + 2, 50) }; }); ws["!cols"] = colWidths; // 워크북 생성 const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, tableLabel || "데이터"); // 파일명 생성 const fileName = `${tableLabel || tableConfig.selectedTable || "export"}_${new Date().toISOString().split("T")[0]}.xlsx`; // 파일 다운로드 XLSX.writeFile(wb, fileName); toast.success(`${exportData.length}개 행이 Excel로 내보내기 되었습니다.`); console.log("✅ Excel 내보내기 완료:", fileName); } catch (error) { console.error("❌ Excel 내보내기 실패:", error); toast.error("Excel 내보내기 중 오류가 발생했습니다."); } }, [filteredData, selectedRows, visibleColumns, columnLabels, joinColumnMapping, categoryMappings, tableLabel, tableConfig.selectedTable, getRowKey]); // 🆕 행 확장/축소 토글 const toggleRowExpand = useCallback(async (rowKey: string, row: any) => { setExpandedRows((prev) => { const newSet = new Set(prev); if (newSet.has(rowKey)) { newSet.delete(rowKey); } else { newSet.add(rowKey); // 상세 데이터 로딩 (아직 없는 경우) if (!detailData[rowKey] && (tableConfig as any).masterDetail?.detailTable) { loadDetailData(rowKey, row); } } return newSet; }); }, [detailData, tableConfig]); // 🆕 상세 데이터 로딩 const loadDetailData = useCallback(async (rowKey: string, row: any) => { const masterDetailConfig = (tableConfig as any).masterDetail; if (!masterDetailConfig?.detailTable) return; try { const { apiClient } = await import("@/lib/api/client"); // masterKey 값 가져오기 const masterKeyField = masterDetailConfig.masterKey || "id"; const masterKeyValue = row[masterKeyField]; // 상세 테이블에서 데이터 조회 const response = await apiClient.post(`/table-management/tables/${masterDetailConfig.detailTable}/data`, { page: 1, size: 100, search: { [masterDetailConfig.detailKey || masterKeyField]: masterKeyValue, }, autoFilter: true, }); const details = response.data?.data?.data || []; setDetailData((prev) => ({ ...prev, [rowKey]: details, })); console.log("✅ 상세 데이터 로딩 완료:", { rowKey, count: details.length }); } catch (error) { console.error("❌ 상세 데이터 로딩 실패:", error); setDetailData((prev) => ({ ...prev, [rowKey]: [], })); } }, [tableConfig]); // 🆕 모든 행 확장/축소 const expandAllRows = useCallback(() => { if (expandedRows.size === filteredData.length) { // 모두 축소 setExpandedRows(new Set()); } else { // 모두 확장 const allKeys = new Set(filteredData.map((row, index) => getRowKey(row, index))); setExpandedRows(allKeys); } }, [expandedRows.size, filteredData, getRowKey]); // 🆕 Multi-Level Headers: Band 정보 계산 const columnBandsInfo = useMemo(() => { const bands = (tableConfig as any).columnBands as ColumnBand[] | undefined; if (!bands || bands.length === 0) return null; // 각 band의 시작 인덱스와 colspan 계산 const bandInfo = bands.map((band) => { const visibleBandColumns = band.columns.filter((colName) => visibleColumns.some((vc) => vc.columnName === colName) ); const startIndex = visibleColumns.findIndex( (vc) => visibleBandColumns.includes(vc.columnName) ); return { caption: band.caption, columns: visibleBandColumns, colSpan: visibleBandColumns.length, startIndex, }; }).filter((b) => b.colSpan > 0); // Band에 포함되지 않은 컬럼 찾기 const bandedColumns = new Set(bands.flatMap((b) => b.columns)); const unbandedColumns = visibleColumns .map((vc, idx) => ({ columnName: vc.columnName, index: idx })) .filter((c) => !bandedColumns.has(c.columnName)); return { bands: bandInfo, unbandedColumns, hasBands: bandInfo.length > 0, }; }, [tableConfig, visibleColumns]); // 🆕 Cascading Lookups: 연계 드롭다운 옵션 로딩 const loadCascadingOptions = useCallback(async ( columnName: string, parentColumnName: string, parentValue: any ) => { const cascadingConfig = (tableConfig as any).cascadingLookups?.[columnName]; if (!cascadingConfig) return; const cacheKey = `${columnName}_${parentValue}`; // 이미 로딩 중이면 스킵 if (loadingCascading[cacheKey]) return; // 이미 캐시된 데이터가 있으면 스킵 if (cascadingOptions[cacheKey]) return; setLoadingCascading((prev) => ({ ...prev, [cacheKey]: true })); try { const { apiClient } = await import("@/lib/api/client"); // API에서 연계 옵션 로딩 const response = await apiClient.post(`/table-management/tables/${cascadingConfig.sourceTable}/data`, { page: 1, size: 1000, search: { [cascadingConfig.parentKeyField || parentColumnName]: parentValue, }, autoFilter: true, }); const items = response.data?.data?.data || []; const options = items.map((item: any) => ({ value: item[cascadingConfig.valueField || "id"], label: item[cascadingConfig.labelField || "name"], })); setCascadingOptions((prev) => ({ ...prev, [cacheKey]: options, })); console.log("✅ Cascading options 로딩 완료:", { columnName, parentValue, count: options.length }); } catch (error) { console.error("❌ Cascading options 로딩 실패:", error); setCascadingOptions((prev) => ({ ...prev, [cacheKey]: [], })); } finally { setLoadingCascading((prev) => ({ ...prev, [cacheKey]: false })); } }, [tableConfig, cascadingOptions, loadingCascading]); // 🆕 Cascading Lookups: 특정 컬럼의 옵션 가져오기 const getCascadingOptions = useCallback((columnName: string, row: any): { value: string; label: string }[] => { const cascadingConfig = (tableConfig as any).cascadingLookups?.[columnName]; if (!cascadingConfig) return []; const parentValue = row[cascadingConfig.parentColumn]; if (parentValue === undefined || parentValue === null) return []; const cacheKey = `${columnName}_${parentValue}`; return cascadingOptions[cacheKey] || []; }, [tableConfig, cascadingOptions]); // 🆕 Virtual Scrolling: 보이는 행 범위 계산 const virtualScrollInfo = useMemo(() => { if (!isVirtualScrollEnabled || filteredData.length === 0) { return { startIndex: 0, endIndex: filteredData.length, visibleData: filteredData, topSpacerHeight: 0, bottomSpacerHeight: 0, totalHeight: filteredData.length * ROW_HEIGHT, }; } const containerHeight = scrollContainerRef.current?.clientHeight || 600; const totalRows = filteredData.length; const totalHeight = totalRows * ROW_HEIGHT; // 현재 보이는 행 범위 계산 const startIndex = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN); const visibleRowCount = Math.ceil(containerHeight / ROW_HEIGHT) + OVERSCAN * 2; const endIndex = Math.min(totalRows, startIndex + visibleRowCount); return { startIndex, endIndex, visibleData: filteredData.slice(startIndex, endIndex), topSpacerHeight: startIndex * ROW_HEIGHT, bottomSpacerHeight: (totalRows - endIndex) * ROW_HEIGHT, totalHeight, }; }, [isVirtualScrollEnabled, filteredData, scrollTop, ROW_HEIGHT, OVERSCAN]); // 🆕 Virtual Scrolling: 스크롤 핸들러 const handleVirtualScroll = useCallback((e: React.UIEvent) => { if (!isVirtualScrollEnabled) return; setScrollTop(e.currentTarget.scrollTop); }, [isVirtualScrollEnabled]); // 🆕 State Persistence: 통합 상태 저장 const saveTableState = useCallback(() => { if (!tableStateKey) return; const state = { columnWidths, columnOrder, sortColumn, sortDirection, groupByColumns, frozenColumns, showGridLines, headerFilters: Object.fromEntries( Object.entries(headerFilters).map(([key, set]) => [key, Array.from(set as Set)]) ), pageSize: localPageSize, timestamp: Date.now(), }; try { localStorage.setItem(tableStateKey, JSON.stringify(state)); console.log("✅ 테이블 상태 저장:", tableStateKey); } catch (error) { console.error("❌ 테이블 상태 저장 실패:", error); } }, [tableStateKey, columnWidths, columnOrder, sortColumn, sortDirection, groupByColumns, frozenColumns, showGridLines, headerFilters, localPageSize]); // 🆕 State Persistence: 통합 상태 복원 const loadTableState = useCallback(() => { if (!tableStateKey) return; try { const saved = localStorage.getItem(tableStateKey); if (!saved) return; const state = JSON.parse(saved); if (state.columnWidths) setColumnWidths(state.columnWidths); if (state.columnOrder) setColumnOrder(state.columnOrder); if (state.sortColumn !== undefined) setSortColumn(state.sortColumn); if (state.sortDirection) setSortDirection(state.sortDirection); if (state.groupByColumns) setGroupByColumns(state.groupByColumns); if (state.frozenColumns) setFrozenColumns(state.frozenColumns); if (state.showGridLines !== undefined) setShowGridLines(state.showGridLines); if (state.headerFilters) { const filters: Record> = {}; Object.entries(state.headerFilters).forEach(([key, values]) => { filters[key] = new Set(values as string[]); }); setHeaderFilters(filters); } console.log("✅ 테이블 상태 복원:", tableStateKey); } catch (error) { console.error("❌ 테이블 상태 복원 실패:", error); } }, [tableStateKey]); // 🆕 State Persistence: 상태 초기화 const resetTableState = useCallback(() => { if (!tableStateKey) return; try { localStorage.removeItem(tableStateKey); setColumnWidths({}); setColumnOrder([]); setSortColumn(null); setSortDirection("asc"); setGroupByColumns([]); setFrozenColumns([]); setShowGridLines(true); setHeaderFilters({}); toast.success("테이블 설정이 초기화되었습니다."); console.log("✅ 테이블 상태 초기화:", tableStateKey); } catch (error) { console.error("❌ 테이블 상태 초기화 실패:", error); } }, [tableStateKey]); // 🆕 State Persistence: 컴포넌트 마운트 시 상태 복원 useEffect(() => { loadTableState(); }, [tableStateKey]); // loadTableState는 의존성에서 제외 (무한 루프 방지) // 🆕 Real-Time Updates: WebSocket 연결 const connectWebSocket = useCallback(() => { if (!isRealTimeEnabled || !tableConfig.selectedTable) return; const wsUrl = (tableConfig as any).wsUrl || `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/ws/table/${tableConfig.selectedTable}`; try { setWsConnectionStatus("connecting"); wsRef.current = new WebSocket(wsUrl); wsRef.current.onopen = () => { setWsConnectionStatus("connected"); console.log("✅ WebSocket 연결됨:", tableConfig.selectedTable); }; wsRef.current.onmessage = (event) => { try { const message = JSON.parse(event.data); console.log("📨 WebSocket 메시지 수신:", message); switch (message.type) { case "insert": // 새 데이터 추가 setRefreshTrigger((prev) => prev + 1); toast.info("새 데이터가 추가되었습니다."); break; case "update": // 데이터 업데이트 setRefreshTrigger((prev) => prev + 1); toast.info("데이터가 업데이트되었습니다."); break; case "delete": // 데이터 삭제 setRefreshTrigger((prev) => prev + 1); toast.info("데이터가 삭제되었습니다."); break; case "refresh": // 전체 새로고침 setRefreshTrigger((prev) => prev + 1); break; default: console.log("알 수 없는 메시지 타입:", message.type); } } catch (error) { console.error("WebSocket 메시지 파싱 오류:", error); } }; wsRef.current.onclose = () => { setWsConnectionStatus("disconnected"); console.log("🔌 WebSocket 연결 종료"); // 자동 재연결 (5초 후) if (isRealTimeEnabled) { reconnectTimeoutRef.current = setTimeout(() => { console.log("🔄 WebSocket 재연결 시도..."); connectWebSocket(); }, 5000); } }; wsRef.current.onerror = (error) => { console.error("❌ WebSocket 오류:", error); setWsConnectionStatus("disconnected"); }; } catch (error) { console.error("WebSocket 연결 실패:", error); setWsConnectionStatus("disconnected"); } }, [isRealTimeEnabled, tableConfig.selectedTable]); // 🆕 Real-Time Updates: 연결 관리 useEffect(() => { if (isRealTimeEnabled) { connectWebSocket(); } return () => { // 정리 if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current); } if (wsRef.current) { wsRef.current.close(); wsRef.current = null; } }; }, [isRealTimeEnabled, tableConfig.selectedTable]); // 🆕 State Persistence: 상태 변경 시 자동 저장 (디바운스) useEffect(() => { const timeoutId = setTimeout(() => { saveTableState(); }, 1000); // 1초 후 저장 (디바운스) return () => clearTimeout(timeoutId); }, [columnWidths, columnOrder, sortColumn, sortDirection, groupByColumns, frozenColumns, showGridLines, headerFilters]); // 🆕 Clipboard: 선택된 데이터 복사 const handleCopy = useCallback(async () => { try { // 선택된 행 데이터 가져오기 let copyData: any[]; if (selectedRows.size > 0) { // 선택된 행만 copyData = filteredData.filter((row, index) => { const rowKey = getRowKey(row, index); return selectedRows.has(rowKey); }); } else if (focusedCell) { // 포커스된 셀만 const row = filteredData[focusedCell.rowIndex]; if (row) { const column = visibleColumns[focusedCell.colIndex]; const value = row[column?.columnName]; await navigator.clipboard.writeText(String(value ?? "")); toast.success("셀 복사됨"); return; } return; } else { toast.info("복사할 데이터를 선택해주세요."); return; } // TSV 형식으로 변환 (탭으로 구분) const exportColumns = visibleColumns.filter((c) => c.columnName !== "__checkbox__"); const headers = exportColumns.map((c) => columnLabels[c.columnName] || c.columnName); const rows = copyData.map((row) => exportColumns.map((c) => { const value = row[c.columnName]; return value !== null && value !== undefined ? String(value).replace(/\t/g, " ").replace(/\n/g, " ") : ""; }).join("\t") ); const tsvContent = [headers.join("\t"), ...rows].join("\n"); await navigator.clipboard.writeText(tsvContent); toast.success(`${copyData.length}행 복사됨`); console.log("✅ 클립보드 복사:", copyData.length, "행"); } catch (error) { console.error("❌ 클립보드 복사 실패:", error); toast.error("복사 실패"); } }, [selectedRows, filteredData, focusedCell, visibleColumns, columnLabels, getRowKey]); // 🆕 전체 행 선택 const handleSelectAllRows = useCallback(() => { if (selectedRows.size === filteredData.length) { // 전체 해제 setSelectedRows(new Set()); setIsAllSelected(false); } else { // 전체 선택 const allKeys = new Set(filteredData.map((row, index) => getRowKey(row, index))); setSelectedRows(allKeys); setIsAllSelected(true); } }, [selectedRows.size, filteredData, getRowKey]); // 🆕 Context Menu: 열기 const handleContextMenu = useCallback((e: React.MouseEvent, rowIndex: number, colIndex: number, row: any) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, rowIndex, colIndex, row, }); }, []); // 🆕 Context Menu: 닫기 const closeContextMenu = useCallback(() => { setContextMenu(null); }, []); // 🆕 Context Menu: 외부 클릭 시 닫기 useEffect(() => { if (contextMenu) { const handleClick = () => closeContextMenu(); document.addEventListener("click", handleClick); return () => document.removeEventListener("click", handleClick); } }, [contextMenu, closeContextMenu]); // 🆕 Search Panel: 통합 검색 실행 const executeGlobalSearch = useCallback((term: string) => { if (!term.trim()) { setSearchHighlights(new Set()); return; } const lowerTerm = term.toLowerCase(); const highlights = new Set(); filteredData.forEach((row, rowIndex) => { visibleColumns.forEach((col, colIndex) => { const value = row[col.columnName]; if (value !== null && value !== undefined) { const strValue = String(value).toLowerCase(); if (strValue.includes(lowerTerm)) { highlights.add(`${rowIndex}-${colIndex}`); } } }); }); setSearchHighlights(highlights); // 첫 번째 검색 결과로 포커스 이동 if (highlights.size > 0) { const firstHighlight = Array.from(highlights)[0]; const [rowIdx, colIdx] = firstHighlight.split("-").map(Number); setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx }); toast.success(`${highlights.size}개 검색 결과`); } else { toast.info("검색 결과가 없습니다"); } }, [filteredData, visibleColumns]); // 🆕 Search Panel: 다음 검색 결과로 이동 const goToNextSearchResult = useCallback(() => { if (searchHighlights.size === 0) return; const highlightArray = Array.from(searchHighlights).sort((a, b) => { const [aRow, aCol] = a.split("-").map(Number); const [bRow, bCol] = b.split("-").map(Number); if (aRow !== bRow) return aRow - bRow; return aCol - bCol; }); if (!focusedCell) { const [rowIdx, colIdx] = highlightArray[0].split("-").map(Number); setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx }); return; } const currentKey = `${focusedCell.rowIndex}-${focusedCell.colIndex}`; const currentIndex = highlightArray.indexOf(currentKey); const nextIndex = (currentIndex + 1) % highlightArray.length; const [rowIdx, colIdx] = highlightArray[nextIndex].split("-").map(Number); setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx }); }, [searchHighlights, focusedCell]); // 🆕 Search Panel: 이전 검색 결과로 이동 const goToPrevSearchResult = useCallback(() => { if (searchHighlights.size === 0) return; const highlightArray = Array.from(searchHighlights).sort((a, b) => { const [aRow, aCol] = a.split("-").map(Number); const [bRow, bCol] = b.split("-").map(Number); if (aRow !== bRow) return aRow - bRow; return aCol - bCol; }); if (!focusedCell) { const lastIdx = highlightArray.length - 1; const [rowIdx, colIdx] = highlightArray[lastIdx].split("-").map(Number); setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx }); return; } const currentKey = `${focusedCell.rowIndex}-${focusedCell.colIndex}`; const currentIndex = highlightArray.indexOf(currentKey); const prevIndex = currentIndex <= 0 ? highlightArray.length - 1 : currentIndex - 1; const [rowIdx, colIdx] = highlightArray[prevIndex].split("-").map(Number); setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx }); }, [searchHighlights, focusedCell]); // 🆕 Search Panel: 검색 초기화 const clearGlobalSearch = useCallback(() => { setGlobalSearchTerm(""); setSearchHighlights(new Set()); setIsSearchPanelOpen(false); }, []); // 🆕 Filter Builder: 조건 추가 const addFilterCondition = useCallback((groupId: string, defaultColumn?: string) => { setFilterGroups((prev) => prev.map((group) => group.id === groupId ? { ...group, conditions: [ ...group.conditions, { id: `cond-${Date.now()}`, column: defaultColumn || "", operator: "contains" as const, value: "", }, ], } : group ) ); }, []); // 🆕 Filter Builder: 조건 삭제 const removeFilterCondition = useCallback((groupId: string, conditionId: string) => { setFilterGroups((prev) => prev.map((group) => group.id === groupId ? { ...group, conditions: group.conditions.filter((c) => c.id !== conditionId), } : group ) ); }, []); // 🆕 Filter Builder: 조건 업데이트 const updateFilterCondition = useCallback( (groupId: string, conditionId: string, field: keyof FilterCondition, value: string) => { setFilterGroups((prev) => prev.map((group) => group.id === groupId ? { ...group, conditions: group.conditions.map((c) => c.id === conditionId ? { ...c, [field]: value } : c ), } : group ) ); }, [] ); // 🆕 Filter Builder: 그룹 추가 const addFilterGroup = useCallback((defaultColumn?: string) => { setFilterGroups((prev) => [ ...prev, { id: `group-${Date.now()}`, logic: "AND" as const, conditions: [ { id: `cond-${Date.now()}`, column: defaultColumn || "", operator: "contains" as const, value: "", }, ], }, ]); }, []); // 🆕 Filter Builder: 그룹 삭제 const removeFilterGroup = useCallback((groupId: string) => { setFilterGroups((prev) => prev.filter((g) => g.id !== groupId)); }, []); // 🆕 Filter Builder: 그룹 로직 변경 const updateGroupLogic = useCallback((groupId: string, logic: "AND" | "OR") => { setFilterGroups((prev) => prev.map((group) => (group.id === groupId ? { ...group, logic } : group)) ); }, []); // 🆕 Filter Builder: 필터 적용 const applyFilterBuilder = useCallback(() => { // 유효한 조건 개수 계산 let validConditions = 0; filterGroups.forEach((group) => { group.conditions.forEach((cond) => { if (cond.column && (cond.operator === "isEmpty" || cond.operator === "isNotEmpty" || cond.value)) { validConditions++; } }); }); setActiveFilterCount(validConditions); setIsFilterBuilderOpen(false); toast.success(`${validConditions}개 필터 조건 적용됨`); }, [filterGroups]); // 🆕 Filter Builder: 필터 초기화 const clearFilterBuilder = useCallback(() => { setFilterGroups([]); setActiveFilterCount(0); toast.info("필터 초기화됨"); }, []); // 🆕 Filter Builder: 조건 평가 함수 const evaluateCondition = useCallback((value: any, condition: FilterCondition): boolean => { const strValue = value !== null && value !== undefined ? String(value).toLowerCase() : ""; const condValue = condition.value.toLowerCase(); switch (condition.operator) { case "equals": return strValue === condValue; case "notEquals": return strValue !== condValue; case "contains": return strValue.includes(condValue); case "notContains": return !strValue.includes(condValue); case "startsWith": return strValue.startsWith(condValue); case "endsWith": return strValue.endsWith(condValue); case "greaterThan": return parseFloat(strValue) > parseFloat(condValue); case "lessThan": return parseFloat(strValue) < parseFloat(condValue); case "greaterOrEqual": return parseFloat(strValue) >= parseFloat(condValue); case "lessOrEqual": return parseFloat(strValue) <= parseFloat(condValue); case "isEmpty": return strValue === "" || value === null || value === undefined; case "isNotEmpty": return strValue !== "" && value !== null && value !== undefined; default: return true; } }, []); // 🆕 Filter Builder: 행이 필터 조건을 만족하는지 확인 const rowPassesFilterBuilder = useCallback( (row: any): boolean => { if (filterGroups.length === 0) return true; // 모든 그룹이 AND로 연결됨 (그룹 간) return filterGroups.every((group) => { const validConditions = group.conditions.filter( (c) => c.column && (c.operator === "isEmpty" || c.operator === "isNotEmpty" || c.value) ); if (validConditions.length === 0) return true; if (group.logic === "AND") { return validConditions.every((cond) => evaluateCondition(row[cond.column], cond)); } else { return validConditions.some((cond) => evaluateCondition(row[cond.column], cond)); } }); }, [filterGroups, evaluateCondition] ); // 🆕 컬럼 드래그 시작 const handleColumnDragStart = useCallback((e: React.DragEvent, index: number) => { if (!isColumnDragEnabled) return; setDraggedColumnIndex(index); e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", `col-${index}`); }, [isColumnDragEnabled]); // 🆕 컬럼 드래그 오버 const handleColumnDragOver = useCallback((e: React.DragEvent, index: number) => { if (!isColumnDragEnabled || draggedColumnIndex === null) return; e.preventDefault(); e.dataTransfer.dropEffect = "move"; if (index !== draggedColumnIndex) { setDropTargetColumnIndex(index); } }, [isColumnDragEnabled, draggedColumnIndex]); // 🆕 컬럼 드래그 종료 const handleColumnDragEnd = useCallback(() => { setDraggedColumnIndex(null); setDropTargetColumnIndex(null); }, []); // 🆕 컬럼 드롭 const handleColumnDrop = useCallback((e: React.DragEvent, targetIndex: number) => { e.preventDefault(); if (!isColumnDragEnabled || draggedColumnIndex === null || draggedColumnIndex === targetIndex) { handleColumnDragEnd(); return; } // 컬럼 순서 변경 const newOrder = [...(columnOrder.length > 0 ? columnOrder : visibleColumns.map((c) => c.columnName))]; const [movedColumn] = newOrder.splice(draggedColumnIndex, 1); newOrder.splice(targetIndex, 0, movedColumn); setColumnOrder(newOrder); toast.info("컬럼 순서가 변경되었습니다."); console.log("✅ 컬럼 순서 변경:", { from: draggedColumnIndex, to: targetIndex }); handleColumnDragEnd(); }, [isColumnDragEnabled, draggedColumnIndex, columnOrder, visibleColumns, handleColumnDragEnd]); // 🆕 행 드래그 시작 const handleRowDragStart = useCallback((e: React.DragEvent, index: number) => { if (!isDragEnabled) return; setDraggedRowIndex(index); e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", String(index)); // 드래그 이미지 설정 (반투명) const dragImage = e.currentTarget.cloneNode(true) as HTMLElement; dragImage.style.opacity = "0.5"; dragImage.style.position = "absolute"; dragImage.style.top = "-1000px"; document.body.appendChild(dragImage); e.dataTransfer.setDragImage(dragImage, 0, 0); setTimeout(() => document.body.removeChild(dragImage), 0); }, [isDragEnabled]); // 🆕 행 드래그 오버 const handleRowDragOver = useCallback((e: React.DragEvent, index: number) => { if (!isDragEnabled || draggedRowIndex === null) return; e.preventDefault(); e.dataTransfer.dropEffect = "move"; if (index !== draggedRowIndex) { setDropTargetIndex(index); } }, [isDragEnabled, draggedRowIndex]); // 🆕 행 드래그 종료 const handleRowDragEnd = useCallback(() => { setDraggedRowIndex(null); setDropTargetIndex(null); }, []); // 🆕 행 드롭 const handleRowDrop = useCallback(async (e: React.DragEvent, targetIndex: number) => { e.preventDefault(); if (!isDragEnabled || draggedRowIndex === null || draggedRowIndex === targetIndex) { handleRowDragEnd(); return; } try { // 로컬 데이터 재정렬 const newData = [...filteredData]; const [movedRow] = newData.splice(draggedRowIndex, 1); newData.splice(targetIndex, 0, movedRow); // 서버에 순서 저장 (order_index 필드가 있는 경우) const orderField = (tableConfig as any).orderField || "order_index"; const hasOrderField = newData[0] && orderField in newData[0]; if (hasOrderField && tableConfig.selectedTable) { const { apiClient } = await import("@/lib/api/client"); const primaryKeyField = tableConfig.primaryKey || "id"; // 영향받는 행들의 순서 업데이트 const updates = newData.map((row, idx) => ({ tableName: tableConfig.selectedTable, keyField: primaryKeyField, keyValue: row[primaryKeyField], updateField: orderField, updateValue: idx + 1, })); // 배치 업데이트 await Promise.all( updates.map((update) => apiClient.put(`/dynamic-form/update-field`, update) ) ); toast.success("순서가 변경되었습니다."); setRefreshTrigger((prev) => prev + 1); } else { // 로컬에서만 순서 변경 (저장 안함) toast.info("순서가 변경되었습니다. (로컬만)"); } console.log("✅ 행 순서 변경:", { from: draggedRowIndex, to: targetIndex }); } catch (error) { console.error("❌ 행 순서 변경 실패:", error); toast.error("순서 변경 중 오류가 발생했습니다."); } handleRowDragEnd(); }, [isDragEnabled, draggedRowIndex, filteredData, tableConfig, handleRowDragEnd]); // 🆕 PDF 내보내기 (인쇄용 HTML 생성) const exportToPdf = useCallback((exportAll: boolean = true) => { try { // 내보낼 데이터 선택 let exportData: any[]; if (exportAll) { exportData = filteredData; } else { exportData = filteredData.filter((row, index) => { const rowKey = getRowKey(row, index); return selectedRows.has(rowKey); }); } if (exportData.length === 0) { toast.error(exportAll ? "내보낼 데이터가 없습니다." : "선택된 행이 없습니다."); return; } // 컬럼 정보 가져오기 (체크박스 제외) const exportColumns = visibleColumns.filter((col) => col.columnName !== "__checkbox__"); // 인쇄용 HTML 생성 const printContent = ` ${tableLabel || tableConfig.selectedTable || "데이터"}

${tableLabel || tableConfig.selectedTable || "데이터 목록"}

출력일: ${new Date().toLocaleDateString("ko-KR")} | 총 ${exportData.length}건
${exportColumns.map((col) => ``).join("")} ${exportData.map((row) => ` ${exportColumns.map((col) => { const mappedColumnName = joinColumnMapping[col.columnName] || col.columnName; let value = row[mappedColumnName]; // 카테고리 매핑 if (categoryMappings[col.columnName] && value !== null && value !== undefined) { const mapping = categoryMappings[col.columnName][String(value)]; if (mapping) value = mapping.label; } const meta = columnMeta[col.columnName]; const inputType = meta?.inputType || (col as any).inputType; const isNumeric = inputType === "number" || inputType === "decimal"; return ``; }).join("")} `).join("")}
${columnLabels[col.columnName] || col.columnName}
${value ?? ""}
`; // 새 창에서 인쇄 const printWindow = window.open("", "_blank"); if (printWindow) { printWindow.document.write(printContent); printWindow.document.close(); printWindow.onload = () => { printWindow.print(); }; toast.success("인쇄 창이 열렸습니다."); } else { toast.error("팝업이 차단되었습니다. 팝업을 허용해주세요."); } } catch (error) { console.error("❌ PDF 내보내기 실패:", error); toast.error("PDF 내보내기 중 오류가 발생했습니다."); } }, [filteredData, selectedRows, visibleColumns, columnLabels, joinColumnMapping, categoryMappings, columnMeta, tableLabel, tableConfig.selectedTable, getRowKey]); // 🆕 편집 중 키보드 핸들러 (간단 버전 - Tab 이동은 visibleColumns 정의 후 처리) const handleEditKeyDown = useCallback((e: React.KeyboardEvent) => { switch (e.key) { case "Enter": e.preventDefault(); saveEditing(); break; case "Escape": e.preventDefault(); cancelEditing(); break; case "Tab": e.preventDefault(); saveEditing(); // Tab 이동은 편집 저장 후 테이블 키보드 핸들러에서 처리 break; } }, [saveEditing, cancelEditing]); // 🆕 편집 입력 필드가 나타나면 자동 포커스 useEffect(() => { if (editingCell && editInputRef.current) { editInputRef.current.focus(); // select()는 input 요소에서만 사용 가능 (select 요소에서는 사용 불가) if (typeof editInputRef.current.select === "function") { editInputRef.current.select(); } } }, [editingCell]); // 🆕 포커스된 셀로 스크롤 useEffect(() => { if (focusedCell && tableContainerRef.current) { const focusedCellElement = tableContainerRef.current.querySelector( `[data-row="${focusedCell.rowIndex}"][data-col="${focusedCell.colIndex}"]` ) as HTMLElement; if (focusedCellElement) { focusedCellElement.scrollIntoView({ block: "nearest", inline: "nearest" }); } } }, [focusedCell]); // 컬럼 드래그앤드롭 기능 제거됨 (테이블 옵션 모달에서 컬럼 순서 변경 가능) const handleClick = (e: React.MouseEvent) => { e.stopPropagation(); onClick?.(); }; // ======================================== // 컬럼 관련 (visibleColumns는 상단에서 정의됨) // ======================================== // 🆕 visibleColumns가 변경될 때마다 현재 컬럼 순서를 부모에게 전달 const lastColumnOrderRef = useRef(""); useEffect(() => { // console.log("🔍 [컬럼 순서 전달 useEffect] 실행됨:", { // hasCallback: !!onSelectedRowsChange, // visibleColumnsLength: visibleColumns.length, // visibleColumnsNames: visibleColumns.map((c) => c.columnName), // }); if (!onSelectedRowsChange) { // console.warn("⚠️ onSelectedRowsChange 콜백이 없습니다!"); return; } if (visibleColumns.length === 0) { // console.warn("⚠️ visibleColumns가 비어있습니다!"); return; } const currentColumnOrder = visibleColumns.map((col) => col.columnName).filter((name) => name !== "__checkbox__"); // 체크박스 컬럼 제외 // console.log("🔍 [컬럼 순서] 체크박스 제외 후:", currentColumnOrder); // 컬럼 순서가 실제로 변경되었을 때만 전달 (무한 루프 방지) const columnOrderString = currentColumnOrder.join(","); // console.log("🔍 [컬럼 순서] 비교:", { // current: columnOrderString, // last: lastColumnOrderRef.current, // isDifferent: columnOrderString !== lastColumnOrderRef.current, // }); if (columnOrderString === lastColumnOrderRef.current) { // console.log("⏭️ 컬럼 순서 변경 없음, 전달 스킵"); return; } lastColumnOrderRef.current = columnOrderString; // console.log("📊 현재 화면 컬럼 순서 전달:", currentColumnOrder); // 선택된 행 데이터 가져오기 const selectedRowsData = data.filter((row, index) => selectedRows.has(getRowKey(row, index))); // 화면에 표시된 데이터를 컬럼 순서대로 재정렬 const reorderedData = data.map((row: any) => { const reordered: any = {}; visibleColumns.forEach((col) => { if (col.columnName in row) { reordered[col.columnName] = row[col.columnName]; } }); // 나머지 컬럼 추가 Object.keys(row).forEach((key) => { if (!(key in reordered)) { reordered[key] = row[key]; } }); return reordered; }); onSelectedRowsChange( Array.from(selectedRows), selectedRowsData, sortColumn, sortDirection, currentColumnOrder, reorderedData, ); }, [visibleColumns.length, visibleColumns.map((c) => c.columnName).join(",")]); // 의존성 단순화 // 🆕 키보드 네비게이션 핸들러 (visibleColumns 정의 후에 배치) const handleTableKeyDown = useCallback((e: React.KeyboardEvent) => { // 편집 중일 때는 테이블 키보드 핸들러 무시 (편집 입력에서 처리) if (editingCell) return; if (!focusedCell || data.length === 0) return; const { rowIndex, colIndex } = focusedCell; const maxRowIndex = data.length - 1; const maxColIndex = visibleColumns.length - 1; switch (e.key) { case "ArrowUp": e.preventDefault(); if (rowIndex > 0) { setFocusedCell({ rowIndex: rowIndex - 1, colIndex }); } break; case "ArrowDown": e.preventDefault(); if (rowIndex < maxRowIndex) { setFocusedCell({ rowIndex: rowIndex + 1, colIndex }); } break; case "ArrowLeft": e.preventDefault(); if (colIndex > 0) { setFocusedCell({ rowIndex, colIndex: colIndex - 1 }); } break; case "ArrowRight": e.preventDefault(); if (colIndex < maxColIndex) { setFocusedCell({ rowIndex, colIndex: colIndex + 1 }); } break; case "Enter": e.preventDefault(); // 현재 행 선택/해제 const enterRow = data[rowIndex]; if (enterRow) { const rowKey = getRowKey(enterRow, rowIndex); const isCurrentlySelected = selectedRows.has(rowKey); handleRowSelection(rowKey, !isCurrentlySelected); } break; case " ": // Space e.preventDefault(); // 체크박스 토글 const spaceRow = data[rowIndex]; if (spaceRow) { const currentRowKey = getRowKey(spaceRow, rowIndex); const isChecked = selectedRows.has(currentRowKey); handleRowSelection(currentRowKey, !isChecked); } break; case "F2": // 🆕 F2: 편집 모드 진입 e.preventDefault(); { const col = visibleColumns[colIndex]; if (col && col.columnName !== "__checkbox__") { // 🆕 편집 불가 컬럼 체크 if (col.editable === false) { toast.warning(`'${col.displayName || col.columnName}' 컬럼은 편집할 수 없습니다.`); break; } const row = data[rowIndex]; const mappedCol = joinColumnMapping[col.columnName] || col.columnName; const val = row?.[mappedCol]; setEditingCell({ rowIndex, colIndex, columnName: col.columnName, originalValue: val }); setEditingValue(val !== null && val !== undefined ? String(val) : ""); } } break; case "b": case "B": // 🆕 Ctrl+B: 배치 편집 모드 토글 if (e.ctrlKey) { e.preventDefault(); setEditMode((prev) => { const newMode = prev === "immediate" ? "batch" : "immediate"; if (newMode === "immediate" && pendingChanges.size > 0) { // 즉시 모드로 전환 시 저장되지 않은 변경사항 경고 const confirmDiscard = window.confirm( `저장되지 않은 ${pendingChanges.size}개의 변경사항이 있습니다. 취소하시겠습니까?` ); if (confirmDiscard) { setPendingChanges(new Map()); setLocalEditedData({}); toast.info("배치 편집 모드 종료"); return "immediate"; } return "batch"; } toast.info(newMode === "batch" ? "배치 편집 모드 시작 (Ctrl+B로 종료)" : "즉시 저장 모드"); return newMode; }); } break; case "s": case "S": // 🆕 Ctrl+S: 배치 저장 if (e.ctrlKey && editMode === "batch") { e.preventDefault(); saveBatchChanges(); } break; case "c": case "C": // 🆕 Ctrl+C: 선택된 행/셀 복사 if (e.ctrlKey) { e.preventDefault(); handleCopy(); } break; case "v": case "V": // 🆕 Ctrl+V: 붙여넣기 (편집 중인 경우만) if (e.ctrlKey && editingCell) { // 기본 동작 허용 (input에서 처리) } break; case "a": case "A": // 🆕 Ctrl+A: 전체 선택 if (e.ctrlKey) { e.preventDefault(); handleSelectAllRows(); } break; case "f": case "F": // 🆕 Ctrl+F: 통합 검색 패널 열기 if (e.ctrlKey) { e.preventDefault(); setIsSearchPanelOpen(true); } break; case "F3": // 🆕 F3: 다음 검색 결과 / Shift+F3: 이전 검색 결과 e.preventDefault(); if (e.shiftKey) { goToPrevSearchResult(); } else { goToNextSearchResult(); } break; case "Home": e.preventDefault(); if (e.ctrlKey) { // Ctrl+Home: 첫 번째 셀로 setFocusedCell({ rowIndex: 0, colIndex: 0 }); } else { // Home: 현재 행의 첫 번째 셀로 setFocusedCell({ rowIndex, colIndex: 0 }); } break; case "End": e.preventDefault(); if (e.ctrlKey) { // Ctrl+End: 마지막 셀로 setFocusedCell({ rowIndex: maxRowIndex, colIndex: maxColIndex }); } else { // End: 현재 행의 마지막 셀로 setFocusedCell({ rowIndex, colIndex: maxColIndex }); } break; case "PageUp": e.preventDefault(); // 10행 위로 setFocusedCell({ rowIndex: Math.max(0, rowIndex - 10), colIndex }); break; case "PageDown": e.preventDefault(); // 10행 아래로 setFocusedCell({ rowIndex: Math.min(maxRowIndex, rowIndex + 10), colIndex }); break; case "Escape": e.preventDefault(); // 포커스 해제 setFocusedCell(null); break; case "Tab": e.preventDefault(); if (e.shiftKey) { // Shift+Tab: 이전 셀 if (colIndex > 0) { setFocusedCell({ rowIndex, colIndex: colIndex - 1 }); } else if (rowIndex > 0) { setFocusedCell({ rowIndex: rowIndex - 1, colIndex: maxColIndex }); } } else { // Tab: 다음 셀 if (colIndex < maxColIndex) { setFocusedCell({ rowIndex, colIndex: colIndex + 1 }); } else if (rowIndex < maxRowIndex) { setFocusedCell({ rowIndex: rowIndex + 1, colIndex: 0 }); } } break; default: // 🆕 직접 타이핑으로 편집 모드 진입 (영문자, 숫자, 한글 등) if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey) { const column = visibleColumns[colIndex]; if (column && column.columnName !== "__checkbox__") { // 🆕 편집 불가 컬럼 체크 if (column.editable === false) { toast.warning(`'${column.displayName || column.columnName}' 컬럼은 편집할 수 없습니다.`); break; } e.preventDefault(); // 편집 시작 (현재 키를 초기값으로) const row = data[rowIndex]; const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; const value = row?.[mappedColumnName]; setEditingCell({ rowIndex, colIndex, columnName: column.columnName, originalValue: value }); setEditingValue(e.key); // 입력한 키로 시작 } } break; } }, [editingCell, focusedCell, data, visibleColumns, joinColumnMapping, selectedRows, getRowKey, handleRowSelection]); const getColumnWidth = (column: ColumnConfig) => { if (column.columnName === "__checkbox__") return 50; if (column.width) return column.width; switch (column.format) { case "date": return 120; case "number": case "currency": return 100; case "boolean": return 80; default: return 150; } }; const renderCheckboxHeader = () => { if (!tableConfig.checkbox?.selectAll) return null; return ; }; const renderCheckboxCell = (row: any, index: number) => { const rowKey = getRowKey(row, index); const isChecked = selectedRows.has(rowKey); return ( handleRowSelection(rowKey, checked as boolean)} aria-label={`행 ${index + 1} 선택`} /> ); }; const formatCellValue = useCallback( (value: any, column: ColumnConfig, rowData?: Record) => { // 🎯 엔티티 컬럼 표시 설정이 있는 경우 - value가 null이어도 rowData에서 조합 가능 // 이 체크를 가장 먼저 수행 (null 체크보다 앞에) if (column.entityDisplayConfig && rowData) { const displayColumns = column.entityDisplayConfig.displayColumns || (column.entityDisplayConfig as any).selectedColumns; const separator = column.entityDisplayConfig.separator; if (displayColumns && displayColumns.length > 0) { // 선택된 컬럼들의 값을 구분자로 조합 const values = displayColumns .map((colName: string) => { // 1. 먼저 직접 컬럼명으로 시도 (기본 테이블 컬럼인 경우) let cellValue = rowData[colName]; // 2. 없으면 ${sourceColumn}_${colName} 형식으로 시도 (조인 테이블 컬럼인 경우) if (cellValue === null || cellValue === undefined) { const joinedKey = `${column.columnName}_${colName}`; cellValue = rowData[joinedKey]; } if (cellValue === null || cellValue === undefined) return ""; return String(cellValue); }) .filter((v: string) => v !== ""); // 빈 값 제외 const result = values.join(separator || " - "); if (result) { return result; // 결과가 있으면 반환 } // 결과가 비어있으면 아래로 계속 진행 (원래 값 사용) } } // value가 null/undefined면 "-" 반환 if (value === null || value === undefined) return "-"; // 🎯 writer 컬럼 자동 변환: user_id -> user_name if (column.columnName === "writer" && rowData && rowData.writer_name) { return rowData.writer_name; } // 🆕 메인 테이블 메타 또는 조인 테이블 메타에서 정보 가져오기 const meta = columnMeta[column.columnName] || joinedColumnMeta[column.columnName]; // inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선) const inputType = meta?.inputType || column.inputType; // 🖼️ 이미지 타입: 작은 썸네일 표시 if (inputType === "image" && value && typeof value === "string") { const imageUrl = getFullImageUrl(value); return ( 이미지 { e.currentTarget.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Crect width='40' height='40' fill='%23f3f4f6'/%3E%3C/svg%3E"; }} /> ); } // 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원, 다중 값 지원) if (inputType === "category") { if (!value) return ""; // 🆕 엔티티 조인 컬럼의 경우 여러 형태로 매핑 찾기 // 1. 원래 컬럼명 (item_info.material) // 2. 점(.) 뒤의 컬럼명만 (material) let mapping = categoryMappings[column.columnName]; if (!mapping && column.columnName.includes(".")) { const simpleColumnName = column.columnName.split(".").pop(); if (simpleColumnName) { mapping = categoryMappings[simpleColumnName]; } } const { Badge } = require("@/components/ui/badge"); // 다중 값 처리: 콤마로 구분된 값들을 분리 const valueStr = String(value); const values = valueStr.includes(",") ? valueStr.split(",").map(v => v.trim()).filter(v => v) : [valueStr]; // 단일 값인 경우 (기존 로직) if (values.length === 1) { const categoryData = mapping?.[values[0]]; const displayLabel = categoryData?.label || values[0]; const displayColor = categoryData?.color || "#64748b"; if (displayColor === "none") { return {displayLabel}; } return ( {displayLabel} ); } // 다중 값인 경우: 여러 배지 렌더링 return (
{values.map((val, idx) => { const categoryData = mapping?.[val]; const displayLabel = categoryData?.label || val; const displayColor = categoryData?.color || "#64748b"; if (displayColor === "none") { return ( {displayLabel} {idx < values.length - 1 && ", "} ); } return ( {displayLabel} ); })}
); } // 코드 타입: 코드 값 → 코드명 변환 if (inputType === "code" && meta?.codeCategory && value) { try { // optimizedConvertCode(categoryCode, codeValue) 순서 주의! const convertedValue = optimizedConvertCode(meta.codeCategory, value); // 변환에 성공했으면 변환된 코드명 반환 if (convertedValue && convertedValue !== value) { return convertedValue; } } catch (error) { console.error(`코드 변환 실패: ${column.columnName}, 카테고리: ${meta.codeCategory}, 값: ${value}`, error); } // 변환 실패 시 원본 코드 값 반환 return String(value); } // 날짜 타입 포맷팅 (yyyy-mm-dd) if (inputType === "date" || inputType === "datetime") { if (value) { try { const date = new Date(value); 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}`; } catch { return String(value); } } return "-"; } // 숫자 타입 포맷팅 (천단위 구분자 설정 확인) if (inputType === "number" || inputType === "decimal") { if (value !== null && value !== undefined && value !== "") { const numValue = typeof value === "string" ? parseFloat(value) : value; if (!isNaN(numValue)) { // thousandSeparator가 false가 아닌 경우(기본값 true) 천단위 구분자 적용 if (column.thousandSeparator !== false) { return numValue.toLocaleString("ko-KR"); } return String(numValue); } } return String(value); } switch (column.format) { case "number": if (value !== null && value !== undefined && value !== "") { const numValue = typeof value === "string" ? parseFloat(value) : value; if (!isNaN(numValue)) { // thousandSeparator가 false가 아닌 경우(기본값 true) 천단위 구분자 적용 if (column.thousandSeparator !== false) { return numValue.toLocaleString("ko-KR"); } return String(numValue); } } return String(value); case "date": if (value) { try { const date = new Date(value); 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}`; } catch { return value; } } return "-"; case "currency": if (typeof value === "number") { // thousandSeparator가 false가 아닌 경우(기본값 true) 천단위 구분자 적용 if (column.thousandSeparator !== false) { return `₩${value.toLocaleString()}`; } return `₩${value}`; } return value; case "boolean": return value ? "예" : "아니오"; default: return String(value); } }, [columnMeta, joinedColumnMeta, optimizedConvertCode, categoryMappings], ); // ======================================== // useEffect 훅 // ======================================== // 필터 설정 localStorage 키 생성 (화면별로 독립적) const filterSettingKey = useMemo(() => { if (!tableConfig.selectedTable) return null; return screenId ? `tableList_filterSettings_${tableConfig.selectedTable}_screen_${screenId}` : `tableList_filterSettings_${tableConfig.selectedTable}`; }, [tableConfig.selectedTable, screenId]); // 그룹 설정 localStorage 키 생성 (화면별로 독립적) const groupSettingKey = useMemo(() => { if (!tableConfig.selectedTable) return null; return screenId ? `tableList_groupSettings_${tableConfig.selectedTable}_screen_${screenId}` : `tableList_groupSettings_${tableConfig.selectedTable}`; }, [tableConfig.selectedTable, screenId]); // 저장된 필터 설정 불러오기 useEffect(() => { if (!filterSettingKey || visibleColumns.length === 0) return; try { const saved = localStorage.getItem(filterSettingKey); if (saved) { const savedFilters = JSON.parse(saved); setVisibleFilterColumns(new Set(savedFilters)); } else { // 초기값: 빈 Set (아무것도 선택 안 함) setVisibleFilterColumns(new Set()); } } catch (error) { console.error("필터 설정 불러오기 실패:", error); setVisibleFilterColumns(new Set()); } }, [filterSettingKey, visibleColumns]); // 필터 설정 저장 const saveFilterSettings = useCallback(() => { if (!filterSettingKey) return; try { localStorage.setItem(filterSettingKey, JSON.stringify(Array.from(visibleFilterColumns))); setIsFilterSettingOpen(false); toast.success("검색 필터 설정이 저장되었습니다"); // 검색 값 초기화 setSearchValues({}); } catch (error) { console.error("필터 설정 저장 실패:", error); toast.error("설정 저장에 실패했습니다"); } }, [filterSettingKey, visibleFilterColumns]); // 필터 컬럼 토글 const toggleFilterVisibility = useCallback((columnName: string) => { setVisibleFilterColumns((prev) => { const newSet = new Set(prev); if (newSet.has(columnName)) { newSet.delete(columnName); } else { newSet.add(columnName); } return newSet; }); }, []); // 전체 선택/해제 const toggleAllFilters = useCallback(() => { const filterableColumns = visibleColumns.filter((col) => col.columnName !== "__checkbox__"); const columnNames = filterableColumns.map((col) => col.columnName); if (visibleFilterColumns.size === columnNames.length) { // 전체 해제 setVisibleFilterColumns(new Set()); } else { // 전체 선택 setVisibleFilterColumns(new Set(columnNames)); } }, [visibleFilterColumns, visibleColumns]); // 표시할 필터 목록 (선택된 컬럼만) const activeFilters = useMemo(() => { return visibleColumns .filter((col) => col.columnName !== "__checkbox__" && visibleFilterColumns.has(col.columnName)) .map((col) => ({ columnName: col.columnName, label: columnLabels[col.columnName] || col.displayName || col.columnName, type: col.format || "text", })); }, [visibleColumns, visibleFilterColumns, columnLabels]); // 그룹 설정 자동 저장 (localStorage) useEffect(() => { if (!groupSettingKey) return; try { localStorage.setItem(groupSettingKey, JSON.stringify(groupByColumns)); } catch (error) { console.error("그룹 설정 저장 실패:", error); } }, [groupSettingKey, groupByColumns]); // 그룹 컬럼 토글 const toggleGroupColumn = useCallback((columnName: string) => { setGroupByColumns((prev) => { if (prev.includes(columnName)) { return prev.filter((col) => col !== columnName); } else { return [...prev, columnName]; } }); }, []); // 사용자 옵션 저장 핸들러 const handleTableOptionsSave = useCallback( (config: { columns: Array<{ columnName: string; label: string; visible: boolean; width?: number; frozen?: boolean }>; showGridLines: boolean; viewMode: "table" | "card" | "grouped-card"; }) => { // 컬럼 순서 업데이트 const newColumnOrder = config.columns.map((col) => col.columnName); setColumnOrder(newColumnOrder); // 컬럼 너비 업데이트 const newWidths: Record = {}; config.columns.forEach((col) => { if (col.width) { newWidths[col.columnName] = col.width; } }); setColumnWidths(newWidths); // 틀고정 컬럼 업데이트 const newFrozenColumns = config.columns.filter((col) => col.frozen).map((col) => col.columnName); setFrozenColumns(newFrozenColumns); // 그리드선 표시 업데이트 setShowGridLines(config.showGridLines); // 보기 모드 업데이트 setViewMode(config.viewMode); // 컬럼 표시/숨기기 업데이트 const newDisplayColumns = displayColumns.map((col) => { const configCol = config.columns.find((c) => c.columnName === col.columnName); if (configCol) { return { ...col, visible: configCol.visible }; } return col; }); setDisplayColumns(newDisplayColumns); toast.success("테이블 옵션이 저장되었습니다"); }, [displayColumns], ); // 그룹 펼치기/접기 토글 const toggleGroupCollapse = useCallback((groupKey: string) => { setCollapsedGroups((prev) => { const newSet = new Set(prev); if (newSet.has(groupKey)) { newSet.delete(groupKey); } else { newSet.add(groupKey); } return newSet; }); }, []); // 그룹 해제 const clearGrouping = useCallback(() => { setGroupByColumns([]); setCollapsedGroups(new Set()); if (groupSettingKey) { localStorage.removeItem(groupSettingKey); } toast.success("그룹이 해제되었습니다"); }, [groupSettingKey]); // 데이터 그룹화 const groupedData = useMemo((): GroupedData[] => { if (groupByColumns.length === 0 || filteredData.length === 0) return []; const grouped = new Map(); filteredData.forEach((item) => { // 그룹 키 생성: "통화:KRW > 단위:EA" const keyParts = groupByColumns.map((col) => { // 카테고리/엔티티 타입인 경우 _name 필드 사용 const inputType = columnMeta?.[col]?.inputType; let displayValue = item[col]; if (inputType === 'category' || inputType === 'entity' || inputType === 'code') { // _name 필드가 있으면 사용 (예: division_name, writer_name) const nameField = `${col}_name`; if (item[nameField] !== undefined && item[nameField] !== null) { displayValue = item[nameField]; } } const label = columnLabels[col] || col; return `${label}:${displayValue !== null && displayValue !== undefined ? displayValue : "-"}`; }); const groupKey = keyParts.join(" > "); if (!grouped.has(groupKey)) { grouped.set(groupKey, []); } grouped.get(groupKey)!.push(item); }); return Array.from(grouped.entries()).map(([groupKey, items]) => { const groupValues: Record = {}; groupByColumns.forEach((col) => { groupValues[col] = items[0]?.[col]; }); // 🆕 그룹별 소계 계산 const groupSummary: Record = {}; // 숫자형 컬럼에 대해 소계 계산 (tableConfig.columns || []).forEach((col: { columnName: string }) => { if (col.columnName === "__checkbox__") return; const colMeta = columnMeta?.[col.columnName]; const inputType = colMeta?.inputType; const isNumeric = inputType === "number" || inputType === "decimal"; if (isNumeric) { const values = items .map((item) => parseFloat(item[col.columnName])) .filter((v) => !isNaN(v)); if (values.length > 0) { const sum = values.reduce((a, b) => a + b, 0); groupSummary[col.columnName] = { sum, avg: sum / values.length, count: values.length, }; } } }); return { groupKey, groupValues, items, count: items.length, summary: groupSummary, // 🆕 그룹별 소계 }; }); }, [data, groupByColumns, columnLabels, columnMeta, tableConfig.columns]); // 저장된 그룹 설정 불러오기 useEffect(() => { if (!groupSettingKey || visibleColumns.length === 0) return; try { const saved = localStorage.getItem(groupSettingKey); if (saved) { const savedGroups = JSON.parse(saved); setGroupByColumns(savedGroups); } } catch (error) { console.error("그룹 설정 불러오기 실패:", error); } }, [groupSettingKey, visibleColumns]); useEffect(() => { fetchColumnLabels(); fetchTableLabel(); }, [tableConfig.selectedTable, fetchColumnLabels, fetchTableLabel]); useEffect(() => { // console.log("🔍 [TableList] useEffect 실행 - 데이터 조회 트리거", { // isDesignMode, // tableName: tableConfig.selectedTable, // currentPage, // sortColumn, // sortDirection, // }); if (!isDesignMode && tableConfig.selectedTable) { fetchTableDataDebounced(); } }, [ tableConfig.selectedTable, currentPage, localPageSize, sortColumn, sortDirection, searchTerm, searchValues, // 필터 값 변경 시에도 데이터 새로고침 refreshKey, refreshTrigger, // 강제 새로고침 트리거 isDesignMode, splitPanelContext?.selectedLeftData, // 🆕 좌측 데이터 선택 변경 시 데이터 새로고침 // fetchTableDataDebounced 제거: useCallback 재생성으로 인한 무한 루프 방지 ]); useEffect(() => { if (tableConfig.refreshInterval && !isDesignMode) { const interval = setInterval(() => { fetchTableDataDebounced(); }, tableConfig.refreshInterval * 1000); return () => clearInterval(interval); } }, [tableConfig.refreshInterval, isDesignMode]); // 🆕 전역 테이블 새로고침 이벤트 리스너 useEffect(() => { const handleRefreshTable = () => { if (tableConfig.selectedTable && !isDesignMode) { console.log("🔄 [TableList] refreshTable 이벤트 수신 - 데이터 새로고침"); setRefreshTrigger((prev) => prev + 1); } }; window.addEventListener("refreshTable", handleRefreshTable); return () => { window.removeEventListener("refreshTable", handleRefreshTable); }; }, [tableConfig.selectedTable, isDesignMode]); // 🎯 컬럼 너비 자동 계산 (내용 기반) const calculateOptimalColumnWidth = useCallback((columnName: string, displayName: string): number => { // 기본 너비 설정 const MIN_WIDTH = 100; const MAX_WIDTH = 400; const PADDING = 48; // 좌우 패딩 + 여유 공간 const HEADER_PADDING = 60; // 헤더 추가 여유 (정렬 아이콘 등) // 헤더 텍스트 너비 계산 (대략 8px per character) const headerWidth = (displayName?.length || columnName.length) * 10 + HEADER_PADDING; // 데이터 셀 너비 계산 (상위 50개 샘플링) const sampleSize = Math.min(50, data.length); let maxDataWidth = headerWidth; for (let i = 0; i < sampleSize; i++) { const cellValue = data[i]?.[columnName]; if (cellValue !== null && cellValue !== undefined) { const cellText = String(cellValue); // 숫자는 좁게, 텍스트는 넓게 계산 const isNumber = !isNaN(Number(cellValue)) && cellValue !== ""; const charWidth = isNumber ? 8 : 9; const cellWidth = cellText.length * charWidth + PADDING; maxDataWidth = Math.max(maxDataWidth, cellWidth); } } // 최소/최대 범위 내로 제한 return Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, Math.ceil(maxDataWidth))); }, [data]); // 🎯 localStorage에서 컬럼 너비 불러오기 및 초기 계산 useEffect(() => { if (!hasInitializedWidths.current && visibleColumns.length > 0 && data.length > 0) { const timer = setTimeout(() => { const storageKey = tableConfig.selectedTable && userId ? `table_column_widths_${tableConfig.selectedTable}_${userId}` : null; // 1. localStorage에서 저장된 너비 불러오기 let savedWidths: Record = {}; if (storageKey) { try { const saved = localStorage.getItem(storageKey); if (saved) { savedWidths = JSON.parse(saved); } } catch (error) { console.error("컬럼 너비 불러오기 실패:", error); } } // 2. 자동 계산 또는 저장된 너비 적용 const newWidths: Record = {}; let hasAnyWidth = false; visibleColumns.forEach((column) => { // 체크박스 컬럼은 제외 (고정 48px) if (column.columnName === "__checkbox__") return; // 저장된 너비가 있으면 우선 사용 if (savedWidths[column.columnName]) { newWidths[column.columnName] = savedWidths[column.columnName]; hasAnyWidth = true; } else { // 저장된 너비가 없으면 자동 계산 const optimalWidth = calculateOptimalColumnWidth( column.columnName, columnLabels[column.columnName] || column.displayName ); newWidths[column.columnName] = optimalWidth; hasAnyWidth = true; } }); if (hasAnyWidth) { setColumnWidths(newWidths); hasInitializedWidths.current = true; } }, 150); // DOM 렌더링 대기 return () => clearTimeout(timer); } }, [visibleColumns, data, tableConfig.selectedTable, userId, calculateOptimalColumnWidth, columnLabels]); // ======================================== // 페이지네이션 JSX // ======================================== const paginationJSX = useMemo(() => { if (!tableConfig.pagination?.enabled || isDesignMode) return null; return (
{/* 중앙 페이지네이션 컨트롤 */}
{currentPage} / {totalPages || 1}
{/* 우측 버튼 그룹 */}
{/* 🆕 내보내기 버튼 (Excel/PDF) */}
Excel
PDF/인쇄
{/* 새로고침 버튼 */}
); }, [tableConfig.pagination, isDesignMode, currentPage, totalPages, totalItems, loading, selectedRows.size, exportToExcel, exportToPdf]); // ======================================== // 렌더링 // ======================================== const domProps = { onClick: handleClick, onDragStart: isDesignMode ? onDragStart : undefined, onDragEnd: isDesignMode ? onDragEnd : undefined, draggable: isDesignMode, className: cn("w-full h-full", className, isDesignMode && "cursor-move"), // customer-item-mapping과 동일 style: componentStyle, }; // 카드 모드 if (tableConfig.displayMode === "card" && !isDesignMode) { return (
{paginationJSX}
); } // SingleTableWithSticky 모드 if (tableConfig.stickyHeader && !isDesignMode) { return (
{/* 필터 헤더는 TableSearchWidget으로 이동 */} {/* 그룹 표시 배지 */} {groupByColumns.length > 0 && (
그룹:
{groupByColumns.map((col, idx) => ( {idx > 0 && } {columnLabels[col] || col} ))}
)}
) => { const column = visibleColumns.find((c) => c.columnName === columnName); return column ? formatCellValue(value, column, rowData) : String(value); }} getColumnWidth={getColumnWidth} containerWidth={calculatedWidth} />
{paginationJSX}
); } // 일반 테이블 모드 (네이티브 HTML 테이블) return ( <>
{/* 필터 헤더는 TableSearchWidget으로 이동 */} {/* 🆕 DevExpress 스타일 기능 툴바 */}
{/* 편집 모드 토글 */}
{/* 내보내기 버튼들 */}
{/* 복사 버튼 */}
{/* 선택 정보 */} {selectedRows.size > 0 && (
{selectedRows.size}개 선택됨
)} {/* 🆕 통합 검색 패널 */}
{isSearchPanelOpen ? (
setGlobalSearchTerm(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { executeGlobalSearch(globalSearchTerm); } else if (e.key === "Escape") { clearGlobalSearch(); } else if (e.key === "F3" || (e.key === "g" && (e.ctrlKey || e.metaKey))) { e.preventDefault(); if (e.shiftKey) { goToPrevSearchResult(); } else { goToNextSearchResult(); } } }} placeholder="검색어 입력... (Enter)" className="border-input bg-background h-7 w-32 rounded border px-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary sm:w-48" autoFocus /> {searchHighlights.size > 0 && ( {searchHighlights.size}개 )}
) : ( )}
{/* 🆕 Filter Builder (고급 필터) 버튼 */}
{activeFilterCount > 0 && ( )}
{/* 새로고침 */}
{/* 🆕 배치 편집 툴바 */} {(editMode === "batch" || pendingChanges.size > 0) && (
배치 편집 모드 {pendingChanges.size > 0 && ( {pendingChanges.size}개 변경사항 )}
)} {/* 그룹 표시 배지 */} {groupByColumns.length > 0 && (
그룹:
{groupByColumns.map((col, idx) => ( {idx > 0 && } {columnLabels[col] || col} ))}
)} {/* 테이블 컨테이너 - 키보드 네비게이션 지원 */}
{/* 스크롤 영역 */}
{/* 테이블 */} {/* 헤더 (sticky) */} {/* 🆕 Multi-Level Headers (Column Bands) */} {columnBandsInfo?.hasBands && ( {visibleColumns.map((column, colIdx) => { // 이 컬럼이 속한 band 찾기 const band = columnBandsInfo.bands.find( (b) => b.columns.includes(column.columnName) && b.startIndex === colIdx ); // band의 첫 번째 컬럼인 경우에만 렌더링 if (band) { return ( ); } // band에 속하지 않은 컬럼 (개별 표시) const isInAnyBand = columnBandsInfo.bands.some( (b) => b.columns.includes(column.columnName) ); if (!isInAnyBand) { return ( ); } // band의 중간 컬럼은 렌더링하지 않음 return null; })} )} {visibleColumns.map((column, columnIndex) => { const columnWidth = columnWidths[column.columnName]; const isFrozen = frozenColumns.includes(column.columnName); const frozenIndex = frozenColumns.indexOf(column.columnName); // 틀고정된 컬럼의 left 위치 계산 let leftPosition = 0; if (isFrozen && frozenIndex > 0) { for (let i = 0; i < frozenIndex; i++) { const frozenCol = frozenColumns[i]; const frozenColWidth = columnWidths[frozenCol] || 150; leftPosition += frozenColWidth; } } // 🆕 Column Reordering 상태 const isColumnDragging = draggedColumnIndex === columnIndex; const isColumnDropTarget = dropTargetColumnIndex === columnIndex; return ( ); })} {/* 바디 (스크롤) */} {loading ? ( ) : error ? ( ) : data.length === 0 ? ( ) : groupByColumns.length > 0 && groupedData.length > 0 ? ( // 그룹화된 렌더링 groupedData.map((group) => { const isCollapsed = collapsedGroups.has(group.groupKey); return ( {/* 그룹 헤더 */} {/* 그룹 데이터 */} {!isCollapsed && group.items.map((row, index) => ( handleRowClick(row, index, e)} > {visibleColumns.map((column, colIndex) => { const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; const cellValue = row[mappedColumnName]; const meta = columnMeta[column.columnName]; const inputType = meta?.inputType || column.inputType; const isNumeric = inputType === "number" || inputType === "decimal"; const isFrozen = frozenColumns.includes(column.columnName); const frozenIndex = frozenColumns.indexOf(column.columnName); // 틀고정된 컬럼의 left 위치 계산 let leftPosition = 0; if (isFrozen && frozenIndex > 0) { for (let i = 0; i < frozenIndex; i++) { const frozenCol = frozenColumns[i]; const frozenColWidth = columnWidths[frozenCol] || 150; leftPosition += frozenColWidth; } } return ( ); })} ))} {/* 🆕 그룹별 소계 행 */} {!isCollapsed && group.summary && Object.keys(group.summary).length > 0 && ( {visibleColumns.map((column, colIndex) => { const summary = group.summary?.[column.columnName]; const meta = columnMeta[column.columnName]; const inputType = meta?.inputType || (column as any).inputType; const isNumeric = inputType === "number" || inputType === "decimal"; if (colIndex === 0 && column.columnName === "__checkbox__") { return ( ); } if (colIndex === 0 && column.columnName !== "__checkbox__") { return ( ); } if (summary) { return ( ); } return )} ); }) ) : ( // 일반 렌더링 (그룹 없음) - 키보드 네비게이션 지원 <> {/* 🆕 Virtual Scrolling: Top Spacer */} {isVirtualScrollEnabled && virtualScrollInfo.topSpacerHeight > 0 && ( )} {/* 데이터 행 렌더링 */} {(isVirtualScrollEnabled ? virtualScrollInfo.visibleData : filteredData).map((row, idx) => { // Virtual Scrolling에서는 실제 인덱스 계산 const index = isVirtualScrollEnabled ? virtualScrollInfo.startIndex + idx : idx; const rowKey = getRowKey(row, index); const isRowSelected = selectedRows.has(rowKey); const isRowFocused = focusedCell?.rowIndex === index; // 🆕 Drag & Drop 상태 const isDragging = draggedRowIndex === index; const isDropTarget = dropTargetIndex === index; return ( handleRowClick(row, index, e)} role="row" aria-selected={isRowSelected} // 🆕 Drag & Drop 이벤트 draggable={isDragEnabled} onDragStart={(e) => handleRowDragStart(e, index)} onDragOver={(e) => handleRowDragOver(e, index)} onDragEnd={handleRowDragEnd} onDrop={(e) => handleRowDrop(e, index)} > {visibleColumns.map((column, colIndex) => { const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; // 🆕 배치 편집: 로컬 수정 데이터 우선 표시 const cellValue = editMode === "batch" ? getDisplayValue(row, index, mappedColumnName) : row[mappedColumnName]; const meta = columnMeta[column.columnName]; const inputType = meta?.inputType || column.inputType; const isNumeric = inputType === "number" || inputType === "decimal"; const isFrozen = frozenColumns.includes(column.columnName); const frozenIndex = frozenColumns.indexOf(column.columnName); // 셀 포커스 상태 const isCellFocused = focusedCell?.rowIndex === index && focusedCell?.colIndex === colIndex; // 🆕 배치 편집: 수정된 셀 여부 const isModified = isCellModified(index, mappedColumnName); // 🆕 유효성 검사 에러 const cellValidationError = getCellValidationError(index, mappedColumnName); // 🆕 검색 하이라이트 여부 const isSearchHighlighted = searchHighlights.has(`${index}-${colIndex}`); // 틀고정된 컬럼의 left 위치 계산 let leftPosition = 0; if (isFrozen && frozenIndex > 0) { for (let i = 0; i < frozenIndex; i++) { const frozenCol = frozenColumns[i]; const frozenColWidth = columnWidths[frozenCol] || 150; leftPosition += frozenColWidth; } } return ( ); })} ); })} {/* 🆕 Virtual Scrolling: Bottom Spacer */} {isVirtualScrollEnabled && virtualScrollInfo.bottomSpacerHeight > 0 && ( )} )} {/* 🆕 데이터 요약 (Total Summaries) */} {summaryData && Object.keys(summaryData).length > 0 && ( {visibleColumns.map((column, colIndex) => { const summary = summaryData[column.columnName]; const columnWidth = columnWidths[column.columnName]; const isFrozen = frozenColumns.includes(column.columnName); const frozenIndex = frozenColumns.indexOf(column.columnName); // 틀고정된 컬럼의 left 위치 계산 let leftPosition = 0; if (isFrozen && frozenIndex > 0) { for (let i = 0; i < frozenIndex; i++) { const frozenCol = frozenColumns[i]; const frozenColWidth = columnWidths[frozenCol] || 150; leftPosition += frozenColWidth; } } const meta = columnMeta[column.columnName]; const inputType = meta?.inputType || (column as any).inputType; const isNumeric = inputType === "number" || inputType === "decimal"; return ( ); })} )}
{band.caption} {columnLabels[column.columnName] || column.columnName}
(columnRefs.current[column.columnName] = el)} className={cn( "text-foreground/90 relative h-8 overflow-hidden text-xs font-bold text-ellipsis whitespace-nowrap select-none sm:h-10 sm:text-sm", column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-2", column.sortable !== false && column.columnName !== "__checkbox__" && "hover:bg-muted/70 cursor-pointer transition-colors", isFrozen && "sticky z-60 shadow-[2px_0_4px_rgba(0,0,0,0.1)]", // 🆕 Column Reordering 스타일 isColumnDragEnabled && column.columnName !== "__checkbox__" && "cursor-grab active:cursor-grabbing", isColumnDragging && "opacity-50 bg-primary/20", isColumnDropTarget && "border-l-4 border-l-primary", )} style={{ textAlign: column.columnName === "__checkbox__" ? "center" : "center", width: column.columnName === "__checkbox__" ? "48px" : columnWidth ? `${columnWidth}px` : undefined, minWidth: column.columnName === "__checkbox__" ? "48px" : undefined, maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined, userSelect: "none", backgroundColor: "hsl(var(--muted))", ...(isFrozen && { left: `${leftPosition}px` }), }} // 🆕 Column Reordering 이벤트 draggable={isColumnDragEnabled && column.columnName !== "__checkbox__"} onDragStart={(e) => handleColumnDragStart(e, columnIndex)} onDragOver={(e) => handleColumnDragOver(e, columnIndex)} onDragEnd={handleColumnDragEnd} onDrop={(e) => handleColumnDrop(e, columnIndex)} onClick={() => { if (isResizing.current) return; if (column.sortable !== false && column.columnName !== "__checkbox__") { handleSort(column.columnName); } }} > {column.columnName === "__checkbox__" ? ( renderCheckboxHeader() ) : (
{/* 🆕 편집 불가 컬럼 표시 */} {column.editable === false && ( )} {columnLabels[column.columnName] || column.displayName} {column.sortable !== false && sortColumn === column.columnName && ( {sortDirection === "asc" ? "↑" : "↓"} )} {/* 🆕 헤더 필터 버튼 */} {tableConfig.headerFilter !== false && columnUniqueValues[column.columnName]?.length > 0 && ( setOpenFilterColumn(open ? column.columnName : null)} > e.stopPropagation()} >
필터: {columnLabels[column.columnName] || column.displayName} {headerFilters[column.columnName]?.size > 0 && ( )}
{columnUniqueValues[column.columnName]?.slice(0, 50).map((val) => { const isSelected = headerFilters[column.columnName]?.has(val); return (
toggleHeaderFilter(column.columnName, val)} >
{isSelected && }
{val || "(빈 값)"}
); })} {(columnUniqueValues[column.columnName]?.length || 0) > 50 && (
...외 {(columnUniqueValues[column.columnName]?.length || 0) - 50}개
)}
)}
)} {/* 리사이즈 핸들 (체크박스 제외) */} {columnIndex < visibleColumns.length - 1 && column.columnName !== "__checkbox__" && (
e.stopPropagation()} // 정렬 클릭 방지 onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); const thElement = columnRefs.current[column.columnName]; if (!thElement) return; isResizing.current = true; const startX = e.clientX; const startWidth = columnWidth || thElement.offsetWidth; // 드래그 중 텍스트 선택 방지 document.body.style.userSelect = "none"; document.body.style.cursor = "col-resize"; const handleMouseMove = (moveEvent: MouseEvent) => { moveEvent.preventDefault(); const diff = moveEvent.clientX - startX; const newWidth = Math.max(80, startWidth + diff); // 직접 DOM 스타일 변경 (리렌더링 없음) if (thElement) { thElement.style.width = `${newWidth}px`; } }; const handleMouseUp = () => { // 최종 너비를 state에 저장 if (thElement) { const finalWidth = Math.max(80, thElement.offsetWidth); setColumnWidths((prev) => { const newWidths = { ...prev, [column.columnName]: finalWidth }; // 🎯 localStorage에 컬럼 너비 저장 (사용자별) if (tableConfig.selectedTable && userId) { const storageKey = `table_column_widths_${tableConfig.selectedTable}_${userId}`; try { localStorage.setItem(storageKey, JSON.stringify(newWidths)); } catch (error) { console.error("컬럼 너비 저장 실패:", error); } } return newWidths; }); } // 텍스트 선택 복원 document.body.style.userSelect = ""; document.body.style.cursor = ""; // 약간의 지연 후 리사이즈 플래그 해제 (클릭 이벤트가 먼저 처리되지 않도록) setTimeout(() => { isResizing.current = false; }, 100); document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); }; document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); }} /> )}
로딩 중...
오류 발생
{error}
데이터가 없습니다
조건을 변경하거나 새로운 데이터를 추가해보세요
toggleGroupCollapse(group.groupKey)} > {isCollapsed ? ( ) : ( )} {group.groupKey} ({group.count}건)
{column.columnName === "__checkbox__" ? renderCheckboxCell(row, index) : formatCellValue(cellValue, column, row)}
소계 소계 ({group.count}건) {summary.sum.toLocaleString()} ; })}
handleCellClick(index, colIndex, e)} onDoubleClick={() => handleCellDoubleClick(index, colIndex, column.columnName, cellValue)} onContextMenu={(e) => handleContextMenu(e, index, colIndex, row)} role="gridcell" tabIndex={isCellFocused ? 0 : -1} > {/* 🆕 인라인 편집 모드 */} {editingCell?.rowIndex === index && editingCell?.colIndex === colIndex ? ( // 🆕 Cascading Lookups: 드롭다운 또는 일반 입력 (() => { const cascadingConfig = (tableConfig as any).cascadingLookups?.[column.columnName]; const options = cascadingConfig ? getCascadingOptions(column.columnName, row) : []; // 부모 값이 변경되면 옵션 로딩 if (cascadingConfig && options.length === 0) { const parentValue = row[cascadingConfig.parentColumn]; if (parentValue !== undefined && parentValue !== null) { loadCascadingOptions(column.columnName, cascadingConfig.parentColumn, parentValue); } } // 카테고리/코드 타입이거나 Cascading Lookup인 경우 드롭다운 const colMeta = columnMeta[column.columnName]; const isCategoryType = colMeta?.inputType === "category" || colMeta?.inputType === "code"; const hasCategoryOptions = categoryMappings[column.columnName] && Object.keys(categoryMappings[column.columnName]).length > 0; if (cascadingConfig || (isCategoryType && hasCategoryOptions)) { const selectOptions = cascadingConfig ? options : Object.entries(categoryMappings[column.columnName] || {}).map(([value, info]) => ({ value, label: info.label, })); return ( ); } // 일반 입력 필드 return ( setEditingValue(e.target.value)} onKeyDown={handleEditKeyDown} onBlur={saveEditing} className="w-full h-full px-2 py-1 sm:px-4 sm:py-1.5 text-xs sm:text-sm border-2 border-primary bg-background focus:outline-none" style={{ textAlign: isNumeric ? "right" : column.align || "left", }} /> ); })() ) : column.columnName === "__checkbox__" ? ( renderCheckboxCell(row, index) ) : ( formatCellValue(cellValue, column, row) )}
{summary ? (
{summary.label} {typeof summary.value === "number" ? summary.value.toLocaleString("ko-KR", { maximumFractionDigits: 2, }) : summary.value}
) : colIndex === 0 ? ( 요약 ) : null}
{/* 페이지네이션 */} {paginationJSX}
{/* 필터 설정 다이얼로그 */} 검색 필터 설정 검색 필터로 사용할 컬럼을 선택하세요. 선택한 컬럼의 검색 입력 필드가 표시됩니다.
{/* 전체 선택/해제 */}
col.columnName !== "__checkbox__").length && visibleColumns.filter((col) => col.columnName !== "__checkbox__").length > 0 } onCheckedChange={toggleAllFilters} /> {visibleFilterColumns.size} / {visibleColumns.filter((col) => col.columnName !== "__checkbox__").length} 개
{/* 컬럼 목록 */}
{visibleColumns .filter((col) => col.columnName !== "__checkbox__") .map((col) => (
toggleFilterVisibility(col.columnName)} />
))}
{/* 선택된 컬럼 개수 안내 */}
{visibleFilterColumns.size === 0 ? ( 검색 필터를 사용하려면 최소 1개 이상의 컬럼을 선택하세요 ) : ( {visibleFilterColumns.size}개의 검색 필터가 표시됩니다 )}
{/* 🆕 Context Menu (우클릭 메뉴) */} {contextMenu && (
e.stopPropagation()} >
{/* 셀 복사 */} {/* 행 복사 */}
{/* 셀 편집 */} {(() => { const col = visibleColumns[contextMenu.colIndex]; const isEditable = col?.editable !== false && col?.columnName !== "__checkbox__"; return ( ); })()} {/* 행 선택/해제 */}
{/* 행 삭제 */}
)} {/* 🆕 Filter Builder 모달 */} 고급 필터 여러 조건을 조합하여 데이터를 필터링합니다.
{filterGroups.length === 0 ? (
필터 조건이 없습니다. 아래 버튼을 클릭하여 조건을 추가하세요.
) : ( filterGroups.map((group, groupIndex) => (
조건 그룹 {groupIndex + 1}
{group.conditions.map((condition) => (
{/* 컬럼 선택 */} {/* 연산자 선택 */} {/* 값 입력 (isEmpty/isNotEmpty가 아닌 경우만) */} {condition.operator !== "isEmpty" && condition.operator !== "isNotEmpty" && ( updateFilterCondition(group.id, condition.id, "value", e.target.value)} placeholder="값 입력" className="border-input bg-background h-8 flex-1 rounded border px-2 text-xs" /> )} {/* 조건 삭제 */}
))}
{/* 조건 추가 버튼 */}
)) )} {/* 그룹 추가 버튼 */}
{/* 테이블 옵션 모달 */} setIsTableOptionsOpen(false)} columns={visibleColumns.map((col) => ({ columnName: col.columnName, label: columnLabels[col.columnName] || col.displayName || col.columnName, visible: col.visible !== false, width: columnWidths[col.columnName], frozen: frozenColumns.includes(col.columnName), }))} onSave={handleTableOptionsSave} tableName={tableConfig.selectedTable || "table"} userId={userId} /> ); }; export const TableListWrapper: React.FC = (props) => { return ; };