mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Overall revenue updates
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user