From 050a08f3301619f346fc8eaebce95416e77e5f3b Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Fri, 24 Jul 2026 16:09:27 +0300 Subject: [PATCH] Boarding report added --- .../src/modules/reports/reports.controller.ts | 6 + .../src/modules/reports/reports.service.ts | 113 ++++++ .../src/app/reports/boarding/layout.tsx | 3 + .../src/app/reports/boarding/page.tsx | 382 ++++++++++++++++++ .../src/components/layout/Sidebar.tsx | 1 + 5 files changed, 505 insertions(+) create mode 100644 apps/edr-passenger-web/backoffice/src/app/reports/boarding/layout.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/app/reports/boarding/page.tsx 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 6f91de06f..52b84da66 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts @@ -53,6 +53,12 @@ export class ReportsController { return this.service.getSeatStatusReport(scheduleId); } + @Get("boarding") + @ApiOperation({ summary: "Boarding report for a schedule — boarded vs not-boarded passengers" }) + getBoardingReport(@Query('scheduleId') scheduleId: string) { + return this.service.getBoardingReport(scheduleId); + } + @Get("payments") @ApiOperation({ summary: "Payments collected for a schedule" }) getPaymentsReport(@Query('scheduleId') scheduleId: string) { 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 1aef00185..49be0859c 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -1022,6 +1022,119 @@ export class ReportsService { return { total: rows.length, rows }; } + async getBoardingReport(scheduleId: string) { + const schedule = await this.prisma.trainSchedule.findUnique({ + where: { id: scheduleId }, + select: { + id: true, + departureAt: true, + arrivalAt: true, + train: { select: { number: true, name: true } }, + originStation: { select: { name: true } }, + destinationStation: { select: { name: true } }, + }, + }); + if (!schedule) return null; + + const tickets = await this.prisma.ticket.findMany({ + where: { + OR: [ + { scheduleId, booking: { status: { in: ['CONFIRMED', 'BOARDED'] } } }, + { leg: 2, booking: { returnScheduleId: scheduleId, status: { in: ['CONFIRMED', 'BOARDED'] } } }, + { scheduleId: null, leg: 1, booking: { scheduleId, status: { in: ['CONFIRMED', 'BOARDED'] } } }, + ], + }, + select: { + id: true, + bookingRef: true, + passengerName: true, + boardedAt: true, + validatorId: true, + status: true, + booking: { + select: { + status: true, + originStationId: true, + destinationStationId: true, + }, + }, + seat: { + select: { + seatNumber: true, + bedPosition: true, + coach: { + select: { + number: true, + coachType: { select: { name: true, seatClasses: { select: { name: true, bedPosition: true } } } }, + }, + }, + }, + }, + }, + orderBy: [{ seat: { coach: { number: 'asc' } } }, { seat: { seatNumber: 'asc' } }], + }); + + const stationIds = [...new Set( + tickets.flatMap(t => [t.booking.originStationId, t.booking.destinationStationId]).filter(Boolean) as string[], + )]; + const stations = stationIds.length > 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 resolveSeatClass = (seat: any): string | null => { + const classes = seat?.coach?.coachType?.seatClasses ?? []; + const matched = seat?.bedPosition + ? classes.find((sc: any) => sc.bedPosition?.toLowerCase() === seat.bedPosition.toLowerCase()) + : null; + return (matched ?? classes[0])?.name ?? seat?.coach?.coachType?.name ?? null; + }; + + const rows = tickets.map(t => ({ + bookingRef: t.bookingRef, + passengerName: t.passengerName, + coachNumber: t.seat?.coach?.number ?? null, + seatNumber: t.seat?.seatNumber ?? null, + seatClassName: resolveSeatClass(t.seat), + origin: t.booking.originStationId ? (stationName.get(t.booking.originStationId) ?? null) : null, + destination: t.booking.destinationStationId ? (stationName.get(t.booking.destinationStationId) ?? null) : null, + boarded: !!t.boardedAt, + boardedAt: t.boardedAt ?? null, + validatorId: t.validatorId ?? null, + bookingStatus: t.booking.status, + })); + + const boardedCount = rows.filter(r => r.boarded).length; + const notBoardedCount = rows.length - boardedCount; + + const byCoach = new Map(); + for (const r of rows) { + const key = r.coachNumber ?? 'Unknown'; + if (!byCoach.has(key)) byCoach.set(key, { coachNumber: key, total: 0, boarded: 0 }); + byCoach.get(key)!.total++; + if (r.boarded) byCoach.get(key)!.boarded++; + } + + return { + schedule: { + id: schedule.id, + trainName: (schedule.train as any)?.name ?? (schedule.train as any)?.number, + origin: (schedule.originStation as any)?.name, + destination: (schedule.destinationStation as any)?.name, + departureAt: schedule.departureAt, + arrivalAt: schedule.arrivalAt, + }, + summary: { + total: rows.length, + boardedCount, + notBoardedCount, + boardingRate: rows.length > 0 ? +((boardedCount / rows.length) * 100).toFixed(1) : 0, + }, + byCoach: [...byCoach.values()].sort((a, b) => a.coachNumber.localeCompare(b.coachNumber)), + rows, + }; + } + async getReport(reportId: string) { return this.prisma.operationalReport.findUnique({ where: { id: reportId }, diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/boarding/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/boarding/layout.tsx new file mode 100644 index 000000000..790272de1 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/reports/boarding/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/boarding/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/boarding/page.tsx new file mode 100644 index 000000000..73ec7012d --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/reports/boarding/page.tsx @@ -0,0 +1,382 @@ +"use client"; + +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { LogIn, Users, CheckCircle, XCircle, BarChart3, Train, Download } from "lucide-react"; +import { apiClient } from "@/lib/api-client"; +import Badge from "@/components/ui/Badge"; +import ActionButton from "@/components/ui/ActionButton"; +import { formatDateTime } from "@/lib/utils"; +import Pagination from "@/components/ui/Pagination"; +import { usePagination } from "@/lib/use-pagination"; + +interface ScheduleOption { + id: string; + label: string; +} + +interface BoardingRow { + bookingRef: string; + passengerName: string; + coachNumber: string | null; + seatNumber: string | null; + seatClassName: string | null; + origin: string | null; + destination: string | null; + boarded: boolean; + boardedAt: string | null; + validatorId: string | null; + bookingStatus: string; +} + +interface BoardingReport { + schedule: { + id: string; + trainName: string; + origin: string; + destination: string; + departureAt: string; + arrivalAt: string; + }; + summary: { + total: number; + boardedCount: number; + notBoardedCount: number; + boardingRate: number; + }; + byCoach: { coachNumber: string; total: number; boarded: number }[]; + rows: BoardingRow[]; +} + +type Tab = "summary" | "details"; + +export default function BoardingReportPage() { + const [scheduleId, setScheduleId] = useState(""); + const [tab, setTab] = useState("summary"); + const [search, setSearch] = useState(""); + const [filterBoarded, setFilterBoarded] = useState<"ALL" | "BOARDED" | "NOT_BOARDED">("ALL"); + + const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery({ + queryKey: ["report-schedules-all"], + queryFn: () => apiClient.get("/reports/schedules?all=true"), + }); + const schedules = schedulesRaw ?? []; + + const { data, isLoading, isError } = useQuery({ + queryKey: ["boarding-report", scheduleId], + queryFn: () => apiClient.get(`/reports/boarding?scheduleId=${scheduleId}`), + enabled: !!scheduleId, + }); + + const filtered = (data?.rows ?? []).filter((r) => { + if (filterBoarded === "BOARDED" && !r.boarded) return false; + if (filterBoarded === "NOT_BOARDED" && r.boarded) return false; + if (search.trim()) { + const q = search.toLowerCase(); + return ( + r.passengerName.toLowerCase().includes(q) || + r.bookingRef.toLowerCase().includes(q) || + (r.seatNumber ?? "").toLowerCase().includes(q) || + (r.coachNumber ?? "").toLowerCase().includes(q) + ); + } + return true; + }); + + const { paged, page, totalPages, setPage, reset } = usePagination(filtered, 50); + + const doExport = () => { + if (!filtered.length) return; + const headers = ["Booking Ref", "Passenger", "Seat Class", "Coach", "Seat", "Origin", "Destination", "Boarded", "Boarded At", "Validator"]; + const rows = filtered.map((r) => [ + r.bookingRef, + r.passengerName, + r.seatClassName ?? "—", + r.coachNumber ?? "—", + r.seatNumber ?? "—", + r.origin ?? "—", + r.destination ?? "—", + r.boarded ? "Yes" : "No", + r.boardedAt ? formatDateTime(r.boardedAt) : "—", + r.validatorId ?? "—", + ]); + const csv = [ + headers.map((h) => `"${h}"`).join(","), + ...rows.map((row) => row.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 = `boarding-${scheduleId}-${new Date().toISOString().split("T")[0]}.csv`; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( +
+
+

Boarding Report

+

+ Boarding status and passenger breakdown for a schedule +

+
+ + {/* Schedule selector */} +
+
+
+ + +
+
+ {isLoading &&

Loading…

} + {isError &&

Failed to load report.

} +
+ + {!scheduleId && ( +
+ +

Select a schedule above to load the boarding report

+
+ )} + + {data && ( + <> + {/* Schedule info */} +
+
+ +
+
+

{data.schedule.trainName}

+

+ {data.schedule.origin} → {data.schedule.destination} · + Departure: {formatDateTime(data.schedule.departureAt)} +

+
+
+ + {/* Tabs */} +
+ + +
+ + {/* Summary Tab */} + {tab === "summary" && ( +
+ {/* KPI cards */} +
+
+
+
+

Total Tickets

+

{data.summary.total}

+

Confirmed passengers

+
+ +
+
+ +
+
+
+

Boarded

+

+ {data.summary.boardedCount} +

+

Scanned at gate

+
+ +
+
+ +
+
+
+

Not Boarded

+

+ {data.summary.notBoardedCount} +

+

No-shows / pending

+
+ +
+
+ +
+
+
+

Boarding Rate

+

+ {data.summary.boardingRate}% +

+
+
+
+
+ +
+
+
+ + {/* By Coach */} + {data.byCoach.length > 0 && ( +
+
+ + + + + + + + + + + + {data.byCoach.map((c) => { + const rate = c.total > 0 ? +((c.boarded / c.total) * 100).toFixed(1) : 0; + return ( + + + + + + + + ); + })} + +
CoachTotalBoardedNot BoardedRate
{c.coachNumber}{c.total}{c.boarded}{c.total - c.boarded} +
+
+
+
+ {rate}% +
+
+
+
+ )} +
+ )} + + {/* Details Tab */} + {tab === "details" && ( +
+
+ { setSearch(e.target.value); reset(); }} + /> + + + Export CSV + +
+
+ + + + {["Booking Ref", "Passenger", "Seat Class · Coach · Seat", "Route", "Status", "Boarded At"].map((h) => ( + + ))} + + + + {paged.map((row, i) => ( + + + + + + + + + ))} + {paged.length === 0 && ( + + + + )} + +
+ {h} +
{row.bookingRef}{row.passengerName} + {row.seatClassName ?? "—"} + {row.coachNumber && · {row.coachNumber}} + {row.seatNumber && · #{row.seatNumber}} + + {row.origin && row.destination ? `${row.origin} → ${row.destination}` : (row.origin ?? row.destination ?? "—")} + + + {row.boarded ? "Boarded" : "Not Boarded"} + + + {row.boardedAt ? formatDateTime(row.boardedAt) : "—"} +
+ No passengers found +
+
+ +
+ )} + + )} + + {!data && !isLoading && scheduleId && ( +
+ No data found for this schedule. +
+ )} +
+ ); +} 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 98f76cc18..ca8e1fb15 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -124,6 +124,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [ { name: 'Overall', href: '/reports/overall', icon: BarChart3, permission: PERMS.reports.view }, { name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view }, { name: 'Passengers', href: '/reports/passengers', icon: Users, permission: PERMS.reports.view }, + { name: 'Boarding', href: '/reports/boarding', icon: LogIn, permission: PERMS.reports.view }, { name: 'Payments', href: '/reports/payments', icon: CreditCard, permission: PERMS.reports.view }, // { name: 'Payment Discrepancy', href: '/reports/payment-discrepancy', icon: AlertTriangle, permission: PERMS.reports.view }, // { name: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: PERMS.reports.view },