diff --git a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts index 1eea923fd..725ddbbfc 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts @@ -8,7 +8,7 @@ import { ApiProduces, } from "@nestjs/swagger"; import { ReportsService } from "./reports.service"; -import { BlockedSeatsRevenueLossQueryDto, GenerateReportDto } from "./reports.dto"; +import { BlockedSeatsRevenueLossQueryDto, FinanceSummaryQueryDto, GenerateReportDto } from "./reports.dto"; import { PassengerStaff } from "../../common/passenger-guards"; import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; @@ -83,6 +83,35 @@ export class ReportsController { return this.service.getPaymentDiscrepancyBySchedule(scheduleId, { search, seatClass, sort }); } + // ── Finance Summary ────────────────────────────────────────────────────── + + @Get("finance") + @ApiOperation({ + summary: "Finance summary — revenue by period, origin/destination segment, and payment method", + description: + "Revenue collected in the window (PaymentIntent.paidAt), grouped by day/week/month, origin → " + + "destination station pair, and payment method. Filter by originStationId and/or destinationStationId " + + "independently to query any station-pair segment (A→B, A→D, B→C), not just a whole predefined route. " + + "Returns per-bucket rows plus roll-ups by period, segment, and method for charting.", + }) + getFinanceSummary(@Query() query: FinanceSummaryQueryDto) { + return this.service.getFinanceSummary(query); + } + + @Get("finance/export") + @ApiOperation({ summary: "Finance summary as CSV — one row per period + route + payment method" }) + @ApiProduces("text/csv") + @ApiOkResponse({ description: "CSV export", schema: { type: "string" } }) + async exportFinanceSummary(@Query() query: FinanceSummaryQueryDto, @Res() res: Response): Promise { + const csv = await this.service.exportFinanceSummaryCsv(query); + res.setHeader("Content-Type", "text/csv; charset=utf-8"); + res.setHeader( + "Content-Disposition", + `attachment; filename="finance-summary-${new Date().toISOString().split("T")[0]}.csv"`, + ); + res.send(csv); + } + // ── Blocked Seat Revenue Loss ────────────────────────────────────────────── @Get("blocked-seats-revenue-loss") diff --git a/apps/edr-passenger-api/src/modules/reports/reports.dto.ts b/apps/edr-passenger-api/src/modules/reports/reports.dto.ts index 8b8049807..6c6b304d4 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.dto.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.dto.ts @@ -1,6 +1,7 @@ import { IsString, IsDateString, IsOptional, IsEnum, IsInt, Min, Max } from 'class-validator'; import { Type } from 'class-transformer'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { PaymentMethodType } from '@prisma/client'; import { SeatBlockReasonCategory } from '../seats/seats.dto'; export enum ReportType { @@ -103,3 +104,31 @@ export class BlockedSeatsRevenueLossQueryDto { }) @IsOptional() @IsEnum(BlockedSeatsLossSortBy) sortBy?: BlockedSeatsLossSortBy; } + +// ── Finance Summary ────────────────────────────────────────────────────────── + +export enum FinanceGranularity { + DAILY = 'daily', + WEEKLY = 'weekly', + MONTHLY = 'monthly', +} + +export class FinanceSummaryQueryDto { + @ApiProperty({ example: '2026-07-01', description: 'Start of the window, inclusive, matched on PaymentIntent.paidAt.' }) + @IsDateString() dateFrom: string; + + @ApiProperty({ example: '2026-07-31', description: 'End of the window, inclusive, matched on PaymentIntent.paidAt.' }) + @IsDateString() dateTo: string; + + @ApiPropertyOptional({ enum: FinanceGranularity, default: FinanceGranularity.DAILY }) + @IsOptional() @IsEnum(FinanceGranularity) granularity?: FinanceGranularity; + + @ApiPropertyOptional({ description: 'Restrict to bookings departing from this station.' }) + @IsOptional() @IsString() originStationId?: string; + + @ApiPropertyOptional({ description: 'Restrict to bookings arriving at this station.' }) + @IsOptional() @IsString() destinationStationId?: string; + + @ApiPropertyOptional({ enum: PaymentMethodType, description: 'Restrict to payments made with this method.' }) + @IsOptional() @IsEnum(PaymentMethodType) method?: PaymentMethodType; +} diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index e899fdb4b..571df697e 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -10,6 +10,8 @@ import { FareEngineService } from "../fare-engine/fare-engine.service"; import { BlockedSeatsLossSortBy, BlockedSeatsRevenueLossQueryDto, + FinanceGranularity, + FinanceSummaryQueryDto, GenerateReportDto, ReportType, } from "./reports.dto"; @@ -84,6 +86,33 @@ function toCsvCell(value: string | number): string { return `"${String(value).replace(/"/g, '""')}"`; } +/** + * Buckets a paid-at timestamp into the requested reporting period, keyed so buckets sort + * chronologically as plain strings. Weekly buckets are labelled by their Monday (UTC). + */ +function periodKeyFor(date: Date, granularity: FinanceGranularity): string { + if (granularity === FinanceGranularity.MONTHLY) { + return date.toISOString().slice(0, 7); + } + if (granularity === FinanceGranularity.WEEKLY) { + const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); + const isoDay = d.getUTCDay() || 7; // Monday=1 .. Sunday=7 + d.setUTCDate(d.getUTCDate() - (isoDay - 1)); + return d.toISOString().split("T")[0]; + } + return date.toISOString().split("T")[0]; +} + +export interface FinanceBucket { + period: string; + originStationId: string; + destinationStationId: string; + segmentLabel: string; + method: string; + bookingCount: number; + revenueEtbMinor: number; +} + @Injectable() export class ReportsService { private readonly logger = new Logger(ReportsService.name); @@ -1051,6 +1080,161 @@ export class ReportsService { return { totalActualEtbMinor, totalPaidEtbMinor, byMethod, rows }; } + // ── Finance Summary ──────────────────────────────────────────────────────── + + /** + * Revenue collected in the window, grouped by reporting period (day/week/month), origin → + * destination station pair, and payment method — the shape finance reconciles against + * provider settlement statements. + * + * Grouped by the booking's own origin/destination, not the parent Route — a route like + * "Sebeta - Dire Dawa" has intermediate stops, and a passenger may have booked any + * sub-segment of it (e.g. Lebu → Adama). Filtering by station lets finance ask about any + * A→B pair, not just whole routes. + * + * Bucketed on `PaymentIntent.paidAt` (cash actually received), not `Booking.createdAt`, + * so a booking made in one period but paid in another lands in the period it was paid. + */ + async getFinanceSummary(query: FinanceSummaryQueryDto) { + const dateFrom = new Date(query.dateFrom + "T00:00:00.000Z"); + const dateTo = new Date(query.dateTo + "T23:59:59.999Z"); + const granularity = query.granularity ?? FinanceGranularity.DAILY; + + const rateRows = await this.prisma.currencyExchangeRate.findMany({ + where: { toCurrency: "ETB" as any }, + orderBy: { effectiveDate: "desc" }, + }); + const rateToEtb = new Map(); + for (const r of rateRows) { + if (!rateToEtb.has(r.fromCurrency)) rateToEtb.set(r.fromCurrency, Number(r.rate)); + } + const toEtbMinor = (minor: number, currency: string): number => { + if (currency === "ETB") return minor; + const rate = rateToEtb.get(currency); + return rate ? Math.round(minor * rate) : minor; + }; + + const bookings = await this.prisma.booking.findMany({ + where: { + paymentIntent: { + paidAt: { gte: dateFrom, lte: dateTo }, + ...(query.method ? { method: query.method } : {}), + }, + ...(query.originStationId ? { originStationId: query.originStationId } : {}), + ...(query.destinationStationId ? { destinationStationId: query.destinationStationId } : {}), + }, + select: { + totalMinor: true, + currency: true, + originStationId: true, + destinationStationId: true, + schedule: { select: { originStationId: true, destinationStationId: true } }, + paymentIntent: { select: { paidAt: true, method: true } }, + }, + }); + + // Booking.originStationId/destinationStationId are set on every create path (guest and + // authenticated booking both pass them from the DTO); the schedule's own endpoints are + // only a fallback for the rare legacy row that predates those columns. + const stationIds = new Set(); + for (const b of bookings) { + const origin = b.originStationId ?? b.schedule.originStationId; + const destination = b.destinationStationId ?? b.schedule.destinationStationId; + if (origin) stationIds.add(origin); + if (destination) stationIds.add(destination); + } + const stations = stationIds.size > 0 + ? await this.prisma.station.findMany({ where: { id: { in: [...stationIds] } }, select: { id: true, name: true } }) + : []; + const stationName = new Map(stations.map((s) => [s.id, s.name])); + + const buckets = new Map(); + const bucketFor = ( + period: string, + originStationId: string, + destinationStationId: string, + segmentLabel: string, + method: string, + ): FinanceBucket => { + const key = `${period}|${originStationId}|${destinationStationId}|${method}`; + let bucket = buckets.get(key); + if (!bucket) { + bucket = { period, originStationId, destinationStationId, segmentLabel, method, bookingCount: 0, revenueEtbMinor: 0 }; + buckets.set(key, bucket); + } + return bucket; + }; + + for (const b of bookings) { + const pi = b.paymentIntent!; + const period = periodKeyFor(pi.paidAt!, granularity); + const originStationId = b.originStationId ?? b.schedule.originStationId ?? "UNKNOWN"; + const destinationStationId = b.destinationStationId ?? b.schedule.destinationStationId ?? "UNKNOWN"; + const segmentLabel = `${stationName.get(originStationId) ?? "Unknown"} → ${stationName.get(destinationStationId) ?? "Unknown"}`; + const bucket = bucketFor(period, originStationId, destinationStationId, segmentLabel, pi.method); + bucket.bookingCount += 1; + bucket.revenueEtbMinor += toEtbMinor(b.totalMinor, b.currency); + } + + const rows = [...buckets.values()].sort((a, b) => + a.period === b.period + ? a.segmentLabel.localeCompare(b.segmentLabel) || a.method.localeCompare(b.method) + : a.period.localeCompare(b.period), + ); + + const totals = rows.reduce( + (acc, r) => { + acc.bookingCount += r.bookingCount; + acc.revenueEtbMinor += r.revenueEtbMinor; + return acc; + }, + { bookingCount: 0, revenueEtbMinor: 0 }, + ); + + const rollUp = (keyOf: (r: FinanceBucket) => string, labelOf: (r: FinanceBucket) => string) => { + const map = new Map(); + for (const r of rows) { + const key = keyOf(r); + let entry = map.get(key); + if (!entry) { + entry = { key, label: labelOf(r), revenueEtbMinor: 0, bookingCount: 0 }; + map.set(key, entry); + } + entry.revenueEtbMinor += r.revenueEtbMinor; + entry.bookingCount += r.bookingCount; + } + return [...map.values()].sort((a, b) => b.revenueEtbMinor - a.revenueEtbMinor); + }; + + return { + granularity, + dateFrom: query.dateFrom, + dateTo: query.dateTo, + currency: "ETB", + totals, + byPeriod: rollUp((r) => r.period, (r) => r.period), + bySegment: rollUp((r) => `${r.originStationId}|${r.destinationStationId}`, (r) => r.segmentLabel), + byMethod: rollUp((r) => r.method, (r) => r.method), + rows, + }; + } + + /** CSV of the finance summary, one row per period + origin/destination segment + payment method. */ + async exportFinanceSummaryCsv(query: FinanceSummaryQueryDto): Promise { + const report = await this.getFinanceSummary(query); + + const headers = ["Period", "Origin → Destination", "Payment Method", "Bookings", "Revenue (ETB)"]; + const rows = report.rows.map((r) => [ + r.period, + r.segmentLabel, + r.method, + r.bookingCount, + (r.revenueEtbMinor / 100).toFixed(2), + ]); + + return [headers, ...rows].map((row) => row.map(toCsvCell).join(",")).join("\n"); + } + async getPaymentDiscrepancyBySchedule(scheduleId: string, params: { search?: string; seatClass?: string; diff --git a/apps/edr-passenger-web/backoffice/package.json b/apps/edr-passenger-web/backoffice/package.json index 9d4327a42..737baeda1 100644 --- a/apps/edr-passenger-web/backoffice/package.json +++ b/apps/edr-passenger-web/backoffice/package.json @@ -16,6 +16,8 @@ "axios": "^1.7.7", "clsx": "^2.1.1", "date-fns": "^3.0.0", + "exceljs": "^4.4.0", + "html-to-image": "^1.11.11", "lucide-react": "^0.446.0", "next": "^14.2.0", "react": "^18.3.1", diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/finance/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/finance/layout.tsx new file mode 100644 index 000000000..790272de1 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/reports/finance/layout.tsx @@ -0,0 +1,3 @@ +export default function Layout({ children }: { children: React.ReactNode }) { + return <>{children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/finance/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/finance/page.tsx new file mode 100644 index 000000000..8e3e2a23b --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/reports/finance/page.tsx @@ -0,0 +1,521 @@ +'use client'; + +import { useMemo, useRef, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Banknote, BookOpen, FileSpreadsheet, Receipt } from 'lucide-react'; +import { + Bar, BarChart, CartesianGrid, Line, LineChart, + ResponsiveContainer, Tooltip as RechartsTooltip, XAxis, YAxis, +} from 'recharts'; +import { toPng } from 'html-to-image'; +import { apiClient } from '@/lib/api-client'; +import { financeApi, type FinanceGranularity, type FinanceSummaryFilters } from '@/lib/api/finance'; +import { buildFinanceWorkbook, type ChartImage } from '@/lib/export/finance-workbook'; +import ActionButton from '@/components/ui/ActionButton'; +import Pagination from '@/components/ui/Pagination'; +import Skeleton from '@/components/ui/Skeleton'; +import { usePagination } from '@/lib/use-pagination'; +import { formatCurrency } from '@/lib/utils'; +import { categoricalColor, getChartPalette } from '@/lib/chart-palette'; +import { useTheme } from '@/lib/theme-store'; + +interface StationOption { + id: string; + name: string; + code: string; +} + +// Fixed order so a method keeps its colour/slot when the method filter narrows the set. +const PAYMENT_METHOD_ORDER = ['TELEBIRR', 'CBE_BIRR', 'EBIRR', 'WAAFI', 'CARD', 'WALLET', 'DMONEY', 'CAC_BANK', 'CBE_BILL'] as const; +const PAYMENT_METHOD_LABELS: Record = { + TELEBIRR: 'Telebirr', + CBE_BIRR: 'CBE Birr', + EBIRR: 'eBirr', + WAAFI: 'Waafi', + CARD: 'Card', + WALLET: 'Wallet', + DMONEY: 'DMoney', + CAC_BANK: 'CAC Bank', + CBE_BILL: 'CBE Bill', +}; +function methodLabel(method: string): string { + return PAYMENT_METHOD_LABELS[method] ?? method; +} + +function periodLabel(period: string, granularity: FinanceGranularity): string { + if (granularity === 'monthly') { + return new Date(`${period}-01T00:00:00`).toLocaleDateString('en-US', { month: 'short', year: 'numeric' }); + } + return new Date(`${period}T00:00:00`).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +const TABLE_PAGE_SIZE = 25; + +/** Mirrors the loaded layout's shape (KPI tiles, two charts, method breakdown, detail table) so nothing jumps when data arrives. */ +function FinanceReportSkeleton() { + return ( +
+
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+ + +
+ +
+ ))} +
+ +
+ {Array.from({ length: 2 }).map((_, i) => ( +
+ + +
+ ))} +
+ +
+ + + +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+ +
+
+ +
+
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+
+
+ ); +} + +export default function FinanceReportPage() { + const isDark = useTheme((s) => s.isDark); + const palette = getChartPalette(isDark); + + const [dateRangePreset, setDateRangePreset] = useState('90'); + const [customFrom, setCustomFrom] = useState(''); + const [customTo, setCustomTo] = useState(''); + const [granularity, setGranularity] = useState('daily'); + const [originStationId, setOriginStationId] = useState(''); + const [destinationStationId, setDestinationStationId] = useState(''); + const [method, setMethod] = useState(''); + const [exporting, setExporting] = useState(false); + + const trendCardRef = useRef(null); + const segmentCardRef = useRef(null); + const methodCardRef = useRef(null); + + const { dateFrom, dateTo } = useMemo(() => { + const end = new Date(); + end.setHours(23, 59, 59, 999); + + if (dateRangePreset === 'custom') { + if (customFrom && customTo) { + return customFrom <= customTo + ? { dateFrom: customFrom, dateTo: customTo } + : { dateFrom: customTo, dateTo: customFrom }; + } + const fallbackStart = new Date(end); + fallbackStart.setDate(end.getDate() - 90); + return { + dateFrom: fallbackStart.toISOString().split('T')[0], + dateTo: end.toISOString().split('T')[0], + }; + } + + const start = new Date(end); + start.setDate(end.getDate() - Number(dateRangePreset)); + return { + dateFrom: start.toISOString().split('T')[0], + dateTo: end.toISOString().split('T')[0], + }; + }, [dateRangePreset, customFrom, customTo]); + + const filters: FinanceSummaryFilters = useMemo( + () => ({ + dateFrom, + dateTo, + granularity, + originStationId: originStationId || undefined, + destinationStationId: destinationStationId || undefined, + method: method || undefined, + }), + [dateFrom, dateTo, granularity, originStationId, destinationStationId, method], + ); + + const { data: stations = [] } = useQuery({ + queryKey: ['stations'], + queryFn: () => apiClient.get('/stations'), + }); + + const { data, isLoading, isFetching, isError } = useQuery({ + queryKey: ['reports-finance', filters], + placeholderData: (previous) => previous, + queryFn: () => financeApi.getSummary(filters), + }); + + const rows = data?.rows ?? []; + const pg = usePagination(rows, TABLE_PAGE_SIZE); + + const resetFilters = () => { + setDateRangePreset('90'); + setCustomFrom(''); + setCustomTo(''); + setGranularity('daily'); + setOriginStationId(''); + setDestinationStationId(''); + setMethod(''); + }; + + /** Captures a chart card as a PNG data URL, sized to the card's actual on-screen pixels. */ + const captureCard = async (node: HTMLDivElement | null): Promise => { + if (!node) return undefined; + const rect = node.getBoundingClientRect(); + const dataUrl = await toPng(node, { pixelRatio: 2, cacheBust: true, backgroundColor: palette.surface }); + return { dataUrl, width: Math.round(rect.width), height: Math.round(rect.height) }; + }; + + const doExport = async () => { + if (!data) return; + setExporting(true); + try { + const [trend, segment, method_] = await Promise.all([ + captureCard(trendCardRef.current), + captureCard(segmentCardRef.current), + captureCard(methodCardRef.current), + ]); + + const stationLabel = (id: string) => stations.find((s) => s.id === id)?.name ?? 'Any'; + + const blob = await buildFinanceWorkbook({ + report: data, + filters: { + dateFrom, + dateTo, + granularity, + originLabel: originStationId ? stationLabel(originStationId) : 'Any', + destinationLabel: destinationStationId ? stationLabel(destinationStationId) : 'Any', + methodLabel: method ? methodLabel(method) : 'All', + }, + methodLabel, + periodLabel, + images: { trend, segment, method: method_ }, + }); + + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `finance-summary-${dateFrom}-to-${dateTo}.xlsx`; + a.click(); + URL.revokeObjectURL(url); + } finally { + setExporting(false); + } + }; + + const trendData = useMemo( + () => + (data?.byPeriod ?? []) + .slice() + .sort((a, b) => a.key.localeCompare(b.key)) + .map((p) => ({ + label: periodLabel(p.key, data!.granularity), + revenue: p.revenueEtbMinor / 100, + })), + [data], + ); + + const segmentData = useMemo( + () => + (data?.bySegment ?? []) + .slice() + .sort((a, b) => b.revenueEtbMinor - a.revenueEtbMinor) + .map((r) => ({ label: r.label, revenue: r.revenueEtbMinor })), + [data], + ); + + const methodBreakdown = useMemo(() => { + const rowsByMethod = data?.byMethod ?? []; + const total = rowsByMethod.reduce((sum, r) => sum + r.revenueEtbMinor, 0); + return rowsByMethod + .slice() + .sort((a, b) => PAYMENT_METHOD_ORDER.indexOf(a.key as any) - PAYMENT_METHOD_ORDER.indexOf(b.key as any)) + .map((r) => ({ + ...r, + color: categoricalColor(palette, PAYMENT_METHOD_ORDER.indexOf(r.key as any)), + sharePercent: total > 0 ? (r.revenueEtbMinor / total) * 100 : 0, + })); + }, [data, palette]); + + const totals = data?.totals; + const hasData = (totals?.bookingCount ?? 0) > 0; + const avgPerBookingMinor = hasData ? Math.round(totals!.revenueEtbMinor / totals!.bookingCount) : 0; + + return ( +
+
+
+

Finance Summary

+

+ Revenue collected by period, origin/destination, and payment method — for daily, weekly, or monthly finance reporting. +

+
+ + Export + +
+ + {/* Filters */} +
+
+
+ + +
+ {dateRangePreset === 'custom' && ( + <> +
+ + setCustomFrom(e.target.value)} /> +
+
+ + setCustomTo(e.target.value)} /> +
+ + )} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + {isFetching && Refreshing…} +
+ {isError &&

Failed to load the finance summary. Check the filters and try again.

} +
+ + {isLoading && !data ? ( + + ) : !hasData ? ( +
+ +

No paid bookings in this window.

+

Widen the date range, or clear the origin/destination/method filters.

+
+ ) : ( +
+ {/* KPI tiles */} +
+
+
+

Revenue

+
+ +
+
+

+ {formatCurrency(totals!.revenueEtbMinor, 'ETB')} +

+
+
+
+

Bookings

+
+ +
+
+

{totals!.bookingCount.toLocaleString()}

+
+
+
+

Avg. per Booking

+
+ +
+
+

{formatCurrency(avgPerBookingMinor, 'ETB')}

+
+
+ + {/* Trend + route charts */} +
+
+

+ Revenue Trend (ETB, {granularity}) +

+ {trendData.length > 0 ? ( + + + + + + `ETB ${Math.round(value).toLocaleString()}`} /> + + + + ) : ( +
No data for selected range
+ )} +
+ +
+

Revenue by Segment

+ {segmentData.length > 0 ? ( + + + + + + `ETB ${Math.round(value).toLocaleString()}`} /> + + + + ) : ( +
No data for selected range
+ )} +
+
+ + {/* Payment method breakdown — part-to-whole stacked bar + legend table */} +
+

