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'; import { List, Rows3, Search, X } from 'lucide-react';
interface ReportTableToolbarProps { interface ReportTableToolbarProps {
grouped: boolean; grouped?: boolean;
onToggleGrouped: () => void; onToggleGrouped?: () => void;
search: string; search: string;
onSearchChange: (value: string) => void; onSearchChange?: (value: string) => void;
onUnselectAll: () => void; onUnselectAll?: () => void;
} }
export function ReportTableToolbar({ export function ReportTableToolbar({
@@ -19,6 +19,7 @@ export function ReportTableToolbar({
}: ReportTableToolbarProps) { }: ReportTableToolbarProps) {
return ( return (
<div className="col md:row md:items-center gap-2 p-2 border-b md:justify-between"> <div className="col md:row md:items-center gap-2 p-2 border-b md:justify-between">
{onSearchChange && (
<div className="relative flex-1 w-full md:max-w-sm"> <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" /> <Search className="absolute left-2 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input <Input
@@ -28,7 +29,9 @@ export function ReportTableToolbar({
className="pl-8" className="pl-8"
/> />
</div> </div>
)}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{onToggleGrouped && (
<Button <Button
variant={'outline'} variant={'outline'}
size="sm" size="sm"
@@ -37,9 +40,12 @@ export function ReportTableToolbar({
> >
{grouped ? 'Grouped' : 'Flat'} {grouped ? 'Grouped' : 'Flat'}
</Button> </Button>
)}
{onUnselectAll && (
<Button variant="outline" size="sm" onClick={onUnselectAll} icon={X}> <Button variant="outline" size="sm" onClick={onUnselectAll} icon={X}>
Unselect All Unselect All
</Button> </Button>
)}
</div> </div>
</div> </div>
); );

View File

@@ -6,7 +6,8 @@ import type { RouterOutputs } from '@/trpc/client';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme'; import { getChartColor } from '@/utils/theme';
import { getPreviousMetric } from '@openpanel/common'; 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 { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator';
import { ReportTableToolbar } from '../common/report-table-toolbar'; import { ReportTableToolbar } from '../common/report-table-toolbar';
import { SerieIcon } from '../common/serie-icon'; import { SerieIcon } from '../common/serie-icon';
@@ -23,6 +24,8 @@ export function ConversionTable({
visibleSeries, visibleSeries,
setVisibleSeries, setVisibleSeries,
}: ConversionTableProps) { }: ConversionTableProps) {
const [sorting, setSorting] = useState<SortingState>([]);
const [globalFilter, setGlobalFilter] = useState('');
const number = useNumber(); const number = useNumber();
const interval = useSelector((state) => state.report.interval); const interval = useSelector((state) => state.report.interval);
const formatDate = useFormatDateInterval({ 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) { if (allSeries.length === 0) {
return null; return null;
} }
@@ -185,41 +307,103 @@ export function ConversionTable({
return ( return (
<div className="flex flex-col border rounded-lg overflow-hidden bg-card mt-8"> <div className="flex flex-col border rounded-lg overflow-hidden bg-card mt-8">
<ReportTableToolbar <ReportTableToolbar
grouped={false} search={globalFilter}
onToggleGrouped={() => {}} onSearchChange={setGlobalFilter}
search=""
onSearchChange={() => {}}
onUnselectAll={() => setVisibleSeries([])} onUnselectAll={() => setVisibleSeries([])}
/> />
<div className="overflow-x-auto"> <div
<div className="inline-block min-w-full align-middle"> className="overflow-x-auto overflow-y-auto"
<table className="w-full"> 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"> <thead className="bg-muted/30 border-b sticky top-0 z-10">
<tr> <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"> <th
Serie 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>
<th className="text-right px-4 py-3 text-[10px] uppercase font-semibold min-w-[100px]"> <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 Avg Rate
<span className="text-muted-foreground">
{getSortIcon('metric-avgRate')}
</span>
</div>
</th> </th>
<th className="text-right px-4 py-3 text-[10px] uppercase font-semibold min-w-[100px]"> <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 Total
<span className="text-muted-foreground">
{getSortIcon('metric-total')}
</span>
</div>
</th> </th>
<th className="text-right px-4 py-3 text-[10px] uppercase font-semibold min-w-[100px]"> <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 Conversions
<span className="text-muted-foreground">
{getSortIcon('metric-conversions')}
</span>
</div>
</th> </th>
{dates.map((date) => ( {dates.map((date) => (
<th <th
key={date} key={date}
className="text-right px-4 py-3 text-[10px] uppercase font-semibold min-w-[100px]" 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)} {formatDate(date)}
<span className="text-muted-foreground">
{getSortIcon(`date-${date}`)}
</span>
</div>
</th> </th>
))} ))}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map((row) => { {filteredAndSortedRows.map((row) => {
const isVisible = visibleSeriesIds.includes(row.serieId); const isVisible = visibleSeriesIds.includes(row.serieId);
const serieIndex = getSerieIndex(row.serieId); const serieIndex = getSerieIndex(row.serieId);
const color = getChartColor(serieIndex); const color = getChartColor(serieIndex);
@@ -236,7 +420,13 @@ export function ConversionTable({
!isVisible && 'opacity-50', !isVisible && 'opacity-50',
)} )}
> >
<td className="px-4 py-3 sticky left-0 bg-card z-10 border-r"> <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"> <div className="flex items-center gap-2">
<Checkbox <Checkbox
checked={isVisible} checked={isVisible}
@@ -321,6 +511,5 @@ export function ConversionTable({
</table> </table>
</div> </div>
</div> </div>
</div>
); );
} }