Passenger report updates

This commit is contained in:
Stephanos A
2026-07-19 20:58:00 +03:00
parent 80915f94ec
commit 264206b57a
3 changed files with 135 additions and 94 deletions

View File

@@ -311,7 +311,17 @@ export class ReportsService {
booking: { status: { in: ['CONFIRMED', 'BOARDED'] } },
},
include: {
booking: { select: { bookingRef: true, status: true, originStationId: true, destinationStationId: true } },
booking: {
select: {
bookingRef: true,
status: true,
originStationId: true,
destinationStationId: true,
totalMinor: true,
currency: true,
_count: { select: { seats: true } },
},
},
seat: { include: { coach: { select: { number: true, coachType: { select: { name: true } } } } } },
},
orderBy: [{ seat: { coach: { number: 'asc' } } }],
@@ -339,6 +349,9 @@ export class ReportsService {
coachType: (bs.seat?.coach as any)?.coachType?.name ?? null,
origin: bs.booking.originStationId ? (stationMap.get(bs.booking.originStationId) ?? null) : null,
destination: bs.booking.destinationStationId ? (stationMap.get(bs.booking.destinationStationId) ?? null) : null,
amountPaidMinor: bs.booking.totalMinor,
currency: bs.booking.currency ?? 'ETB',
isGroupBooking: (bs.booking._count?.seats ?? 0) > 1,
}));
}

View File