Revenue by Payment Method

+

Share of revenue, in ETB

+
`${methodLabel(m.key)} ${m.sharePercent.toFixed(0)}%`) + .join(', ')}`} + > + {methodBreakdown.map((m, i) => ( +
+ ))} +
+ + + + + + + + + + + {methodBreakdown.map((m) => ( + + + + + + + ))} + +
MethodBookingsShareRevenue
+ + + {m.bookingCount.toLocaleString()}{m.sharePercent.toFixed(1)}%{formatCurrency(m.revenueEtbMinor, 'ETB')}
+
+ + {/* Detail table */} +
+
+

+ Period × Segment × Method detail +

+
+
+ + + + {['Period', 'Segment', 'Method', 'Bookings', 'Revenue'].map((h) => ( + + ))} + + + + {pg.paged.map((r, i) => ( + + + + + + + + ))} + {pg.paged.length === 0 && ( + + + + )} + +
+ {h} +
{periodLabel(r.period, granularity)}{r.segmentLabel}{methodLabel(r.method)}{r.bookingCount.toLocaleString()}{formatCurrency(r.revenueEtbMinor, 'ETB')}
No rows on this page
+
+ +
+
+ )} +
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx index 616488917..58a773b2f 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -123,6 +123,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [ title: 'Analytics & Reports', items: [ { name: 'Overall', href: '/reports/overall', icon: BarChart3, permission: PERMS.reports.view }, + { name: 'Finance', href: '/reports/finance', icon: DollarSign, permission: PERMS.reports.view }, { name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view }, { name: 'Blocked Seats', href: '/reports/blocked-seats', icon: Ban, permission: PERMS.reports.view }, { name: 'Passengers', href: '/reports/passengers', icon: Users, permission: PERMS.reports.view }, diff --git a/apps/edr-passenger-web/backoffice/src/components/ui/Skeleton.tsx b/apps/edr-passenger-web/backoffice/src/components/ui/Skeleton.tsx new file mode 100644 index 000000000..e6f24fb02 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/components/ui/Skeleton.tsx @@ -0,0 +1,10 @@ +import { cn } from '@/lib/utils'; + +interface SkeletonProps { + className?: string; +} + +/** A shimmering placeholder block. Give it the size/shape of the content it stands in for. */ +export default function Skeleton({ className }: SkeletonProps) { + return