This commit is contained in:
Carl-Gerhard Lindesvärd
2025-11-25 22:23:25 +01:00
parent 727a218e6b
commit 95af86dc44
2 changed files with 344 additions and 149 deletions

View File

@@ -3,11 +3,11 @@ import { Input } from '@/components/ui/input';
import { List, Rows3, Search, X } from 'lucide-react';
interface ReportTableToolbarProps {
grouped: boolean;
onToggleGrouped: () => void;
grouped?: boolean;
onToggleGrouped?: () => void;
search: string;
onSearchChange: (value: string) => void;
onUnselectAll: () => void;
onSearchChange?: (value: string) => void;
onUnselectAll?: () => void;
}
export function ReportTableToolbar({
@@ -19,27 +19,33 @@ export function ReportTableToolbar({
}: ReportTableToolbarProps) {
return (
<div className="col md:row md:items-center gap-2 p-2 border-b md:justify-between">
<div className="relative flex-1 w-full md:max-w-sm">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="Search..."
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-8"
/>
</div>
{onSearchChange && (
<div className="relative flex-1 w-full md:max-w-sm">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="Search..."
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-8"
/>
</div>
)}
<div className="flex items-center gap-2">
<Button
variant={'outline'}
size="sm"
onClick={onToggleGrouped}
icon={grouped ? Rows3 : List}
>
{grouped ? 'Grouped' : 'Flat'}
</Button>
<Button variant="outline" size="sm" onClick={onUnselectAll} icon={X}>
Unselect All
</Button>
{onToggleGrouped && (
<Button
variant={'outline'}
size="sm"
onClick={onToggleGrouped}
icon={grouped ? Rows3 : List}
>
{grouped ? 'Grouped' : 'Flat'}
</Button>
)}
{onUnselectAll && (
<Button variant="outline" size="sm" onClick={onUnselectAll} icon={X}>
Unselect All
</Button>
)}
</div>
</div>
);

View File

@@ -6,7 +6,8 @@ import type { RouterOutputs } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme';
import { getPreviousMetric } from '@openpanel/common';
import { useMemo } from 'react';
import type { SortingState } from '@tanstack/react-table';
import { useMemo, useState } from 'react';
import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator';
import { ReportTableToolbar } from '../common/report-table-toolbar';
import { SerieIcon } from '../common/serie-icon';
@@ -23,6 +24,8 @@ export function ConversionTable({
visibleSeries,
setVisibleSeries,
}: ConversionTableProps) {
const [sorting, setSorting] = useState<SortingState>([]);
const [globalFilter, setGlobalFilter] = useState('');
const number = useNumber();
const interval = useSelector((state) => state.report.interval);
const formatDate = useFormatDateInterval({
@@ -178,6 +181,125 @@ export function ConversionTable({
});
};
// Filter and sort rows
const filteredAndSortedRows = 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.some((name) =>
name?.toLowerCase().includes(searchLower),
)
) {
return true;
}
// Search in breakdown values
if (
row.breakdownValues.some((val) =>
val?.toLowerCase().includes(searchLower),
)
) {
return true;
}
// Search in metric values
if (
String(row.avgRate).toLowerCase().includes(searchLower) ||
String(row.total).toLowerCase().includes(searchLower) ||
String(row.conversions).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 (sorting.length > 0) {
result = [...result].sort((a, b) => {
for (const sort of sorting) {
const { id, desc } = sort;
let aValue: any;
let bValue: any;
if (id === 'serie-name') {
aValue = a.serieName.join(' > ') ?? '';
bValue = b.serieName.join(' > ') ?? '';
} else if (id === 'metric-avgRate') {
aValue = a.avgRate ?? 0;
bValue = b.avgRate ?? 0;
} else if (id === 'metric-total') {
aValue = a.total ?? 0;
bValue = b.total ?? 0;
} else if (id === 'metric-conversions') {
aValue = a.conversions ?? 0;
bValue = b.conversions ?? 0;
} else if (id.startsWith('date-')) {
const date = id.replace('date-', '');
aValue = a.dateValues[date] ?? 0;
bValue = b.dateValues[date] ?? 0;
} else {
continue;
}
// Handle null/undefined values
if (aValue == null && bValue == null) continue;
if (aValue == null) return 1;
if (bValue == null) return -1;
// Compare values
if (typeof aValue === 'string' && typeof bValue === 'string') {
const comparison = aValue.localeCompare(bValue);
if (comparison !== 0) return desc ? -comparison : comparison;
} else {
if (aValue < bValue) return desc ? 1 : -1;
if (aValue > bValue) return desc ? -1 : 1;
}
}
return 0;
});
}
return result;
}, [rows, globalFilter, sorting]);
const handleSort = (columnId: string) => {
setSorting((prev) => {
const existingSort = prev.find((s) => s.id === columnId);
if (existingSort) {
if (existingSort.desc) {
// Toggle to ascending if already descending
return [{ id: columnId, desc: false }];
}
// Remove sort if already ascending
return [];
}
// Start with descending (highest first)
return [{ id: columnId, desc: true }];
});
};
const getSortIcon = (columnId: string) => {
const sort = sorting.find((s) => s.id === columnId);
if (!sort) return '⇅';
return sort.desc ? '↓' : '↑';
};
if (allSeries.length === 0) {
return null;
}
@@ -185,141 +307,208 @@ export function ConversionTable({
return (
<div className="flex flex-col border rounded-lg overflow-hidden bg-card mt-8">
<ReportTableToolbar
grouped={false}
onToggleGrouped={() => {}}
search=""
onSearchChange={() => {}}
search={globalFilter}
onSearchChange={setGlobalFilter}
onUnselectAll={() => setVisibleSeries([])}
/>
<div className="overflow-x-auto">
<div className="inline-block min-w-full align-middle">
<table className="w-full">
<thead className="bg-muted/30 border-b sticky top-0 z-10">
<tr>
<th className="text-left px-4 py-3 text-[10px] uppercase font-semibold sticky left-0 bg-muted/30 z-20 min-w-[200px] border-r">
Serie
</th>
<th className="text-right px-4 py-3 text-[10px] uppercase font-semibold min-w-[100px]">
<div
className="overflow-x-auto overflow-y-auto"
style={{
width: '100%',
maxHeight: '600px',
}}
>
<table className="w-full" style={{ minWidth: 'fit-content' }}>
<thead className="bg-muted/30 border-b sticky top-0 z-10">
<tr>
<th
className="text-left h-10 px-4 text-[10px] uppercase font-semibold sticky left-0 bg-card z-20 min-w-[200px] border-r border-border whitespace-nowrap"
style={{
boxShadow: '2px 0 4px -2px var(--border)',
}}
>
<div className="flex items-center">Serie</div>
</th>
<th
className="text-right h-10 px-4 text-[10px] uppercase font-semibold min-w-[100px] cursor-pointer hover:bg-muted/50 select-none border-r border-border whitespace-nowrap"
onClick={() => handleSort('metric-avgRate')}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSort('metric-avgRate');
}
}}
>
<div className="flex items-center justify-end gap-1.5">
Avg Rate
</th>
<th className="text-right px-4 py-3 text-[10px] uppercase font-semibold min-w-[100px]">
<span className="text-muted-foreground">
{getSortIcon('metric-avgRate')}
</span>
</div>
</th>
<th
className="text-right h-10 px-4 text-[10px] uppercase font-semibold min-w-[100px] cursor-pointer hover:bg-muted/50 select-none border-r border-border whitespace-nowrap"
onClick={() => handleSort('metric-total')}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSort('metric-total');
}
}}
>
<div className="flex items-center justify-end gap-1.5">
Total
</th>
<th className="text-right px-4 py-3 text-[10px] uppercase font-semibold min-w-[100px]">
<span className="text-muted-foreground">
{getSortIcon('metric-total')}
</span>
</div>
</th>
<th
className="text-right h-10 px-4 text-[10px] uppercase font-semibold min-w-[100px] cursor-pointer hover:bg-muted/50 select-none border-r border-border whitespace-nowrap"
onClick={() => handleSort('metric-conversions')}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSort('metric-conversions');
}
}}
>
<div className="flex items-center justify-end gap-1.5">
Conversions
</th>
{dates.map((date) => (
<th
key={date}
className="text-right px-4 py-3 text-[10px] uppercase font-semibold min-w-[100px]"
>
<span className="text-muted-foreground">
{getSortIcon('metric-conversions')}
</span>
</div>
</th>
{dates.map((date) => (
<th
key={date}
className="text-right h-10 px-4 text-[10px] uppercase font-semibold min-w-[100px] cursor-pointer hover:bg-muted/50 select-none border-r border-border whitespace-nowrap"
onClick={() => handleSort(`date-${date}`)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSort(`date-${date}`);
}
}}
>
<div className="flex items-center justify-end gap-1.5">
{formatDate(date)}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => {
const isVisible = visibleSeriesIds.includes(row.serieId);
const serieIndex = getSerieIndex(row.serieId);
const color = getChartColor(serieIndex);
const previousMetric =
row.prevAvgRate !== undefined
? getPreviousMetric(row.avgRate, row.prevAvgRate)
: null;
<span className="text-muted-foreground">
{getSortIcon(`date-${date}`)}
</span>
</div>
</th>
))}
</tr>
</thead>
<tbody>
{filteredAndSortedRows.map((row) => {
const isVisible = visibleSeriesIds.includes(row.serieId);
const serieIndex = getSerieIndex(row.serieId);
const color = getChartColor(serieIndex);
const previousMetric =
row.prevAvgRate !== undefined
? getPreviousMetric(row.avgRate, row.prevAvgRate)
: null;
return (
<tr
key={row.id}
className={cn(
'border-b hover:bg-muted/30 transition-colors',
!isVisible && 'opacity-50',
return (
<tr
key={row.id}
className={cn(
'border-b hover:bg-muted/30 transition-colors',
!isVisible && 'opacity-50',
)}
>
<td
className="px-4 py-3 sticky left-0 z-10 border-r border-border"
style={{
backgroundColor: 'var(--card)',
boxShadow: '2px 0 4px -2px var(--border)',
}}
>
<div className="flex items-center gap-2">
<Checkbox
checked={isVisible}
onCheckedChange={() =>
toggleSerieVisibility(row.serieId)
}
style={{
borderColor: color,
backgroundColor: isVisible ? color : 'transparent',
}}
className="h-4 w-4 shrink-0"
/>
<div
className="w-[3px] rounded-full shrink-0"
style={{ background: color }}
/>
<SerieIcon name={row.serieName} />
<SerieName name={row.serieName} className="truncate" />
</div>
</td>
<td
className="px-4 py-3 text-right font-mono text-sm"
style={getCellBackgroundStyle(
row.avgRate,
metricRanges.avgRate.min,
metricRanges.avgRate.max,
'purple',
)}
>
<td className="px-4 py-3 sticky left-0 bg-card z-10 border-r">
<div className="flex items-center gap-2">
<Checkbox
checked={isVisible}
onCheckedChange={() =>
toggleSerieVisibility(row.serieId)
}
style={{
borderColor: color,
backgroundColor: isVisible ? color : 'transparent',
}}
className="h-4 w-4 shrink-0"
/>
<div
className="w-[3px] rounded-full shrink-0"
style={{ background: color }}
/>
<SerieIcon name={row.serieName} />
<SerieName name={row.serieName} className="truncate" />
</div>
</td>
<td
className="px-4 py-3 text-right font-mono text-sm"
style={getCellBackgroundStyle(
row.avgRate,
metricRanges.avgRate.min,
metricRanges.avgRate.max,
'purple',
<div className="flex items-center justify-end gap-2">
<span>
{number.formatWithUnit(row.avgRate / 100, '%')}
</span>
{previousMetric && (
<PreviousDiffIndicatorPure {...previousMetric} />
)}
>
<div className="flex items-center justify-end gap-2">
<span>
{number.formatWithUnit(row.avgRate / 100, '%')}
</span>
{previousMetric && (
<PreviousDiffIndicatorPure {...previousMetric} />
</div>
</td>
<td
className="px-4 py-3 text-right font-mono text-sm"
style={getCellBackgroundStyle(
row.total,
metricRanges.total.min,
metricRanges.total.max,
'purple',
)}
>
{number.format(row.total)}
</td>
<td
className="px-4 py-3 text-right font-mono text-sm"
style={getCellBackgroundStyle(
row.conversions,
metricRanges.conversions.min,
metricRanges.conversions.max,
'purple',
)}
>
{number.format(row.conversions)}
</td>
{dates.map((date) => {
const value = row.dateValues[date] ?? 0;
return (
<td
key={date}
className="px-4 py-3 text-right font-mono text-sm"
style={getCellBackgroundStyle(
value,
dateRanges[date]!.min,
dateRanges[date]!.max,
'emerald',
)}
</div>
</td>
<td
className="px-4 py-3 text-right font-mono text-sm"
style={getCellBackgroundStyle(
row.total,
metricRanges.total.min,
metricRanges.total.max,
'purple',
)}
>
{number.format(row.total)}
</td>
<td
className="px-4 py-3 text-right font-mono text-sm"
style={getCellBackgroundStyle(
row.conversions,
metricRanges.conversions.min,
metricRanges.conversions.max,
'purple',
)}
>
{number.format(row.conversions)}
</td>
{dates.map((date) => {
const value = row.dateValues[date] ?? 0;
return (
<td
key={date}
className="px-4 py-3 text-right font-mono text-sm"
style={getCellBackgroundStyle(
value,
dateRanges[date]!.min,
dateRanges[date]!.max,
'emerald',
)}
>
{number.formatWithUnit(value / 100, '%')}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
>
{number.formatWithUnit(value / 100, '%')}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);