Merge pull request #1282 from Tria-plc/alpha

feat: ( dashboard ) add booking charts
This commit is contained in:
Abubeker Yasin
2026-08-13 23:32:25 +03:00
committed by GitHub
4 changed files with 537 additions and 1 deletions

View File

@@ -1,4 +1,4 @@
import { Controller, Get, Param, SetMetadata, UseGuards } from '@nestjs/common';
import { Controller, Get, Param, Query, SetMetadata, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { DashboardService } from './dashboard.service';
import { JwtGuard } from '../../common/jwt.guard';
@@ -16,6 +16,27 @@ export class DashboardController {
@ApiOperation({ summary: 'Backoffice summary: totals and revenue by currency' })
getBackofficeStats() { return this.service.getBackofficeStats(); }
// Two segments, so the single-segment `@Get(':passengerId')` below cannot swallow it
// however the routes are ordered. Staff-guarded like backoffice-stats, not JwtGuard.
@Get('analytics/bookings')
@PassengerStaff([PASSENGER_PERMS.dashboard.view, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({
summary: 'Booking analytics for the dashboard charts',
description:
'Revenue trend, daily confirmed bookings, booking status distribution and payment-method split over the ' +
'last `days` days (default 30), bucketed by booking creation date.\n\n' +
'Revenue and the daily count cover CONFIRMED and BOARDED bookings; the status and payment-method ' +
'breakdowns cover every booking in range — the same asymmetry the /reports/overall page applies, kept so ' +
'the two agree.\n\n' +
'Revenue is returned per currency and unconverted; the caller applies its own exchange rates. These ' +
'figures answer "what was booked" and will not match the Revenue Breakdown card, which requires a ' +
'SUCCEEDED payment intent and answers "what was collected".',
})
getBookingAnalytics(@Query('days') days?: string) {
return this.service.getBookingAnalytics(days ? Number(days) : undefined);
}
@Get(':passengerId')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')

View File

@@ -3,6 +3,11 @@ import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service';
// ── Booking analytics (backoffice dashboard charts) ──────────────────────────
const MS_PER_DAY_ANALYTICS = 24 * 60 * 60 * 1000;
const ANALYTICS_DEFAULT_DAYS = 30;
const ANALYTICS_MAX_DAYS = 365;
@Injectable()
export class DashboardService {
constructor(
@@ -63,6 +68,109 @@ export class DashboardService {
};
}
/**
* Booking analytics for the backoffice dashboard charts — revenue trend, daily
* confirmed bookings, status distribution and payment-method split.
*
* Ported from the client-side computation on `/reports/overall`, which pulled up to
* 5000 bookings into the browser and grouped them there. The dashboard is the landing
* page and refetches on an interval, so the grouping happens here instead.
*
* Two asymmetries are inherited from that report on purpose, so the dashboard and the
* report show the same figures:
* - Revenue and the daily count use CONFIRMED and BOARDED only; the status and
* payment-method breakdowns use every booking in range.
* - Everything buckets on `createdAt` — when the booking was made, not when the
* train departs.
*
* Revenue here will NOT equal the dashboard's Revenue Breakdown card, which
* additionally requires a SUCCEEDED PaymentIntent and prefers the display amounts
* (see getBackofficeStats). Different question, deliberately not reconciled: this is
* "what was booked", that is "what was collected".
*/
async getBookingAnalytics(daysRaw?: number) {
const days = Math.min(
Math.max(Math.trunc(daysRaw || ANALYTICS_DEFAULT_DAYS), 1),
ANALYTICS_MAX_DAYS,
);
const to = new Date();
const from = new Date(to.getTime() - days * MS_PER_DAY_ANALYTICS);
const bookings = await this.prisma.booking.findMany({
where: { createdAt: { gte: from, lte: to } },
select: {
createdAt: true,
status: true,
totalMinor: true,
currency: true,
paymentIntent: { select: { method: true } },
},
});
const isConfirmed = (status: string) => status === 'CONFIRMED' || status === 'BOARDED';
// Day buckets keyed on the UTC calendar date, so the axis and the bars derive from
// one value and cannot disagree.
const byDayMap = new Map<
string,
{ date: string; bookings: number; revenueByCurrency: Map<string, number> }
>();
const statusCounts = new Map<string, number>();
const methodCounts = new Map<string, number>();
for (const booking of bookings) {
// Status and payment method count every booking in range.
const status = booking.status ?? 'UNKNOWN';
statusCounts.set(status, (statusCounts.get(status) ?? 0) + 1);
const method = booking.paymentIntent?.method ?? 'UNKNOWN';
methodCounts.set(method, (methodCounts.get(method) ?? 0) + 1);
// Revenue and the daily count are confirmed travel only.
if (!isConfirmed(booking.status)) continue;
const date = booking.createdAt.toISOString().slice(0, 10);
const bucket =
byDayMap.get(date) ?? { date, bookings: 0, revenueByCurrency: new Map<string, number>() };
bucket.bookings += 1;
const currency = booking.currency ?? 'ETB';
bucket.revenueByCurrency.set(
currency,
(bucket.revenueByCurrency.get(currency) ?? 0) + (booking.totalMinor ?? 0),
);
byDayMap.set(date, bucket);
}
const byDay = [...byDayMap.values()]
.sort((a, b) => a.date.localeCompare(b.date))
.map((bucket) => ({
date: bucket.date,
bookings: bucket.bookings,
revenueByCurrency: [...bucket.revenueByCurrency.entries()].map(
([currency, totalMinor]) => ({ currency, totalMinor }),
),
}));
const rank = <T extends { count: number }>(rows: T[]) =>
rows.sort((a, b) => b.count - a.count);
return {
window: { from, to, days },
totals: {
bookings: bookings.length,
confirmedBookings: bookings.filter((b) => isConfirmed(b.status)).length,
},
byDay,
statusDistribution: rank(
[...statusCounts.entries()].map(([status, count]) => ({ status, count })),
),
paymentMethods: rank(
[...methodCounts.entries()].map(([method, count]) => ({ method, count })),
),
};
}
async getHomeDashboard(passengerId: string) {
const now = new Date();
const [passenger, upcomingBooking, wallet, promos, weatherAlerts, stationSignals, savedRoutes] = await Promise.all([