mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
@@ -18,6 +18,12 @@ export class ReportsController {
|
||||
return this.service.generateReport(dto);
|
||||
}
|
||||
|
||||
@Get('schedules')
|
||||
@ApiOperation({ summary: 'List schedules for the passengers report picker' })
|
||||
listSchedulesForPicker() {
|
||||
return this.service.listSchedulesForPicker();
|
||||
}
|
||||
|
||||
@Get('passengers')
|
||||
@ApiOperation({ summary: 'Passengers report for a specific schedule' })
|
||||
getOccupancyReport(@Query('scheduleId') scheduleId: string) {
|
||||
|
||||
@@ -286,6 +286,24 @@ export class ReportsService {
|
||||
};
|
||||
}
|
||||
|
||||
async listSchedulesForPicker() {
|
||||
const schedules = await this.prisma.trainSchedule.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
departureAt: true,
|
||||
train: { select: { number: true } },
|
||||
originStation: { select: { name: true } },
|
||||
destinationStation: { select: { name: true } },
|
||||
},
|
||||
orderBy: { departureAt: 'desc' },
|
||||
take: 200,
|
||||
});
|
||||
return schedules.map(s => ({
|
||||
id: s.id,
|
||||
label: `${s.train.number} · ${s.originStation.name} → ${s.destinationStation.name} · ${new Date(s.departureAt).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' })}`,
|
||||
}));
|
||||
}
|
||||
|
||||
async getReport(reportId: string) {
|
||||
return this.prisma.operationalReport.findUnique({ where: { id: reportId } });
|
||||
}
|
||||
|
||||
@@ -7,15 +7,10 @@ import { apiClient } from '@/lib/api-client';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
|
||||
interface ScheduleOption { id: string; label: string; }
|
||||
|
||||
interface PassengersReport {
|
||||
schedule: {
|
||||
id: string;
|
||||
trainName: string;
|
||||
origin: string;
|
||||
destination: string;
|
||||
departureAt: string;
|
||||
arrivalAt: string;
|
||||
};
|
||||
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 }[];
|
||||
@@ -25,8 +20,20 @@ interface PassengersReport {
|
||||
|
||||
export default function PassengersReportPage() {
|
||||
const [scheduleId, setScheduleId] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [submittedId, setSubmittedId] = 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}`),
|
||||
@@ -54,20 +61,43 @@ export default function PassengersReportPage() {
|
||||
<p className="text-muted-foreground mt-1">Occupancy and passenger breakdown for a schedule</p>
|
||||
</div>
|
||||
|
||||
{/* Schedule ID input */}
|
||||
{/* Schedule picker */}
|
||||
<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 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>
|
||||
)}
|
||||
</div>
|
||||
<ActionButton onClick={() => setSubmittedId(scheduleId)} disabled={!scheduleId.trim() || isLoading}>
|
||||
<ActionButton onClick={() => setSubmittedId(scheduleId)} disabled={!scheduleId || isLoading}>
|
||||
Load Report
|
||||
</ActionButton>
|
||||
{data && (
|
||||
@@ -77,7 +107,7 @@ export default function PassengersReportPage() {
|
||||
)}
|
||||
</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>}
|
||||
{isError && <p className="text-xs text-red-500 mt-2">Failed to load report.</p>}
|
||||
</div>
|
||||
|
||||
{data && (
|
||||
@@ -158,7 +188,6 @@ export default function PassengersReportPage() {
|
||||
|
||||
{/* 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">
|
||||
@@ -179,7 +208,6 @@ export default function PassengersReportPage() {
|
||||
</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">
|
||||
@@ -193,7 +221,6 @@ export default function PassengersReportPage() {
|
||||
</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">
|
||||
|
||||
Reference in New Issue
Block a user