mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 15:25:45 +00:00
Added financial report
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Banknote, BookOpen, FileSpreadsheet, Receipt } from 'lucide-react';
|
||||
import {
|
||||
Bar, BarChart, CartesianGrid, Line, LineChart,
|
||||
ResponsiveContainer, Tooltip as RechartsTooltip, XAxis, YAxis,
|
||||
} from 'recharts';
|
||||
import { toPng } from 'html-to-image';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { financeApi, type FinanceGranularity, type FinanceSummaryFilters } from '@/lib/api/finance';
|
||||
import { buildFinanceWorkbook, type ChartImage } from '@/lib/export/finance-workbook';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import Pagination from '@/components/ui/Pagination';
|
||||
import Skeleton from '@/components/ui/Skeleton';
|
||||
import { usePagination } from '@/lib/use-pagination';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { categoricalColor, getChartPalette } from '@/lib/chart-palette';
|
||||
import { useTheme } from '@/lib/theme-store';
|
||||
|
||||
interface StationOption {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
// Fixed order so a method keeps its colour/slot when the method filter narrows the set.
|
||||
const PAYMENT_METHOD_ORDER = ['TELEBIRR', 'CBE_BIRR', 'EBIRR', 'WAAFI', 'CARD', 'WALLET', 'DMONEY', 'CAC_BANK', 'CBE_BILL'] as const;
|
||||
const PAYMENT_METHOD_LABELS: Record<string, string> = {
|
||||
TELEBIRR: 'Telebirr',
|
||||
CBE_BIRR: 'CBE Birr',
|
||||
EBIRR: 'eBirr',
|
||||
WAAFI: 'Waafi',
|
||||
CARD: 'Card',
|
||||
WALLET: 'Wallet',
|
||||
DMONEY: 'DMoney',
|
||||
CAC_BANK: 'CAC Bank',
|
||||
CBE_BILL: 'CBE Bill',
|
||||
};
|
||||
function methodLabel(method: string): string {
|
||||
return PAYMENT_METHOD_LABELS[method] ?? method;
|
||||
}
|
||||
|
||||
function periodLabel(period: string, granularity: FinanceGranularity): string {
|
||||
if (granularity === 'monthly') {
|
||||
return new Date(`${period}-01T00:00:00`).toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
|
||||
}
|
||||
return new Date(`${period}T00:00:00`).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
const TABLE_PAGE_SIZE = 25;
|
||||
|
||||
/** Mirrors the loaded layout's shape (KPI tiles, two charts, method breakdown, detail table) so nothing jumps when data arrives. */
|
||||
function FinanceReportSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="card flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="h-7 w-7 rounded-lg" />
|
||||
</div>
|
||||
<Skeleton className="h-7 w-32" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<div key={i} className="card">
|
||||
<Skeleton className="h-4 w-40 mb-4" />
|
||||
<Skeleton className="h-[280px] w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<Skeleton className="h-4 w-56 mb-2" />
|
||||
<Skeleton className="h-3 w-32 mb-4" />
|
||||
<Skeleton className="h-7 w-full mb-4" />
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-5 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-0">
|
||||
<div className="px-4 pt-4 pb-3">
|
||||
<Skeleton className="h-4 w-52" />
|
||||
</div>
|
||||
<div className="px-4 pb-4 space-y-2">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FinanceReportPage() {
|
||||
const isDark = useTheme((s) => s.isDark);
|
||||
const palette = getChartPalette(isDark);
|
||||
|
||||
const [dateRangePreset, setDateRangePreset] = useState('90');
|
||||
const [customFrom, setCustomFrom] = useState('');
|
||||
const [customTo, setCustomTo] = useState('');
|
||||
const [granularity, setGranularity] = useState<FinanceGranularity>('daily');
|
||||
const [originStationId, setOriginStationId] = useState('');
|
||||
const [destinationStationId, setDestinationStationId] = useState('');
|
||||
const [method, setMethod] = useState('');
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const trendCardRef = useRef<HTMLDivElement>(null);
|
||||
const segmentCardRef = useRef<HTMLDivElement>(null);
|
||||
const methodCardRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { dateFrom, dateTo } = useMemo(() => {
|
||||
const end = new Date();
|
||||
end.setHours(23, 59, 59, 999);
|
||||
|
||||
if (dateRangePreset === 'custom') {
|
||||
if (customFrom && customTo) {
|
||||
return customFrom <= customTo
|
||||
? { dateFrom: customFrom, dateTo: customTo }
|
||||
: { dateFrom: customTo, dateTo: customFrom };
|
||||
}
|
||||
const fallbackStart = new Date(end);
|
||||
fallbackStart.setDate(end.getDate() - 90);
|
||||
return {
|
||||
dateFrom: fallbackStart.toISOString().split('T')[0],
|
||||
dateTo: end.toISOString().split('T')[0],
|
||||
};
|
||||
}
|
||||
|
||||
const start = new Date(end);
|
||||
start.setDate(end.getDate() - Number(dateRangePreset));
|
||||
return {
|
||||
dateFrom: start.toISOString().split('T')[0],
|
||||
dateTo: end.toISOString().split('T')[0],
|
||||
};
|
||||
}, [dateRangePreset, customFrom, customTo]);
|
||||
|
||||
const filters: FinanceSummaryFilters = useMemo(
|
||||
() => ({
|
||||
dateFrom,
|
||||
dateTo,
|
||||
granularity,
|
||||
originStationId: originStationId || undefined,
|
||||
destinationStationId: destinationStationId || undefined,
|
||||
method: method || undefined,
|
||||
}),
|
||||
[dateFrom, dateTo, granularity, originStationId, destinationStationId, method],
|
||||
);
|
||||
|
||||
const { data: stations = [] } = useQuery<StationOption[]>({
|
||||
queryKey: ['stations'],
|
||||
queryFn: () => apiClient.get<StationOption[]>('/stations'),
|
||||
});
|
||||
|
||||
const { data, isLoading, isFetching, isError } = useQuery({
|
||||
queryKey: ['reports-finance', filters],
|
||||
placeholderData: (previous) => previous,
|
||||
queryFn: () => financeApi.getSummary(filters),
|
||||
});
|
||||
|
||||
const rows = data?.rows ?? [];
|
||||
const pg = usePagination(rows, TABLE_PAGE_SIZE);
|
||||
|
||||
const resetFilters = () => {
|
||||
setDateRangePreset('90');
|
||||
setCustomFrom('');
|
||||
setCustomTo('');
|
||||
setGranularity('daily');
|
||||
setOriginStationId('');
|
||||
setDestinationStationId('');
|
||||
setMethod('');
|
||||
};
|
||||
|
||||
/** Captures a chart card as a PNG data URL, sized to the card's actual on-screen pixels. */
|
||||
const captureCard = async (node: HTMLDivElement | null): Promise<ChartImage | undefined> => {
|
||||
if (!node) return undefined;
|
||||
const rect = node.getBoundingClientRect();
|
||||
const dataUrl = await toPng(node, { pixelRatio: 2, cacheBust: true, backgroundColor: palette.surface });
|
||||
return { dataUrl, width: Math.round(rect.width), height: Math.round(rect.height) };
|
||||
};
|
||||
|
||||
const doExport = async () => {
|
||||
if (!data) return;
|
||||
setExporting(true);
|
||||
try {
|
||||
const [trend, segment, method_] = await Promise.all([
|
||||
captureCard(trendCardRef.current),
|
||||
captureCard(segmentCardRef.current),
|
||||
captureCard(methodCardRef.current),
|
||||
]);
|
||||
|
||||
const stationLabel = (id: string) => stations.find((s) => s.id === id)?.name ?? 'Any';
|
||||
|
||||
const blob = await buildFinanceWorkbook({
|
||||
report: data,
|
||||
filters: {
|
||||
dateFrom,
|
||||
dateTo,
|
||||
granularity,
|
||||
originLabel: originStationId ? stationLabel(originStationId) : 'Any',
|
||||
destinationLabel: destinationStationId ? stationLabel(destinationStationId) : 'Any',
|
||||
methodLabel: method ? methodLabel(method) : 'All',
|
||||
},
|
||||
methodLabel,
|
||||
periodLabel,
|
||||
images: { trend, segment, method: method_ },
|
||||
});
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `finance-summary-${dateFrom}-to-${dateTo}.xlsx`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const trendData = useMemo(
|
||||
() =>
|
||||
(data?.byPeriod ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => a.key.localeCompare(b.key))
|
||||
.map((p) => ({
|
||||
label: periodLabel(p.key, data!.granularity),
|
||||
revenue: p.revenueEtbMinor / 100,
|
||||
})),
|
||||
[data],
|
||||
);
|
||||
|
||||
const segmentData = useMemo(
|
||||
() =>
|
||||
(data?.bySegment ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => b.revenueEtbMinor - a.revenueEtbMinor)
|
||||
.map((r) => ({ label: r.label, revenue: r.revenueEtbMinor })),
|
||||
[data],
|
||||
);
|
||||
|
||||
const methodBreakdown = useMemo(() => {
|
||||
const rowsByMethod = data?.byMethod ?? [];
|
||||
const total = rowsByMethod.reduce((sum, r) => sum + r.revenueEtbMinor, 0);
|
||||
return rowsByMethod
|
||||
.slice()
|
||||
.sort((a, b) => PAYMENT_METHOD_ORDER.indexOf(a.key as any) - PAYMENT_METHOD_ORDER.indexOf(b.key as any))
|
||||
.map((r) => ({
|
||||
...r,
|
||||
color: categoricalColor(palette, PAYMENT_METHOD_ORDER.indexOf(r.key as any)),
|
||||
sharePercent: total > 0 ? (r.revenueEtbMinor / total) * 100 : 0,
|
||||
}));
|
||||
}, [data, palette]);
|
||||
|
||||
const totals = data?.totals;
|
||||
const hasData = (totals?.bookingCount ?? 0) > 0;
|
||||
const avgPerBookingMinor = hasData ? Math.round(totals!.revenueEtbMinor / totals!.bookingCount) : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Finance Summary</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Revenue collected by period, origin/destination, and payment method — for daily, weekly, or monthly finance reporting.
|
||||
</p>
|
||||
</div>
|
||||
<ActionButton icon={FileSpreadsheet} variant="secondary" onClick={doExport} loading={exporting} disabled={!hasData}>
|
||||
Export
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="card">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-6">
|
||||
<div>
|
||||
<label className="label">Date Range</label>
|
||||
<select className="input" value={dateRangePreset} onChange={(e) => setDateRangePreset(e.target.value)}>
|
||||
<option value="7">Last 7 Days</option>
|
||||
<option value="30">Last 30 Days</option>
|
||||
<option value="90">Last 90 Days</option>
|
||||
<option value="custom">Custom Range</option>
|
||||
</select>
|
||||
</div>
|
||||
{dateRangePreset === 'custom' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="label">Start Date</label>
|
||||
<input type="date" className="input" value={customFrom} onChange={(e) => setCustomFrom(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">End Date</label>
|
||||
<input type="date" className="input" value={customTo} onChange={(e) => setCustomTo(e.target.value)} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div>
|
||||
<label className="label">Granularity</label>
|
||||
<select className="input" value={granularity} onChange={(e) => setGranularity(e.target.value as FinanceGranularity)}>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Origin</label>
|
||||
<select className="input" value={originStationId} onChange={(e) => setOriginStationId(e.target.value)}>
|
||||
<option value="">Any origin</option>
|
||||
{stations.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Destination</label>
|
||||
<select className="input" value={destinationStationId} onChange={(e) => setDestinationStationId(e.target.value)}>
|
||||
<option value="">Any destination</option>
|
||||
{stations.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Payment Method</label>
|
||||
<select className="input" value={method} onChange={(e) => setMethod(e.target.value)}>
|
||||
<option value="">All methods</option>
|
||||
{PAYMENT_METHOD_ORDER.map((m) => (
|
||||
<option key={m} value={m}>{methodLabel(m)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<button type="button" onClick={resetFilters} className="text-xs text-primary hover:underline">
|
||||
Reset filters
|
||||
</button>
|
||||
{isFetching && <span className="text-xs text-muted-foreground">Refreshing…</span>}
|
||||
</div>
|
||||
{isError && <p className="text-xs text-red-500 mt-3">Failed to load the finance summary. Check the filters and try again.</p>}
|
||||
</div>
|
||||
|
||||
{isLoading && !data ? (
|
||||
<FinanceReportSkeleton />
|
||||
) : !hasData ? (
|
||||
<div className="card py-16 text-center text-muted-foreground">
|
||||
<Banknote className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p>No paid bookings in this window.</p>
|
||||
<p className="text-xs mt-1">Widen the date range, or clear the origin/destination/method filters.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={isFetching ? 'opacity-60 transition-opacity space-y-6' : 'space-y-6'}>
|
||||
{/* KPI tiles */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="card flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Revenue</p>
|
||||
<div className="rounded-lg bg-emerald-100 dark:bg-emerald-900/30 p-1.5">
|
||||
<Banknote className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-emerald-600 dark:text-emerald-400 tabular-nums mt-1">
|
||||
{formatCurrency(totals!.revenueEtbMinor, 'ETB')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="card flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Bookings</p>
|
||||
<div className="rounded-lg bg-blue-100 dark:bg-blue-900/30 p-1.5">
|
||||
<BookOpen className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-2xl font-bold tabular-nums mt-1">{totals!.bookingCount.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="card flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Avg. per Booking</p>
|
||||
<div className="rounded-lg bg-amber-100 dark:bg-amber-900/30 p-1.5">
|
||||
<Receipt className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-2xl font-bold tabular-nums mt-1">{formatCurrency(avgPerBookingMinor, 'ETB')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trend + route charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="card" ref={trendCardRef}>
|
||||
<h3 className="text-base font-semibold mb-4">
|
||||
Revenue Trend <span className="text-xs font-normal text-muted-foreground">(ETB, {granularity})</span>
|
||||
</h3>
|
||||
{trendData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<LineChart data={trendData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={palette.grid} />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 11, fill: palette.textMuted }} />
|
||||
<YAxis tick={{ fontSize: 11, fill: palette.textMuted }} />
|
||||
<RechartsTooltip formatter={(value: number) => `ETB ${Math.round(value).toLocaleString()}`} />
|
||||
<Line type="monotone" dataKey="revenue" name="Revenue" stroke={palette.sequential} dot={{ r: 3 }} strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">No data for selected range</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card" ref={segmentCardRef}>
|
||||
<h3 className="text-base font-semibold mb-4">Revenue by Segment</h3>
|
||||
{segmentData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<BarChart data={segmentData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={palette.grid} />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 11, fill: palette.textMuted }} />
|
||||
<YAxis tick={{ fontSize: 11, fill: palette.textMuted }} />
|
||||
<RechartsTooltip formatter={(value: number) => `ETB ${Math.round(value).toLocaleString()}`} />
|
||||
<Bar dataKey="revenue" fill={palette.sequential} radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">No data for selected range</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment method breakdown — part-to-whole stacked bar + legend table */}
|
||||
<div className="card" ref={methodCardRef}>
|
||||
<h3 className="text-base font-semibold text-foreground">Revenue by Payment Method</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-4">Share of revenue, in ETB</p>
|
||||
<div
|
||||
className="flex w-full h-7 rounded-md overflow-hidden"
|
||||
role="img"
|
||||
aria-label={`Revenue by payment method: ${methodBreakdown
|
||||
.map((m) => `${methodLabel(m.key)} ${m.sharePercent.toFixed(0)}%`)
|
||||
.join(', ')}`}
|
||||
>
|
||||
{methodBreakdown.map((m, i) => (
|
||||
<div
|
||||
key={m.key}
|
||||
className="h-full"
|
||||
style={{ width: `${m.sharePercent}%`, background: m.color, marginRight: i < methodBreakdown.length - 1 ? 2 : 0 }}
|
||||
title={`${methodLabel(m.key)} — ${formatCurrency(m.revenueEtbMinor, 'ETB')}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<table className="w-full text-sm mt-4">
|
||||
<thead>
|
||||
<tr className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
<th className="text-left font-medium py-2">Method</th>
|
||||
<th className="text-right font-medium py-2">Bookings</th>
|
||||
<th className="text-right font-medium py-2">Share</th>
|
||||
<th className="text-right font-medium py-2">Revenue</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{methodBreakdown.map((m) => (
|
||||
<tr key={m.key}>
|
||||
<td className="py-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="h-2.5 w-2.5 rounded-sm shrink-0" style={{ background: m.color }} aria-hidden="true" />
|
||||
<span className="text-foreground">{methodLabel(m.key)}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 text-right tabular-nums text-muted-foreground">{m.bookingCount.toLocaleString()}</td>
|
||||
<td className="py-2 text-right tabular-nums text-muted-foreground">{m.sharePercent.toFixed(1)}%</td>
|
||||
<td className="py-2 text-right tabular-nums text-foreground font-medium">{formatCurrency(m.revenueEtbMinor, 'ETB')}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Detail table */}
|
||||
<div className="card p-0">
|
||||
<div className="px-4 pt-4 pb-3">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Period × Segment × Method detail
|
||||
</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
{['Period', 'Segment', 'Method', 'Bookings', 'Revenue'].map((h) => (
|
||||
<th key={h} className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{pg.paged.map((r, i) => (
|
||||
<tr key={`${r.period}-${r.originStationId}-${r.destinationStationId}-${r.method}-${i}`} className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
|
||||
<td className="px-4 py-3 whitespace-nowrap text-foreground">{periodLabel(r.period, granularity)}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-muted-foreground">{r.segmentLabel}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-muted-foreground">{methodLabel(r.method)}</td>
|
||||
<td className="px-4 py-3 tabular-nums whitespace-nowrap">{r.bookingCount.toLocaleString()}</td>
|
||||
<td className="px-4 py-3 tabular-nums whitespace-nowrap font-medium">{formatCurrency(r.revenueEtbMinor, 'ETB')}</td>
|
||||
</tr>
|
||||
))}
|
||||
{pg.paged.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="py-8 text-center text-sm text-muted-foreground">No rows on this page</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination currentPage={pg.page} totalPages={pg.totalPages} onPageChange={pg.setPage} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user