Merge pull request #817 from Tria-plc/quick-fix-main

Quick fix main
This commit is contained in:
Stephanos A.
2026-07-19 21:09:03 +03:00
committed by GitHub
3 changed files with 185 additions and 104 deletions

View File

@@ -323,9 +323,12 @@ export class ReportsService {
status: true,
originStationId: true,
destinationStationId: true,
totalMinor: true,
currency: true,
_count: { select: { seats: true } },
},
},
seat: { include: { coach: { select: { number: true } } } },
seat: { include: { coach: { select: { number: true, coachType: { select: { name: true } } } } } },
},
orderBy: [{ seat: { coach: { number: 'asc' } } }, { seat: { seatNumber: 'asc' } }],
});
@@ -347,12 +350,19 @@ export class ReportsService {
return seats.map(bs => ({
bookingRef: bs.booking.bookingRef,
passengerName: bs.passengerName,
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,
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,
origin: bs.booking.originStationId ? (stationName.get(bs.booking.originStationId) ?? null) : null,
destination: bs.booking.destinationStationId ? (stationName.get(bs.booking.destinationStationId) ?? null) : null,
amountPaidMinor: bs.booking.totalMinor,
currency: bs.booking.currency ?? 'ETB',
isGroupBooking: (bs.booking._count?.seats ?? 0) > 1,
}));
}

View File

@@ -21,10 +21,19 @@ interface PassengersReport {
interface PassengerRow {
bookingRef: string;
passengerName: string;
coachSeat: string;
origin: string;
destination: string;
departureAt: string | null;
passengerCategory: string;
idDocumentType: string | null;
idDocumentNumber: string | null;
passportNumber: string | null;
passportCountry: 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';
@@ -54,12 +63,25 @@ export default function PassengersReportPage() {
enabled: !!scheduleId,
});
const filteredList = listSearch.trim()
? passengerList.filter(p =>
p.passengerName.toLowerCase().includes(listSearch.toLowerCase()) ||
p.bookingRef.toLowerCase().includes(listSearch.toLowerCase()),
)
: passengerList;
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;
})
.sort((a, b) => a.bookingRef.localeCompare(b.bookingRef));
const downloadCsv = (csv: string, filename: string) => {
const blob = new Blob([csv], { type: 'text/csv' });
@@ -77,10 +99,14 @@ export default function PassengersReportPage() {
const doExportList = () => {
if (!passengerList.length) return;
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,
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.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`);
};
@@ -272,38 +298,58 @@ export default function PassengersReportPage() {
<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>
<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">Name</th>
<th className="pb-2 pr-4">Nationality</th>
<th className="pb-2 pr-4">Coach · Seat</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 font-medium">{p.passengerName}</td>
<td className="py-2 pr-4 text-xs">
{p.passportNumber ? (
<>
<span className="text-muted-foreground">{p.passportCountry ?? 'Intl'}</span>
<span className="ml-1 font-mono">{p.passportNumber}</span>
</>
) : (
<span className="text-muted-foreground">{p.idDocumentNumber ?? '—'}</span>
)}
</td>
<td className="py-2 pr-4 font-mono text-xs">
{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 && 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>
</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>
))}
{filteredList.length === 0 && (
<tr><td colSpan={6} className="py-8 text-center text-sm text-muted-foreground">No passengers found</td></tr>
)}
</tbody>
</table>
</div>
</div>
)}

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>