diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index bd66d04d1..29a262722 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -320,8 +320,8 @@ export class BookingsService { id: b.id, bookingRef: b.bookingRef, status: b.status, - totalMinor: b.totalMinor, - currency: b.currency || null, + totalMinor: b.displayTotalMinor ?? b.totalMinor, + currency: b.displayCurrency ?? b.currency ?? null, displayCurrency: b.displayCurrency ?? null, displayTotalMinor: b.displayTotalMinor ?? null, adultCount: b.adultCount, @@ -578,7 +578,7 @@ export class BookingsService { const mappedPkg = pkgItems.map((b: any) => ({ id: b.id, bookingRef: b.bookingRef, status: b.status, - totalMinor: b.totalMinor, currency: b.currency || b.displayCurrency, + totalMinor: b.displayTotalMinor ?? b.totalMinor, currency: b.displayCurrency || b.currency, displayCurrency: b.displayCurrency, displayTotalMinor: b.displayTotalMinor, contactEmail: b.contactEmail, contactPhone: b.contactPhone, bookingType: 'PACKAGE', packageId: b.packageId, priceTierId: b.priceTierId, @@ -732,8 +732,8 @@ export class BookingsService { id: b.id, bookingRef: b.bookingRef, status: b.status, - totalMinor: b.totalMinor, - currency: b.currency || b.displayCurrency, + totalMinor: b.displayTotalMinor ?? b.totalMinor, + currency: b.displayCurrency || b.currency, displayCurrency: b.displayCurrency, displayTotalMinor: b.displayTotalMinor, contactEmail: b.contactEmail, @@ -1822,8 +1822,8 @@ export class BookingsService { id: pkgBooking.id, bookingRef: pkgBooking.bookingRef, status: pkgBooking.status, - totalMinor: pkgBooking.totalMinor, - currency: pkgBooking.currency || pkgBooking.displayCurrency, + totalMinor: pkgBooking.displayTotalMinor ?? pkgBooking.totalMinor, + currency: pkgBooking.displayCurrency || pkgBooking.currency, adultCount: pkgBooking.passengerCount, childCount: 0, displayCurrency: pkgBooking.displayCurrency, @@ -1852,7 +1852,7 @@ export class BookingsService { fullName: p.passengerName, category: 'ADULT', leg: 1, - fareMinor: Math.round(pkgBooking.totalMinor / pkgBooking.passengerCount), + fareMinor: Math.round((pkgBooking.displayTotalMinor ?? pkgBooking.totalMinor) / pkgBooking.passengerCount), verifaydaVerified: false, seat: null, })), 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..5c1d69c51 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('occupancy') + @ApiOperation({ summary: 'Occupancy 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..12f48b41f 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,122 @@ 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; + + // Per-coach breakdown + 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, + })); + + // Per-origin station breakdown (using booking's originStationId) + 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 byOrigin = [...originMap.values()].sort((a, b) => b.passengers - a.passengers); + + // Per-destination station breakdown + 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 byDestination = [...destMap.values()].sort((a, b) => b.passengers - a.passengers); + + // Per-class breakdown + 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, + byDestination, + }; + } + async getReport(reportId: string) { return this.prisma.operationalReport.findUnique({ where: { id: reportId } }); } diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts index 675168cf9..5a648307d 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts @@ -54,11 +54,16 @@ export class CreateScheduleDto { plannedTimes?: PlannedStopTimeDto[]; } +export class CoachAssignmentDto { + @ApiProperty({ example: 'coach-uuid' }) @IsString() coachId: string; + @ApiProperty({ example: 1 }) @IsInt() @Min(1) positionNumber: number; +} + export class UpdateScheduleDto { @ApiPropertyOptional({ example: '2026-06-15T08:00:00Z', description: 'Scheduled departure from the first stop (origin)' }) @IsOptional() @IsDateString() departureAt?: string; @ApiPropertyOptional({ example: '2026-06-15T20:00:00Z', description: 'Scheduled arrival at the last stop (destination)' }) @IsOptional() @IsDateString() arrivalAt?: string; @ApiPropertyOptional({ enum: TripStatus, example: TripStatus.SCHEDULED }) @IsOptional() @IsEnum(TripStatus) status?: TripStatus; - @ApiPropertyOptional({ type: Array, description: 'List of coaches to assign' }) @IsOptional() @IsArray() coaches?: Array<{ coachId: string; positionNumber: number }>; + @ApiPropertyOptional({ type: [CoachAssignmentDto], description: 'List of coaches to assign' }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => CoachAssignmentDto) coaches?: CoachAssignmentDto[]; @ApiPropertyOptional({ example: false, description: 'Exclude from public search (reserved for packages)' }) @IsOptional() isPackageOnly?: boolean; } diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts index b45b93a33..d82b24f71 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -53,6 +53,7 @@ export class TicketsController { @ApiQuery({ name: 'originStationId', required: false }) @ApiQuery({ name: 'destinationStationId', required: false }) @ApiQuery({ name: 'arrivalDate', required: false }) + @ApiQuery({ name: 'departureDate', required: false }) @ApiQuery({ name: 'dateFrom', required: false }) @ApiQuery({ name: 'dateTo', required: false }) @ApiQuery({ name: 'coachId', required: false }) @@ -64,6 +65,7 @@ export class TicketsController { @Query('originStationId') originStationId?: string, @Query('destinationStationId') destinationStationId?: string, @Query('arrivalDate') arrivalDate?: string, + @Query('departureDate') departureDate?: string, @Query('dateFrom') dateFrom?: string, @Query('dateTo') dateTo?: string, @Query('coachId') coachId?: string, @@ -76,6 +78,7 @@ export class TicketsController { originStationId, destinationStationId, arrivalDate, + departureDate, dateFrom, dateTo, coachId, diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index 2769620d1..ada0b1ed6 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -27,7 +27,7 @@ export class TicketsService { @InjectDataSource() private readonly dataSource: DataSource, ) {} - async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; dateFrom?: string; dateTo?: string; coachId?: string; skip: number; take: number }) { + async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; departureDate?: string; arrivalDate?: string; dateFrom?: string; dateTo?: string; coachId?: string; skip: number; take: number }) { const where: any = {}; if (filters.search) { where.OR = [ @@ -41,10 +41,16 @@ export class TicketsService { where.status = filters.status; } if (filters.originStationId) { - where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, originStationId: filters.originStationId } }; + where.booking = { ...where.booking, originStationId: filters.originStationId }; } if (filters.destinationStationId) { - where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, destinationStationId: filters.destinationStationId } }; + where.booking = { ...where.booking, destinationStationId: filters.destinationStationId }; + } + if (filters.departureDate) { + const start = new Date(filters.departureDate); + const end = new Date(filters.departureDate); + end.setDate(end.getDate() + 1); + where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, departureAt: { gte: start, lt: end } } }; } if (filters.arrivalDate) { const start = new Date(filters.arrivalDate); @@ -70,7 +76,7 @@ export class TicketsService { include: { booking: { include: { - schedule: { include: { originStation: true, destinationStation: true, train: true } }, + schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, returnSchedule: { include: { originStation: true, destinationStation: true } }, passenger: { include: { travelerProfiles: true } }, seats: { include: { seat: { include: { coach: true } } } }, @@ -146,6 +152,18 @@ export class TicketsService { contactPhone: t.booking?.contactPhone, returnSchedule: t.booking?.returnSchedule ?? null, seats: t.booking?.seats ?? [], + originStation: (() => { + const id = t.booking?.originStationId; + if (!id) return t.booking?.schedule?.originStation ?? null; + const stop = t.booking?.schedule?.stopTimes?.find((st: any) => st.stationId === id); + return stop?.station ?? t.booking?.schedule?.originStation ?? null; + })(), + destinationStation: (() => { + const id = t.booking?.destinationStationId; + if (!id) return t.booking?.schedule?.destinationStation ?? null; + const stop = t.booking?.schedule?.stopTimes?.find((st: any) => st.stationId === id); + return stop?.station ?? t.booking?.schedule?.destinationStation ?? null; + })(), }, schedule: t.booking?.schedule, seat: t.seat ? { 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..eb0af3572 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/layout.tsx @@ -0,0 +1,5 @@ +import DashboardLayout from '../../dashboard/layout'; + +export default function PassengersLayout({ 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..8ff54da6a --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx @@ -0,0 +1,315 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Users, Armchair, TrendingUp, Train, Download } from 'lucide-react'; +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts'; +import { apiClient } from '@/lib/api-client'; +import { formatDateTime } from '@/lib/utils'; +import ActionButton from '@/components/ui/ActionButton'; + +const COLORS = ['#10b981', '#3b82f6', '#f59e0b', '#8b5cf6', '#ef4444', '#06b6d4']; + +function StatCard({ label, value, sub, icon: Icon, color }: { label: string; value: string | number; sub?: string; icon: any; color: string }) { + return ( +
+
+

{label}

+
+
+

{value}

+ {sub &&

{sub}

} +
+ ); +} + +export default function PassengersReportPage() { + const [scheduleId, setScheduleId] = useState(''); + + const { data: schedules = [] } = useQuery({ + queryKey: ['schedules-list'], + queryFn: () => apiClient.get('/schedules'), + select: (d: any) => d?.items ?? (Array.isArray(d) ? d : []), + }); + + const { data, isFetching } = useQuery({ + queryKey: ['occupancy-report', scheduleId], + queryFn: () => apiClient.get(`/reports/passengers?scheduleId=${scheduleId}`), + enabled: !!scheduleId, + }); + + const report = data as any; + + const doExport = () => { + if (!report) return; + const rows = [ + ['Coach', 'Type', 'Total Seats', 'Booked', 'Occupancy %'], + ...report.byCoach.map((c: any) => [c.coachNumber, c.coachType, c.totalSeats, c.booked, c.occupancyRate]), + ]; + const csv = rows.map(r => r.map((v: any) => `"${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 = `occupancy-${scheduleId}-${new Date().toISOString().split('T')[0]}.csv`; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( +
+
+

Passengers Report

+

Select a schedule to view passenger occupancy breakdown

+
+ + {/* Schedule Selector */} +
+
+
+ + +
+ {isFetching &&

Loading…

} + {report && ( + + Export CSV + + )} +
+
+ + {isFetching && ( +
Loading passengers data…
+ )} + + {report && ( + <> + {/* Schedule Info */} +
+
+ +
+
+

{report.schedule.trainName}

+

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

+
+
+ + {/* Summary Cards */} +
+ + + +
+ +
+ {/* By Coach */} +
+

Occupancy by Coach

+ {report.byCoach.length > 0 ? ( + <> + + + + `${v}%`} tick={{ fontSize: 11 }} /> + `Coach ${v}`} /> + [`${v}%`, 'Occupancy']} /> + + {report.byCoach.map((_: any, i: number) => ( + + ))} + + + + + + + + + + + + + + + {report.byCoach.map((c: any, i: number) => ( + + + + + + + + ))} + +
CoachTypeBookedTotalRate
Coach {c.coachNumber}{c.coachType}{c.booked}{c.totalSeats}{c.occupancyRate}%
+ + ) : ( +

No coach data

+ )} +
+ + {/* By Class */} +
+

Occupancy by Class

+ {report.byClass.length > 0 ? ( + <> + + + + + `${v}%`} tick={{ fontSize: 11 }} /> + [`${v}%`, 'Occupancy']} /> + + {report.byClass.map((_: any, i: number) => ( + + ))} + + + + + + + + + + + + + + {report.byClass.map((c: any, i: number) => ( + + + + + + + ))} + +
ClassBookedTotalRate
{c.className}{c.booked}{c.totalSeats}{c.occupancyRate}%
+ + ) : ( +

No class data

+ )} +
+ + {/* By Origin */} +
+

Passengers by Boarding Station

+ {report.byOrigin.length > 0 ? ( + + + + + + + + + + {report.byOrigin.map((o: any, i: number) => { + const pct = report.summary.totalPassengers > 0 + ? ((o.passengers / report.summary.totalPassengers) * 100).toFixed(1) + : '0'; + return ( + + + + + + ); + })} + +
StationPassengersShare
+
+
+ {o.stationName} +
+
{o.passengers}{pct}%
+ ) : ( +

No boarding station data

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

Passengers by Alighting Station

+ {report.byDestination.length > 0 ? ( + + + + + + + + + + {report.byDestination.map((d: any, i: number) => { + const pct = report.summary.totalPassengers > 0 + ? ((d.passengers / report.summary.totalPassengers) * 100).toFixed(1) + : '0'; + return ( + + + + + + ); + })} + +
StationPassengersShare
+
+
+ {d.stationName} +
+
{d.passengers}{pct}%
+ ) : ( +

No alighting station data

+ )} +
+
+ + )} + + {!report && !isFetching && scheduleId && ( +
No data found for this schedule.
+ )} + + {!scheduleId && ( +
+ +

Select a schedule above to load the occupancy report

+
+ )} +
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx index 21b6e6253..1105e5b67 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx @@ -15,7 +15,7 @@ import { formatDateTime, formatCurrency, formatDateTimeShort } from '@/lib/utils import { useAuthStore } from '@/lib/auth-store'; export default function TicketsPage() { - const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', arrivalDate: '', dateFrom: '', dateTo: '', coachId: '' }); + const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', departureDate: '', arrivalDate: '', dateFrom: '', dateTo: '', coachId: '' }); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [ticketToDelete, setTicketToDelete] = useState(null); const [deleteError, setDeleteError] = useState(null); @@ -71,6 +71,7 @@ export default function TicketsPage() { status: filters.status || undefined, originStationId: filters.originStationId || undefined, destinationStationId: filters.destinationStationId || undefined, + departureDate: filters.departureDate || undefined, arrivalDate: filters.arrivalDate || undefined, dateFrom: filters.dateFrom || undefined, dateTo: filters.dateTo || undefined, @@ -358,11 +359,13 @@ export default function TicketsPage() { render: (ticket: any) => { const isRoundTrip = ticket.booking?.bookingType === 'ROUND_TRIP' || ticket.booking?.bookingType === 'ROUND_TRIP_TRANSIT'; const returnDeparture = ticket.booking?.returnSchedule?.departureAt; - + const origin = ticket.booking?.originStation?.name || ticket.schedule?.originStation?.name || 'N/A'; + const destination = ticket.booking?.destinationStation?.name || ticket.schedule?.destinationStation?.name || 'N/A'; + return (
- {ticket.schedule?.originStation?.name || 'N/A'} → {ticket.schedule?.destinationStation?.name || 'N/A'} + {origin} → {destination}
{!isRoundTrip ? ( @@ -551,7 +554,7 @@ export default function TicketsPage() { Error loading tickets: {error instanceof Error ? error.message : 'Unknown error'}
)} -
+
- + setFilters({ ...filters, arrivalDate: e.target.value })} + value={filters.departureDate} + onChange={(e) => setFilters({ ...filters, departureDate: e.target.value })} />
+
+ + +
{showExtraFilters && ( -
+
-
- - -
- - + + 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 62fa655c7..00e594601 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -121,8 +121,9 @@ const navigationSections: { title: string; items: NavItem[] }[] = [ { title: 'Analytics & Reports', items: [ - { name: 'Overall', href: '/reports', icon: BarChart3, permission: PERMS.reports.view }, - { name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view }, + { 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 }, ] },