/** * TinyPivot React - Main DataGrid Component * Excel-like data grid with optional pivot table functionality */ import type { AIAnalystConfig, AIConversationUpdateEvent, AIDataLoadedEvent, AIErrorEvent, AIQueryExecutedEvent, ChartConfig, DateFormat, DateRange, DrillThroughResult, NumberFormat, PivotLayout, Theme, } from '@smallwebco/tinypivot-core' import type { AIAnalystHandle } from './AIAnalyst' import { canUseDrillThrough, formatDate as coreFormatDate, formatNumber as coreFormatNumber, getDrillThroughRows, getFieldUniqueValues } from '@smallwebco/tinypivot-core' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { useExcelGrid } from '../hooks/useExcelGrid' import { copyToClipboard, exportPivotToCSV, exportPivotToXLSX, exportToCSV, exportToXLSX, formatSelectionForClipboard, } from '../hooks/useGridFeatures' import { useLicense } from '../hooks/useLicense' import { usePivotTable } from '../hooks/usePivotTable' import { AIAnalyst } from './AIAnalyst' import { ChartBuilder } from './ChartBuilder' import { ColumnFilter } from './ColumnFilter' import { DrillThroughModal } from './DrillThroughModal' import { ExportMenu } from './ExportMenu' import { PivotConfig } from './PivotConfig' import { PivotSkeleton } from './PivotSkeleton' // Re-export AI types for public API export type { AIAnalystConfig, AIConversationUpdateEvent, AIDataLoadedEvent, AIErrorEvent, AIQueryExecutedEvent } interface DataGridProps { data: Record[] loading?: boolean rowHeight?: number headerHeight?: number fontSize?: 'xs' | 'sm' | 'base' showPivot?: boolean enableExport?: boolean enableSearch?: boolean enablePagination?: boolean pageSize?: number enableColumnResize?: boolean enableClipboard?: boolean theme?: Theme stripedRows?: boolean exportFilename?: string enableVerticalResize?: boolean initialHeight?: number minHeight?: number maxHeight?: number /** Number display format */ numberFormat?: NumberFormat /** Date display format */ dateFormat?: DateFormat /** Override auto-detected chart field roles per column name */ fieldRoleOverrides?: Record /** AI Data Analyst configuration (Pro feature, disabled by default) */ aiAnalyst?: AIAnalystConfig /** Enable row group collapse/expand (default true) */ enableDrillDown?: boolean /** Enable drill-through on double-click (Pro feature, default true) */ enableDrillThrough?: boolean /** Pivot row layout: 'grouped' merges repeated parent cells, 'tabular' repeats every value */ pivotLayout?: PivotLayout onCellClick?: (payload: { row: number col: number value: unknown rowData: Record }) => void onSelectionChange?: (payload: { cells: Array<{ row: number, col: number }>, values: unknown[] }) => void onExport?: (payload: { rowCount: number, filename: string }) => void onCopy?: (payload: { text: string, cellCount: number }) => void // AI Analyst events onAIDataLoaded?: (payload: AIDataLoadedEvent) => void onAIConversationUpdate?: (payload: AIConversationUpdateEvent) => void onAIQueryExecuted?: (payload: AIQueryExecutedEvent) => void onAIError?: (payload: AIErrorEvent) => void // Drill-down/through events onCollapseChange?: (collapsedPaths: string[]) => void onDrillThrough?: (result: DrillThroughResult) => void } const MIN_COL_WIDTH = 120 const MAX_COL_WIDTH = 350 export function DataGrid({ data, loading = false, fontSize: initialFontSize = 'xs', showPivot = true, enableExport = true, enableSearch = true, enablePagination = false, pageSize = 50, enableColumnResize = true, enableClipboard = true, theme = 'light', stripedRows = true, exportFilename = 'data-export.csv', enableVerticalResize = true, initialHeight = 600, minHeight = 300, maxHeight = 1200, numberFormat = 'us', dateFormat = 'iso', fieldRoleOverrides, aiAnalyst, enableDrillDown = true, enableDrillThrough = true, pivotLayout = 'grouped', onCellClick, onExport, onCopy, onAIDataLoaded, onAIConversationUpdate, onAIQueryExecuted, onAIError, onCollapseChange, onDrillThrough, }: DataGridProps) { const { showWatermark, canUsePivot, canUseCharts, canUseAIAnalyst, canUseXlsxExport, isDemo, licenseInfo } = useLicense() // Drill-through state const [drillThroughResult, setDrillThroughResult] = useState(null) const [showDrillThroughModal, setShowDrillThroughModal] = useState(false) // Check if AI Analyst should be shown (enabled in config + licensed) const showAIAnalyst = aiAnalyst?.enabled && canUseAIAnalyst // Theme handling const currentTheme = useMemo(() => { if (theme === 'auto') { return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' } return theme }, [theme]) // State const [currentFontSize, setCurrentFontSize] = useState(initialFontSize) const [globalSearchTerm, setGlobalSearchTerm] = useState('') const [showSearchInput, setShowSearchInput] = useState(false) const [currentPage, setCurrentPage] = useState(1) const [columnWidths, setColumnWidths] = useState>({}) const [resizingColumnId, setResizingColumnId] = useState(null) const [resizeStartX, setResizeStartX] = useState(0) const [resizeStartWidth, setResizeStartWidth] = useState(0) const [gridHeight, setGridHeight] = useState(initialHeight) const [isResizingVertically, setIsResizingVertically] = useState(false) const [verticalResizeStartY, setVerticalResizeStartY] = useState(0) const [verticalResizeStartHeight, setVerticalResizeStartHeight] = useState(0) const [showCopyToast, setShowCopyToast] = useState(false) const [copyToastMessage, setCopyToastMessage] = useState('') const [viewMode, setViewMode] = useState<'ai' | 'grid' | 'pivot' | 'chart'>('grid') // AI Analyst ref (for accessing loadFullData) const aiAnalystRef = useRef(null) // AI-loaded data (replaces current data when AI loads results) const [aiLoadedData, setAiLoadedData] = useState[] | null>(null) // Loading state for full data reset const [isLoadingFullData, setIsLoadingFullData] = useState(false) // Data to display - AI loaded data takes precedence const displayData = useMemo(() => aiLoadedData || data, [aiLoadedData, data]) const [_chartConfig, setChartConfig] = useState(null) const handleChartConfigChange = useCallback((config: ChartConfig) => { setChartConfig(config) }, []) const [showPivotConfig, setShowPivotConfig] = useState(true) const [draggingField, setDraggingField] = useState(null) const [activeFilterColumn, setActiveFilterColumn] = useState(null) const [filterDropdownPosition, setFilterDropdownPosition] = useState({ top: 0, left: 0, maxHeight: 400 }) const [selectedCell, setSelectedCell] = useState<{ row: number, col: number } | null>(null) const [selectionStart, setSelectionStart] = useState<{ row: number, col: number } | null>(null) const [selectionEnd, setSelectionEnd] = useState<{ row: number, col: number } | null>(null) const [isSelecting, setIsSelecting] = useState(false) const tableContainerRef = useRef(null) const tableBodyRef = useRef(null) const fontSizeOptions = [ { value: 'xs' as const, label: 'S' }, { value: 'sm' as const, label: 'M' }, { value: 'base' as const, label: 'L' }, ] // Grid composable const { table, columnKeys, filteredRowCount, totalRowCount, getColumnStats, hasActiveFilter, setColumnFilter, getColumnFilterValues, clearAllFilters, toggleSort, getSortDirection, columnFilters, activeFilters, // Numeric range filters setNumericRangeFilter, getNumericRangeFilter, // Date range filters setDateRangeFilter, getDateRangeFilter, } = useExcelGrid({ data: displayData, enableSorting: true, enableFiltering: true }) // Filtered data for pivot table const filteredDataForPivot = useMemo(() => { const filteredRows = table.getFilteredRowModel().rows return filteredRows.map(row => row.original) }, [table, columnFilters]) // Pivot table composable const { rowFields: pivotRowFields, columnFields: pivotColumnFields, valueFields: pivotValueFields, showRowTotals: pivotShowRowTotals, showColumnTotals: pivotShowColumnTotals, calculatedFields: pivotCalculatedFields, availableFields: pivotAvailableFields, isConfigured: pivotIsConfigured, pivotResult, addRowField, removeRowField, addColumnField, removeColumnField, addValueField, removeValueField, updateValueFieldAggregation, clearConfig: clearPivotConfig, autoSuggestConfig, setShowRowTotals: setPivotShowRowTotals, setShowColumnTotals: setPivotShowColumnTotals, setRowFields, setColumnFields, addCalculatedField, removeCalculatedField, toggleCollapsedPath, fieldFilters: pivotFieldFilters, activeFieldFilters: pivotActiveFieldFilters, setFieldFilter: setPivotFieldFilter, } = usePivotTable(filteredDataForPivot, enableDrillDown) // Unique values per axis field — powers the axis filter UI in PivotConfig const getPivotFieldValues = useCallback( (field: string): string[] => getFieldUniqueValues(filteredDataForPivot, field), [filteredDataForPivot], ) const handleToggleCollapse = useCallback( (key: string, altKey: boolean) => { const next = toggleCollapsedPath(key, altKey, pivotRowFields, pivotResult) onCollapseChange?.(Array.from(next)) }, [toggleCollapsedPath, pivotRowFields, pivotResult, onCollapseChange], ) const handleDrillThroughCell = useCallback( (payload: { rowPath: string[], columnPath: string[], valueFieldIndex: number }) => { if (!enableDrillThrough) return if (!canUseDrillThrough(licenseInfo)) { console.warn('[TinyPivot] "Drill-through" requires a Pro license. Visit https://tiny-pivot.com/#pricing to upgrade.') return } const pivotConfig = { rowFields: pivotRowFields, columnFields: pivotColumnFields, valueFields: pivotValueFields, showRowTotals: pivotShowRowTotals, showColumnTotals: pivotShowColumnTotals, fieldFilters: pivotActiveFieldFilters, } const result = getDrillThroughRows( filteredDataForPivot, pivotConfig, payload.rowPath, payload.columnPath, payload.valueFieldIndex, ) setDrillThroughResult(result) setShowDrillThroughModal(true) onDrillThrough?.(result) }, [ enableDrillThrough, licenseInfo, pivotRowFields, pivotColumnFields, pivotValueFields, pivotShowRowTotals, pivotShowColumnTotals, pivotActiveFieldFilters, filteredDataForPivot, onDrillThrough, ], ) const handleCloseDrillThroughModal = useCallback(() => { setShowDrillThroughModal(false) }, []) // Active filters info for display const activeFilterInfo = useMemo(() => { if (activeFilters.length === 0) return null return activeFilters.map((f) => { if (f.type === 'range' && f.range) { // Format numeric range filter display const parts = [] if (f.range.min !== null) parts.push(`\u2265 ${f.range.min}`) if (f.range.max !== null) parts.push(`\u2264 ${f.range.max}`) return { column: f.column, valueCount: 1, displayText: parts.join(' and '), isRange: true, } } if (f.type === 'dateRange' && f.dateRange) { // Format date range filter display const parts = [] if (f.dateRange.min !== null) parts.push(`from ${coreFormatDate(f.dateRange.min, dateFormat)}`) if (f.dateRange.max !== null) parts.push(`to ${coreFormatDate(f.dateRange.max, dateFormat)}`) return { column: f.column, valueCount: 1, displayText: parts.join(' '), isRange: true, } } return { column: f.column, valueCount: f.values?.length || 0, values: f.values || [], isRange: false, } }) }, [activeFilters, dateFormat]) // Rows - depends on columnFilters to recompute when filters change const rows = useMemo(() => table.getFilteredRowModel().rows, [table, columnFilters]) // Filtered data based on global search const searchFilteredData = useMemo(() => { if (!globalSearchTerm.trim() || !enableSearch) { return rows } const term = globalSearchTerm.toLowerCase().trim() return rows.filter((row) => { for (const col of columnKeys) { const value = row.original[col] if (value === null || value === undefined) continue if (String(value).toLowerCase().includes(term)) { return true } } return false }) }, [rows, globalSearchTerm, enableSearch, columnKeys]) // Paginated rows const totalSearchedRows = searchFilteredData.length const totalPages = useMemo(() => { if (!enablePagination) return 1 return Math.max(1, Math.ceil(totalSearchedRows / pageSize)) }, [enablePagination, totalSearchedRows, pageSize]) const paginatedRows = useMemo(() => { if (!enablePagination) return searchFilteredData const start = (currentPage - 1) * pageSize const end = start + pageSize return searchFilteredData.slice(start, end) }, [enablePagination, searchFilteredData, currentPage, pageSize]) // Reset to page 1 when filters or search changes useEffect(() => { setCurrentPage(1) }, [columnFilters, globalSearchTerm]) // Selection bounds const selectionBounds = useMemo(() => { if (!selectionStart || !selectionEnd) return null return { minRow: Math.min(selectionStart.row, selectionEnd.row), maxRow: Math.max(selectionStart.row, selectionEnd.row), minCol: Math.min(selectionStart.col, selectionEnd.col), maxCol: Math.max(selectionStart.col, selectionEnd.col), } }, [selectionStart, selectionEnd]) // Selection stats const selectionStats = useMemo(() => { if (!selectionBounds) return null const { minRow, maxRow, minCol, maxCol } = selectionBounds const values: number[] = [] let count = 0 for (let r = minRow; r <= maxRow; r++) { const row = rows[r] if (!row) continue for (let c = minCol; c <= maxCol; c++) { const colId = columnKeys[c] if (!colId) continue const value = row.original[colId] count++ if (value !== null && value !== undefined && value !== '') { const num = typeof value === 'number' ? value : Number.parseFloat(String(value)) if (!Number.isNaN(num)) { values.push(num) } } } } if (values.length === 0) return { count, sum: null, avg: null, numericCount: 0 } const sum = values.reduce((a, b) => a + b, 0) const avg = sum / values.length return { count, sum, avg, numericCount: values.length } }, [selectionBounds, rows, columnKeys]) // Calculate column widths useEffect(() => { // Skip during SSR (no document available) if (typeof document === 'undefined') return if (displayData.length === 0) return const widths: Record = {} const sampleSize = Math.min(100, displayData.length) const canvas = document.createElement('canvas') const ctx = canvas.getContext('2d') if (!ctx) return ctx.font = '13px system-ui, -apple-system, sans-serif' for (const key of columnKeys) { let maxWidth = ctx.measureText(key).width + 56 for (let i = 0; i < sampleSize; i++) { const value = displayData[i][key] const text = value === null || value === undefined ? '' : String(value) const width = ctx.measureText(text).width + 28 maxWidth = Math.max(maxWidth, width) } widths[key] = Math.min(Math.max(maxWidth, MIN_COL_WIDTH), MAX_COL_WIDTH) } setColumnWidths(widths) }, [displayData, columnKeys]) // Column resize handlers const startColumnResize = useCallback( (columnId: string, event: React.MouseEvent) => { if (!enableColumnResize) return event.preventDefault() event.stopPropagation() setResizingColumnId(columnId) setResizeStartX(event.clientX) setResizeStartWidth(columnWidths[columnId] || MIN_COL_WIDTH) }, [enableColumnResize, columnWidths], ) useEffect(() => { if (!resizingColumnId) return const handleResizeMove = (event: MouseEvent) => { const diff = event.clientX - resizeStartX const newWidth = Math.max(MIN_COL_WIDTH, Math.min(MAX_COL_WIDTH, resizeStartWidth + diff)) setColumnWidths(prev => ({ ...prev, [resizingColumnId]: newWidth, })) } const handleResizeEnd = () => { setResizingColumnId(null) } document.addEventListener('mousemove', handleResizeMove) document.addEventListener('mouseup', handleResizeEnd) return () => { document.removeEventListener('mousemove', handleResizeMove) document.removeEventListener('mouseup', handleResizeEnd) } }, [resizingColumnId, resizeStartX, resizeStartWidth]) // Vertical resize handlers const startVerticalResize = useCallback( (event: React.MouseEvent) => { if (!enableVerticalResize) return event.preventDefault() setIsResizingVertically(true) setVerticalResizeStartY(event.clientY) setVerticalResizeStartHeight(gridHeight) }, [enableVerticalResize, gridHeight], ) useEffect(() => { if (!isResizingVertically) return const handleVerticalResizeMove = (event: MouseEvent) => { const diff = event.clientY - verticalResizeStartY const newHeight = Math.max(minHeight, Math.min(maxHeight, verticalResizeStartHeight + diff)) setGridHeight(newHeight) } const handleVerticalResizeEnd = () => { setIsResizingVertically(false) } document.addEventListener('mousemove', handleVerticalResizeMove) document.addEventListener('mouseup', handleVerticalResizeEnd) return () => { document.removeEventListener('mousemove', handleVerticalResizeMove) document.removeEventListener('mouseup', handleVerticalResizeEnd) } }, [isResizingVertically, verticalResizeStartY, verticalResizeStartHeight, minHeight, maxHeight]) // Export handlers const handleExport = useCallback(() => { if (viewMode === 'pivot') { if (!pivotResult) return const pivotFilename = exportFilename.replace('.csv', '-pivot.csv') exportPivotToCSV( { headers: pivotResult.headers, rowHeaders: pivotResult.rowHeaders, data: pivotResult.data, rowTotals: pivotResult.rowTotals, columnTotals: pivotResult.columnTotals, grandTotal: pivotResult.grandTotal, showRowTotals: pivotShowRowTotals, showColumnTotals: pivotShowColumnTotals, }, pivotRowFields, pivotColumnFields, pivotValueFields, { filename: pivotFilename }, ) onExport?.({ rowCount: pivotResult.rowHeaders.length, filename: pivotFilename }) return } const dataToExport = enableSearch && globalSearchTerm.trim() ? searchFilteredData.map(row => row.original) : rows.map(row => row.original) exportToCSV(dataToExport, columnKeys, { filename: exportFilename, includeHeaders: true, }) onExport?.({ rowCount: dataToExport.length, filename: exportFilename }) }, [ viewMode, pivotResult, exportFilename, pivotShowRowTotals, pivotShowColumnTotals, pivotRowFields, pivotColumnFields, pivotValueFields, enableSearch, globalSearchTerm, searchFilteredData, rows, columnKeys, onExport, ]) const handleExportXLSX = useCallback(async () => { if (viewMode === 'pivot') { if (!pivotResult) return const xlsxFilename = exportFilename.replace('.csv', '-pivot.xlsx') try { await exportPivotToXLSX( { headers: pivotResult.headers, rowHeaders: pivotResult.rowHeaders, data: pivotResult.data, rowTotals: pivotResult.rowTotals, columnTotals: pivotResult.columnTotals, grandTotal: pivotResult.grandTotal, showRowTotals: pivotShowRowTotals, showColumnTotals: pivotShowColumnTotals, }, pivotRowFields, pivotColumnFields, pivotValueFields, { filename: xlsxFilename }, { rows: filteredDataForPivot, columns: columnKeys }, ) onExport?.({ rowCount: pivotResult.rowHeaders.length, filename: xlsxFilename }) } catch (err) { console.error('[TinyPivot] XLSX pivot export failed:', err) } return } const dataToExport = enableSearch && globalSearchTerm.trim() ? searchFilteredData.map(row => row.original) : rows.map(row => row.original) const xlsxFilename = exportFilename.replace('.csv', '.xlsx') try { await exportToXLSX(dataToExport, columnKeys, { filename: xlsxFilename }) onExport?.({ rowCount: dataToExport.length, filename: xlsxFilename }) } catch (err) { console.error('[TinyPivot] XLSX export failed:', err) } }, [ viewMode, pivotResult, exportFilename, pivotShowRowTotals, pivotShowColumnTotals, pivotRowFields, pivotColumnFields, pivotValueFields, enableSearch, globalSearchTerm, searchFilteredData, rows, columnKeys, filteredDataForPivot, onExport, ]) // Copy to clipboard const copySelectionToClipboard = useCallback(() => { if (!selectionBounds || !enableClipboard) return const text = formatSelectionForClipboard( rows.map(r => r.original), columnKeys, selectionBounds, ) copyToClipboard( text, () => { const cellCount = (selectionBounds.maxRow - selectionBounds.minRow + 1) * (selectionBounds.maxCol - selectionBounds.minCol + 1) setCopyToastMessage(`Copied ${cellCount} cell${cellCount > 1 ? 's' : ''}`) setShowCopyToast(true) setTimeout(setShowCopyToast, 2000, false) onCopy?.({ text, cellCount }) }, (err) => { setCopyToastMessage('Copy failed') setShowCopyToast(true) setTimeout(setShowCopyToast, 2000, false) console.error('Copy failed:', err) }, ) }, [selectionBounds, enableClipboard, rows, columnKeys, onCopy]) // Cell selection handlers const handleMouseDown = useCallback( (rowIndex: number, colIndex: number, event: React.MouseEvent) => { event.preventDefault() if (event.shiftKey && selectedCell) { setSelectionEnd({ row: rowIndex, col: colIndex }) } else { setSelectedCell({ row: rowIndex, col: colIndex }) setSelectionStart({ row: rowIndex, col: colIndex }) setSelectionEnd({ row: rowIndex, col: colIndex }) setIsSelecting(true) } const row = rows[rowIndex] if (row) { const colId = columnKeys[colIndex] onCellClick?.({ row: rowIndex, col: colIndex, value: row.original[colId], rowData: row.original, }) } }, [selectedCell, rows, columnKeys, onCellClick], ) const handleMouseEnter = useCallback( (rowIndex: number, colIndex: number) => { if (isSelecting) { setSelectionEnd({ row: rowIndex, col: colIndex }) } }, [isSelecting], ) // Track if we're showing AI-shaped data (filtered/aggregated by AI queries) const isShowingAIData = aiLoadedData !== null // Reset to full original dataset // If AI Analyst is enabled and has a selected data source, load full data from that source // Otherwise, just clear the AI loaded data to show original props.data const resetToFullData = useCallback(async () => { // If we have AI Analyst with a selected data source, load full data from it if (aiAnalystRef.current?.selectedDataSource) { setIsLoadingFullData(true) try { const fullData = await aiAnalystRef.current.loadFullData() if (fullData && fullData.length > 0) { setAiLoadedData(fullData) } else { // Fall back to props.data if loading fails setAiLoadedData(null) } } catch (err) { console.warn('Failed to load full data:', err) setAiLoadedData(null) } finally { setIsLoadingFullData(false) } } else { // No AI Analyst or no selected data source - just clear filters by resetting to props.data setAiLoadedData(null) } // Also clear any grid filters clearAllFilters() }, [clearAllFilters]) // AI Analyst event handlers const handleAIDataLoaded = useCallback( (payload: AIDataLoadedEvent) => { setAiLoadedData(payload.data) // Don't auto-switch to grid - let user decide when to view results onAIDataLoaded?.(payload) }, [onAIDataLoaded], ) const handleAIConversationUpdate = useCallback( (payload: AIConversationUpdateEvent) => { onAIConversationUpdate?.(payload) }, [onAIConversationUpdate], ) const handleAIQueryExecuted = useCallback( (payload: AIQueryExecutedEvent) => { onAIQueryExecuted?.(payload) }, [onAIQueryExecuted], ) const handleAIError = useCallback( (payload: AIErrorEvent) => { onAIError?.(payload) }, [onAIError], ) useEffect(() => { const handleMouseUp = () => setIsSelecting(false) document.addEventListener('mouseup', handleMouseUp) return () => document.removeEventListener('mouseup', handleMouseUp) }, []) // Keyboard handling useEffect(() => { const handleKeydown = (event: KeyboardEvent) => { // Handle Ctrl+C / Cmd+C for clipboard if ((event.ctrlKey || event.metaKey) && event.key === 'c' && selectionBounds) { event.preventDefault() copySelectionToClipboard() return } // Handle Escape if (event.key === 'Escape') { setSelectedCell(null) setSelectionStart(null) setSelectionEnd(null) setShowSearchInput(false) setGlobalSearchTerm('') } } document.addEventListener('keydown', handleKeydown) return () => document.removeEventListener('keydown', handleKeydown) }, [selectionBounds, copySelectionToClipboard]) // Filter dropdown const openFilterDropdown = useCallback( (columnId: string, event: React.MouseEvent) => { event.stopPropagation() const target = event.currentTarget as HTMLElement const headerCell = target.closest('.vpg-header-cell') as HTMLElement const rect = headerCell?.getBoundingClientRect() || target.getBoundingClientRect() const dropdownWidth = 280 const padding = 12 let left = rect.left if (left + dropdownWidth > window.innerWidth - padding) { left = window.innerWidth - dropdownWidth - padding } left = Math.max(padding, left) const spaceBelow = window.innerHeight - rect.bottom - padding const spaceAbove = rect.top - padding let top: number let maxDropdownHeight: number if (spaceBelow >= 300 || spaceBelow >= spaceAbove) { top = rect.bottom + 4 maxDropdownHeight = Math.min(400, spaceBelow - 4) } else { maxDropdownHeight = Math.min(400, spaceAbove - 4) top = rect.top - maxDropdownHeight - 4 } setFilterDropdownPosition({ top, left, maxHeight: maxDropdownHeight }) setActiveFilterColumn(columnId) }, [], ) const closeFilterDropdown = useCallback(() => { setActiveFilterColumn(null) }, []) const handleFilter = useCallback( (columnId: string, values: string[]) => { setColumnFilter(columnId, values) }, [setColumnFilter], ) const handleRangeFilter = useCallback( (columnId: string, range: import('@smallwebco/tinypivot-core').NumericRange | null) => { setNumericRangeFilter(columnId, range) }, [setNumericRangeFilter], ) const handleDateRangeFilter = useCallback( (columnId: string, range: DateRange | null) => { setDateRangeFilter(columnId, range) }, [setDateRangeFilter], ) const handleSort = useCallback( (columnId: string, direction: 'asc' | 'desc' | null) => { if (direction === null) { const current = getSortDirection(columnId) if (current) { toggleSort(columnId) if (getSortDirection(columnId)) { toggleSort(columnId) } } } else { const current = getSortDirection(columnId) if (current === null) { toggleSort(columnId) if (direction === 'desc' && getSortDirection(columnId) === 'asc') { toggleSort(columnId) } } else if (current !== direction) { toggleSort(columnId) } } }, [getSortDirection, toggleSort], ) const isCellSelected = useCallback( (rowIndex: number, colIndex: number): boolean => { if (!selectionBounds) { return selectedCell?.row === rowIndex && selectedCell?.col === colIndex } const { minRow, maxRow, minCol, maxCol } = selectionBounds return rowIndex >= minRow && rowIndex <= maxRow && colIndex >= minCol && colIndex <= maxCol }, [selectionBounds, selectedCell], ) const formatStatValue = (value: number | null): string => { if (value === null) return '-' return coreFormatNumber(value, numberFormat) } // Format cell value const noFormatPatterns = /^(?:.*_)?(?:id|code|year|month|quarter|day|week|date|zip|phone|fax|ssn|ein|npi|ndc|gpi|hcpcs|icd|cpt|rx|bin|pcn|group|member|claim|rx_number|script|fill)(?:_.*)?$/i const shouldFormatNumber = (columnId: string): boolean => { return !noFormatPatterns.test(columnId) } const formatCellValueDisplay = (value: unknown, columnId: string): string => { if (value === null || value === undefined) return '' if (value === '') return '' const stats = getColumnStats(columnId) if (stats.type === 'date') { return coreFormatDate(value, dateFormat) } if (stats.type === 'number') { const num = typeof value === 'number' ? value : Number.parseFloat(String(value)) if (Number.isNaN(num)) return String(value) if (shouldFormatNumber(columnId) && Math.abs(num) >= 1000) { return coreFormatNumber(num, numberFormat) } if (Number.isInteger(num)) { return String(num) } return coreFormatNumber(num, numberFormat, { maximumFractionDigits: 4 }) } return String(value) } const totalTableWidth = useMemo(() => { return columnKeys.reduce((sum, key) => sum + (columnWidths[key] || MIN_COL_WIDTH), 0) }, [columnKeys, columnWidths]) const activeFilterCount = columnFilters.length return (
{/* Copy Toast */} {showCopyToast && (
{copyToastMessage}
)} {/* Toolbar */}
{/* View mode toggle */} {showPivot && (
{/* AI Analyst button (first, only if enabled) */} {showAIAnalyst && ( )} {aiAnalyst?.enabled && !canUseAIAnalyst && ( )}
)} {/* Grid mode controls */} {viewMode === 'grid' && ( <> {/* Search */} {enableSearch && (
{!showSearchInput ? ( ) : (
setGlobalSearchTerm(e.target.value)} className="vpg-search-input" placeholder="Search all columns..." onKeyDown={(e) => { if (e.key === 'Escape') { setShowSearchInput(false) setGlobalSearchTerm('') } }} autoFocus /> {globalSearchTerm && ( )}
)}
)}
Size:
{fontSizeOptions.map(opt => ( ))}
{activeFilterCount > 0 && (
{activeFilterCount} {' '} filter {activeFilterCount > 1 ? 's' : ''}
)} {globalSearchTerm && (
{totalSearchedRows} {' '} match {totalSearchedRows !== 1 ? 'es' : ''}
)} )} {/* Pivot mode controls */} {viewMode === 'pivot' && canUsePivot && ( <> {pivotIsConfigured && (
Pivot configured
)} )}
{viewMode === 'grid' && activeFilterCount > 0 && ( )} {/* Reset to full data button (when showing AI-filtered results) */} {viewMode === 'grid' && isShowingAIData && ( )} {/* Copy button */} {enableClipboard && selectionBounds && viewMode === 'grid' && ( )} {/* Export dropdown menu */} {enableExport && viewMode === 'grid' && ( key === 'csv' ? handleExport() : handleExportXLSX()} /> )} {enableExport && viewMode === 'pivot' && pivotIsConfigured && ( key === 'csv' ? handleExport() : handleExportXLSX()} /> )}
{/* AI Analyst View - always render if enabled, use CSS to hide for state preservation */} {showAIAnalyst && aiAnalyst && (
)} {/* Grid View */} {viewMode === 'grid' && (
{loading && (
Loading data...
)} {!loading && displayData.length === 0 && (
No data available
)} {!loading && displayData.length > 0 && filteredRowCount === 0 && (
No matching records
)} {!loading && filteredRowCount > 0 && (
{columnKeys.map(colId => ( ))} {paginatedRows.map((row, rowIndex) => ( {columnKeys.map((colId, colIndex) => ( ))} ))}
{ const target = e.target as HTMLElement if (target.closest('.vpg-dropdown-arrow')) { openFilterDropdown(colId, e) } }} >
{colId}
{getSortDirection(colId) && ( {getSortDirection(colId) === 'asc' ? ( ) : ( )} )} {hasActiveFilter(colId) && ( )}
{enableColumnResize && (
startColumnResize(colId, e)} /> )}
handleMouseDown(rowIndex, colIndex, e)} onMouseEnter={() => handleMouseEnter(rowIndex, colIndex)} > {formatCellValueDisplay(row.original[colId], colId)}
)}
)} {/* Pivot View */} {viewMode === 'pivot' && (
{showPivotConfig && canUsePivot && (
setDraggingField(field)} onDragEnd={() => setDraggingField(null)} onUpdateAggregation={updateValueFieldAggregation} onAddRowField={addRowField} onRemoveRowField={removeRowField} onAddColumnField={addColumnField} onRemoveColumnField={removeColumnField} onAddValueField={addValueField} onRemoveValueField={removeValueField} onAddCalculatedField={addCalculatedField} onRemoveCalculatedField={removeCalculatedField} onUpdateCalculatedField={addCalculatedField} theme={currentTheme} fieldFilters={pivotFieldFilters} getFieldValues={getPivotFieldValues} onUpdateFieldFilter={setPivotFieldFilter} />
)}
0} fontSize={currentFontSize} activeFilters={activeFilterInfo} totalRowCount={totalRowCount} filteredRowCount={filteredRowCount} enableDrillDown={enableDrillDown} enableDrillThrough={enableDrillThrough} pivotLayout={pivotLayout} onAddRowField={addRowField} onRemoveRowField={removeRowField} onAddColumnField={addColumnField} onRemoveColumnField={removeColumnField} onAddValueField={addValueField} onRemoveValueField={removeValueField} onUpdateAggregation={updateValueFieldAggregation} onReorderRowFields={setRowFields} onReorderColumnFields={setColumnFields} onToggleCollapse={handleToggleCollapse} onDrillThroughCell={handleDrillThroughCell} theme={currentTheme} />
)} {/* Chart View */} {viewMode === 'chart' && (
{/* Filter indicator for chart */} {activeFilterInfo && activeFilterInfo.length > 0 && (
Chart showing {filteredRowCount.toLocaleString()} {' '} of {totalRowCount.toLocaleString()} {' '} records
)}
)} {/* Footer */}
{viewMode === 'grid' ? ( enablePagination ? ( <> {((currentPage - 1) * pageSize + 1).toLocaleString()} - {Math.min(currentPage * pageSize, totalSearchedRows).toLocaleString()} of {totalSearchedRows.toLocaleString()} {totalSearchedRows !== totalRowCount && ( ( {totalRowCount.toLocaleString()} {' '} total) )} ) : filteredRowCount === totalRowCount && totalSearchedRows === totalRowCount ? ( {totalRowCount.toLocaleString()} {' '} records ) : ( <> {totalSearchedRows.toLocaleString()} of {totalRowCount.toLocaleString()} records ) ) : viewMode === 'pivot' ? ( <> Pivot Table {totalRowCount.toLocaleString()} {' '} source records ) : ( <> Chart Builder {totalRowCount.toLocaleString()} {' '} records )}
{/* Pagination controls */} {enablePagination && viewMode === 'grid' && totalPages > 1 && (
Page {' '} {currentPage} {' '} of {' '} {totalPages}
)} {viewMode === 'grid' && selectionStats && selectionStats.count > 1 && (
Count: {selectionStats.count} {selectionStats.numericCount > 0 && ( <> | Sum: {formatStatValue(selectionStats.sum)} | Avg: {formatStatValue(selectionStats.avg)} )}
)}
{isDemo ? (
DEMO Pro features enabled Get License →
) : showWatermark ? ( Powered by TinyPivot ) : null}
{/* Vertical Resize Handle */} {enableVerticalResize && (
)} {/* Filter Dropdown Portal */} {activeFilterColumn && typeof document !== 'undefined' && createPortal(
handleFilter(activeFilterColumn, values)} onRangeFilter={range => handleRangeFilter(activeFilterColumn, range)} onDateRangeFilter={range => handleDateRangeFilter(activeFilterColumn, range)} onSort={dir => handleSort(activeFilterColumn, dir)} onClose={closeFilterDropdown} />
, document.body, )}
) }