mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Passengers report
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user