Passengers report

This commit is contained in:
Stephanos A
2026-07-18 12:27:59 +03:00
parent 5f000edbed
commit 8f0670ff9c
5 changed files with 319 additions and 0 deletions

View File

@@ -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) {

View File

@@ -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<string, { coachNumber: string; coachType: string; totalSeats: number; booked: number }>();
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<string, { stationName: string; passengers: number }>();
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<string, { stationName: string; passengers: number }>();
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<string, { className: string; totalSeats: number; booked: number }>();
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 } });
}

View File

@@ -0,0 +1,3 @@
export default function Layout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

View File

@@ -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<PassengersReport>({
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 (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Passengers Report</h1>
<p className="text-muted-foreground mt-1">Occupancy and passenger breakdown for a schedule</p>
</div>
{/* Schedule ID input */}
<div className="card">
<div className="flex items-end gap-4 flex-wrap">
<div className="flex-1 min-w-64">
<label className="label">Schedule ID</label>
<input
type="text"
className="input"
placeholder="Enter schedule UUID…"
value={scheduleId}
onChange={(e) => setScheduleId(e.target.value)}
/>
</div>
<ActionButton onClick={() => setSubmittedId(scheduleId)} disabled={!scheduleId.trim() || isLoading}>
Load Report
</ActionButton>
{data && (
<ActionButton icon={Download} variant="secondary" onClick={doExport}>
Export CSV
</ActionButton>
)}
</div>
{isLoading && <p className="text-xs text-muted-foreground mt-2">Loading</p>}
{isError && <p className="text-xs text-red-500 mt-2">Failed to load report. Check the schedule ID.</p>}
</div>
{data && (
<>
{/* Schedule info */}
<div className="card">
<p className="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-3">Schedule</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 text-sm">
<div><span className="text-muted-foreground">Train</span><p className="font-semibold">{data.schedule.trainName ?? '—'}</p></div>
<div><span className="text-muted-foreground">Route</span><p className="font-semibold">{data.schedule.origin} {data.schedule.destination}</p></div>
<div><span className="text-muted-foreground">Departure</span><p className="font-semibold">{formatDateTime(data.schedule.departureAt)}</p></div>
</div>
</div>
{/* Summary cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="card flex flex-col gap-1">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Total Seats</p>
<div className="rounded-lg bg-blue-100 dark:bg-blue-900/30 p-1.5"><Armchair className="h-4 w-4 text-blue-600 dark:text-blue-400" /></div>
</div>
<p className="text-2xl font-bold tabular-nums mt-1">{data.summary.totalSeats}</p>
</div>
<div className="card flex flex-col gap-1">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Passengers</p>
<div className="rounded-lg bg-emerald-100 dark:bg-emerald-900/30 p-1.5"><Users className="h-4 w-4 text-emerald-600 dark:text-emerald-400" /></div>
</div>
<p className="text-2xl font-bold tabular-nums mt-1">{data.summary.totalPassengers}</p>
</div>
<div className="card flex flex-col gap-1">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Occupancy Rate</p>
<div className="rounded-lg bg-purple-100 dark:bg-purple-900/30 p-1.5"><BarChart3 className="h-4 w-4 text-purple-600 dark:text-purple-400" /></div>
</div>
<p className="text-2xl font-bold tabular-nums mt-1">{data.summary.occupancyRate}%</p>
<div className="w-full bg-muted rounded-full h-1.5 mt-1">
<div className="bg-purple-500 h-1.5 rounded-full" style={{ width: `${data.summary.occupancyRate}%` }} />
</div>
</div>
</div>
{/* By Coach */}
<div className="card">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">By Coach</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-xs text-muted-foreground uppercase tracking-wider">
<th className="pb-2 pr-4">Coach</th>
<th className="pb-2 pr-4">Type</th>
<th className="pb-2 pr-4 text-right">Seats</th>
<th className="pb-2 pr-4 text-right">Booked</th>
<th className="pb-2">Occupancy</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{data.byCoach.map((c) => (
<tr key={c.coachNumber} className="hover:bg-muted/30">
<td className="py-2 pr-4 font-semibold">{c.coachNumber}</td>
<td className="py-2 pr-4 text-muted-foreground">{c.coachType}</td>
<td className="py-2 pr-4 text-right tabular-nums">{c.totalSeats}</td>
<td className="py-2 pr-4 text-right tabular-nums">{c.booked}</td>
<td className="py-2">
<div className="flex items-center gap-2">
<div className="flex-1 bg-muted rounded-full h-1.5">
<div className="bg-emerald-500 h-1.5 rounded-full" style={{ width: `${c.occupancyRate}%` }} />
</div>
<span className="tabular-nums text-xs w-10 text-right">{c.occupancyRate}%</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* By Class + By Origin/Destination */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* By Class */}
<div className="card">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">By Class</h3>
<div className="space-y-3">
{data.byClass.map((c) => (
<div key={c.className}>
<div className="flex justify-between text-sm mb-1">
<span className="font-medium">{c.className}</span>
<span className="tabular-nums text-muted-foreground">{c.booked}/{c.totalSeats}</span>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 bg-muted rounded-full h-1.5">
<div className="bg-blue-500 h-1.5 rounded-full" style={{ width: `${c.occupancyRate}%` }} />
</div>
<span className="text-xs tabular-nums w-10 text-right">{c.occupancyRate}%</span>
</div>
</div>
))}
</div>
</div>
{/* By Origin */}
<div className="card">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">By Boarding Station</h3>
<div className="space-y-2">
{data.byOrigin.map((o) => (
<div key={o.stationName} className="flex justify-between text-sm">
<span className="text-muted-foreground truncate">{o.stationName}</span>
<span className="font-semibold tabular-nums ml-2">{o.passengers}</span>
</div>
))}
{data.byOrigin.length === 0 && <p className="text-xs text-muted-foreground">No data</p>}
</div>
</div>
{/* By Destination */}
<div className="card">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">By Alighting Station</h3>
<div className="space-y-2">
{data.byDestination.map((d) => (
<div key={d.stationName} className="flex justify-between text-sm">
<span className="text-muted-foreground truncate">{d.stationName}</span>
<span className="font-semibold tabular-nums ml-2">{d.passengers}</span>
</div>
))}
{data.byDestination.length === 0 && <p className="text-xs text-muted-foreground">No data</p>}
</div>
</div>
</div>
</>
)}
</div>
);
}

View File

@@ -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 },
]
},