Passengers report updates

This commit is contained in:
Stephanos A
2026-07-18 18:21:15 +03:00
parent aac162ca3c
commit 3fc36b358f
3 changed files with 282 additions and 157 deletions

View File

@@ -24,6 +24,12 @@ export class ReportsController {
return this.service.listSchedulesForPicker();
}
@Get('passengers/list')
@ApiOperation({ summary: 'Flat passenger list for a specific schedule' })
getPassengerList(@Query('scheduleId') scheduleId: string) {
return this.service.getPassengerList(scheduleId);
}
@Get('passengers')
@ApiOperation({ summary: 'Passengers report for a specific schedule' })
getOccupancyReport(@Query('scheduleId') scheduleId: string) {

View File

@@ -286,6 +286,35 @@ export class ReportsService {
};
}
async getPassengerList(scheduleId: string) {
const seats = await this.prisma.bookingSeat.findMany({
where: {
scheduleId,
booking: { status: { in: ['CONFIRMED', 'BOARDED'] } },
},
include: {
booking: { select: { bookingRef: true, status: true, originStationId: true, destinationStationId: true } },
seat: { include: { coach: { select: { number: true, coachType: { select: { name: true } } } } } },
},
orderBy: [{ seat: { coach: { number: 'asc' } } }],
});
return seats.map(bs => ({
bookingRef: bs.booking.bookingRef,
bookingStatus: bs.booking.status,
passengerName: bs.passengerName,
dateOfBirth: bs.dateOfBirth,
passengerCategory: bs.passengerCategory,
idDocumentType: bs.idDocumentType,
idDocumentNumber: bs.idDocumentNumber,
passportNumber: bs.passportNumber,
passportCountry: bs.passportCountry,
seatLabel: bs.seatLabelSnapshot,
coachNumber: bs.seat?.coach?.number ?? null,
coachType: (bs.seat?.coach as any)?.coachType?.name ?? null,
}));
}
async listSchedulesForPicker() {
const schedules = await this.prisma.trainSchedule.findMany({
select: {

View File

@@ -18,42 +18,82 @@ interface PassengersReport {
byDestination: { stationName: string; passengers: number }[];
}
interface PassengerRow {
bookingRef: string;
bookingStatus: string;
passengerName: string;
dateOfBirth: string | null;
passengerCategory: string;
idDocumentType: string | null;
idDocumentNumber: string | null;
passportNumber: string | null;
passportCountry: string | null;
seatLabel: string | null;
coachNumber: string | null;
coachType: string | null;
}
type Tab = 'occupancy' | 'list';
export default function PassengersReportPage() {
const [scheduleId, setScheduleId] = useState('');
const [search, setSearch] = useState('');
const [submittedId, setSubmittedId] = useState('');
const [tab, setTab] = useState<Tab>('occupancy');
const [listSearch, setListSearch] = useState('');
const { data: schedules = [], isLoading: loadingSchedules } = useQuery<ScheduleOption[]>({
queryKey: ['report-schedules'],
queryFn: () => apiClient.get('/reports/schedules'),
});
const filtered = search.trim()
? schedules.filter(s => s.label.toLowerCase().includes(search.toLowerCase()))
: schedules;
const selected = schedules.find(s => s.id === scheduleId);
const { data, isLoading, isError } = useQuery<PassengersReport>({
queryKey: ['passengers-report', submittedId],
queryFn: () => apiClient.get(`/reports/passengers?scheduleId=${submittedId}`),
enabled: !!submittedId,
queryKey: ['passengers-report', scheduleId],
queryFn: () => apiClient.get(`/reports/passengers?scheduleId=${scheduleId}`),
enabled: !!scheduleId,
});
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 { data: passengerList = [], isLoading: listLoading } = useQuery<PassengerRow[]>({
queryKey: ['passengers-list', scheduleId],
queryFn: () => apiClient.get(`/reports/passengers/list?scheduleId=${scheduleId}`),
enabled: !!scheduleId,
});
const filteredList = listSearch.trim()
? passengerList.filter(p =>
p.passengerName.toLowerCase().includes(listSearch.toLowerCase()) ||
p.bookingRef.toLowerCase().includes(listSearch.toLowerCase()) ||
(p.idDocumentNumber ?? '').toLowerCase().includes(listSearch.toLowerCase()) ||
(p.passportNumber ?? '').toLowerCase().includes(listSearch.toLowerCase()),
)
: passengerList;
const downloadCsv = (csv: string, filename: string) => {
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();
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
};
const doExportOccupancy = () => {
if (!data) return;
const rows = data.byCoach.map(c => [c.coachNumber, c.coachType, String(c.totalSeats), String(c.booked), `${c.occupancyRate}%`]);
const csv = [['Coach', 'Type', 'Total Seats', 'Booked', 'Occupancy'].join(','), ...rows.map(r => r.join(','))].join('\n');
downloadCsv(csv, `occupancy-${scheduleId}.csv`);
};
const doExportList = () => {
if (!passengerList.length) return;
const headers = ['Booking Ref', 'Status', 'Name', 'DOB', 'Category', 'ID Type', 'ID Number', 'Passport', 'Country', 'Seat', 'Coach', 'Class'];
const rows = passengerList.map(p => [
p.bookingRef, p.bookingStatus, p.passengerName, p.dateOfBirth ?? '',
p.passengerCategory, p.idDocumentType ?? '', p.idDocumentNumber ?? '',
p.passportNumber ?? '', p.passportCountry ?? '',
p.seatLabel ?? '', p.coachNumber ?? '', p.coachType ?? '',
].map(v => `"${String(v).replace(/"/g, '""')}"`));
const csv = [headers.join(','), ...rows.map(r => r.join(','))].join('\n');
downloadCsv(csv, `passengers-${scheduleId}.csv`);
};
return (
<div className="space-y-6">
<div>
@@ -61,52 +101,31 @@ export default function PassengersReportPage() {
<p className="text-muted-foreground mt-1">Occupancy and passenger breakdown for a schedule</p>
</div>
{/* Schedule picker */}
{/* Schedule selector */}
<div className="card">
<div className="flex items-end gap-4 flex-wrap">
<div className="flex-1 min-w-72">
<label className="label">Schedule</label>
<div className="relative">
<input
type="text"
className="input pr-8"
placeholder={loadingSchedules ? 'Loading schedules…' : 'Search by train, route or date…'}
value={search || selected?.label || ''}
onChange={(e) => { setSearch(e.target.value); setScheduleId(''); }}
onFocus={(e) => { setSearch(e.target.value); }}
/>
{(search || scheduleId) && (
<button
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-xs"
onClick={() => { setSearch(''); setScheduleId(''); }}
></button>
)}
</div>
{search && !scheduleId && (
<div className="border border-border rounded-md mt-1 max-h-56 overflow-y-auto bg-background shadow-md z-10 relative">
{filtered.length === 0
? <p className="text-xs text-muted-foreground px-3 py-2">No schedules found</p>
: filtered.map(s => (
<button
key={s.id}
className="w-full text-left px-3 py-2 text-sm hover:bg-muted truncate"
onClick={() => { setScheduleId(s.id); setSearch(''); }}
>{s.label}</button>
))
}
</div>
)}
<select
className="input"
value={scheduleId}
onChange={e => { setScheduleId(e.target.value); setTab('occupancy'); setListSearch(''); }}
disabled={loadingSchedules}
>
<option value="">{loadingSchedules ? 'Loading schedules…' : 'Select a schedule…'}</option>
{schedules.map(s => (
<option key={s.id} value={s.id}>{s.label}</option>
))}
</select>
</div>
<ActionButton onClick={() => setSubmittedId(scheduleId)} disabled={!scheduleId || isLoading}>
Load Report
</ActionButton>
{data && (
<ActionButton icon={Download} variant="secondary" onClick={doExport}>
Export CSV
</ActionButton>
{data && tab === 'occupancy' && (
<ActionButton icon={Download} variant="secondary" onClick={doExportOccupancy}>Export CSV</ActionButton>
)}
{passengerList.length > 0 && tab === 'list' && (
<ActionButton icon={Download} variant="secondary" onClick={doExportList}>Export CSV</ActionButton>
)}
</div>
{isLoading && <p className="text-xs text-muted-foreground mt-2">Loading</p>}
{(isLoading || listLoading) && <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.</p>}
</div>
@@ -122,118 +141,189 @@ export default function PassengersReportPage() {
</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>
{/* Tabs */}
<div className="border-b border-border flex">
{(['occupancy', 'list'] as Tab[]).map(t => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-5 py-2.5 text-sm font-medium border-b-2 transition-colors ${tab === t ? 'border-emerald-500 text-emerald-600 dark:text-emerald-400' : 'border-transparent text-muted-foreground hover:text-foreground'}`}
>
{t === 'occupancy' ? 'Occupancy' : `Passenger List${passengerList.length ? ` (${passengerList.length})` : ''}`}
</button>
))}
</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">
{/* Occupancy tab */}
{tab === 'occupancy' && (
<div className="space-y-6">
<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>
<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>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<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-emerald-500 h-1.5 rounded-full" style={{ width: `${c.occupancyRate}%` }} />
<div className="bg-blue-500 h-1.5 rounded-full" style={{ width: `${c.occupancyRate}%` }} />
</div>
<span className="tabular-nums text-xs w-10 text-right">{c.occupancyRate}%</span>
<span className="text-xs tabular-nums 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">
<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 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>
<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>
)}
<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>}
{/* Passenger List tab */}
{tab === 'list' && (
<div className="card space-y-4">
<input
type="text"
className="input max-w-sm"
placeholder="Search by name, booking ref or ID…"
value={listSearch}
onChange={e => setListSearch(e.target.value)}
/>
<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">#</th>
<th className="pb-2 pr-4">Name</th>
<th className="pb-2 pr-4">Category</th>
<th className="pb-2 pr-4">ID / Passport</th>
<th className="pb-2 pr-4">Seat</th>
<th className="pb-2 pr-4">Coach</th>
<th className="pb-2 pr-4">Booking Ref</th>
<th className="pb-2">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{filteredList.map((p, i) => (
<tr key={`${p.bookingRef}-${i}`} className="hover:bg-muted/30">
<td className="py-2 pr-4 text-muted-foreground tabular-nums">{i + 1}</td>
<td className="py-2 pr-4 font-medium">{p.passengerName}</td>
<td className="py-2 pr-4">
<span className={`text-xs font-semibold px-1.5 py-0.5 rounded ${p.passengerCategory === 'CHILD' ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' : 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'}`}>
{p.passengerCategory}
</span>
</td>
<td className="py-2 pr-4 text-muted-foreground text-xs">
{p.idDocumentNumber ?? p.passportNumber ?? '—'}
{p.passportCountry && <span className="ml-1 text-muted-foreground/60">({p.passportCountry})</span>}
</td>
<td className="py-2 pr-4 font-mono text-xs">{p.seatLabel ?? '—'}</td>
<td className="py-2 pr-4 text-muted-foreground">
{p.coachNumber ?? '—'}
{p.coachType && <span className="ml-1 text-xs text-muted-foreground/60">({p.coachType})</span>}
</td>
<td className="py-2 pr-4 font-mono text-xs">{p.bookingRef}</td>
<td className="py-2">
<span className={`text-xs font-semibold px-1.5 py-0.5 rounded ${p.bookingStatus === 'BOARDED' ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' : 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'}`}>
{p.bookingStatus}
</span>
</td>
</tr>
))}
{filteredList.length === 0 && (
<tr><td colSpan={8} className="py-8 text-center text-sm text-muted-foreground">No passengers found</td></tr>
)}
</tbody>
</table>
</div>
</div>
<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>