wip
This commit is contained in:
@@ -3,6 +3,7 @@ import type { IChartData } from '@/trpc/client';
|
|||||||
|
|
||||||
export type TableRow = {
|
export type TableRow = {
|
||||||
id: string;
|
id: string;
|
||||||
|
serieId: string; // Serie ID for visibility/color lookup
|
||||||
serieName: string;
|
serieName: string;
|
||||||
breakdownValues: string[];
|
breakdownValues: string[];
|
||||||
count: number;
|
count: number;
|
||||||
@@ -11,11 +12,10 @@ export type TableRow = {
|
|||||||
min: number;
|
min: number;
|
||||||
max: number;
|
max: number;
|
||||||
dateValues: Record<string, number>; // date -> count
|
dateValues: Record<string, number>; // date -> count
|
||||||
originalSerie: IChartData['series'][0];
|
// Group metadata
|
||||||
// Group metadata for collapse functionality
|
groupKey?: string;
|
||||||
groupKey?: string; // Unique key for the group this row belongs to
|
parentGroupKey?: string;
|
||||||
parentGroupKey?: string; // Key of parent group (for nested groups)
|
isSummaryRow?: boolean;
|
||||||
isSummaryRow?: boolean; // True if this is a summary row for a collapsed group
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GroupedTableRow = TableRow & {
|
export type GroupedTableRow = TableRow & {
|
||||||
@@ -166,7 +166,14 @@ export function findGroup<T>(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert hierarchical groups to TanStack Table's expandable row format
|
* 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(
|
export function groupsToExpandableRows(
|
||||||
groups: Array<GroupedItem<TableRow>>,
|
groups: Array<GroupedItem<TableRow>>,
|
||||||
@@ -181,37 +188,35 @@ export function groupsToExpandableRows(
|
|||||||
const currentPath = [...parentPath, group.group];
|
const currentPath = [...parentPath, group.group];
|
||||||
const subRows: ExpandableTableRow[] = [];
|
const subRows: ExpandableTableRow[] = [];
|
||||||
|
|
||||||
// Separate nested groups from actual items
|
// Separate nested groups from individual data items
|
||||||
const nestedGroups: GroupedItem<TableRow>[] = [];
|
const nestedGroups: GroupedItem<TableRow>[] = [];
|
||||||
const actualItems: TableRow[] = [];
|
const individualItems: TableRow[] = [];
|
||||||
|
|
||||||
for (const item of group.items) {
|
for (const item of group.items) {
|
||||||
if (item && typeof item === 'object' && 'items' in item) {
|
if (item && typeof item === 'object' && 'items' in item) {
|
||||||
nestedGroups.push(item);
|
nestedGroups.push(item);
|
||||||
} else if (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) {
|
for (const nestedGroup of nestedGroups) {
|
||||||
subRows.push(...processGroup(nestedGroup, currentPath));
|
subRows.push(...processGroup(nestedGroup, currentPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process actual items
|
// Process individual data items (leaf nodes)
|
||||||
actualItems.forEach((item, index) => {
|
individualItems.forEach((item, index) => {
|
||||||
|
// Build breakdownDisplay: first row shows all values, subsequent rows show parent path + item values
|
||||||
const breakdownDisplay: (string | null)[] = [];
|
const breakdownDisplay: (string | null)[] = [];
|
||||||
const breakdownValues = item.breakdownValues;
|
const breakdownValues = item.breakdownValues;
|
||||||
|
|
||||||
// Build breakdownDisplay based on hierarchy
|
for (let i = 0; i < breakdownCount; i++) {
|
||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
// First row shows all breakdown values
|
// First row: show all breakdown values
|
||||||
for (let i = 0; i < breakdownCount; i++) {
|
|
||||||
breakdownDisplay.push(breakdownValues[i] ?? null);
|
breakdownDisplay.push(breakdownValues[i] ?? null);
|
||||||
}
|
} else {
|
||||||
} else {
|
// Subsequent rows: show parent path values, then item values
|
||||||
// Subsequent rows: show values from parent path, then item values
|
|
||||||
for (let i = 0; i < breakdownCount; i++) {
|
|
||||||
if (i < currentPath.length) {
|
if (i < currentPath.length) {
|
||||||
breakdownDisplay.push(currentPath[i] ?? null);
|
breakdownDisplay.push(currentPath[i] ?? null);
|
||||||
} else if (i < breakdownValues.length) {
|
} else if (i < breakdownValues.length) {
|
||||||
@@ -227,18 +232,20 @@ export function groupsToExpandableRows(
|
|||||||
breakdownDisplay,
|
breakdownDisplay,
|
||||||
groupKey: group.groupKey,
|
groupKey: group.groupKey,
|
||||||
parentGroupKey: group.parentGroupKey,
|
parentGroupKey: group.parentGroupKey,
|
||||||
// Explicitly mark as NOT a group header or summary row
|
|
||||||
isGroupHeader: false,
|
isGroupHeader: false,
|
||||||
isSummaryRow: false,
|
isSummaryRow: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// If this group has subRows and is not the last breakdown level, create a group header row
|
// 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)
|
// Don't create group headers for the last breakdown level (level === breakdownCount - 1)
|
||||||
// because it would just duplicate the rows
|
// 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 =
|
const shouldCreateGroupHeader =
|
||||||
subRows.length > 0 &&
|
subRows.length > 0 &&
|
||||||
(group.level < breakdownCount || group.level === -1); // -1 is serie level
|
(group.level === -1 || group.level < breakdownCount - 1);
|
||||||
|
|
||||||
if (shouldCreateGroupHeader) {
|
if (shouldCreateGroupHeader) {
|
||||||
// Create a summary row for the group
|
// Create a summary row for the group
|
||||||
@@ -416,6 +423,7 @@ export function createFlatRows(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: serie.id,
|
id: serie.id,
|
||||||
|
serieId: serie.id,
|
||||||
serieName: serie.names[0] ?? '',
|
serieName: serie.names[0] ?? '',
|
||||||
breakdownValues: serie.names.slice(1),
|
breakdownValues: serie.names.slice(1),
|
||||||
count: serie.metrics.count ?? 0,
|
count: serie.metrics.count ?? 0,
|
||||||
@@ -424,7 +432,6 @@ export function createFlatRows(
|
|||||||
min: serie.metrics.min,
|
min: serie.metrics.min,
|
||||||
max: serie.metrics.max,
|
max: serie.metrics.max,
|
||||||
dateValues,
|
dateValues,
|
||||||
originalSerie: serie,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -451,10 +458,11 @@ export function createGroupedRowsHierarchical(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create hierarchical groups using groupByNames
|
// 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) => ({
|
const itemsWithNames = flatRows.map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
names: [row.serieName, ...row.breakdownValues], // Serie name + breakdown values
|
names: [row.serieName, ...row.breakdownValues],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return groupByNames(itemsWithNames);
|
return groupByNames(itemsWithNames);
|
||||||
@@ -552,6 +560,8 @@ export function createSummaryRow(
|
|||||||
groupKey: string,
|
groupKey: string,
|
||||||
breakdownCount: number,
|
breakdownCount: number,
|
||||||
): GroupedTableRow {
|
): GroupedTableRow {
|
||||||
|
const firstRow = groupRows[0]!;
|
||||||
|
|
||||||
// Aggregate metrics from all rows in the group
|
// Aggregate metrics from all rows in the group
|
||||||
const totalSum = groupRows.reduce((sum, row) => sum + row.sum, 0);
|
const totalSum = groupRows.reduce((sum, row) => sum + row.sum, 0);
|
||||||
const totalCount = groupRows.reduce((sum, row) => sum + row.count, 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 totalMin = Math.min(...groupRows.map((row) => row.min));
|
||||||
const totalMax = Math.max(...groupRows.map((row) => row.max));
|
const totalMax = Math.max(...groupRows.map((row) => row.max));
|
||||||
|
|
||||||
// Aggregate date values
|
// Aggregate date values across all rows
|
||||||
const dateValues: Record<string, number> = {};
|
const dateValues: Record<string, number> = {};
|
||||||
const allDates = new Set<string>();
|
|
||||||
groupRows.forEach((row) => {
|
groupRows.forEach((row) => {
|
||||||
Object.keys(row.dateValues).forEach((date) => {
|
Object.keys(row.dateValues).forEach((date) => {
|
||||||
allDates.add(date);
|
|
||||||
dateValues[date] = (dateValues[date] ?? 0) + row.dateValues[date];
|
dateValues[date] = (dateValues[date] ?? 0) + row.dateValues[date];
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get breakdown values from first row
|
// Build breakdownDisplay: show first breakdown value, rest are null
|
||||||
const firstRow = groupRows[0]!;
|
const breakdownDisplay: (string | null)[] = [
|
||||||
const breakdownDisplay: (string | null)[] = [];
|
firstRow.breakdownValues[0] ?? null,
|
||||||
breakdownDisplay.push(firstRow.breakdownValues[0] ?? null);
|
...Array(breakdownCount - 1).fill(null),
|
||||||
// Fill remaining breakdowns with null (empty)
|
];
|
||||||
for (let i = 1; i < breakdownCount; i++) {
|
|
||||||
breakdownDisplay.push(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `summary-${groupKey}`,
|
id: `summary-${groupKey}`,
|
||||||
|
serieId: firstRow.serieId,
|
||||||
serieName: firstRow.serieName,
|
serieName: firstRow.serieName,
|
||||||
breakdownValues: firstRow.breakdownValues,
|
breakdownValues: firstRow.breakdownValues,
|
||||||
count: totalCount,
|
count: totalCount,
|
||||||
@@ -589,7 +595,6 @@ export function createSummaryRow(
|
|||||||
min: totalMin,
|
min: totalMin,
|
||||||
max: totalMax,
|
max: totalMax,
|
||||||
dateValues,
|
dateValues,
|
||||||
originalSerie: firstRow.originalSerie,
|
|
||||||
groupKey,
|
groupKey,
|
||||||
isSummaryRow: true,
|
isSummaryRow: true,
|
||||||
breakdownDisplay,
|
breakdownDisplay,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from '@tanstack/react-table';
|
} from '@tanstack/react-table';
|
||||||
import {
|
import {
|
||||||
type VirtualItem,
|
type VirtualItem,
|
||||||
|
useVirtualizer,
|
||||||
useWindowVirtualizer,
|
useWindowVirtualizer,
|
||||||
} from '@tanstack/react-virtual';
|
} from '@tanstack/react-virtual';
|
||||||
import throttle from 'lodash.throttle';
|
import throttle from 'lodash.throttle';
|
||||||
@@ -25,6 +26,7 @@ import { ChevronDown, ChevronRight } from 'lucide-react';
|
|||||||
import type * as React from 'react';
|
import type * as React from 'react';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { Tooltiper } from '@/components/ui/tooltip';
|
||||||
import { ReportTableToolbar } from './report-table-toolbar';
|
import { ReportTableToolbar } from './report-table-toolbar';
|
||||||
import {
|
import {
|
||||||
type ExpandableTableRow,
|
type ExpandableTableRow,
|
||||||
@@ -58,26 +60,101 @@ const ROW_HEIGHT = 48; // h-12
|
|||||||
interface VirtualRowProps {
|
interface VirtualRowProps {
|
||||||
row: Row<TableRow | GroupedTableRow>;
|
row: Row<TableRow | GroupedTableRow>;
|
||||||
virtualRow: VirtualItem;
|
virtualRow: VirtualItem;
|
||||||
gridTemplateColumns: string;
|
|
||||||
pinningStylesMap: Map<string, React.CSSProperties>;
|
pinningStylesMap: Map<string, React.CSSProperties>;
|
||||||
headers: Header<TableRow | GroupedTableRow, unknown>[];
|
headers: Header<TableRow | GroupedTableRow, unknown>[];
|
||||||
isResizingRef: React.MutableRefObject<boolean>;
|
isResizingRef: React.MutableRefObject<boolean>;
|
||||||
resizingColumnId: string | null;
|
resizingColumnId: string | null;
|
||||||
setResizingColumnId: (id: string | null) => void;
|
setResizingColumnId: (id: string | null) => void;
|
||||||
|
// Horizontal virtualization props
|
||||||
|
leftPinnedColumns: Header<TableRow | GroupedTableRow, unknown>['column'][];
|
||||||
|
scrollableColumns: Header<TableRow | GroupedTableRow, unknown>['column'][];
|
||||||
|
rightPinnedColumns: Header<TableRow | GroupedTableRow, unknown>['column'][];
|
||||||
|
virtualColumns: VirtualItem[];
|
||||||
|
leftPinnedWidth: number;
|
||||||
|
scrollableColumnsTotalWidth: number;
|
||||||
|
rightPinnedWidth: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VirtualRow = function VirtualRow({
|
const VirtualRow = function VirtualRow({
|
||||||
row,
|
row,
|
||||||
virtualRow,
|
virtualRow,
|
||||||
gridTemplateColumns,
|
|
||||||
pinningStylesMap,
|
pinningStylesMap,
|
||||||
headers,
|
headers,
|
||||||
isResizingRef,
|
isResizingRef,
|
||||||
resizingColumnId,
|
resizingColumnId,
|
||||||
setResizingColumnId,
|
setResizingColumnId,
|
||||||
|
leftPinnedColumns,
|
||||||
|
scrollableColumns,
|
||||||
|
rightPinnedColumns,
|
||||||
|
virtualColumns,
|
||||||
|
leftPinnedWidth,
|
||||||
|
scrollableColumnsTotalWidth,
|
||||||
|
rightPinnedWidth,
|
||||||
}: VirtualRowProps) {
|
}: VirtualRowProps) {
|
||||||
const cells = row.getVisibleCells();
|
const cells = row.getVisibleCells();
|
||||||
|
|
||||||
|
const renderCell = (
|
||||||
|
column: Header<TableRow | GroupedTableRow, unknown>['column'],
|
||||||
|
header: Header<TableRow | GroupedTableRow, unknown> | 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 (
|
||||||
|
<div
|
||||||
|
key={cell.id}
|
||||||
|
style={{
|
||||||
|
width: `${header.getSize()}px`,
|
||||||
|
minWidth: column.columnDef.minSize,
|
||||||
|
maxWidth: column.columnDef.maxSize,
|
||||||
|
...pinningStyles,
|
||||||
|
}}
|
||||||
|
className={cn('relative overflow-hidden border-r')}
|
||||||
|
>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
{canResize && isPinned && (
|
||||||
|
<div
|
||||||
|
data-resize-handle
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
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',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={virtualRow.key}
|
key={virtualRow.key}
|
||||||
@@ -85,76 +162,56 @@ const VirtualRow = function VirtualRow({
|
|||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
width: '100%',
|
width: leftPinnedWidth + scrollableColumnsTotalWidth + rightPinnedWidth,
|
||||||
height: `${virtualRow.size}px`,
|
height: `${virtualRow.size}px`,
|
||||||
transform: `translateY(${virtualRow.start}px)`,
|
transform: `translateY(${virtualRow.start}px)`,
|
||||||
display: 'grid',
|
display: 'flex',
|
||||||
gridTemplateColumns,
|
|
||||||
minWidth: 'fit-content',
|
minWidth: 'fit-content',
|
||||||
}}
|
}}
|
||||||
className="border-b hover:bg-muted/30 transition-colors"
|
className="border-b hover:bg-muted/30 transition-colors"
|
||||||
>
|
>
|
||||||
{headers.map((header) => {
|
{/* Left Pinned Columns */}
|
||||||
const column = header.column;
|
{leftPinnedColumns.map((column) => {
|
||||||
const cell = cells.find((c) => c.column.id === column.id);
|
const header = headers.find((h) => h.column.id === column.id);
|
||||||
if (!cell) return null;
|
return renderCell(column, header);
|
||||||
|
})}
|
||||||
|
|
||||||
const isBreakdown = column.columnDef.meta?.isBreakdown ?? false;
|
{/* Scrollable Columns (Virtualized) */}
|
||||||
const pinningStyles = pinningStylesMap.get(column.id) ?? {};
|
<div
|
||||||
const canResize = column.getCanResize();
|
style={{
|
||||||
const isPinned = column.columnDef.meta?.pinned === 'left';
|
position: 'relative',
|
||||||
const isResizing = resizingColumnId === column.id;
|
width: scrollableColumnsTotalWidth,
|
||||||
|
height: `${virtualRow.size}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={cell.id}
|
key={cell.id}
|
||||||
style={{
|
style={{
|
||||||
width: `${header.getSize()}px`,
|
position: 'absolute',
|
||||||
minWidth: column.columnDef.minSize,
|
left: `${virtualCol.start}px`,
|
||||||
maxWidth: column.columnDef.maxSize,
|
width: `${virtualCol.size}px`,
|
||||||
...pinningStyles,
|
height: `${virtualRow.size}px`,
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn('relative overflow-hidden')}
|
||||||
'border-r border-border relative overflow-hidden',
|
>
|
||||||
isBreakdown && 'border-r-2',
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
)}
|
</div>
|
||||||
>
|
);
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
})}
|
||||||
{canResize && isPinned && (
|
</div>
|
||||||
<div
|
|
||||||
data-resize-handle
|
{/* Right Pinned Columns */}
|
||||||
onMouseDown={(e) => {
|
{rightPinnedColumns.map((column) => {
|
||||||
e.stopPropagation();
|
const header = headers.find((h) => h.column.id === column.id);
|
||||||
isResizingRef.current = true;
|
return renderCell(column, header);
|
||||||
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',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -165,7 +222,7 @@ export function ReportTable({
|
|||||||
visibleSeries,
|
visibleSeries,
|
||||||
setVisibleSeries,
|
setVisibleSeries,
|
||||||
}: ReportTableProps) {
|
}: ReportTableProps) {
|
||||||
const [grouped, setGrouped] = useState(true);
|
const [grouped, setGrouped] = useState(false);
|
||||||
const [expanded, setExpanded] = useState<ExpandedState>({});
|
const [expanded, setExpanded] = useState<ExpandedState>({});
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
const [globalFilter, setGlobalFilter] = useState('');
|
const [globalFilter, setGlobalFilter] = useState('');
|
||||||
@@ -501,7 +558,6 @@ export function ReportTable({
|
|||||||
// Multiple series: calculate ranges across individual rows only
|
// Multiple series: calculate ranges across individual rows only
|
||||||
if (individualRows.length === 0) {
|
if (individualRows.length === 0) {
|
||||||
// No individual rows found - this shouldn't happen, but handle gracefully
|
// No individual rows found - this shouldn't happen, but handle gracefully
|
||||||
console.warn('No individual rows found for range calculation');
|
|
||||||
} else {
|
} else {
|
||||||
individualRows.forEach((row) => {
|
individualRows.forEach((row) => {
|
||||||
// Calculate metric ranges
|
// Calculate metric ranges
|
||||||
@@ -534,31 +590,36 @@ export function ReportTable({
|
|||||||
return { metricRanges, dateRanges };
|
return { metricRanges, dateRanges };
|
||||||
}, [rows, dates]);
|
}, [rows, dates]);
|
||||||
|
|
||||||
// Helper to get background color and opacity for a value
|
// Helper to get background color style and opacity for a value
|
||||||
const getCellBackground = (
|
// Returns both style and opacity (for text color calculation) to avoid parsing
|
||||||
|
const getCellBackgroundStyle = (
|
||||||
value: number,
|
value: number,
|
||||||
min: number,
|
min: number,
|
||||||
max: number,
|
max: number,
|
||||||
className?: string,
|
colorClass: 'purple' | 'emerald' = 'emerald',
|
||||||
): { opacity: number; className: string } => {
|
): { style: React.CSSProperties; opacity: number } => {
|
||||||
if (value === 0) {
|
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
|
// If min equals max (e.g. single row or all values same), show moderate opacity
|
||||||
|
let opacity: number;
|
||||||
if (max === min) {
|
if (max === min) {
|
||||||
return {
|
opacity = 0.5;
|
||||||
opacity: 0.5,
|
} else {
|
||||||
className: cn('bg-highlight dark:bg-emerald-700', className),
|
const percentage = (value - min) / (max - min);
|
||||||
};
|
opacity = Math.max(0.05, Math.min(1, percentage));
|
||||||
}
|
}
|
||||||
|
|
||||||
const percentage = (value - min) / (max - min);
|
// Use rgba colors directly instead of opacity + background class
|
||||||
const opacity = Math.max(0.05, Math.min(1, percentage));
|
const backgroundColor =
|
||||||
|
colorClass === 'purple'
|
||||||
|
? `rgba(168, 85, 247, ${opacity})` // purple-500
|
||||||
|
: `rgba(16, 185, 129, ${opacity})`; // emerald-500
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
style: { backgroundColor },
|
||||||
opacity,
|
opacity,
|
||||||
className: cn('bg-highlight dark:bg-emerald-700', className),
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -616,7 +677,7 @@ export function ReportTable({
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const original = row.original;
|
const original = row.original;
|
||||||
const serieName = original.serieName;
|
const serieName = original.serieName;
|
||||||
const serieId = original.originalSerie.id;
|
const serieId = original.serieId;
|
||||||
const isVisible = visibleSeriesIds.includes(serieId);
|
const isVisible = visibleSeriesIds.includes(serieId);
|
||||||
const serieIndex = getSerieIndex(serieId);
|
const serieIndex = getSerieIndex(serieId);
|
||||||
const color = getChartColor(serieIndex);
|
const color = getChartColor(serieIndex);
|
||||||
@@ -645,10 +706,7 @@ export function ReportTable({
|
|||||||
if (firstRowInGroup.id === original.id) {
|
if (firstRowInGroup.id === original.id) {
|
||||||
isFirstRowInGroup = true;
|
isFirstRowInGroup = true;
|
||||||
} else {
|
} else {
|
||||||
// Only mute if this is not the first row and the serie name matches
|
isMuted = true;
|
||||||
if (firstRowInGroup.serieName === serieName) {
|
|
||||||
isMuted = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -656,7 +714,6 @@ export function ReportTable({
|
|||||||
const originalRow = row.original as ExpandableTableRow | TableRow;
|
const originalRow = row.original as ExpandableTableRow | TableRow;
|
||||||
const isGroupHeader =
|
const isGroupHeader =
|
||||||
'isGroupHeader' in originalRow && originalRow.isGroupHeader === true;
|
'isGroupHeader' in originalRow && originalRow.isGroupHeader === true;
|
||||||
const canExpand = grouped ? (row.getCanExpand?.() ?? false) : false;
|
|
||||||
const isExpanded = grouped ? (row.getIsExpanded?.() ?? false) : false;
|
const isExpanded = grouped ? (row.getIsExpanded?.() ?? false) : false;
|
||||||
const isSerieGroupHeader =
|
const isSerieGroupHeader =
|
||||||
isGroupHeader &&
|
isGroupHeader &&
|
||||||
@@ -664,10 +721,28 @@ export function ReportTable({
|
|||||||
originalRow.groupLevel === -1;
|
originalRow.groupLevel === -1;
|
||||||
const hasSubRows =
|
const hasSubRows =
|
||||||
'subRows' in originalRow && (originalRow.subRows?.length ?? 0) > 0;
|
'subRows' in originalRow && (originalRow.subRows?.length ?? 0) > 0;
|
||||||
|
const isExpandable = grouped && isSerieGroupHeader && hasSubRows;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 px-4 h-12">
|
<div className="flex items-center gap-2 px-4 h-12">
|
||||||
{grouped && isSerieGroupHeader && hasSubRows && (
|
<Checkbox
|
||||||
|
checked={isVisible}
|
||||||
|
onCheckedChange={() => toggleSerieVisibility(serieId)}
|
||||||
|
style={{
|
||||||
|
borderColor: color,
|
||||||
|
backgroundColor: isVisible ? color : 'transparent',
|
||||||
|
}}
|
||||||
|
className="h-4 w-4 shrink-0"
|
||||||
|
/>
|
||||||
|
<SerieName
|
||||||
|
name={serieName}
|
||||||
|
className={cn(
|
||||||
|
'truncate',
|
||||||
|
!isExpandable && grouped && 'text-muted-foreground/40',
|
||||||
|
isExpandable && 'font-semibold',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{isExpandable && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -691,23 +766,6 @@ export function ReportTable({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<Checkbox
|
|
||||||
checked={isVisible}
|
|
||||||
onCheckedChange={() => toggleSerieVisibility(serieId)}
|
|
||||||
style={{
|
|
||||||
borderColor: color,
|
|
||||||
backgroundColor: isVisible ? color : 'transparent',
|
|
||||||
}}
|
|
||||||
className="h-4 w-4 shrink-0"
|
|
||||||
/>
|
|
||||||
<SerieName
|
|
||||||
name={serieName}
|
|
||||||
className={cn(
|
|
||||||
'truncate',
|
|
||||||
isMuted && 'text-muted-foreground/50',
|
|
||||||
(isFirstRowInGroup || isGroupHeader) && 'font-semibold',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -806,11 +864,6 @@ export function ReportTable({
|
|||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
{allExpanded ? (
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
<span>{propertyName}</span>
|
<span>{propertyName}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -818,7 +871,6 @@ export function ReportTable({
|
|||||||
meta: {
|
meta: {
|
||||||
pinned: 'left',
|
pinned: 'left',
|
||||||
isBreakdown: true,
|
isBreakdown: true,
|
||||||
breakdownIndex: index,
|
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const original = row.original as ExpandableTableRow | TableRow;
|
const original = row.original as ExpandableTableRow | TableRow;
|
||||||
@@ -827,82 +879,30 @@ export function ReportTable({
|
|||||||
const canExpand = row.getCanExpand?.() ?? false;
|
const canExpand = row.getCanExpand?.() ?? false;
|
||||||
const isExpanded = row.getIsExpanded?.() ?? false;
|
const isExpanded = row.getIsExpanded?.() ?? false;
|
||||||
|
|
||||||
let value: string | null;
|
const value: string | number | null =
|
||||||
let isMuted = false;
|
original.breakdownValues[index] ?? null;
|
||||||
let isFirstRowInGroup = false;
|
const isLastBreakdown = index === breakdownPropertyNames.length - 1;
|
||||||
|
const isMuted = (!isLastBreakdown && !canExpand && grouped) || !value;
|
||||||
|
|
||||||
if (
|
// For group headers, only show value at the group level, hide deeper breakdowns
|
||||||
'breakdownDisplay' in original &&
|
if (isGroupHeader && 'groupLevel' in original) {
|
||||||
grouped &&
|
const groupLevel = original.groupLevel ?? 0;
|
||||||
original.breakdownDisplay !== undefined
|
if (index !== groupLevel) {
|
||||||
) {
|
return <div className="flex items-center gap-2 px-4 h-12" />;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 (
|
return (
|
||||||
<div className="flex items-center gap-2 px-4 h-12">
|
<div className="flex items-center gap-2 px-4 h-12">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'truncate block leading-[48px]',
|
||||||
|
isMuted && 'text-muted-foreground/50',
|
||||||
|
isGroupHeader && 'font-semibold',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{value || '(Not set)'}
|
||||||
|
</span>
|
||||||
{canExpand &&
|
{canExpand &&
|
||||||
index ===
|
index ===
|
||||||
('groupLevel' in original ? (original.groupLevel ?? 0) : 0) &&
|
('groupLevel' in original ? (original.groupLevel ?? 0) : 0) &&
|
||||||
@@ -923,16 +923,6 @@ export function ReportTable({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'truncate block leading-[48px]',
|
|
||||||
(!value || isMuted) && 'text-muted-foreground/50',
|
|
||||||
(isSummary || shouldBeBold || isGroupHeader) &&
|
|
||||||
'font-semibold',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{value || ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -966,21 +956,6 @@ export function ReportTable({
|
|||||||
const isIndividualRow = !isSummary && !isGroupHeader;
|
const isIndividualRow = !isSummary && !isGroupHeader;
|
||||||
const range = metricRanges[metric.key];
|
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
|
// Only apply colors to individual rows, not summary or group header rows
|
||||||
// Also check that range is valid (not still at initial values)
|
// Also check that range is valid (not still at initial values)
|
||||||
const hasValidRange =
|
const hasValidRange =
|
||||||
@@ -988,32 +963,21 @@ export function ReportTable({
|
|||||||
range.min !== Number.POSITIVE_INFINITY &&
|
range.min !== Number.POSITIVE_INFINITY &&
|
||||||
range.max !== Number.NEGATIVE_INFINITY;
|
range.max !== Number.NEGATIVE_INFINITY;
|
||||||
|
|
||||||
const { opacity, className } =
|
const { style: backgroundStyle, opacity: bgOpacity } =
|
||||||
isIndividualRow && hasValidRange
|
isIndividualRow && hasValidRange
|
||||||
? getCellBackground(
|
? getCellBackgroundStyle(value, range.min, range.max, 'purple')
|
||||||
value,
|
: { style: {}, opacity: 0 };
|
||||||
range.min,
|
|
||||||
range.max,
|
|
||||||
'bg-purple-400 dark:bg-purple-700',
|
|
||||||
)
|
|
||||||
: { opacity: 0, className: '' };
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-12 w-full">
|
<div
|
||||||
<div
|
className={cn(
|
||||||
className={cn(className, 'absolute inset-0 w-full h-full')}
|
'h-12 w-full text-right font-mono text-sm px-4 flex items-center justify-end',
|
||||||
style={{ opacity }}
|
'[text-shadow:_0_0_3px_rgb(0_0_0_/_20%)] shadow-[inset_-1px_-1px_0_var(--border)]',
|
||||||
/>
|
(isSummary || isGroupHeader) && 'font-semibold',
|
||||||
<div
|
)}
|
||||||
className={cn(
|
style={backgroundStyle}
|
||||||
'relative text-right font-mono text-sm px-4 h-full flex items-center justify-end',
|
>
|
||||||
(isSummary || isGroupHeader) && 'font-semibold',
|
{number.format(value)}
|
||||||
opacity > 0.7 &&
|
|
||||||
'text-white [text-shadow:_0_0_3px_rgb(0_0_0_/_20%)]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{number.format(value)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1042,27 +1006,23 @@ export function ReportTable({
|
|||||||
range &&
|
range &&
|
||||||
range.min !== Number.POSITIVE_INFINITY &&
|
range.min !== Number.POSITIVE_INFINITY &&
|
||||||
range.max !== Number.NEGATIVE_INFINITY;
|
range.max !== Number.NEGATIVE_INFINITY;
|
||||||
const { opacity, className } =
|
const { style: backgroundStyle, opacity: bgOpacity } =
|
||||||
isIndividualRow && hasValidRange
|
isIndividualRow && hasValidRange
|
||||||
? getCellBackground(value, range.min, range.max)
|
? getCellBackgroundStyle(value, range.min, range.max, 'emerald')
|
||||||
: { opacity: 0, className: '' };
|
: { style: {}, opacity: 0 };
|
||||||
|
|
||||||
|
const needsLightText = bgOpacity > 0.7;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-12 w-full">
|
<div
|
||||||
<div
|
className={cn(
|
||||||
className={cn(className, 'absolute inset-0 w-full h-full')}
|
'h-12 w-full text-right font-mono text-sm px-4 flex items-center justify-end',
|
||||||
style={{ opacity }}
|
'[text-shadow:_0_0_3px_rgb(0_0_0_/_20%)] shadow-[inset_-1px_-1px_0_var(--border)]',
|
||||||
/>
|
(isSummary || isGroupHeader) && 'font-semibold',
|
||||||
<div
|
)}
|
||||||
className={cn(
|
style={backgroundStyle}
|
||||||
'relative text-right font-mono text-sm px-4 h-full flex items-center justify-end',
|
>
|
||||||
(isSummary || isGroupHeader) && 'font-semibold',
|
{number.format(value)}
|
||||||
opacity > 0.7 &&
|
|
||||||
'text-white [text-shadow:_0_0_3px_rgb(0_0_0_/_20%)]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{number.format(value)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1211,15 +1171,57 @@ export function ReportTable({
|
|||||||
.getAllLeafColumns()
|
.getAllLeafColumns()
|
||||||
.filter((col) => table.getState().columnVisibility[col.id] !== false);
|
.filter((col) => table.getState().columnVisibility[col.id] !== false);
|
||||||
|
|
||||||
// Get pinned columns
|
// Separate columns into pinned and scrollable
|
||||||
const leftPinnedColumns = table
|
const leftPinnedColumns = headerColumns.filter(
|
||||||
.getAllColumns()
|
(col) => col.columnDef.meta?.pinned === 'left',
|
||||||
.filter((col) => col.columnDef.meta?.pinned === 'left')
|
);
|
||||||
.filter((col): col is NonNullable<typeof col> => col !== undefined);
|
const rightPinnedColumns = headerColumns.filter(
|
||||||
const rightPinnedColumns = table
|
(col) => col.columnDef.meta?.pinned === 'right',
|
||||||
.getAllColumns()
|
);
|
||||||
.filter((col) => col.columnDef.meta?.pinned === 'right')
|
const scrollableColumns = headerColumns.filter(
|
||||||
.filter((col): col is NonNullable<typeof col> => col !== undefined);
|
(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
|
// Pre-compute grid template columns string and headers
|
||||||
const { gridTemplateColumns, headers } = useMemo(() => {
|
const { gridTemplateColumns, headers } = useMemo(() => {
|
||||||
@@ -1299,19 +1301,37 @@ export function ReportTable({
|
|||||||
onSearchChange={setGlobalFilter}
|
onSearchChange={setGlobalFilter}
|
||||||
onUnselectAll={() => setVisibleSeries([])}
|
onUnselectAll={() => setVisibleSeries([])}
|
||||||
/>
|
/>
|
||||||
<div ref={parentRef} className="overflow-x-auto">
|
<div
|
||||||
<div className="relative" style={{ minWidth: 'fit-content' }}>
|
ref={parentRef}
|
||||||
|
className="overflow-x-auto"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative"
|
||||||
|
style={{
|
||||||
|
width:
|
||||||
|
leftPinnedWidth + scrollableColumnsTotalWidth + rightPinnedWidth,
|
||||||
|
minWidth: 'fit-content',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div
|
<div
|
||||||
className="sticky top-0 z-20 bg-card border-b"
|
className="sticky top-0 z-20 bg-card border-b"
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'flex',
|
||||||
gridTemplateColumns,
|
width:
|
||||||
|
leftPinnedWidth +
|
||||||
|
scrollableColumnsTotalWidth +
|
||||||
|
rightPinnedWidth,
|
||||||
minWidth: 'fit-content',
|
minWidth: 'fit-content',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{table.getHeaderGroups()[0]?.headers.map((header) => {
|
{/* Left Pinned Columns */}
|
||||||
const column = header.column;
|
{leftPinnedColumns.map((column) => {
|
||||||
|
const header = headers.find((h) => h.column.id === column.id);
|
||||||
|
if (!header) return null;
|
||||||
const headerContent = column.columnDef.header;
|
const headerContent = column.columnDef.header;
|
||||||
const isBreakdown = column.columnDef.meta?.isBreakdown ?? false;
|
const isBreakdown = column.columnDef.meta?.isBreakdown ?? false;
|
||||||
const pinningStyles = getPinningStyles(column);
|
const pinningStyles = getPinningStyles(column);
|
||||||
@@ -1421,6 +1441,172 @@ export function ReportTable({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Scrollable Columns (Virtualized) */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
width: scrollableColumnsTotalWidth,
|
||||||
|
height: '40px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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 (
|
||||||
|
<div
|
||||||
|
key={header.id}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${virtualCol.start}px`,
|
||||||
|
width: `${virtualCol.size}px`,
|
||||||
|
height: '40px',
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'px-4 flex items-center text-[10px] uppercase font-semibold bg-muted/30 border-r border-border whitespace-nowrap',
|
||||||
|
isMetricOrDate && 'text-right',
|
||||||
|
canSort && 'cursor-pointer hover:bg-muted/50 select-none',
|
||||||
|
)}
|
||||||
|
onClick={
|
||||||
|
canSort
|
||||||
|
? (e) => {
|
||||||
|
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}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 flex-1">
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: typeof headerContent === 'function'
|
||||||
|
? flexRender(headerContent, header.getContext())
|
||||||
|
: headerContent}
|
||||||
|
{canSort && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{isSorted === 'asc'
|
||||||
|
? '↑'
|
||||||
|
: isSorted === 'desc'
|
||||||
|
? '↓'
|
||||||
|
: '⇅'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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 (
|
||||||
|
<div
|
||||||
|
key={header.id}
|
||||||
|
style={{
|
||||||
|
width: `${header.getSize()}px`,
|
||||||
|
minWidth: header.column.columnDef.minSize,
|
||||||
|
maxWidth: header.column.columnDef.maxSize,
|
||||||
|
...pinningStyles,
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'h-10 px-4 flex items-center text-[10px] uppercase font-semibold bg-muted/30 border-r border-border whitespace-nowrap relative',
|
||||||
|
isMetricOrDate && 'text-right',
|
||||||
|
canSort && 'cursor-pointer hover:bg-muted/50 select-none',
|
||||||
|
)}
|
||||||
|
onClick={
|
||||||
|
canSort
|
||||||
|
? (e) => {
|
||||||
|
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}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 flex-1">
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: typeof headerContent === 'function'
|
||||||
|
? flexRender(headerContent, header.getContext())
|
||||||
|
: headerContent}
|
||||||
|
{canSort && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{isSorted === 'asc'
|
||||||
|
? '↑'
|
||||||
|
: isSorted === 'desc'
|
||||||
|
? '↓'
|
||||||
|
: '⇅'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Virtualized Body */}
|
{/* Virtualized Body */}
|
||||||
@@ -1442,12 +1628,18 @@ export function ReportTable({
|
|||||||
...virtualRow,
|
...virtualRow,
|
||||||
start: virtualRow.start - virtualizer.options.scrollMargin,
|
start: virtualRow.start - virtualizer.options.scrollMargin,
|
||||||
}}
|
}}
|
||||||
gridTemplateColumns={gridTemplateColumns}
|
|
||||||
pinningStylesMap={pinningStylesMap}
|
pinningStylesMap={pinningStylesMap}
|
||||||
headers={headers}
|
headers={headers}
|
||||||
isResizingRef={isResizingRef}
|
isResizingRef={isResizingRef}
|
||||||
resizingColumnId={resizingColumnId}
|
resizingColumnId={resizingColumnId}
|
||||||
setResizingColumnId={setResizingColumnId}
|
setResizingColumnId={setResizingColumnId}
|
||||||
|
leftPinnedColumns={leftPinnedColumns}
|
||||||
|
scrollableColumns={scrollableColumns}
|
||||||
|
rightPinnedColumns={rightPinnedColumns}
|
||||||
|
virtualColumns={virtualColumns}
|
||||||
|
leftPinnedWidth={leftPinnedWidth}
|
||||||
|
scrollableColumnsTotalWidth={scrollableColumnsTotalWidth}
|
||||||
|
rightPinnedWidth={rightPinnedWidth}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -23,9 +23,12 @@ const chartColors = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function getChartColor(index: number): string {
|
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 {
|
export function getChartTranslucentColor(index: number): string {
|
||||||
return chartColors[index % chartColors.length]!.translucent;
|
return (
|
||||||
|
chartColors[index % chartColors.length]?.translucent ||
|
||||||
|
chartColors[0].translucent
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user