mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 00:38:11 +00:00
Reports update
This commit is contained in:
@@ -18,6 +18,12 @@ export class ReportsController {
|
|||||||
return this.service.generateReport(dto);
|
return this.service.generateReport(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('schedules')
|
||||||
|
@ApiOperation({ summary: 'List schedules for the passengers report picker' })
|
||||||
|
listSchedulesForPicker() {
|
||||||
|
return this.service.listSchedulesForPicker();
|
||||||
|
}
|
||||||
|
|
||||||
@Get('passengers')
|
@Get('passengers')
|
||||||
@ApiOperation({ summary: 'Passengers report for a specific schedule' })
|
@ApiOperation({ summary: 'Passengers report for a specific schedule' })
|
||||||
getOccupancyReport(@Query('scheduleId') scheduleId: string) {
|
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) {
|
async getReport(reportId: string) {
|
||||||
return this.prisma.operationalReport.findUnique({ where: { id: reportId } });
|
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 ActionButton from '@/components/ui/ActionButton';
|
||||||
import { formatDateTime } from '@/lib/utils';
|
import { formatDateTime } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface ScheduleOption { id: string; label: string; }
|
||||||
|
|
||||||
interface PassengersReport {
|
interface PassengersReport {
|
||||||
schedule: {
|
schedule: { id: string; trainName: string; origin: string; destination: string; departureAt: string; arrivalAt: string; };
|
||||||
id: string;
|
|
||||||
trainName: string;
|
|
||||||
origin: string;
|
|
||||||
destination: string;
|
|
||||||
departureAt: string;
|
|
||||||
arrivalAt: string;
|
|
||||||
};
|
|
||||||
summary: { totalSeats: number; totalPassengers: number; occupancyRate: number };
|
summary: { totalSeats: number; totalPassengers: number; occupancyRate: number };
|
||||||
byCoach: { coachNumber: string; coachType: string; totalSeats: number; booked: number; occupancyRate: number }[];
|
byCoach: { coachNumber: string; coachType: string; totalSeats: number; booked: number; occupancyRate: number }[];
|
||||||
byClass: { className: 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() {
|
export default function PassengersReportPage() {
|
||||||
const [scheduleId, setScheduleId] = useState('');
|
const [scheduleId, setScheduleId] = useState('');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
const [submittedId, setSubmittedId] = 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>({
|
const { data, isLoading, isError } = useQuery<PassengersReport>({
|
||||||
queryKey: ['passengers-report', submittedId],
|
queryKey: ['passengers-report', submittedId],
|
||||||
queryFn: () => apiClient.get(`/reports/passengers?scheduleId=${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>
|
<p className="text-muted-foreground mt-1">Occupancy and passenger breakdown for a schedule</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Schedule ID input */}
|
{/* Schedule picker */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="flex items-end gap-4 flex-wrap">
|
<div className="flex items-end gap-4 flex-wrap">
|
||||||
<div className="flex-1 min-w-64">
|
<div className="flex-1 min-w-72">
|
||||||
<label className="label">Schedule ID</label>
|
<label className="label">Schedule</label>
|
||||||
<input
|
<div className="relative">
|
||||||
type="text"
|
<input
|
||||||
className="input"
|
type="text"
|
||||||
placeholder="Enter schedule UUID…"
|
className="input pr-8"
|
||||||
value={scheduleId}
|
placeholder={loadingSchedules ? 'Loading schedules…' : 'Search by train, route or date…'}
|
||||||
onChange={(e) => setScheduleId(e.target.value)}
|
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>
|
</div>
|
||||||
<ActionButton onClick={() => setSubmittedId(scheduleId)} disabled={!scheduleId.trim() || isLoading}>
|
<ActionButton onClick={() => setSubmittedId(scheduleId)} disabled={!scheduleId || isLoading}>
|
||||||
Load Report
|
Load Report
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
{data && (
|
{data && (
|
||||||
@@ -77,7 +107,7 @@ export default function PassengersReportPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isLoading && <p className="text-xs text-muted-foreground mt-2">Loading…</p>}
|
{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>
|
</div>
|
||||||
|
|
||||||
{data && (
|
{data && (
|
||||||
@@ -158,7 +188,6 @@ export default function PassengersReportPage() {
|
|||||||
|
|
||||||
{/* By Class + By Origin/Destination */}
|
{/* By Class + By Origin/Destination */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
{/* By Class */}
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">By Class</h3>
|
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">By Class</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -179,7 +208,6 @@ export default function PassengersReportPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* By Origin */}
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">By Boarding Station</h3>
|
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">By Boarding Station</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -193,7 +221,6 @@ export default function PassengersReportPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* By Destination */}
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">By Alighting Station</h3>
|
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">By Alighting Station</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user