mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1282 from Tria-plc/alpha
feat: ( dashboard ) add booking charts
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user