mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 14:08:11 +00:00
Passengers report
This commit is contained in:
@@ -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