@@ -27,12 +27,14 @@ interface PassengerRow {
idDocumentNumber: string | null;
passportNumber: string | null;
passportCountry: string | null;
nationality: string | null;
seatLabel: string | null;
coachNumber: string | null;
coachType: string | null;
origin: string | null;
destination: string | null;
amountPaidMinor: number;
currency: string;
isGroupBooking: boolean;
}
type Tab = 'occupancy' | 'list';
@@ -65,20 +67,22 @@ export default function PassengersReportPage() {
const coachOptions = [...new Set(passengerList.map(p => p.coachNumber).filter(Boolean))].sort() as string[];
const originOptions = [...new Set(passengerList.map(p => p.origin).filter(Boolean))].sort() as string[];
const filteredList = passengerList.filter(p => {
if (filterCoach && p.coachNumber !== filterCoach) return false;
if (filterOrigin && p.origin !== filterOrigin) return false;
if (listSearch.trim()) {
const q = listSearch.toLowerCase();
return (
p.passengerName.toLowerCase().includes(q) ||
p.bookingRef.toLowerCase().includes(q) ||
(p.idDocumentNumber ?? '').toLowerCase().includes(q) ||
(p.passportNumber ?? '').toLowerCase().includes(q)
);
}
return true;
});
const filteredList = passengerList
.filter(p => {
if (filterCoach && p.coachNumber !== filterCoach) return false;
if (filterOrigin && p.origin !== filterOrigin) return false;
if (listSearch.trim()) {
const q = listSearch.toLowerCase();
return (
p.passengerName.toLowerCase().includes(q) ||
p.bookingRef.toLowerCase().includes(q) ||
(p.idDocumentNumber ?? '').toLowerCase().includes(q) ||
(p.passportNumber ?? '').toLowerCase().includes(q)
);
}
return true;
})
.sort((a, b) => a.bookingRef.localeCompare(b.bookingRef));
const downloadCsv = (csv: string, filename: string) => {
const blob = new Blob([csv], { type: 'text/csv' });
@@ -96,12 +100,14 @@ export default function PassengersReportPage() {
const doExportList = () => {
if (!passengerList.length) return;
const headers = ['Booking Ref', 'Status', 'Name', 'Category', 'Nationality / Passport', 'Coach · Seat', 'Origin', 'Destination'];
const rows = passengerList.map(p => [
p.bookingRef, p.bookingStatus, p.passengerName, p.passengerCategory,
const headers = ['Booking Ref', 'Name', 'Nationality', 'Coach · Seat', 'Trip', 'Amount Paid', 'Group Booking'];
const rows = [...passengerList].sort((a, b) => a.bookingRef.localeCompare(b.bookingRef)).map(p => [
p.bookingRef, p.passengerName,
p.passportNumber ? `${p.passportCountry ?? ''} · ${p.passportNumber}` : (p.idDocumentNumber ?? ''),
p.coachNumber && p.seatLabel ? `${p.coachNumber} · ${p.seatLabel}` : (p.coachNumber ?? p.seatLabel ?? ''),
p.origin ?? '', p.destination ?? '',
p.origin && p.destination ? `${p.origin}${p.destination}` : (p.origin ?? p.destination ?? ''),
`${(p.amountPaidMinor / 100).toFixed(2)} ${p.currency}`,
p.isGroupBooking ? 'Yes' : 'No',
].map(v => `"${String(v).replace(/"/g, '""')}"`));
downloadCsv([headers.join(','), ...rows.map(r => r.join(','))].join('\n'), `passengers-${scheduleId}.csv`);
};
@@ -314,27 +320,18 @@ export default function PassengersReportPage() {
<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">Nationality / Passport</th>
<th className="pb-2 pr-4">Nationality</th>
<th className="pb-2 pr-4">Coach · Seat</th>
<th className="pb-2 pr-4">Origin</th>
<th className="pb-2 pr-4">Destination</th>
<th className="pb-2 pr-4">Booking Ref</th>
<th className="pb-2">Status</th>
<th className="pb-2 pr-4">Trip</th>
<th className="pb-2 pr-4">Amount Paid</th>
<th className="pb-2">Booking Ref</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-xs">
{p.passportNumber ? (
<>
@@ -347,21 +344,27 @@ export default function PassengersReportPage() {
</td>
<td className="py-2 pr-4 font-mono text-xs">
{p.coachNumber && p.seatLabel
? `${p.coachNumber} · ${p.seatLabel}`
? <>{p.coachNumber} · {p.seatLabel}{p.coachType && <span className="font-sans text-muted-foreground ml-1">({p.coachType})</span>}</>
: (p.coachNumber ?? p.seatLabel ?? '—')}
</td>
<td className="py-2 pr-4 text-muted-foreground text-xs">{p.origin ?? '—'}</td>
<td className="py-2 pr-4 text-muted-foreground text-xs">{p.destination ?? '—'}</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 className="py-2 pr-4 text-muted-foreground text-xs">
{p.origin && p.destination ? `${p.origin}${p.destination}` : (p.origin ?? p.destination ?? '—')}
</td>
<td className="py-2 pr-4 text-xs">
<div className="flex items-center gap-1.5">
<span className="tabular-nums font-medium">
{(p.amountPaidMinor / 100).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} {p.currency}
</span>
{p.isGroupBooking && (
<span className="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-semibold bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">Group</span>
)}
</div>
</td>
<td className="py-2 font-mono text-xs">{p.bookingRef}</td>
</tr>
))}
{filteredList.length === 0 && (
<tr><td colSpan={9} className="py-8 text-center text-sm text-muted-foreground">No passengers found</td></tr>
<tr><td colSpan={6} className="py-8 text-center text-sm text-muted-foreground">No passengers found</td></tr>
)}
</tbody>
</table>

View File

@@ -3,6 +3,68 @@
import { useState, useEffect } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, Loader2, Zap, Trash2, Edit, Search, X, GripVertical } from 'lucide-react';
// ── 12-hour datetime picker ──────────────────────────────────────────────────
interface DTPProps { label: string; value: string; onChange: (v: string) => void; required?: boolean; }
/** value / onChange use "YYYY-MM-DDTHH:mm" (24-hr, local) — same as datetime-local */
function DateTimePicker({ label, value, onChange, required }: DTPProps) {
const datePart = value.slice(0, 10);
const timePart = value.slice(11, 16); // HH:mm 24-hr
const hour24 = timePart ? parseInt(timePart.slice(0, 2), 10) : 12;
const minute = timePart ? timePart.slice(3, 5) : '00';
const period = hour24 >= 12 ? 'PM' : 'AM';
const hour12 = hour24 % 12 === 0 ? 12 : hour24 % 12;
const emit = (d: string, h12: number, m: string, p: string) => {
if (!d) return;
const h24 = p === 'AM' ? (h12 === 12 ? 0 : h12) : (h12 === 12 ? 12 : h12 + 12);
onChange(`${d}T${String(h24).padStart(2, '0')}:${m}`);
};
return (
<div>
<label className="label">{label}{required && ' *'}</label>
<div className="flex gap-2">
<input
type="date"
className="input flex-1"
value={datePart}
required={required}
onChange={e => emit(e.target.value, hour12, minute, period)}
/>
<select
className="input w-20"
value={hour12}
onChange={e => emit(datePart, parseInt(e.target.value, 10), minute, period)}
>
{Array.from({ length: 12 }, (_, i) => i + 1).map(h => (
<option key={h} value={h}>{h}</option>
))}
</select>
<select
className="input w-20"
value={minute}
onChange={e => emit(datePart, hour12, e.target.value, period)}
>
{['00','05','10','15','20','25','30','35','40','45','50','55'].map(m => (
<option key={m} value={m}>{m}</option>
))}
</select>
<select
className="input w-20"
value={period}
onChange={e => emit(datePart, hour12, minute, e.target.value)}
>
<option value="AM">AM</option>
<option value="PM">PM</option>
</select>
</div>
</div>
);
}
// ────────────────────────────────────────────────────────────────────────────
import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
@@ -327,18 +389,15 @@ export default function SchedulesPage() {
const handleEditClick = (schedule: Schedule) => {
setEditingSchedule(schedule);
// Convert UTC dates to local time for datetime-local input
// datetime-local expects local time (no timezone info)
const dep = new Date(schedule.departureAt);
const arr = new Date(schedule.arrivalAt);
// Format UTC ISO string as local YYYY-MM-DDTHH:mm for the picker
const toLocalDT = (iso: string) => {
const d = new Date(iso);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
};
// Convert to local time by adding the timezone offset
const depLocal = new Date(dep.getTime() + dep.getTimezoneOffset() * 60000);
const arrLocal = new Date(arr.getTime() + arr.getTimezoneOffset() * 60000);
// Format for datetime-local input (YYYY-MM-DDTHH:mm)
const depStr = depLocal.toISOString().slice(0, 16);
const arrStr = arrLocal.toISOString().slice(0, 16);
const depStr = toLocalDT(schedule.departureAt);
const arrStr = toLocalDT(schedule.arrivalAt);
setEditForm({
departureAt: depStr,
@@ -696,15 +755,9 @@ export default function SchedulesPage() {
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Departure *</label>
<input type="datetime-local" className="input" value={addForm.departureAt} onChange={(e) => setAddForm({ ...addForm, departureAt: e.target.value })} required />
</div>
<div>
<label className="label">Arrival *</label>
<input type="datetime-local" className="input" value={addForm.arrivalAt} onChange={(e) => setAddForm({ ...addForm, arrivalAt: e.target.value })} required />
</div>
<div className="grid grid-cols-1 gap-4">
<DateTimePicker label="Departure" value={addForm.departureAt} onChange={v => setAddForm({ ...addForm, departureAt: v })} required />
<DateTimePicker label="Arrival" value={addForm.arrivalAt} onChange={v => setAddForm({ ...addForm, arrivalAt: v })} required />
</div>
<div className="border-t pt-4">
@@ -832,16 +885,7 @@ export default function SchedulesPage() {
</div>
</div>
<div>
<label className="label">Departure Date & Time *</label>
<input
type="datetime-local"
value={bulkForm.startDateTime}
onChange={(e) => setBulkForm({ ...bulkForm, startDateTime: e.target.value })}
className="input"
required
/>
</div>
<DateTimePicker label="Departure Date & Time" value={bulkForm.startDateTime} onChange={v => setBulkForm({ ...bulkForm, startDateTime: v })} required />
<div className="grid grid-cols-3 gap-4">
<div>
@@ -1019,28 +1063,9 @@ export default function SchedulesPage() {
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Departure Date & Time *</label>
<input
type="datetime-local"
value={editForm.departureAt}
onChange={(e) => setEditForm({ ...editForm, departureAt: e.target.value })}
className="input"
required
/>
</div>
<div>
<label className="label">Arrival Date & Time *</label>
<input
type="datetime-local"
value={editForm.arrivalAt}
onChange={(e) => setEditForm({ ...editForm, arrivalAt: e.target.value })}
className="input"
required
/>
</div>
<div className="grid grid-cols-1 gap-4">
<DateTimePicker label="Departure Date & Time" value={editForm.departureAt} onChange={v => setEditForm({ ...editForm, departureAt: v })} required />
<DateTimePicker label="Arrival Date & Time" value={editForm.arrivalAt} onChange={v => setEditForm({ ...editForm, arrivalAt: v })} required />
</div>
<div>