mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Report number updates
This commit is contained in:
@@ -105,7 +105,7 @@ export class ReportsService {
|
||||
const tripData = schedules.map(schedule => {
|
||||
const totalSeats = schedule.coachAssignments.reduce((sum, a) => sum + a.coach.seats.length, 0);
|
||||
const bookedSeats = schedule.bookings.reduce(
|
||||
(sum, b) => sum + b.seats.filter((s: any) => s.scheduleId === schedule.id).length, 0,
|
||||
(sum, b) => sum + b.seats.filter((s: any) => s.leg === 1).length, 0,
|
||||
);
|
||||
const occupancyRate = totalSeats > 0 ? (bookedSeats / totalSeats) * 100 : 0;
|
||||
return { scheduleId: schedule.id, departureAt: schedule.departureAt, totalSeats, bookedSeats, occupancyRate: +occupancyRate.toFixed(2) };
|
||||
@@ -217,7 +217,7 @@ export class ReportsService {
|
||||
where: { status: { in: ['CONFIRMED', 'BOARDED'] } },
|
||||
include: {
|
||||
seats: {
|
||||
where: { scheduleId },
|
||||
where: { leg: 1 },
|
||||
include: {
|
||||
seat: { include: { coach: { include: { coachType: true } } } },
|
||||
},
|
||||
@@ -313,27 +313,46 @@ export class ReportsService {
|
||||
async getPassengerList(scheduleId: string) {
|
||||
const seats = await this.prisma.bookingSeat.findMany({
|
||||
where: {
|
||||
scheduleId,
|
||||
booking: { status: { in: ['CONFIRMED', 'BOARDED'] } },
|
||||
leg: 1,
|
||||
booking: { scheduleId, status: { in: ['CONFIRMED', 'BOARDED'] } },
|
||||
},
|
||||
include: {
|
||||
booking: { select: { bookingRef: true, status: true } },
|
||||
seat: { include: { coach: { select: { number: true, coachType: { select: { name: true } } } } } },
|
||||
booking: {
|
||||
select: {
|
||||
bookingRef: true,
|
||||
status: true,
|
||||
originStationId: true,
|
||||
destinationStationId: true,
|
||||
},
|
||||
},
|
||||
seat: { include: { coach: { select: { number: true } } } },
|
||||
},
|
||||
orderBy: [{ seat: { coach: { number: 'asc' } } }],
|
||||
orderBy: [{ seat: { coach: { number: 'asc' } } }, { seat: { seatNumber: 'asc' } }],
|
||||
});
|
||||
|
||||
// Resolve station names in one query
|
||||
const stationIds = [...new Set(
|
||||
seats.flatMap(bs => [bs.booking.originStationId, bs.booking.destinationStationId]).filter(Boolean) as string[],
|
||||
)];
|
||||
const stations = stationIds.length > 0
|
||||
? await this.prisma.station.findMany({ where: { id: { in: stationIds } }, select: { id: true, name: true } })
|
||||
: [];
|
||||
const stationName = new Map(stations.map(s => [s.id, s.name]));
|
||||
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: scheduleId },
|
||||
select: { departureAt: true },
|
||||
});
|
||||
|
||||
return seats.map(bs => ({
|
||||
bookingRef: bs.booking.bookingRef,
|
||||
bookingStatus: bs.booking.status,
|
||||
passengerName: bs.passengerName,
|
||||
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,
|
||||
coachSeat: bs.seat?.coach?.number && bs.seatLabelSnapshot
|
||||
? `${bs.seat.coach.number}·${bs.seatLabelSnapshot}`
|
||||
: (bs.seatLabelSnapshot ?? '—'),
|
||||
origin: bs.booking.originStationId ? (stationName.get(bs.booking.originStationId) ?? '—') : '—',
|
||||
destination: bs.booking.destinationStationId ? (stationName.get(bs.booking.destinationStationId) ?? '—') : '—',
|
||||
departureAt: schedule?.departureAt ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -600,6 +600,9 @@ export class SeatsService {
|
||||
|
||||
async getBlockedSeats() {
|
||||
const blocks = await this.prisma.seatBlock.findMany({
|
||||
where: {
|
||||
NOT: { reason: { startsWith: 'MAINTENANCE:' } },
|
||||
},
|
||||
include: {
|
||||
seat: { include: { coach: { select: { number: true } } } },
|
||||
},
|
||||
|
||||
@@ -20,16 +20,11 @@ interface PassengersReport {
|
||||
|
||||
interface PassengerRow {
|
||||
bookingRef: string;
|
||||
bookingStatus: string;
|
||||
passengerName: string;
|
||||
passengerCategory: string;
|
||||
idDocumentType: string | null;
|
||||
idDocumentNumber: string | null;
|
||||
passportNumber: string | null;
|
||||
passportCountry: string | null;
|
||||
seatLabel: string | null;
|
||||
coachNumber: string | null;
|
||||
coachType: string | null;
|
||||
coachSeat: string;
|
||||
origin: string;
|
||||
destination: string;
|
||||
departureAt: string | null;
|
||||
}
|
||||
|
||||
type Tab = 'occupancy' | 'list';
|
||||
@@ -60,9 +55,7 @@ export default function PassengersReportPage() {
|
||||
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()),
|
||||
p.bookingRef.toLowerCase().includes(listSearch.toLowerCase()),
|
||||
)
|
||||
: passengerList;
|
||||
|
||||
@@ -82,11 +75,10 @@ export default function PassengersReportPage() {
|
||||
|
||||
const doExportList = () => {
|
||||
if (!passengerList.length) return;
|
||||
const headers = ['Booking Ref', 'Status', 'Name', 'Category', 'ID Type', 'ID Number', 'Passport', 'Country', 'Seat', 'Coach', 'Class'];
|
||||
const rows = passengerList.map(p => [
|
||||
p.bookingRef, p.bookingStatus, p.passengerName, p.passengerCategory,
|
||||
p.idDocumentType ?? '', p.idDocumentNumber ?? '', p.passportNumber ?? '',
|
||||
p.passportCountry ?? '', p.seatLabel ?? '', p.coachNumber ?? '', p.coachType ?? '',
|
||||
const headers = ['#', 'Name', 'Coach·Seat', 'Origin', 'Destination', 'Date', 'Booking Ref'];
|
||||
const rows = passengerList.map((p, i) => [
|
||||
String(i + 1), p.passengerName, p.coachSeat, p.origin, p.destination,
|
||||
p.departureAt ? formatDateTime(p.departureAt) : '—', p.bookingRef,
|
||||
].map(v => `"${String(v).replace(/"/g, '""')}"`));
|
||||
downloadCsv([headers.join(','), ...rows.map(r => r.join(','))].join('\n'), `passengers-${scheduleId}.csv`);
|
||||
};
|
||||
@@ -118,9 +110,6 @@ export default function PassengersReportPage() {
|
||||
{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 || 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>}
|
||||
@@ -267,62 +256,52 @@ export default function PassengersReportPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<input
|
||||
type="text"
|
||||
className="input max-w-sm flex-1"
|
||||
placeholder="Search by name or booking ref…"
|
||||
value={listSearch}
|
||||
onChange={e => setListSearch(e.target.value)}
|
||||
/>
|
||||
{passengerList.length > 0 && (
|
||||
<ActionButton icon={Download} variant="secondary" onClick={doExportList}>Export CSV</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
<div className="card p-0">
|
||||
<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="px-4 py-3">#</th>
|
||||
<th className="px-4 py-3">Name</th>
|
||||
<th className="px-4 py-3">Coach · Seat</th>
|
||||
<th className="px-4 py-3">Origin</th>
|
||||
<th className="px-4 py-3">Destination</th>
|
||||
<th className="px-4 py-3">Date</th>
|
||||
<th className="px-4 py-3">Booking Ref</th>
|
||||
</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>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{filteredList.map((p, i) => (
|
||||
<tr key={`${p.bookingRef}-${i}`} className="hover:bg-muted/30">
|
||||
<td className="px-4 py-3 text-muted-foreground tabular-nums">{i + 1}</td>
|
||||
<td className="px-4 py-3 font-medium">{p.passengerName}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">{p.coachSeat}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{p.origin}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{p.destination}</td>
|
||||
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">{p.departureAt ? formatDateTime(p.departureAt) : '—'}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">{p.bookingRef}</td>
|
||||
</tr>
|
||||
))}
|
||||
{filteredList.length === 0 && (
|
||||
<tr><td colSpan={7} className="py-8 text-center text-sm text-muted-foreground">No passengers found</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -155,7 +155,7 @@ export default function SeatStatusReportPage() {
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
@@ -206,20 +206,6 @@ export default function SeatStatusReportPage() {
|
||||
</div>
|
||||
<Ban className="h-8 w-8 text-slate-500 opacity-30" />
|
||||
</div>
|
||||
{blockedSeats.length > 0 && (
|
||||
<div className="mt-3 border-t border-border pt-3 flex flex-col gap-1 max-h-32 overflow-y-auto">
|
||||
{blockedSeats.map((b: any) => (
|
||||
<div key={b.id} className="flex items-center justify-between text-xs">
|
||||
<span className="font-medium text-foreground">
|
||||
Seat {b.seatNumber} · Coach {b.coachNumber}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate max-w-24" title={b.reason}>
|
||||
{b.reason}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user