mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 17:38:12 +00:00
Passengers report
This commit is contained in:
@@ -18,6 +18,12 @@ export class ReportsController {
|
|||||||
return this.service.generateReport(dto);
|
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')
|
@Get(':reportId')
|
||||||
@ApiOperation({ summary: 'Get report by ID' })
|
@ApiOperation({ summary: 'Get report by ID' })
|
||||||
getReport(@Param('reportId') reportId: string) {
|
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) {
|
async getReport(reportId: string) {
|
||||||
return this.prisma.operationalReport.findUnique({ where: { id: reportId } });
|
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: [
|
items: [
|
||||||
{ name: 'Overall', href: '/reports', icon: BarChart3, 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: '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 },
|
// { name: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: PERMS.reports.view },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user