From 3bbeb927cc4240d8d7fc69298abdf73a82706c41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Tue, 25 Nov 2025 09:18:48 +0100 Subject: [PATCH] wip --- .../report-chart/common/report-table-utils.ts | 83 +- .../report-chart/common/report-table.tsx | 712 +++++++++++------- apps/start/src/utils/theme.ts | 7 +- 3 files changed, 501 insertions(+), 301 deletions(-) diff --git a/apps/start/src/components/report-chart/common/report-table-utils.ts b/apps/start/src/components/report-chart/common/report-table-utils.ts index d29d5fb0..83187dff 100644 --- a/apps/start/src/components/report-chart/common/report-table-utils.ts +++ b/apps/start/src/components/report-chart/common/report-table-utils.ts @@ -3,6 +3,7 @@ import type { IChartData } from '@/trpc/client'; export type TableRow = { id: string; + serieId: string; // Serie ID for visibility/color lookup serieName: string; breakdownValues: string[]; count: number; @@ -11,11 +12,10 @@ export type TableRow = { min: number; max: number; dateValues: Record; // date -> count - originalSerie: IChartData['series'][0]; - // Group metadata for collapse functionality - groupKey?: string; // Unique key for the group this row belongs to - parentGroupKey?: string; // Key of parent group (for nested groups) - isSummaryRow?: boolean; // True if this is a summary row for a collapsed group + // Group metadata + groupKey?: string; + parentGroupKey?: string; + isSummaryRow?: boolean; }; export type GroupedTableRow = TableRow & { @@ -166,7 +166,14 @@ export function findGroup( /** * Convert hierarchical groups to TanStack Table's expandable row format - * This creates rows with subRows that TanStack Table can expand/collapse natively + * + * Transforms nested GroupedItem structure into flat ExpandableTableRow array + * that TanStack Table can use with its native expanding feature. + * + * Key behaviors: + * - Serie level (level -1) and breakdown levels 0 to breakdownCount-2 create group headers + * - Last breakdown level (breakdownCount-1) does NOT create group headers (always individual rows) + * - Individual rows are explicitly marked as NOT group headers or summary rows */ export function groupsToExpandableRows( groups: Array>, @@ -181,37 +188,35 @@ export function groupsToExpandableRows( const currentPath = [...parentPath, group.group]; const subRows: ExpandableTableRow[] = []; - // Separate nested groups from actual items + // Separate nested groups from individual data items const nestedGroups: GroupedItem[] = []; - const actualItems: TableRow[] = []; + const individualItems: TableRow[] = []; for (const item of group.items) { if (item && typeof item === 'object' && 'items' in item) { nestedGroups.push(item); } else if (item) { - actualItems.push(item); + individualItems.push(item); } } - // Process nested groups (they become subRows) + // Process nested groups recursively (they become expandable group headers) for (const nestedGroup of nestedGroups) { subRows.push(...processGroup(nestedGroup, currentPath)); } - // Process actual items - actualItems.forEach((item, index) => { + // Process individual data items (leaf nodes) + individualItems.forEach((item, index) => { + // Build breakdownDisplay: first row shows all values, subsequent rows show parent path + item values const breakdownDisplay: (string | null)[] = []; const breakdownValues = item.breakdownValues; - // Build breakdownDisplay based on hierarchy - if (index === 0) { - // First row shows all breakdown values - for (let i = 0; i < breakdownCount; i++) { + for (let i = 0; i < breakdownCount; i++) { + if (index === 0) { + // First row: show all breakdown values breakdownDisplay.push(breakdownValues[i] ?? null); - } - } else { - // Subsequent rows: show values from parent path, then item values - for (let i = 0; i < breakdownCount; i++) { + } else { + // Subsequent rows: show parent path values, then item values if (i < currentPath.length) { breakdownDisplay.push(currentPath[i] ?? null); } else if (i < breakdownValues.length) { @@ -227,18 +232,20 @@ export function groupsToExpandableRows( breakdownDisplay, groupKey: group.groupKey, parentGroupKey: group.parentGroupKey, - // Explicitly mark as NOT a group header or summary row isGroupHeader: false, isSummaryRow: false, }); }); // If this group has subRows and is not the last breakdown level, create a group header row - // Don't create group headers for the last breakdown level (level === breakdownCount) - // because it would just duplicate the rows + // Don't create group headers for the last breakdown level (level === breakdownCount - 1) + // because the last breakdown should always be individual rows + // -1 is serie level (should be grouped) + // 0 to breakdownCount-2 are breakdown levels (should be grouped) + // breakdownCount-1 is the last breakdown level (should NOT be grouped, always individual) const shouldCreateGroupHeader = subRows.length > 0 && - (group.level < breakdownCount || group.level === -1); // -1 is serie level + (group.level === -1 || group.level < breakdownCount - 1); if (shouldCreateGroupHeader) { // Create a summary row for the group @@ -416,6 +423,7 @@ export function createFlatRows( return { id: serie.id, + serieId: serie.id, serieName: serie.names[0] ?? '', breakdownValues: serie.names.slice(1), count: serie.metrics.count ?? 0, @@ -424,7 +432,6 @@ export function createFlatRows( min: serie.metrics.min, max: serie.metrics.max, dateValues, - originalSerie: serie, }; }); } @@ -451,10 +458,11 @@ export function createGroupedRowsHierarchical( } // Create hierarchical groups using groupByNames - // Group by serie name first, then by breakdown values + // Note: groupByNames expects items with a `names` array, so we create a temporary array + // This is a minor inefficiency but keeps groupByNames generic and reusable const itemsWithNames = flatRows.map((row) => ({ ...row, - names: [row.serieName, ...row.breakdownValues], // Serie name + breakdown values + names: [row.serieName, ...row.breakdownValues], })); return groupByNames(itemsWithNames); @@ -552,6 +560,8 @@ export function createSummaryRow( groupKey: string, breakdownCount: number, ): GroupedTableRow { + const firstRow = groupRows[0]!; + // Aggregate metrics from all rows in the group const totalSum = groupRows.reduce((sum, row) => sum + row.sum, 0); const totalCount = groupRows.reduce((sum, row) => sum + row.count, 0); @@ -560,27 +570,23 @@ export function createSummaryRow( const totalMin = Math.min(...groupRows.map((row) => row.min)); const totalMax = Math.max(...groupRows.map((row) => row.max)); - // Aggregate date values + // Aggregate date values across all rows const dateValues: Record = {}; - const allDates = new Set(); groupRows.forEach((row) => { Object.keys(row.dateValues).forEach((date) => { - allDates.add(date); dateValues[date] = (dateValues[date] ?? 0) + row.dateValues[date]; }); }); - // Get breakdown values from first row - const firstRow = groupRows[0]!; - const breakdownDisplay: (string | null)[] = []; - breakdownDisplay.push(firstRow.breakdownValues[0] ?? null); - // Fill remaining breakdowns with null (empty) - for (let i = 1; i < breakdownCount; i++) { - breakdownDisplay.push(null); - } + // Build breakdownDisplay: show first breakdown value, rest are null + const breakdownDisplay: (string | null)[] = [ + firstRow.breakdownValues[0] ?? null, + ...Array(breakdownCount - 1).fill(null), + ]; return { id: `summary-${groupKey}`, + serieId: firstRow.serieId, serieName: firstRow.serieName, breakdownValues: firstRow.breakdownValues, count: totalCount, @@ -589,7 +595,6 @@ export function createSummaryRow( min: totalMin, max: totalMax, dateValues, - originalSerie: firstRow.originalSerie, groupKey, isSummaryRow: true, breakdownDisplay, diff --git a/apps/start/src/components/report-chart/common/report-table.tsx b/apps/start/src/components/report-chart/common/report-table.tsx index 3e086ada..028e64c5 100644 --- a/apps/start/src/components/report-chart/common/report-table.tsx +++ b/apps/start/src/components/report-chart/common/report-table.tsx @@ -18,6 +18,7 @@ import { } from '@tanstack/react-table'; import { type VirtualItem, + useVirtualizer, useWindowVirtualizer, } from '@tanstack/react-virtual'; import throttle from 'lodash.throttle'; @@ -25,6 +26,7 @@ import { ChevronDown, ChevronRight } from 'lucide-react'; import type * as React from 'react'; import { useEffect, useMemo, useRef, useState } from 'react'; +import { Tooltiper } from '@/components/ui/tooltip'; import { ReportTableToolbar } from './report-table-toolbar'; import { type ExpandableTableRow, @@ -58,26 +60,101 @@ const ROW_HEIGHT = 48; // h-12 interface VirtualRowProps { row: Row; virtualRow: VirtualItem; - gridTemplateColumns: string; pinningStylesMap: Map; headers: Header[]; isResizingRef: React.MutableRefObject; resizingColumnId: string | null; setResizingColumnId: (id: string | null) => void; + // Horizontal virtualization props + leftPinnedColumns: Header['column'][]; + scrollableColumns: Header['column'][]; + rightPinnedColumns: Header['column'][]; + virtualColumns: VirtualItem[]; + leftPinnedWidth: number; + scrollableColumnsTotalWidth: number; + rightPinnedWidth: number; } const VirtualRow = function VirtualRow({ row, virtualRow, - gridTemplateColumns, pinningStylesMap, headers, isResizingRef, resizingColumnId, setResizingColumnId, + leftPinnedColumns, + scrollableColumns, + rightPinnedColumns, + virtualColumns, + leftPinnedWidth, + scrollableColumnsTotalWidth, + rightPinnedWidth, }: VirtualRowProps) { const cells = row.getVisibleCells(); + const renderCell = ( + column: Header['column'], + header: Header | undefined, + ) => { + const cell = cells.find((c) => c.column.id === column.id); + if (!cell || !header) return null; + + const isBreakdown = column.columnDef.meta?.isBreakdown ?? false; + const pinningStyles = pinningStylesMap.get(column.id) ?? {}; + const canResize = column.getCanResize(); + const isPinned = column.columnDef.meta?.pinned === 'left'; + const isResizing = resizingColumnId === column.id; + + return ( +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} + {canResize && isPinned && ( +
{ + e.stopPropagation(); + isResizingRef.current = true; + setResizingColumnId(column.id); + header.getResizeHandler()(e); + }} + onMouseUp={() => { + setTimeout(() => { + isResizingRef.current = false; + setResizingColumnId(null); + }, 0); + }} + onTouchStart={(e) => { + e.stopPropagation(); + isResizingRef.current = true; + setResizingColumnId(column.id); + header.getResizeHandler()(e); + }} + onTouchEnd={() => { + setTimeout(() => { + isResizingRef.current = false; + setResizingColumnId(null); + }, 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', + isResizing && 'bg-primary', + )} + /> + )} +
+ ); + }; + return (
- {headers.map((header) => { - const column = header.column; - const cell = cells.find((c) => c.column.id === column.id); - if (!cell) return null; + {/* Left Pinned Columns */} + {leftPinnedColumns.map((column) => { + const header = headers.find((h) => h.column.id === column.id); + return renderCell(column, header); + })} - const isBreakdown = column.columnDef.meta?.isBreakdown ?? false; - const pinningStyles = pinningStylesMap.get(column.id) ?? {}; - const canResize = column.getCanResize(); - const isPinned = column.columnDef.meta?.pinned === 'left'; - const isResizing = resizingColumnId === column.id; + {/* Scrollable Columns (Virtualized) */} +
+ {virtualColumns.map((virtualCol) => { + const column = scrollableColumns[virtualCol.index]; + if (!column) return null; + const header = headers.find((h) => h.column.id === column.id); + const cell = cells.find((c) => c.column.id === column.id); + if (!cell || !header) return null; - return ( -
- {flexRender(cell.column.columnDef.cell, cell.getContext())} - {canResize && isPinned && ( -
{ - e.stopPropagation(); - isResizingRef.current = true; - setResizingColumnId(column.id); - header.getResizeHandler()(e); - }} - onMouseUp={() => { - setTimeout(() => { - isResizingRef.current = false; - setResizingColumnId(null); - }, 0); - }} - onTouchStart={(e) => { - e.stopPropagation(); - isResizingRef.current = true; - setResizingColumnId(column.id); - header.getResizeHandler()(e); - }} - onTouchEnd={() => { - setTimeout(() => { - isResizingRef.current = false; - setResizingColumnId(null); - }, 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', - isResizing && 'bg-primary', - )} - /> - )} -
- ); + return ( +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ ); + })} +
+ + {/* Right Pinned Columns */} + {rightPinnedColumns.map((column) => { + const header = headers.find((h) => h.column.id === column.id); + return renderCell(column, header); })}
); @@ -165,7 +222,7 @@ export function ReportTable({ visibleSeries, setVisibleSeries, }: ReportTableProps) { - const [grouped, setGrouped] = useState(true); + const [grouped, setGrouped] = useState(false); const [expanded, setExpanded] = useState({}); const [sorting, setSorting] = useState([]); const [globalFilter, setGlobalFilter] = useState(''); @@ -501,7 +558,6 @@ export function ReportTable({ // Multiple series: calculate ranges across individual rows only if (individualRows.length === 0) { // No individual rows found - this shouldn't happen, but handle gracefully - console.warn('No individual rows found for range calculation'); } else { individualRows.forEach((row) => { // Calculate metric ranges @@ -534,31 +590,36 @@ export function ReportTable({ return { metricRanges, dateRanges }; }, [rows, dates]); - // Helper to get background color and opacity for a value - const getCellBackground = ( + // Helper to get background color style and opacity for a value + // Returns both style and opacity (for text color calculation) to avoid parsing + const getCellBackgroundStyle = ( value: number, min: number, max: number, - className?: string, - ): { opacity: number; className: string } => { + colorClass: 'purple' | 'emerald' = 'emerald', + ): { style: React.CSSProperties; opacity: number } => { if (value === 0) { - return { opacity: 0, className: '' }; + return { style: {}, opacity: 0 }; } // If min equals max (e.g. single row or all values same), show moderate opacity + let opacity: number; if (max === min) { - return { - opacity: 0.5, - className: cn('bg-highlight dark:bg-emerald-700', className), - }; + opacity = 0.5; + } else { + const percentage = (value - min) / (max - min); + opacity = Math.max(0.05, Math.min(1, percentage)); } - const percentage = (value - min) / (max - min); - const opacity = Math.max(0.05, Math.min(1, percentage)); + // Use rgba colors directly instead of opacity + background class + const backgroundColor = + colorClass === 'purple' + ? `rgba(168, 85, 247, ${opacity})` // purple-500 + : `rgba(16, 185, 129, ${opacity})`; // emerald-500 return { + style: { backgroundColor }, opacity, - className: cn('bg-highlight dark:bg-emerald-700', className), }; }; @@ -616,7 +677,7 @@ export function ReportTable({ cell: ({ row }) => { const original = row.original; const serieName = original.serieName; - const serieId = original.originalSerie.id; + const serieId = original.serieId; const isVisible = visibleSeriesIds.includes(serieId); const serieIndex = getSerieIndex(serieId); const color = getChartColor(serieIndex); @@ -645,10 +706,7 @@ export function ReportTable({ if (firstRowInGroup.id === original.id) { isFirstRowInGroup = true; } else { - // Only mute if this is not the first row and the serie name matches - if (firstRowInGroup.serieName === serieName) { - isMuted = true; - } + isMuted = true; } } } @@ -656,7 +714,6 @@ export function ReportTable({ const originalRow = row.original as ExpandableTableRow | TableRow; const isGroupHeader = 'isGroupHeader' in originalRow && originalRow.isGroupHeader === true; - const canExpand = grouped ? (row.getCanExpand?.() ?? false) : false; const isExpanded = grouped ? (row.getIsExpanded?.() ?? false) : false; const isSerieGroupHeader = isGroupHeader && @@ -664,10 +721,28 @@ export function ReportTable({ originalRow.groupLevel === -1; const hasSubRows = 'subRows' in originalRow && (originalRow.subRows?.length ?? 0) > 0; + const isExpandable = grouped && isSerieGroupHeader && hasSubRows; return (
- {grouped && isSerieGroupHeader && hasSubRows && ( + toggleSerieVisibility(serieId)} + style={{ + borderColor: color, + backgroundColor: isVisible ? color : 'transparent', + }} + className="h-4 w-4 shrink-0" + /> + + {isExpandable && ( )} - toggleSerieVisibility(serieId)} - style={{ - borderColor: color, - backgroundColor: isVisible ? color : 'transparent', - }} - className="h-4 w-4 shrink-0" - /> -
); }, @@ -806,11 +864,6 @@ export function ReportTable({ role="button" tabIndex={0} > - {allExpanded ? ( - - ) : ( - - )} {propertyName}
); @@ -818,7 +871,6 @@ export function ReportTable({ meta: { pinned: 'left', isBreakdown: true, - breakdownIndex: index, }, cell: ({ row }) => { const original = row.original as ExpandableTableRow | TableRow; @@ -827,82 +879,30 @@ export function ReportTable({ const canExpand = row.getCanExpand?.() ?? false; const isExpanded = row.getIsExpanded?.() ?? false; - let value: string | null; - let isMuted = false; - let isFirstRowInGroup = false; + const value: string | number | null = + original.breakdownValues[index] ?? null; + const isLastBreakdown = index === breakdownPropertyNames.length - 1; + const isMuted = (!isLastBreakdown && !canExpand && grouped) || !value; - if ( - 'breakdownDisplay' in original && - grouped && - original.breakdownDisplay !== undefined - ) { - value = original.breakdownDisplay[index] ?? null; - - // For group headers, show the group value at the appropriate level - if (isGroupHeader && 'groupLevel' in original) { - const groupLevel = original.groupLevel ?? 0; - if (index === groupLevel) { - value = original.groupValue ?? null; - } else if (index < groupLevel) { - // Show parent group values from the path - // This would need to be calculated from the hierarchy - value = null; // Will be handled by breakdownDisplay - } else { - // For breakdowns deeper than the group level, don't show anything - // (e.g., if group is at COUNTRY level, don't show CITY) - value = null; - } + // For group headers, only show value at the group level, hide deeper breakdowns + if (isGroupHeader && 'groupLevel' in original) { + const groupLevel = original.groupLevel ?? 0; + if (index !== groupLevel) { + return
; } - - // Check if this is the first row in the group and if this breakdown should be bold - if ( - value && - 'groupKey' in original && - original.groupKey && - !original.isSummaryRow - ) { - // Find all rows in this group from the current rows array - const groupRows = rows.filter( - (r): r is GroupedTableRow | ExpandableTableRow => - 'groupKey' in r && - r.groupKey === original.groupKey && - !('isSummaryRow' in r && r.isSummaryRow), - ); - - if (groupRows.length > 0) { - const firstRowInGroup = groupRows[0]!; - - // Check if this is the first row in the group - if (firstRowInGroup.id === original.id) { - isFirstRowInGroup = true; - } else { - // Only mute if this is not the first row and the value matches - const firstRowValue = - 'breakdownValues' in firstRowInGroup - ? firstRowInGroup.breakdownValues[index] - : null; - if (firstRowValue === value) { - isMuted = true; - } - } - } - } - } else { - value = - 'breakdownValues' in original - ? (original.breakdownValues[index] ?? null) - : null; } - const isSummary = - 'isSummaryRow' in original && original.isSummaryRow === true; - // Make bold if it's the first row in group and this is one of the first breakdown columns - // (all breakdowns except the last one) - const shouldBeBold = - isFirstRowInGroup && index < breakdownPropertyNames.length - 1; - return (
+ + {value || '(Not set)'} + {canExpand && index === ('groupLevel' in original ? (original.groupLevel ?? 0) : 0) && @@ -923,16 +923,6 @@ export function ReportTable({ )} )} - - {value || ''} -
); }, @@ -966,21 +956,6 @@ export function ReportTable({ const isIndividualRow = !isSummary && !isGroupHeader; const range = metricRanges[metric.key]; - // Debug: Check first few rows - if (metric.key === 'count' && row.index < 5) { - console.log(`[FIX CHECK] Row ${row.index}:`, { - isSummaryRowValue: - 'isSummaryRow' in original ? original.isSummaryRow : 'NOT SET', - isGroupHeaderValue: - 'isGroupHeader' in original - ? original.isGroupHeader - : 'NOT SET', - isSummary, - isGroupHeader, - isIndividualRow, - }); - } - // Only apply colors to individual rows, not summary or group header rows // Also check that range is valid (not still at initial values) const hasValidRange = @@ -988,32 +963,21 @@ export function ReportTable({ range.min !== Number.POSITIVE_INFINITY && range.max !== Number.NEGATIVE_INFINITY; - const { opacity, className } = + const { style: backgroundStyle, opacity: bgOpacity } = isIndividualRow && hasValidRange - ? getCellBackground( - value, - range.min, - range.max, - 'bg-purple-400 dark:bg-purple-700', - ) - : { opacity: 0, className: '' }; + ? getCellBackgroundStyle(value, range.min, range.max, 'purple') + : { style: {}, opacity: 0 }; return ( -
-
-
0.7 && - 'text-white [text-shadow:_0_0_3px_rgb(0_0_0_/_20%)]', - )} - > - {number.format(value)} -
+
+ {number.format(value)}
); }, @@ -1042,27 +1006,23 @@ export function ReportTable({ range && range.min !== Number.POSITIVE_INFINITY && range.max !== Number.NEGATIVE_INFINITY; - const { opacity, className } = + const { style: backgroundStyle, opacity: bgOpacity } = isIndividualRow && hasValidRange - ? getCellBackground(value, range.min, range.max) - : { opacity: 0, className: '' }; + ? getCellBackgroundStyle(value, range.min, range.max, 'emerald') + : { style: {}, opacity: 0 }; + + const needsLightText = bgOpacity > 0.7; return ( -
-
-
0.7 && - 'text-white [text-shadow:_0_0_3px_rgb(0_0_0_/_20%)]', - )} - > - {number.format(value)} -
+
+ {number.format(value)}
); }, @@ -1211,15 +1171,57 @@ export function ReportTable({ .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); + // Separate columns into pinned and scrollable + const leftPinnedColumns = headerColumns.filter( + (col) => col.columnDef.meta?.pinned === 'left', + ); + const rightPinnedColumns = headerColumns.filter( + (col) => col.columnDef.meta?.pinned === 'right', + ); + const scrollableColumns = headerColumns.filter( + (col) => !col.columnDef.meta?.pinned, + ); + + // Calculate widths for virtualization + const leftPinnedWidth = useMemo( + () => leftPinnedColumns.reduce((sum, col) => sum + col.getSize(), 0), + [leftPinnedColumns, columnSizing], + ); + const rightPinnedWidth = useMemo( + () => rightPinnedColumns.reduce((sum, col) => sum + col.getSize(), 0), + [rightPinnedColumns, columnSizing], + ); + const scrollableColumnsTotalWidth = useMemo( + () => scrollableColumns.reduce((sum, col) => sum + col.getSize(), 0), + [scrollableColumns, columnSizing], + ); + + // Horizontal virtualization for scrollable columns + // Only virtualize if we have enough columns to benefit from it + const shouldVirtualizeHorizontal = scrollableColumns.length > 10; + + const horizontalVirtualizer = useVirtualizer({ + count: scrollableColumns.length, + getScrollElement: () => parentRef.current, + estimateSize: (index) => + scrollableColumns[index]?.getSize() ?? DEFAULT_COLUMN_WIDTH, + horizontal: true, + overscan: shouldVirtualizeHorizontal ? 5 : scrollableColumns.length, + }); + + // Get virtual columns - if not virtualizing, return all columns + const virtualColumns = shouldVirtualizeHorizontal + ? horizontalVirtualizer.getVirtualItems() + : scrollableColumns.map((col, index) => ({ + index, + start: scrollableColumns + .slice(0, index) + .reduce((sum, c) => sum + c.getSize(), 0), + size: col.getSize(), + key: col.id, + end: 0, + lane: 0, + })); // Pre-compute grid template columns string and headers const { gridTemplateColumns, headers } = useMemo(() => { @@ -1299,19 +1301,37 @@ export function ReportTable({ onSearchChange={setGlobalFilter} onUnselectAll={() => setVisibleSeries([])} /> -
-
+
+
{/* Header */}
- {table.getHeaderGroups()[0]?.headers.map((header) => { - const column = header.column; + {/* Left Pinned Columns */} + {leftPinnedColumns.map((column) => { + const header = headers.find((h) => h.column.id === column.id); + if (!header) return null; const headerContent = column.columnDef.header; const isBreakdown = column.columnDef.meta?.isBreakdown ?? false; const pinningStyles = getPinningStyles(column); @@ -1421,6 +1441,172 @@ export function ReportTable({
); })} + + {/* Scrollable Columns (Virtualized) */} +
+ {virtualColumns.map((virtualCol) => { + const column = scrollableColumns[virtualCol.index]; + if (!column) return null; + const header = headers.find((h) => h.column.id === column.id); + if (!header) return null; + + const headerContent = header.column.columnDef.header; + const isBreakdown = + header.column.columnDef.meta?.isBreakdown ?? false; + const isMetricOrDate = + header.column.id.startsWith('metric-') || + header.column.id.startsWith('date-'); + const canSort = header.column.getCanSort(); + const isSorted = header.column.getIsSorted(); + + return ( +
{ + if ( + isResizingRef.current || + header.column.getIsResizing() || + (e.target as HTMLElement).closest( + '[data-resize-handle]', + ) + ) { + return; + } + header.column.toggleSorting(); + } + : undefined + } + onKeyDown={ + canSort + ? (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + header.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' + ? '↓' + : '⇅'} + + )} +
+
+ ); + })} +
+ + {/* Right Pinned Columns */} + {rightPinnedColumns.map((column) => { + const header = headers.find((h) => h.column.id === column.id); + if (!header) return null; + + const headerContent = header.column.columnDef.header; + const isBreakdown = + header.column.columnDef.meta?.isBreakdown ?? false; + const pinningStyles = getPinningStyles(header.column); + const isMetricOrDate = + header.column.id.startsWith('metric-') || + header.column.id.startsWith('date-'); + const canSort = header.column.getCanSort(); + const isSorted = header.column.getIsSorted(); + const canResize = header.column.getCanResize(); + + return ( +
{ + if ( + isResizingRef.current || + header.column.getIsResizing() || + (e.target as HTMLElement).closest( + '[data-resize-handle]', + ) + ) { + return; + } + header.column.toggleSorting(); + } + : undefined + } + onKeyDown={ + canSort + ? (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + header.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' + ? '↓' + : '⇅'} + + )} +
+
+ ); + })}
{/* Virtualized Body */} @@ -1442,12 +1628,18 @@ export function ReportTable({ ...virtualRow, start: virtualRow.start - virtualizer.options.scrollMargin, }} - gridTemplateColumns={gridTemplateColumns} pinningStylesMap={pinningStylesMap} headers={headers} isResizingRef={isResizingRef} resizingColumnId={resizingColumnId} setResizingColumnId={setResizingColumnId} + leftPinnedColumns={leftPinnedColumns} + scrollableColumns={scrollableColumns} + rightPinnedColumns={rightPinnedColumns} + virtualColumns={virtualColumns} + leftPinnedWidth={leftPinnedWidth} + scrollableColumnsTotalWidth={scrollableColumnsTotalWidth} + rightPinnedWidth={rightPinnedWidth} /> ); })} diff --git a/apps/start/src/utils/theme.ts b/apps/start/src/utils/theme.ts index c70f4931..111aa87b 100644 --- a/apps/start/src/utils/theme.ts +++ b/apps/start/src/utils/theme.ts @@ -23,9 +23,12 @@ const chartColors = [ ]; export function getChartColor(index: number): string { - return chartColors[index % chartColors.length]!.main; + return chartColors[index % chartColors.length]?.main || chartColors[0].main; } export function getChartTranslucentColor(index: number): string { - return chartColors[index % chartColors.length]!.translucent; + return ( + chartColors[index % chartColors.length]?.translucent || + chartColors[0].translucent + ); }