Overall revenue updates

This commit is contained in:
Stephanos A
2026-08-11 15:02:16 +03:00
parent dc2078dd28
commit bd8187578e

View File

@@ -24,6 +24,8 @@ export default function ReportsPage() {
const [dateRange, setDateRange] = useState('30');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [routeOrigin, setRouteOrigin] = useState('');
const [routeDestination, setRouteDestination] = useState('');
const [exportModalOpen, setExportModalOpen] = useState(false);
const [exportFormat, setExportFormat] = useState<'csv' | 'excel' | 'pdf'>('csv');
@@ -78,6 +80,12 @@ export default function ReportsPage() {
return r ? 1 / r.rate : null;
};
const getBookingTicketCount = (booking: any): number => {
if (Array.isArray(booking.tickets)) return booking.tickets.length;
if (typeof booking.ticketCount === 'number') return booking.ticketCount;
return 0;
};
const calcGrand = (rows: { currency: string; totalMinor: number }[]) =>
rows.reduce((sum, { currency, totalMinor }) => {
const rate = toEtbRate(currency);
@@ -107,6 +115,14 @@ export default function ReportsPage() {
const confirmedBookings = bookings.filter((b: any) => b.status !== 'CANCELLED' && b.status !== 'REFUNDED');
const totalRevenueMinor = confirmedBookings.reduce((sum, b: any) => {
const rate = toEtbRate(b.currency);
return rate !== null ? sum + Math.round((b.totalMinor || 0) * rate) : sum;
}, 0);
const totalBookingsCount = confirmedBookings.length;
const totalTicketsCount = confirmedBookings.reduce((sum, b: any) => sum + getBookingTicketCount(b), 0);
const byDate = confirmedBookings.reduce((acc: Record<string, any>, b: any) => {
const date = new Date(b.createdAt).toISOString().split('T')[0];
if (!acc[date]) acc[date] = { totalMinor: 0, count: 0 };
@@ -123,7 +139,50 @@ export default function ReportsPage() {
bookings: d.count || 0,
}));
const avgDailyRevenue = chartData.length > 0 ? Math.round(overallGrand / 100 / chartData.length) : 0;
const avgDailyRevenueMinor = chartData.length > 0 ? Math.round(totalRevenueMinor / chartData.length) : 0;
const totalRegularBookingsCount = confirmedBookings.filter((b: any) => {
const bookingType = String(b.bookingType || '').toUpperCase();
return bookingType !== 'PACKAGE' && !b.packageId;
}).length;
const totalPackageBookingsCount = confirmedBookings.filter((b: any) => {
const bookingType = String(b.bookingType || '').toUpperCase();
return bookingType === 'PACKAGE' || Boolean(b.packageId);
}).length;
const filteredRouteBookings = confirmedBookings.filter((b: any) => {
const origin = b.schedule?.originStation?.name || b.originStationName || b.origin || 'Unknown';
const destination = b.schedule?.destinationStation?.name || b.destinationStationName || b.destination || 'Unknown';
if (routeOrigin && origin !== routeOrigin) return false;
if (routeDestination && destination !== routeDestination) return false;
return true;
});
const routeOriginOptions = [
...new Set(confirmedBookings.map((b: any) => b.schedule?.originStation?.name || b.originStationName || b.origin || 'Unknown').filter(Boolean)),
].sort() as string[];
const routeDestinationOptions = [
...new Set(confirmedBookings.map((b: any) => b.schedule?.destinationStation?.name || b.destinationStationName || b.destination || 'Unknown').filter(Boolean)),
].sort() as string[];
const routeRevenueData = Object.entries(
filteredRouteBookings.reduce((acc: Record<string, { totalEtbMinor: number; bookings: number }>, b: any) => {
const origin = b.schedule?.originStation?.name || b.originStationName || b.origin || 'Unknown';
const destination = b.schedule?.destinationStation?.name || b.destinationStationName || b.destination || 'Unknown';
const route = `${origin}${destination}`;
const rate = toEtbRate(b.currency);
const etbMinor = rate !== null ? Math.round((b.totalMinor || 0) * rate) : 0;
if (!acc[route]) acc[route] = { totalEtbMinor: 0, bookings: 0 };
acc[route].totalEtbMinor += etbMinor;
acc[route].bookings += 1;
return acc;
}, {}),
).map(([route, value]) => ({
route,
totalEtbMinor: value.totalEtbMinor,
bookings: value.bookings,
})).sort((a, b) => b.totalEtbMinor - a.totalEtbMinor);
const REPORT_COLS = ['Date', 'Revenue (ETB)', 'Confirmed Bookings'];
@@ -161,6 +220,20 @@ export default function ReportsPage() {
setExportModalOpen(false);
};
const doExportRouteRevenue = () => {
if (!routeRevenueData.length) { alert('No route revenue to export'); return; }
const rows = routeRevenueData.map((row) => [row.route, String(row.bookings), formatCurrency(row.totalEtbMinor, 'ETB')]);
const headers = ['Route', 'Bookings', 'Revenue (ETB)'];
const csv = [headers.map((h) => `"${h}"`).join(','), ...rows.map((r) => r.map((v) => `"${v}"`).join(','))].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `route-revenue-${dates.startDate}-${dates.endDate}.csv`;
a.click();
URL.revokeObjectURL(url);
};
const renderCurrencyRow = ({ currency, totalMinor }: { currency: string; totalMinor: number }) => {
const rate = toEtbRate(currency);
const etbMinor = rate !== null ? Math.round(totalMinor * rate) : null;
@@ -231,7 +304,7 @@ export default function ReportsPage() {
</div>
</div>
<p className="text-2xl font-bold text-emerald-600 dark:text-emerald-400 tabular-nums mt-1">
{statsLoading ? '—' : formatCurrency(overallGrand, 'ETB')}
{isLoading ? '—' : formatCurrency(totalRevenueMinor, 'ETB')}
</p>
<div className="flex flex-col gap-1 border-t border-border pt-2 mt-1">
<div className="flex justify-between text-xs">
@@ -254,16 +327,16 @@ export default function ReportsPage() {
</div>
</div>
<p className="text-2xl font-bold tabular-nums mt-1">
{statsLoading ? '—' : (stats?.totalBookings ?? 0).toLocaleString()}
{isLoading ? '—' : totalBookingsCount.toLocaleString()}
</p>
<div className="flex flex-col gap-1 border-t border-border pt-2 mt-1">
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Regular</span>
<span className="font-semibold tabular-nums">{statsLoading ? '—' : (stats?.totalNormalBookings ?? 0).toLocaleString()}</span>
<span className="font-semibold tabular-nums">{isLoading ? '—' : totalRegularBookingsCount.toLocaleString()}</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Package</span>
<span className="font-semibold tabular-nums">{statsLoading ? '—' : (stats?.totalPackageBookings ?? 0).toLocaleString()}</span>
<span className="font-semibold tabular-nums">{isLoading ? '—' : totalPackageBookingsCount.toLocaleString()}</span>
</div>
</div>
</div>
@@ -277,16 +350,16 @@ export default function ReportsPage() {
</div>
</div>
<p className="text-2xl font-bold tabular-nums mt-1">
{statsLoading ? '—' : (stats?.totalTickets ?? 0).toLocaleString()}
{isLoading ? '—' : totalTicketsCount.toLocaleString()}
</p>
<div className="flex flex-col gap-1 border-t border-border pt-2 mt-1">
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Regular</span>
<span className="font-semibold tabular-nums">{statsLoading ? '—' : (stats?.totalNormalTickets ?? 0).toLocaleString()}</span>
<span className="font-semibold tabular-nums">{isLoading ? '—' : totalRegularBookingsCount.toLocaleString()}</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Package</span>
<span className="font-semibold tabular-nums">{statsLoading ? '—' : (stats?.totalPackageTickets ?? 0).toLocaleString()}</span>
<span className="font-semibold tabular-nums">{isLoading ? '—' : totalPackageBookingsCount.toLocaleString()}</span>
</div>
</div>
</div>
@@ -300,7 +373,7 @@ export default function ReportsPage() {
</div>
</div>
<p className="text-2xl font-bold tabular-nums mt-1">
{isLoading ? '—' : formatCurrency(avgDailyRevenue * 100, 'ETB')}
{isLoading ? '—' : formatCurrency(avgDailyRevenueMinor, 'ETB')}
</p>
<p className="text-xs text-muted-foreground mt-auto pt-2 border-t border-border">
Over {chartData.length} active day{chartData.length !== 1 ? 's' : ''} in range
@@ -368,6 +441,85 @@ export default function ReportsPage() {
)}
</div>
{/* Route Revenue Breakdown */}
<div className="card">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-4">
<div>
<h2 className="text-sm font-semibold uppercase tracking-widest text-muted-foreground">
Revenue by Route
</h2>
<p className="text-xs text-muted-foreground">
Confirmed booking revenue for the selected date range, grouped by route.
</p>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>{routeRevenueData.length} route{routeRevenueData.length !== 1 ? 's' : ''}</span>
<button
type="button"
className="text-primary underline"
onClick={() => {
setRouteOrigin('');
setRouteDestination('');
}}
>
Clear filters
</button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mb-4">
<div>
<label className="label">Origin</label>
<select
className="input"
value={routeOrigin}
onChange={(e) => setRouteOrigin(e.target.value)}
disabled={isLoading}
>
<option value="">All origins</option>
{routeOriginOptions.map((origin) => (
<option key={origin} value={origin}>{origin}</option>
))}
</select>
</div>
<div>
<label className="label">Destination</label>
<select
className="input"
value={routeDestination}
onChange={(e) => setRouteDestination(e.target.value)}
disabled={isLoading}
>
<option value="">All destinations</option>
{routeDestinationOptions.map((destination) => (
<option key={destination} value={destination}>{destination}</option>
))}
</select>
</div>
<div className="flex items-end justify-end">
<ActionButton variant="secondary" onClick={doExportRouteRevenue} disabled={isLoading || routeRevenueData.length === 0}>
Export route revenue
</ActionButton>
</div>
</div>
{routeRevenueData.length === 0 ? (
<p className="text-sm text-muted-foreground">No route revenue data available for this range.</p>
) : (
<div className="space-y-3">
{routeRevenueData.slice(0, 10).map((route) => (
<div key={route.route} className="grid grid-cols-1 md:grid-cols-[1.4fr_0.8fr_0.8fr] gap-3 items-center rounded-md bg-muted/20 p-3">
<div className="text-sm font-medium break-words">{route.route}</div>
<div className="text-sm text-muted-foreground">{route.bookings.toLocaleString()} booking{route.bookings !== 1 ? 's' : ''}</div>
<div className="text-right text-sm font-semibold tabular-nums">
{formatCurrency(route.totalEtbMinor, 'ETB')}
</div>
</div>
))}
</div>
)}
</div>
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Revenue Trend */}
@@ -489,8 +641,8 @@ export default function ReportsPage() {
{ label: 'Confirmed', value: bookings.filter((b: any) => b.status === 'CONFIRMED').length, fromStats: false },
{ label: 'Boarded', value: bookings.filter((b: any) => b.status === 'BOARDED').length, fromStats: false },
{ label: 'Cancelled', value: bookings.filter((b: any) => b.status === 'CANCELLED').length, fromStats: false },
{ label: 'Regular Bookings', value: stats?.totalNormalBookings ?? 0, fromStats: true },
{ label: 'Package Bookings', value: stats?.totalPackageBookings ?? 0, fromStats: true },
{ label: 'Regular Bookings', value: totalRegularBookingsCount, fromStats: false },
{ label: 'Package Bookings', value: totalPackageBookingsCount, fromStats: false },
].map(({ label, value, fromStats }) => (
<div key={label} className="border border-border rounded-lg p-3 text-center">
<p className="text-xs text-muted-foreground">{label}</p>