Added financial report

This commit is contained in:
Roba Boru
2026-08-12 11:24:12 +03:00
parent e2e1685b62
commit bdcdb4f047
12 changed files with 1170 additions and 232 deletions

View File

@@ -0,0 +1,3 @@
export default function Layout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

View File

@@ -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>
);
}