import { Checkbox } from '@/components/ui/checkbox'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow as UITableRow, } from '@/components/ui/table'; import { useFormatDateInterval } from '@/hooks/use-format-date-interval'; import { useNumber } from '@/hooks/use-numer-formatter'; import { useSelector } from '@/redux'; import type { IChartData } from '@/trpc/client'; import { cn } from '@/utils/cn'; import { getChartColor } from '@/utils/theme'; import type { ColumnDef } from '@tanstack/react-table'; import { type SortingState, flexRender, getCoreRowModel, getFilteredRowModel, getSortedRowModel, useReactTable, } from '@tanstack/react-table'; import { type VirtualItem, useWindowVirtualizer, } from '@tanstack/react-virtual'; import throttle from 'lodash.throttle'; import { ChevronDown, ChevronRight } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; import type * as React from 'react'; import { ReportTableToolbar } from './report-table-toolbar'; import { type GroupedTableRow, type TableRow, createSummaryRow, transformToTableData, } from './report-table-utils'; import { SerieName } from './serie-name'; declare module '@tanstack/react-table' { interface ColumnMeta { pinned?: 'left' | 'right'; isBreakdown?: boolean; breakdownIndex?: number; } } interface ReportTableProps { data: IChartData; visibleSeries: IChartData['series'] | string[]; setVisibleSeries: React.Dispatch>; } const DEFAULT_COLUMN_WIDTH = 150; const ROW_HEIGHT = 48; // h-12 export function ReportTable({ data, visibleSeries, setVisibleSeries, }: ReportTableProps) { const [grouped, setGrouped] = useState(true); const [collapsedGroups, setCollapsedGroups] = useState>( new Set(), ); const [sorting, setSorting] = useState([]); const [globalFilter, setGlobalFilter] = useState(''); const [columnSizing, setColumnSizing] = useState>({}); const isResizingRef = useRef(false); const parentRef = useRef(null); const [scrollMargin, setScrollMargin] = useState(0); const number = useNumber(); const interval = useSelector((state) => state.report.interval); const breakdowns = useSelector((state) => state.report.breakdowns); const formatDate = useFormatDateInterval({ interval, short: true, }); // Transform data to table format const { rows: rawRows, dates, breakdownPropertyNames, } = useMemo( () => transformToTableData(data, breakdowns, grouped), [data, breakdowns, grouped], ); // Filter rows based on collapsed groups and create summary rows const rows = useMemo(() => { if (!grouped || collapsedGroups.size === 0) { return rawRows; } const processedRows: (TableRow | GroupedTableRow)[] = []; const groupedRows = rawRows as GroupedTableRow[]; // Group rows by their groupKey const rowsByGroup = new Map(); groupedRows.forEach((row) => { if (row.groupKey) { if (!rowsByGroup.has(row.groupKey)) { rowsByGroup.set(row.groupKey, []); } rowsByGroup.get(row.groupKey)!.push(row); } else { // Rows without groupKey go directly to processed processedRows.push(row); } }); // Process each group rowsByGroup.forEach((groupRows, groupKey) => { if (collapsedGroups.has(groupKey)) { // Group is collapsed - show summary row const summaryRow = createSummaryRow( groupRows, groupKey, breakdownPropertyNames.length, ); processedRows.push(summaryRow); } else { // Group is expanded - show all rows processedRows.push(...groupRows); } }); return processedRows; }, [rawRows, collapsedGroups, grouped, breakdownPropertyNames.length]); // Filter rows based on global search and apply sorting const filteredRows = useMemo(() => { let result = rows; // Apply search filter if (globalFilter.trim()) { const searchLower = globalFilter.toLowerCase(); result = rows.filter((row) => { // Search in serie name if (row.serieName.toLowerCase().includes(searchLower)) return true; // Search in breakdown values if ( row.breakdownValues.some((val) => val?.toLowerCase().includes(searchLower), ) ) { return true; } // Search in metric values const metrics = ['sum', 'average', 'min', 'max'] as const; if ( metrics.some((metric) => String(row[metric]).toLowerCase().includes(searchLower), ) ) { return true; } // Search in date values if ( Object.values(row.dateValues).some((val) => String(val).toLowerCase().includes(searchLower), ) ) { return true; } return false; }); } // Apply sorting - if grouped, sort within each group if (grouped && sorting.length > 0 && result.length > 0) { const groupedRows = result as GroupedTableRow[]; // Group rows by their groupKey const rowsByGroup = new Map(); const ungroupedRows: GroupedTableRow[] = []; groupedRows.forEach((row) => { if (row.groupKey) { if (!rowsByGroup.has(row.groupKey)) { rowsByGroup.set(row.groupKey, []); } rowsByGroup.get(row.groupKey)!.push(row); } else { ungroupedRows.push(row); } }); // Sort function based on current sort state const sortFn = (a: GroupedTableRow, b: GroupedTableRow) => { for (const sort of sorting) { const { id, desc } = sort; let aValue: any; let bValue: any; if (id === 'serie-name') { aValue = a.serieName; bValue = b.serieName; } else if (id.startsWith('breakdown-')) { const index = Number.parseInt(id.replace('breakdown-', ''), 10); aValue = a.breakdownValues[index] ?? ''; bValue = b.breakdownValues[index] ?? ''; } else if (id.startsWith('metric-')) { const metric = id.replace('metric-', '') as keyof TableRow; aValue = a[metric]; bValue = b[metric]; } else if (id.startsWith('date-')) { const date = id.replace('date-', ''); aValue = a.dateValues[date] ?? 0; bValue = b.dateValues[date] ?? 0; } else { continue; } // Compare values if (aValue < bValue) return desc ? 1 : -1; if (aValue > bValue) return desc ? -1 : 1; } return 0; }; // Sort groups themselves by their first row's sort value const groupsArray = Array.from(rowsByGroup.entries()); groupsArray.sort((a, b) => { const aFirst = a[1][0]; const bFirst = b[1][0]; if (!aFirst || !bFirst) return 0; return sortFn(aFirst, bFirst); }); // Rebuild result with sorted groups const finalResult: GroupedTableRow[] = []; groupsArray.forEach(([, groupRows]) => { const sorted = [...groupRows].sort(sortFn); finalResult.push(...sorted); }); finalResult.push(...ungroupedRows.sort(sortFn)); return finalResult; } return result; }, [rows, globalFilter, grouped, sorting]); // Calculate min/max values for color visualization const { metricRanges, dateRanges } = useMemo(() => { const metricRanges: Record = { sum: { min: Number.POSITIVE_INFINITY, max: Number.NEGATIVE_INFINITY }, average: { min: Number.POSITIVE_INFINITY, max: Number.NEGATIVE_INFINITY, }, min: { min: Number.POSITIVE_INFINITY, max: Number.NEGATIVE_INFINITY }, max: { min: Number.POSITIVE_INFINITY, max: Number.NEGATIVE_INFINITY }, }; const dateRanges: Record = {}; dates.forEach((date) => { dateRanges[date] = { min: Number.POSITIVE_INFINITY, max: Number.NEGATIVE_INFINITY, }; }); rows.forEach((row) => { // Calculate metric ranges Object.keys(metricRanges).forEach((key) => { const value = row[key as keyof typeof row] as number; if (typeof value === 'number') { metricRanges[key]!.min = Math.min(metricRanges[key]!.min, value); metricRanges[key]!.max = Math.max(metricRanges[key]!.max, value); } }); // Calculate date ranges dates.forEach((date) => { const value = row.dateValues[date] ?? 0; if (!dateRanges[date]) { dateRanges[date] = { min: Number.POSITIVE_INFINITY, max: Number.NEGATIVE_INFINITY, }; } dateRanges[date]!.min = Math.min(dateRanges[date]!.min, value); dateRanges[date]!.max = Math.max(dateRanges[date]!.max, value); }); }); return { metricRanges, dateRanges }; }, [rows, dates]); // Helper to get background color and opacity for a value const getCellBackground = ( value: number, min: number, max: number, ): { opacity: number; className: string } => { if (value === 0 || max === min) { return { opacity: 0, className: '' }; } const percentage = (value - min) / (max - min); const opacity = Math.max(0.05, Math.min(1, percentage)); return { opacity, className: 'bg-highlight dark:bg-emerald-700', }; }; // Normalize visibleSeries to string array const visibleSeriesIds = useMemo(() => { if (visibleSeries.length === 0) return []; if (typeof visibleSeries[0] === 'string') { return visibleSeries as string[]; } return (visibleSeries as IChartData['series']).map((s) => s.id); }, [visibleSeries]); // Get serie index for color const getSerieIndex = (serieId: string): number => { return data.series.findIndex((s) => s.id === serieId); }; // Toggle serie visibility const toggleSerieVisibility = (serieId: string) => { setVisibleSeries((prev) => { if (prev.includes(serieId)) { return prev.filter((id) => id !== serieId); } return [...prev, serieId]; }); }; // Toggle group collapse const toggleGroupCollapse = (groupKey: string) => { setCollapsedGroups((prev) => { const next = new Set(prev); if (next.has(groupKey)) { next.delete(groupKey); } else { next.add(groupKey); } return next; }); }; // Define columns const columns = useMemo[]>(() => { const cols: ColumnDef[] = []; // Serie name column (pinned left) with checkbox cols.push({ id: 'serie-name', header: 'Serie', accessorKey: 'serieName', enableSorting: true, size: DEFAULT_COLUMN_WIDTH, meta: { pinned: 'left', }, cell: ({ row }) => { const serieName = row.original.serieName; const serieId = row.original.originalSerie.id; const isVisible = visibleSeriesIds.includes(serieId); const serieIndex = getSerieIndex(serieId); const color = getChartColor(serieIndex); return (
toggleSerieVisibility(serieId)} style={{ borderColor: color, backgroundColor: isVisible ? color : 'transparent', }} className="h-4 w-4 shrink-0" />
); }, }); // Breakdown columns (pinned left, collapsible) breakdownPropertyNames.forEach((propertyName, index) => { const isLastBreakdown = index === breakdownPropertyNames.length - 1; const isCollapsible = grouped && !isLastBreakdown; cols.push({ id: `breakdown-${index}`, enableSorting: true, enableResizing: true, size: columnSizing[`breakdown-${index}`] ?? DEFAULT_COLUMN_WIDTH, minSize: 100, maxSize: 500, accessorFn: (row) => { if ('breakdownDisplay' in row && grouped) { return row.breakdownDisplay[index] ?? ''; } return row.breakdownValues[index] ?? ''; }, header: ({ column }) => { if (!isCollapsible) { return propertyName; } // Find all unique group keys for this breakdown level const groupKeys = new Set(); (rawRows as GroupedTableRow[]).forEach((row) => { if (row.groupKey) { groupKeys.add(row.groupKey); } }); // Check if all groups at this level are collapsed const allCollapsed = Array.from(groupKeys).every((key) => collapsedGroups.has(key), ); return (
{ // Toggle all groups at this breakdown level groupKeys.forEach((key) => toggleGroupCollapse(key)); }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); groupKeys.forEach((key) => toggleGroupCollapse(key)); } }} role="button" tabIndex={0} > {allCollapsed ? ( ) : ( )} {propertyName}
); }, meta: { pinned: 'left', isBreakdown: true, breakdownIndex: index, }, cell: ({ row }) => { const original = row.original; let value: string | null; if ('breakdownDisplay' in original && grouped) { value = original.breakdownDisplay[index] ?? null; } else { value = original.breakdownValues[index] ?? null; } const isSummary = original.isSummaryRow ?? false; return ( {value || ''} ); }, }); }); // Metric columns const metrics = [ { key: 'sum', label: 'Sum' }, { key: 'average', label: 'Average' }, { key: 'min', label: 'Min' }, { key: 'max', label: 'Max' }, ] as const; metrics.forEach((metric) => { cols.push({ id: `metric-${metric.key}`, header: metric.label, accessorKey: metric.key, enableSorting: true, size: 100, cell: ({ row }) => { const value = row.original[metric.key]; const isSummary = row.original.isSummaryRow ?? false; const range = metricRanges[metric.key]; const { opacity, className } = range ? getCellBackground(value, range.min, range.max) : { opacity: 0, className: '' }; return (
0.7 && 'text-white [text-shadow:_0_0_3px_rgb(0_0_0_/_20%)]', )} > {number.format(value)}
); }, }); }); // Date columns dates.forEach((date) => { cols.push({ id: `date-${date}`, header: formatDate(date), accessorFn: (row) => row.dateValues[date] ?? 0, enableSorting: true, size: 100, cell: ({ row }) => { const value = row.original.dateValues[date] ?? 0; const isSummary = row.original.isSummaryRow ?? false; const range = dateRanges[date]; const { opacity, className } = range ? getCellBackground(value, range.min, range.max) : { opacity: 0, className: '' }; return (
0.7 && 'text-white [text-shadow:_0_0_3px_rgb(0_0_0_/_20%)]', )} > {number.format(value)}
); }, }); }); return cols; }, [ breakdownPropertyNames, dates, formatDate, number, grouped, visibleSeriesIds, collapsedGroups, rawRows, metricRanges, dateRanges, columnSizing, ]); const table = useReactTable({ data: filteredRows, columns, getCoreRowModel: getCoreRowModel(), getSortedRowModel: grouped ? getCoreRowModel() : getSortedRowModel(), // Disable TanStack sorting when grouped getFilteredRowModel: getFilteredRowModel(), filterFns: { isWithinRange: () => true, }, enableColumnResizing: true, columnResizeMode: 'onChange', state: { sorting, columnSizing, }, onSortingChange: setSorting, onColumnSizingChange: setColumnSizing, globalFilterFn: () => true, // We handle filtering manually manualSorting: grouped, // Manual sorting when grouped }); // Virtualization setup useEffect(() => { const updateScrollMargin = throttle(() => { if (parentRef.current) { setScrollMargin( parentRef.current.getBoundingClientRect().top + window.scrollY, ); } }, 500); updateScrollMargin(); window.addEventListener('resize', updateScrollMargin); return () => { window.removeEventListener('resize', updateScrollMargin); }; }, []); // Handle global mouseup to reset resize flag useEffect(() => { const handleMouseUp = () => { if (isResizingRef.current) { // Small delay to ensure resize handlers complete setTimeout(() => { isResizingRef.current = false; }, 100); } }; window.addEventListener('mouseup', handleMouseUp); window.addEventListener('touchend', handleMouseUp); return () => { window.removeEventListener('mouseup', handleMouseUp); window.removeEventListener('touchend', handleMouseUp); }; }, []); const virtualizer = useWindowVirtualizer({ count: filteredRows.length, estimateSize: () => ROW_HEIGHT, overscan: 10, scrollMargin, }); const virtualRows = virtualizer.getVirtualItems(); // Get visible columns in order const headerColumns = table .getAllLeafColumns() .filter((col) => table.getState().columnVisibility[col.id] !== false); // Get pinned columns const leftPinnedColumns = table .getAllColumns() .filter((col) => col.columnDef.meta?.pinned === 'left') .filter((col): col is NonNullable => col !== undefined); const rightPinnedColumns = table .getAllColumns() .filter((col) => col.columnDef.meta?.pinned === 'right') .filter((col): col is NonNullable => col !== undefined); // Helper to get pinning styles const getPinningStyles = ( column: ReturnType | undefined, ) => { if (!column) return {}; const isPinned = column.columnDef.meta?.pinned; if (!isPinned) return {}; const pinnedColumns = isPinned === 'left' ? leftPinnedColumns : rightPinnedColumns; const columnIndex = pinnedColumns.findIndex((c) => c.id === column.id); const isLastPinned = columnIndex === pinnedColumns.length - 1 && isPinned === 'left'; const isFirstRightPinned = columnIndex === 0 && isPinned === 'right'; let left = 0; if (isPinned === 'left') { for (let i = 0; i < columnIndex; i++) { left += pinnedColumns[i]!.getSize(); } } return { position: 'sticky' as const, left: isPinned === 'left' ? `${left}px` : undefined, right: isPinned === 'right' ? '0px' : undefined, zIndex: 10, backgroundColor: 'var(--card)', boxShadow: isLastPinned ? '-4px 0 4px -4px var(--border) inset' : isFirstRightPinned ? '4px 0 4px -4px var(--border) inset' : undefined, }; }; if (rows.length === 0) { return null; } return (
setGrouped(!grouped)} search={globalFilter} onSearchChange={setGlobalFilter} onUnselectAll={() => setVisibleSeries([])} />
{/* Header */}
`${h.getSize()}px`) .join(' ') ?? '', minWidth: 'fit-content', }} > {table.getHeaderGroups()[0]?.headers.map((header) => { const column = header.column; const headerContent = column.columnDef.header; const isBreakdown = column.columnDef.meta?.isBreakdown ?? false; const pinningStyles = getPinningStyles(column); const isMetricOrDate = column.id.startsWith('metric-') || column.id.startsWith('date-'); const canSort = column.getCanSort(); const isSorted = column.getIsSorted(); const canResize = column.getCanResize(); const isPinned = column.columnDef.meta?.pinned === 'left'; return (
{ // Don't trigger sort if clicking on resize handle or if we just finished resizing if ( isResizingRef.current || column.getIsResizing() || (e.target as HTMLElement).closest( '[data-resize-handle]', ) ) { return; } column.toggleSorting(); } : undefined } onKeyDown={ canSort ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); column.toggleSorting(); } } : undefined } role={canSort ? 'button' : undefined} tabIndex={canSort ? 0 : undefined} >
{header.isPlaceholder ? null : typeof headerContent === 'function' ? flexRender(headerContent, header.getContext()) : headerContent} {canSort && ( {isSorted === 'asc' ? '↑' : isSorted === 'desc' ? '↓' : '⇅'} )}
{canResize && isPinned && (
{ e.stopPropagation(); isResizingRef.current = true; header.getResizeHandler()(e); }} onMouseUp={() => { // Use setTimeout to allow the resize to complete before resetting setTimeout(() => { isResizingRef.current = false; }, 0); }} onTouchStart={(e) => { e.stopPropagation(); isResizingRef.current = true; header.getResizeHandler()(e); }} onTouchEnd={() => { setTimeout(() => { isResizingRef.current = false; }, 0); }} className={cn( 'absolute right-0 top-0 h-full w-1 cursor-col-resize touch-none select-none bg-transparent hover:bg-primary/50 transition-colors', header.column.getIsResizing() && 'bg-primary', )} /> )}
); })}
{/* Virtualized Body */}
{virtualRows.map((virtualRow) => { const tableRow = table.getRowModel().rows[virtualRow.index]; if (!tableRow) return null; return (
`${h.getSize()}px`) .join(' ') ?? '', minWidth: 'fit-content', }} className="border-b hover:bg-muted/30 transition-colors" > {table.getHeaderGroups()[0]?.headers.map((header) => { const column = header.column; const cell = tableRow .getVisibleCells() .find((c) => c.column.id === column.id); if (!cell) return null; const isBreakdown = column.columnDef.meta?.isBreakdown ?? false; const pinningStyles = getPinningStyles(column); const isMetricOrDate = column.id.startsWith('metric-') || column.id.startsWith('date-'); const canResize = column.getCanResize(); const isPinned = column.columnDef.meta?.pinned === 'left'; return (
{flexRender( cell.column.columnDef.cell, cell.getContext(), )} {canResize && isPinned && (
{ e.stopPropagation(); isResizingRef.current = true; header.getResizeHandler()(e); }} onMouseUp={() => { setTimeout(() => { isResizingRef.current = false; }, 0); }} onTouchStart={(e) => { e.stopPropagation(); isResizingRef.current = true; header.getResizeHandler()(e); }} onTouchEnd={() => { setTimeout(() => { isResizingRef.current = false; }, 0); }} className={cn( 'absolute right-0 top-0 h-full w-1 cursor-col-resize touch-none select-none bg-transparent hover:bg-primary/50 transition-colors', column.getIsResizing() && 'bg-primary', )} /> )}
); })}
); })}
); }