diff --git a/apps/edr-passenger-api/src/modules/dashboard/dashboard.controller.ts b/apps/edr-passenger-api/src/modules/dashboard/dashboard.controller.ts index eb7bd33b6..6f74ab412 100644 --- a/apps/edr-passenger-api/src/modules/dashboard/dashboard.controller.ts +++ b/apps/edr-passenger-api/src/modules/dashboard/dashboard.controller.ts @@ -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') diff --git a/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts b/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts index f7e015e11..79891a023 100644 --- a/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts +++ b/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts @@ -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 } + >(); + const statusCounts = new Map(); + const methodCounts = new Map(); + + 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() }; + 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 = (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([ diff --git a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx index 571d63c9d..8bd064c59 100644 --- a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx @@ -12,6 +12,7 @@ import { ScanLine, } from "lucide-react"; import { dashboardApi } from "@/lib/api/dashboard"; +import DashboardBookingCharts from "@/components/dashboard/DashboardBookingCharts"; import { apiClient } from "@/lib/api-client"; import { formatCurrency } from "@/lib/utils"; import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from "recharts"; @@ -322,6 +323,9 @@ function DashboardPageContent() { + {/* Booking charts — self-contained; degrades to a single line if its endpoint fails. */} + + {/* Revenue breakdown */}

diff --git a/apps/edr-passenger-web/backoffice/src/components/dashboard/DashboardBookingCharts.tsx b/apps/edr-passenger-web/backoffice/src/components/dashboard/DashboardBookingCharts.tsx new file mode 100644 index 000000000..d9c1803f6 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/components/dashboard/DashboardBookingCharts.tsx @@ -0,0 +1,403 @@ +"use client"; + +/** + * Booking charts for the backoffice dashboard — revenue trend, daily confirmed + * bookings, status distribution and payment-method split over the last 30 days. + * + * Ported from `/reports/overall`, which computes the same four panels in the browser + * from a 5000-row booking fetch. Here the grouping is done by + * `GET /dashboard/analytics/bookings` so the landing page stays light. + * + * Revenue on this panel answers "what was booked" — CONFIRMED and BOARDED bookings by + * creation date. The Revenue Breakdown card below answers "what was collected" (it also + * requires a SUCCEEDED payment intent). The two will not match, which is why each says + * what it measures in its own heading. + */ + +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + Bar, + BarChart, + CartesianGrid, + Cell, + LabelList, + Line, + LineChart, + Pie, + PieChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { apiClient } from "@/lib/api-client"; +import { categoricalColor, getChartPalette } from "@/lib/chart-palette"; +import { useTheme } from "@/lib/theme-store"; +import { formatCurrency } from "@/lib/utils"; + +interface BookingAnalytics { + window: { from: string; to: string; days: number }; + totals: { bookings: number; confirmedBookings: number }; + byDay: { + date: string; + bookings: number; + revenueByCurrency: { currency: string; totalMinor: number }[]; + }[]; + statusDistribution: { status: string; count: number }[]; + paymentMethods: { method: string; count: number }[]; +} + +/** + * Fixed colour domain for booking status. Keyed by position in the enum rather than by + * rank in the data, so a day with no cancellations does not repaint the other slices. + */ +const STATUS_ORDER = [ + "CONFIRMED", + "BOARDED", + "PENDING_PAYMENT", + "CANCELLED", + "REFUNDED", + "NO_SHOW", +] as const; + +const STATUS_LABELS: Record = { + CONFIRMED: "Confirmed", + BOARDED: "Boarded", + PENDING_PAYMENT: "Pending payment", + CANCELLED: "Cancelled", + REFUNDED: "Refunded", + NO_SHOW: "No show", + DRAFT: "Draft", + UNKNOWN: "Unknown", +}; + +const MAX_METHOD_BARS = 6; + +/** `YYYY-MM-DD` → `5 Mar`, parsed by parts so no timezone can shift the label. */ +function formatDayLabel(date: string): string { + const [, month, day] = date.split("-"); + const monthName = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ][Number(month) - 1]; + return `${Number(day)} ${monthName}`; +} + +function prettyMethod(method: string): string { + return method + .toLowerCase() + .replace(/_/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()); +} + +export default function DashboardBookingCharts() { + const isDark = useTheme((s) => s.isDark); + const palette = getChartPalette(isDark); + + // Same query key the dashboard page already uses, so this shares its cache rather + // than issuing a second request for the rates. + const { data: exchangeRates = [] } = useQuery({ + queryKey: ["currencies"], + queryFn: () => apiClient.get("/currencies"), + select: (d: any) => (Array.isArray(d) ? d : (d?.data ?? d?.items ?? [])), + }); + + const { data, isLoading, isError } = useQuery({ + queryKey: ["dashboard-booking-analytics"], + queryFn: () => apiClient.get("/dashboard/analytics/bookings"), + staleTime: 60_000, + }); + + // Matches the conversion the dashboard page applies to its revenue cards. + const toEtbRate = (currency: string): number | null => { + if (currency === "ETB") return 1; + const r = exchangeRates.find( + (x: any) => x.fromCurrency === "ETB" && x.toCurrency === currency, + ); + return r ? 1 / r.rate : null; + }; + + const dayRows = useMemo( + () => + (data?.byDay ?? []).map((d) => ({ + label: formatDayLabel(d.date), + bookings: d.bookings, + // A currency with no rate on file is left out rather than counted at 1:1. + revenueMinor: d.revenueByCurrency.reduce((sum, r) => { + const rate = toEtbRate(r.currency); + return rate !== null ? sum + Math.round(r.totalMinor * rate) : sum; + }, 0), + })), + // eslint-disable-next-line react-hooks/exhaustive-deps + [data, exchangeRates], + ); + + const statusRows = useMemo( + () => + (data?.statusDistribution ?? []) + .filter((s) => s.count > 0) + .map((s) => ({ + name: STATUS_LABELS[s.status] ?? s.status, + value: s.count, + color: categoricalColor( + palette, + STATUS_ORDER.indexOf(s.status as (typeof STATUS_ORDER)[number]) >= 0 + ? STATUS_ORDER.indexOf(s.status as (typeof STATUS_ORDER)[number]) + : STATUS_ORDER.length, + ), + })), + [data, palette], + ); + + const methodRows = useMemo( + () => + (data?.paymentMethods ?? []).slice(0, MAX_METHOD_BARS).map((m) => ({ + label: prettyMethod(m.method), + count: m.count, + })), + [data], + ); + + const tooltipStyle = { + background: palette.tooltipBg, + border: `1px solid ${palette.tooltipBorder}`, + borderRadius: 8, + fontSize: 12, + }; + + if (isLoading) { + return ( +
+

Loading booking analytics…

+
+ ); + } + + // The dashboard's other cards stand on their own, so a failure here degrades to a + // single quiet line rather than taking the page down. + if (isError || !data) { + return ( +
+

