mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Passenger report updates
This commit is contained in:
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user