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 0c0d02ea4..a4da5b208 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts @@ -18,6 +18,12 @@ export class ReportsController { return this.service.generateReport(dto); } + @Get('passengers') + @ApiOperation({ summary: 'Passengers report for a specific schedule' }) + getOccupancyReport(@Query('scheduleId') scheduleId: string) { + return this.service.getOccupancyBySchedule(scheduleId); + } + @Get(':reportId') @ApiOperation({ summary: 'Get report by ID' }) getReport(@Param('reportId') reportId: 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 e37cac0d3..f4141ecb6 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -191,6 +191,101 @@ export class ReportsService { }; } + async getOccupancyBySchedule(scheduleId: string) { + const schedule = await this.prisma.trainSchedule.findUnique({ + where: { id: scheduleId }, + include: { + originStation: true, + destinationStation: true, + train: true, + coachAssignments: { + include: { + coach: { + include: { + coachType: true, + seats: { select: { id: true } }, + }, + }, + }, + }, + bookings: { + where: { status: { in: ['CONFIRMED', 'BOARDED'] } }, + include: { + seats: { + include: { + seat: { include: { coach: { include: { coachType: true } } } }, + }, + }, + }, + }, + stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, + }, + }); + + if (!schedule) return null; + + const totalSeats = (schedule as any).coachAssignments.reduce((s: number, a: any) => s + a.coach.seats.length, 0); + const allBookingSeats = (schedule as any).bookings.flatMap((b: any) => b.seats); + const totalPassengers = allBookingSeats.length; + const occupancyRate = totalSeats > 0 ? +((totalPassengers / totalSeats) * 100).toFixed(1) : 0; + + const coachMap = new Map(); + for (const assignment of (schedule as any).coachAssignments) { + const c = assignment.coach; + coachMap.set(c.id, { coachNumber: c.number, coachType: (c as any).coachType?.name ?? 'Unknown', totalSeats: c.seats.length, booked: 0 }); + } + for (const bs of allBookingSeats) { + const coachId = bs.seat?.coachId; + if (coachId && coachMap.has(coachId)) coachMap.get(coachId)!.booked++; + } + const byCoach = [...coachMap.values()].map(c => ({ ...c, occupancyRate: c.totalSeats > 0 ? +((c.booked / c.totalSeats) * 100).toFixed(1) : 0 })); + + const originMap = new Map(); + for (const booking of (schedule as any).bookings) { + const stationId = booking.originStationId ?? schedule.originStationId; + const stationName = (schedule as any).stopTimes.find((st: any) => st.stationId === stationId)?.station?.name ?? (schedule as any).originStation?.name ?? stationId; + if (!originMap.has(stationId)) originMap.set(stationId, { stationName, passengers: 0 }); + originMap.get(stationId)!.passengers += booking.seats.length; + } + + const destMap = new Map(); + for (const booking of (schedule as any).bookings) { + const stationId = booking.destinationStationId ?? schedule.destinationStationId; + const stationName = (schedule as any).stopTimes.find((st: any) => st.stationId === stationId)?.station?.name ?? (schedule as any).destinationStation?.name ?? stationId; + if (!destMap.has(stationId)) destMap.set(stationId, { stationName, passengers: 0 }); + destMap.get(stationId)!.passengers += booking.seats.length; + } + + const classMap = new Map(); + for (const assignment of (schedule as any).coachAssignments) { + const typeName = (assignment.coach as any).coachType?.name ?? 'Unknown'; + if (!classMap.has(typeName)) classMap.set(typeName, { className: typeName, totalSeats: 0, booked: 0 }); + classMap.get(typeName)!.totalSeats += assignment.coach.seats.length; + } + for (const bs of allBookingSeats) { + const typeName = bs.seat?.coach?.coachType?.name ?? 'Unknown'; + if (!classMap.has(typeName)) classMap.set(typeName, { className: typeName, totalSeats: 0, booked: 0 }); + classMap.get(typeName)!.booked++; + } + const byClass = [...classMap.values()].map(c => ({ ...c, occupancyRate: c.totalSeats > 0 ? +((c.booked / c.totalSeats) * 100).toFixed(1) : 0 })); + + return { + schedule: { + id: schedule.id, + trainName: (schedule as any).train?.name ?? (schedule as any).train?.number, + origin: (schedule as any).originStation?.name, + destination: (schedule as any).destinationStation?.name, + departureAt: schedule.departureAt, + arrivalAt: schedule.arrivalAt, + }, + summary: { totalSeats, totalPassengers, occupancyRate }, + byCoach, + byClass, + byOrigin: [...originMap.values()].sort((a, b) => b.passengers - a.passengers), + byDestination: [...destMap.values()].sort((a, b) => b.passengers - a.passengers), + }; + } + async getReport(reportId: string) { return this.prisma.operationalReport.findUnique({ where: { id: reportId } }); } diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/layout.tsx new file mode 100644 index 000000000..790272de1 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/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/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx new file mode 100644 index 000000000..8b7ef240f --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx @@ -0,0 +1,214 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Users, Armchair, BarChart3, Download } from 'lucide-react'; +import { apiClient } from '@/lib/api-client'; +import ActionButton from '@/components/ui/ActionButton'; +import { formatDateTime } from '@/lib/utils'; + +interface PassengersReport { + schedule: { + id: string; + trainName: string; + origin: string; + destination: string; + departureAt: string; + arrivalAt: string; + }; + summary: { totalSeats: number; totalPassengers: number; occupancyRate: number }; + byCoach: { coachNumber: string; coachType: string; totalSeats: number; booked: number; occupancyRate: number }[]; + byClass: { className: string; totalSeats: number; booked: number; occupancyRate: number }[]; + byOrigin: { stationName: string; passengers: number }[]; + byDestination: { stationName: string; passengers: number }[]; +} + +export default function PassengersReportPage() { + const [scheduleId, setScheduleId] = useState(''); + const [submittedId, setSubmittedId] = useState(''); + + const { data, isLoading, isError } = useQuery({ + queryKey: ['passengers-report', submittedId], + queryFn: () => apiClient.get(`/reports/passengers?scheduleId=${submittedId}`), + enabled: !!submittedId, + }); + + const doExport = () => { + if (!data) return; + const rows = data.byCoach.map((c) => [c.coachNumber, c.coachType, String(c.totalSeats), String(c.booked), `${c.occupancyRate}%`]); + const headers = ['Coach', 'Type', 'Total Seats', 'Booked', 'Occupancy']; + const csv = [headers.join(','), ...rows.map((r) => r.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 = `passengers-report-${submittedId}.csv`; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( +
+
+

Passengers Report

+

Occupancy and passenger breakdown for a schedule

+
+ + {/* Schedule ID input */} +
+
+
+ + setScheduleId(e.target.value)} + /> +
+ setSubmittedId(scheduleId)} disabled={!scheduleId.trim() || isLoading}> + Load Report + + {data && ( + + Export CSV + + )} +
+ {isLoading &&

Loading…

} + {isError &&

Failed to load report. Check the schedule ID.

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

Schedule

+
+
Train

{data.schedule.trainName ?? '—'}

+
Route

{data.schedule.origin} → {data.schedule.destination}

+
Departure

{formatDateTime(data.schedule.departureAt)}

+
+
+ + {/* Summary cards */} +
+
+
+

Total Seats

+
+
+

{data.summary.totalSeats}

+
+
+
+

Passengers

+
+
+

{data.summary.totalPassengers}

+
+
+
+

Occupancy Rate

+
+
+

{data.summary.occupancyRate}%

+
+
+
+
+
+ + {/* By Coach */} +
+

By Coach

+
+ + + + + + + + + + + + {data.byCoach.map((c) => ( + + + + + + + + ))} + +
CoachTypeSeatsBookedOccupancy
{c.coachNumber}{c.coachType}{c.totalSeats}{c.booked} +
+
+
+
+ {c.occupancyRate}% +
+
+
+
+ + {/* By Class + By Origin/Destination */} +
+ {/* By Class */} +
+

By Class

+
+ {data.byClass.map((c) => ( +
+
+ {c.className} + {c.booked}/{c.totalSeats} +
+
+
+
+
+ {c.occupancyRate}% +
+
+ ))} +
+
+ + {/* By Origin */} +
+

By Boarding Station

+
+ {data.byOrigin.map((o) => ( +
+ {o.stationName} + {o.passengers} +
+ ))} + {data.byOrigin.length === 0 &&

No data

} +
+
+ + {/* By Destination */} +
+

By Alighting Station

+
+ {data.byDestination.map((d) => ( +
+ {d.stationName} + {d.passengers} +
+ ))} + {data.byDestination.length === 0 &&

No data

} +
+
+
+ + )} +
+ ); +} 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 45da6d8ca..83c7271d0 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -121,6 +121,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [ items: [ { name: 'Overall', href: '/reports', 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: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: PERMS.reports.view }, ] },