+ Booking analytics are unavailable right now. +

+
+ ); + } + + const hasDays = dayRows.length > 0; + const rangeLabel = `Last ${data.window.days} days`; + + const emptyPanel = ( +
+ No bookings in this range +
+ ); + + return ( +
+ {/* Revenue Trend */} +
+

+ Revenue Trend +

+

+ {rangeLabel} · value of confirmed bookings on the day they were made, in ETB. + Not the same as collected revenue below. +

+ {hasDays ? ( + + + + + Math.round(v / 100).toLocaleString()} + /> + [formatCurrency(value, "ETB"), "Revenue"]} + /> + + + + ) : ( + emptyPanel + )} +
+ + {/* Daily Confirmed Bookings */} +
+

+ Daily Confirmed Bookings +

+

+ {rangeLabel} · how many bookings were confirmed each day. +

+ {hasDays ? ( + + + + + + + + + + ) : ( + emptyPanel + )} +
+ + {/* Booking Status Distribution */} +
+

+ Booking Status Distribution +

+

+ {rangeLabel} · every booking made in the range, by current status. +

+ {statusRows.length > 0 ? ( + <> + {/* Legend with text labels and counts — the palette's light-mode contrast is + validated only with that relief in place. */} +
+ {statusRows.map((s) => ( +
+ + + {s.name} · {s.value} + +
+ ))} +
+ + + + {statusRows.map((s) => ( + + ))} + + + + + + ) : ( + emptyPanel + )} +
+ + {/* Payment Methods */} +
+

+ Payment Methods +

+

+ {rangeLabel} · which method each booking used. “Unknown” means no + payment was started. +

+ {methodRows.length > 0 ? ( + + + + + + + + + + + + ) : ( + emptyPanel + )} +
+
+ ); +}