Merge branch 'alpha' into passenger/feat/iam

This commit is contained in:
Abubeker Yasin
2026-06-23 14:47:24 +03:00
37 changed files with 1271 additions and 1048 deletions

View File

@@ -2,7 +2,7 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Filter, Download, Eye, XCircle, Trash2 } from 'lucide-react';
import { Download, Eye, XCircle, Trash2 } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import Pagination from '@/components/ui/Pagination';
@@ -13,13 +13,21 @@ import { bookingsApi, apiClient } from '@/lib/api';
import { formatCurrency, formatDateTime } from '@/lib/utils';
import { BookingFilters } from '@/types';
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
<div className="bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-1">{label}</p>
<p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '—'}</p>
</div>
);
const SectionHeader = ({ title }: { title: string }) => (
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />{title}
</h3>
);
export default function BookingsPage() {
const [filters, setFilters] = useState<BookingFilters>({
page: 1,
pageSize: 20,
search: '',
status: '',
});
const [filters, setFilters] = useState<BookingFilters>({ page: 1, pageSize: 20, search: '', status: '' });
const [selectedBooking, setSelectedBooking] = useState<any>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [bookingToDelete, setBookingToDelete] = useState<any>(null);
@@ -28,14 +36,8 @@ export default function BookingsPage() {
const [exportDateFrom, setExportDateFrom] = useState('');
const [exportDateTo, setExportDateTo] = useState('');
const [exportColumns, setExportColumns] = useState<Record<string, boolean>>({
bookingRef: true,
passenger: true,
status: true,
bookingType: false,
passengerCount: false,
totalMinor: true,
paymentStatus: true,
createdAt: true,
bookingRef: true, bookingType: false, passengerNames: true, contactPhone: true,
contactEmail: true, passengerCount: false, paymentStatus: true, totalMinor: true, status: true, createdAt: true,
});
const queryClient = useQueryClient();
@@ -45,10 +47,6 @@ export default function BookingsPage() {
queryFn: () => bookingsApi.getAll(filters),
});
if (error) {
console.error('Bookings API Error:', error);
}
const cancelMutation = useMutation({
mutationFn: ({ id, reason }: { id: string; reason?: string }) => bookingsApi.cancel(id, reason),
onSuccess: () => {
@@ -56,9 +54,7 @@ export default function BookingsPage() {
setSuccessMessage('Booking cancelled successfully');
setTimeout(() => setSuccessMessage(''), 3000);
},
onError: (error: any) => {
alert(`Error: ${error.message || 'Failed to cancel booking'}`);
},
onError: (error: any) => alert(`Error: ${error.message || 'Failed to cancel booking'}`),
});
const deleteMutation = useMutation({
@@ -77,26 +73,22 @@ export default function BookingsPage() {
});
const handleCancel = async (booking: any) => {
if (window.confirm(`Are you sure you want to cancel booking ${booking.bookingRef}? This will process a refund.`)) {
if (window.confirm(`Cancel booking ${booking.bookingRef}? This will process a refund.`)) {
await cancelMutation.mutateAsync({ id: booking.id, reason: 'Cancelled by admin' });
}
};
const handleDeleteClick = (booking: any) => {
setBookingToDelete(booking);
setDeleteConfirmOpen(true);
};
const handleConfirmDelete = async () => {
if (bookingToDelete) {
await deleteMutation.mutateAsync(bookingToDelete.id);
}
};
const BOOKING_COLS = [
{ key: 'bookingRef', label: 'Booking Reference' }, { key: 'journeyType', label: 'Journey Type' },
{ key: 'passengerNames', label: 'Passenger Names' }, { key: 'contactPhone', label: 'Contact Phone' },
{ key: 'contactEmail', label: 'Contact Email' }, { key: 'passengerCount', label: 'Passenger Count' },
{ key: 'paymentStatus', label: 'Payment Status' }, { key: 'totalMinor', label: 'Amount' },
{ key: 'status', label: 'Status' }, { key: 'createdAt', label: 'Created At' },
];
const confirmExport = () => {
const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k);
if (cols.length === 0) { alert('Please select at least one column'); return; }
if (!cols.length) { alert('Please select at least one column'); return; }
const exportItems = (data?.items || []).filter((b: any) => {
if (!exportDateFrom && !exportDateTo) return true;
const d = b.createdAt ? new Date(b.createdAt).toISOString().split('T')[0] : null;
@@ -104,27 +96,27 @@ export default function BookingsPage() {
if (exportDateTo && (!d || d > exportDateTo)) return false;
return true;
});
const csv = [
cols.join(','),
BOOKING_COLS.map(c => `"${c.label}"`).join(','),
...exportItems.map((booking: any) => {
const values = cols.map(col => {
switch (col) {
const values = BOOKING_COLS.filter(c => cols.includes(c.key)).map(({ key }) => {
switch (key) {
case 'bookingRef': return booking.bookingRef;
case 'passenger': return booking.passenger?.fullName || booking.contactEmail || 'Guest';
case 'status': return booking.status;
case 'bookingType': return booking.bookingType || 'N/A';
case 'passengerCount': return booking.adultCount + booking.childCount;
case 'totalMinor': return booking.totalMinor;
case 'journeyType': return booking.bookingType || 'N/A';
case 'passengerNames': return booking.passengerNames?.join(', ') || 'N/A';
case 'contactPhone': return booking.contactPhone || 'N/A';
case 'contactEmail': return booking.contactEmail || 'N/A';
case 'passengerCount': return (booking.adultCount ?? 0) + (booking.childCount ?? 0);
case 'paymentStatus': return booking.paymentIntent?.status || 'PENDING';
case 'createdAt': return booking.createdAt;
case 'totalMinor': return formatCurrency(booking.totalMinor, booking.currency);
case 'status': return booking.status;
case 'createdAt': return booking.createdAt ? formatDateTime(booking.createdAt) : '';
default: return '';
}
});
return values.map(v => `"${v}"`).join(',');
}),
].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -136,91 +128,61 @@ export default function BookingsPage() {
const columns = [
{
key: 'bookingRef',
label: 'Reference',
sortable: true,
render: (booking: any) => (
<span className="font-mono font-semibold">{booking.bookingRef}</span>
),
},
{
key: 'passenger',
label: 'Passenger',
key: 'bookingRef', label: 'Reference', sortable: true,
render: (booking: any) => (
<div>
<div className="font-medium">{booking.passenger?.fullName || booking.contactEmail || 'Guest'}</div>
<div className="text-sm text-muted-foreground">{booking.contactPhone || booking.passenger?.phone}</div>
<div className="font-mono font-semibold">{booking.bookingRef}</div>
<div className="text-xs text-muted-foreground">{booking.bookingType || 'ONE_WAY'}</div>
</div>
),
},
{
key: 'bookingType',
label: 'Type',
sortable: true,
render: (booking: any) => booking.bookingType || 'ONE_WAY',
},
{
key: 'passengerCount',
label: 'Passengers',
key: 'passengerNames', label: 'Names',
render: (booking: any) => {
const adults = booking.adultCount || 0;
const children = booking.childCount || 0;
if (adults === 0 && children === 0) return '—';
const parts = [`Adult: ${adults}`];
if (children > 0) parts.push(`Child: ${children}`);
return parts.join(' / ');
const names: string[] = booking.passengerNames || [];
if (!names.length) return <span className="text-muted-foreground"></span>;
return <div className="flex flex-col gap-0.5">{names.map((n, i) => <span key={i} className="text-sm">{n}</span>)}</div>;
},
},
{
key: 'status',
label: 'Status',
key: 'contact', label: 'Contact',
render: (booking: any) => (
<Badge variant="status" status={booking.status}>{booking.status}</Badge>
<div>
<div className="font-medium">{booking.contactPhone || booking.passenger?.phone}</div>
<div className="text-sm text-muted-foreground">{booking.contactEmail || booking.passenger?.email}</div>
</div>
),
},
{
key: 'totalMinor',
label: 'Amount',
sortable: true,
render: (booking: any) => formatCurrency(booking.totalMinor, booking.currency),
key: 'passengerCount', label: 'Passengers',
render: (booking: any) => {
const adults = booking.adultCount || 0, children = booking.childCount || 0;
if (!adults && !children) return '—';
return <><div>Adult: {adults}</div><div className="text-sm text-muted-foreground">Child: {children}</div></>;
},
},
{
key: 'paymentStatus',
label: 'Payment',
key: 'paymentStatus', label: 'Payment',
render: (booking: any) => (
<Badge variant="status" status={booking.paymentIntent?.status || 'PENDING'}>
{booking.paymentIntent?.status || 'PENDING'}
</Badge>
<div>
<Badge variant="status" status={booking.paymentIntent?.status || 'PENDING'}>{booking.paymentIntent?.status || 'PENDING'}</Badge>
<div className="text-sm text-muted-foreground">{formatCurrency(booking.totalMinor, booking.currency)}</div>
</div>
),
},
{
key: 'createdAt',
label: 'Created',
sortable: true,
render: (booking: any) => formatDateTime(booking.createdAt),
key: 'status', label: 'Status',
render: (booking: any) => <Badge variant="status" status={booking.status}>{booking.status}</Badge>,
},
];
const actions = [
{ label: 'View Details', onClick: (b: any) => setSelectedBooking(b), variant: 'secondary' as const, icon: Eye },
{
label: 'View Details',
onClick: (booking: any) => setSelectedBooking(booking),
variant: 'secondary' as const,
icon: Eye,
},
{
label: 'Cancel Booking',
onClick: handleCancel,
variant: 'danger' as const,
icon: XCircle,
show: (booking: any) => booking.status !== 'CANCELLED' && booking.status !== 'COMPLETED',
},
{
label: 'Delete',
onClick: handleDeleteClick,
variant: 'danger' as const,
icon: Trash2,
label: 'Cancel Booking', onClick: handleCancel, variant: 'danger' as const, icon: XCircle,
show: (b: any) => b.status !== 'CANCELLED' && b.status !== 'BOARDED',
},
{ label: 'Delete', onClick: (b: any) => { setBookingToDelete(b); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2 },
];
return (
@@ -235,9 +197,7 @@ export default function BookingsPage() {
<div className="card">
{successMessage && (
<div className="mb-4 rounded-lg bg-green-50 dark:bg-green-900/20 p-4 text-sm text-green-800 dark:text-green-200">
{successMessage}
</div>
<div className="mb-4 rounded-lg bg-green-50 dark:bg-green-900/20 p-4 text-sm text-green-800 dark:text-green-200"> {successMessage}</div>
)}
{error && (
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-sm text-red-800 dark:text-red-200">
@@ -246,222 +206,195 @@ export default function BookingsPage() {
)}
<div className="mb-4 flex flex-wrap gap-4">
<div className="flex-1">
<input
type="text"
placeholder="Search by reference, email, or phone..."
className="input"
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 1 })}
/>
<input type="text" placeholder="Search by reference, email, or phone..." className="input"
value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 1 })} />
</div>
<select
className="input w-48"
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value || undefined, page: 1 })}
>
<select className="input w-48" value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value || undefined, page: 1 })}>
<option value="">All Status</option>
<option value="PENDING_PAYMENT">Pending Payment</option>
<option value="CONFIRMED">Confirmed</option>
<option value="CANCELLED">Cancelled</option>
<option value="COMPLETED">Completed</option>
<option value="BOARDED">Boarded</option>
</select>
<ActionButton variant="secondary" icon={Filter}>More Filters</ActionButton>
</div>
<DataTable
data={data?.items || []}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage="No bookings found"
/>
<DataTable data={data?.items || []} columns={columns} actions={actions} loading={isLoading} emptyMessage="No bookings found" />
{data?.meta && (
<Pagination
currentPage={data.meta.page}
totalPages={data.meta.totalPages}
onPageChange={(page) => setFilters({ ...filters, page })}
/>
<Pagination currentPage={data.meta.page} totalPages={data.meta.totalPages}
onPageChange={(page) => setFilters({ ...filters, page })} />
)}
</div>
{/* Booking Details Modal */}
<Modal isOpen={!!selectedBooking} onClose={() => setSelectedBooking(null)} title="Booking Details" size="xl">
{selectedBooking && (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Booking Reference</label>
<p className="text-lg font-semibold font-mono">{selectedBooking.bookingRef}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Status</label>
<div className="mt-1">
<Badge variant="status" status={selectedBooking.status}>{selectedBooking.status}</Badge>
</div>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Booking Type</label>
<p className="text-lg font-semibold">{selectedBooking.bookingType || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Created</label>
<p className="text-lg font-semibold">{formatDateTime(selectedBooking.createdAt)}</p>
</div>
</div>
<hr className="border-muted" />
{selectedBooking && (() => {
const b = selectedBooking;
const isRoundTrip = b.bookingType === 'ROUND_TRIP' || b.bookingType === 'ROUND_TRIP_TRANSIT';
return (
<div>
<h3 className="text-lg font-semibold mb-3">Passenger Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Name</label>
<p className="text-lg font-semibold">{selectedBooking.passenger?.fullName || selectedBooking.contactEmail || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Email</label>
<p className="text-lg font-semibold">{selectedBooking.contactEmail || selectedBooking.passenger?.email || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Phone</label>
<p className="text-lg font-semibold">{selectedBooking.contactPhone || selectedBooking.passenger?.phone || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Passenger ID</label>
<p className="text-sm font-mono">{selectedBooking.passengerId || 'N/A'}</p>
</div>
</div>
</div>
<hr className="border-muted" />
<div>
<h3 className="text-lg font-semibold mb-3">Journey Details</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Adults</label>
<p className="text-lg font-semibold">{selectedBooking.adultCount || 0}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Children</label>
<p className="text-lg font-semibold">{selectedBooking.childCount || 0}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Schedule ID</label>
<p className="text-sm font-mono">{selectedBooking.scheduleId || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Promo Code</label>
<p className="text-lg font-semibold">{selectedBooking.promoCode || 'None'}</p>
</div>
</div>
</div>
<hr className="border-muted" />
<div>
<h3 className="text-lg font-semibold mb-3">Payment Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Amount</label>
<p className="text-lg font-semibold">{formatCurrency(selectedBooking.totalMinor, selectedBooking.currency)}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Payment Status</label>
<div className="mt-1">
<Badge variant="status" status={selectedBooking.paymentIntent?.status || 'PENDING'}>
{selectedBooking.paymentIntent?.status || 'PENDING'}
</Badge>
{/* Gradient header */}
<div className="-mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r from-emerald-600 to-emerald-700 rounded-t-lg">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-emerald-100 text-xs font-semibold uppercase tracking-widest mb-1">Booking Reference</p>
<p className="text-white text-3xl font-mono font-bold tracking-wider">{b.bookingRef}</p>
</div>
<div className="text-right shrink-0">
<Badge variant="status" status={b.status}>{b.status}</Badge>
<p className="text-emerald-200 text-xs mt-2">{formatDateTime(b.createdAt)}</p>
</div>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Paid At</label>
<p className="text-lg font-semibold">{selectedBooking.paidAt ? formatDateTime(selectedBooking.paidAt) : 'Not paid'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Display Currency</label>
<p className="text-lg font-semibold">{selectedBooking.displayCurrency || selectedBooking.currency}</p>
<div className="mt-4 flex flex-wrap gap-2">
{[
(b.bookingType || 'ONE_WAY').replace(/_/g, ' '),
`${b.adultCount ?? 0} Adult${(b.adultCount ?? 0) !== 1 ? 's' : ''}${(b.childCount ?? 0) > 0 ? ` · ${b.childCount} Child${b.childCount !== 1 ? 'ren' : ''}` : ''}`,
b.displayCurrency || b.currency || 'ETB',
].map((tag) => (
<span key={tag} className="inline-flex items-center gap-1.5 bg-white/20 text-white text-xs font-medium px-3 py-1 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-200" />{tag}
</span>
))}
</div>
</div>
</div>
<hr className="border-muted" />
<div className="space-y-6">
{/* Passenger */}
<section>
<SectionHeader title="Passenger" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Full Name" value={b.passenger?.fullName || b.contactEmail} />
<Field label="Email" value={b.contactEmail || b.passenger?.email} />
<Field label="Phone" value={b.contactPhone || b.passenger?.phone} />
<Field label="Passenger ID" value={b.passengerId} mono truncate />
</div>
</section>
<div>
<h3 className="text-lg font-semibold mb-3">Additional Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Source</label>
<p className="text-lg font-semibold">{selectedBooking.source || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Last Updated</label>
<p className="text-lg font-semibold">{formatDateTime(selectedBooking.updatedAt)}</p>
</div>
{/* Journey */}
<section>
<SectionHeader title="Journey" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Origin" value={b.schedule?.originStation?.name} />
<Field label="Destination" value={b.schedule?.destinationStation?.name} />
<Field label="Departure" value={b.schedule?.departureAt ? formatDateTime(b.schedule.departureAt) : ''} />
<Field label="Arrival" value={b.schedule?.arrivalAt ? formatDateTime(b.schedule.arrivalAt) : ''} />
<Field label="Adults" value={String(b.adultCount ?? 0)} />
<Field label="Children" value={String(b.childCount ?? 0)} />
<Field label="Promo Code" value={b.promoCode || 'None'} />
<Field label="Schedule ID" value={b.scheduleId} mono truncate />
</div>
</section>
{/* Return leg */}
{isRoundTrip && (
<section>
<SectionHeader title="Return Leg" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Leg Status" value={(b.returnLegStatus || '—').replace(/_/g, ' ')} />
<Field label="Outbound Boarded" value={b.outboundBoardedAt ? formatDateTime(b.outboundBoardedAt) : 'Not yet'} />
<Field label="Return Boarded" value={b.returnBoardedAt ? formatDateTime(b.returnBoardedAt) : 'Not yet'} />
<Field label="Return Schedule ID" value={b.returnScheduleId} mono truncate />
</div>
</section>
)}
{/* Payment */}
<section>
<SectionHeader title="Payment" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-100 dark:border-emerald-800 rounded-lg p-3 col-span-2">
<p className="text-xs text-emerald-700 dark:text-emerald-400 mb-1">Total Amount</p>
<p className="text-xl font-bold text-emerald-800 dark:text-emerald-300">{formatCurrency(b.totalMinor, b.currency || 'ETB')}</p>
{b.displayCurrency && b.displayCurrency !== (b.currency || 'ETB') && (
<p className="text-xs text-emerald-600 dark:text-emerald-500 mt-0.5">
{formatCurrency(b.displayTotalMinor ?? b.totalMinor, b.displayCurrency)}
</p>
)}
</div>
<div className="bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-2">Payment Status</p>
<Badge variant="status" status={b.paymentIntent?.status || 'PENDING'}>{b.paymentIntent?.status || 'PENDING'}</Badge>
</div>
<Field label="Method" value={b.paymentIntent?.method || '—'} />
<Field label="Paid At" value={b.paidAt ? formatDateTime(b.paidAt) : 'Not paid'} />
<Field label="Display Currency" value={b.displayCurrency || b.currency || 'ETB'} />
<Field label="Payment ID" value={b.paymentIntent?.id || '—'} mono truncate />
</div>
</section>
{/* Seats */}
{b.seats && b.seats.length > 0 && (
<section>
<SectionHeader title={`Seats (${b.seats.length})`} />
<div className="divide-y divide-muted rounded-lg border border-muted overflow-hidden">
{b.seats.map((bs: any, i: number) => (
<div key={i} className="flex items-center justify-between px-4 py-3 bg-muted/20 hover:bg-muted/40 transition-colors">
<div className="flex items-center gap-3">
<span className="w-6 h-6 rounded-full bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-400 text-xs font-bold flex items-center justify-center shrink-0">{i + 1}</span>
<div>
<p className="text-sm font-semibold">{bs.passengerName || '—'}</p>
<p className="text-xs text-muted-foreground">
{bs.passengerCategory || '—'}{bs.leg ? ` · Leg ${bs.leg}` : ''}{bs.idDocumentType ? ` · ${bs.idDocumentType}` : ''}
{bs.verifaydaVerified ? ' · ✓ Verified' : ''}
</p>
</div>
</div>
<div className="text-right">
<p className="text-sm font-mono font-semibold">{bs.seat?.seatNumber || bs.seatId || '—'}</p>
<p className="text-xs text-muted-foreground">{formatCurrency(bs.fareMinor ?? 0, b.currency || 'ETB')}</p>
</div>
</div>
))}
</div>
</section>
)}
{/* Timestamps */}
<section>
<SectionHeader title="Timestamps & Meta" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Created" value={formatDateTime(b.createdAt)} />
<Field label="Last Updated" value={formatDateTime(b.updatedAt)} />
<Field label="Source / Device" value={b.source || b.userAgent || '—'} truncate />
</div>
</section>
</div>
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
<ActionButton variant="secondary" onClick={() => setSelectedBooking(null)}>Close</ActionButton>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton variant="secondary" onClick={() => setSelectedBooking(null)}>Close</ActionButton>
</div>
</div>
)}
);
})()}
</Modal>
{/* Delete Confirmation Dialog */}
<ConfirmDialog
isOpen={deleteConfirmOpen}
onClose={() => { setDeleteConfirmOpen(false); setBookingToDelete(null); }}
onConfirm={handleConfirmDelete}
onConfirm={async () => { if (bookingToDelete) await deleteMutation.mutateAsync(bookingToDelete.id); }}
title="Delete Booking"
message={`Are you sure you want to permanently delete booking ${bookingToDelete?.bookingRef}? This action cannot be undone and will release all associated seats.`}
confirmText="Delete"
cancelText="Cancel"
isLoading={deleteMutation.isPending}
isDanger={true}
message={`Permanently delete booking ${bookingToDelete?.bookingRef}? This cannot be undone and will release all associated seats.`}
confirmText="Delete" cancelText="Cancel" isLoading={deleteMutation.isPending} isDanger
/>
{/* Export Modal */}
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Bookings" size="md">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Date From (Created)</label>
<input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} />
</div>
<div>
<label className="label">Date To (Created)</label>
<input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} />
</div>
<div><label className="label">Date From (Created)</label><input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} /></div>
<div><label className="label">Date To (Created)</label><input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} /></div>
</div>
<div>
<p className="text-sm font-medium mb-2">Select Columns</p>
<div className="space-y-2 max-h-56 overflow-y-auto">
{[
{ key: 'bookingRef', label: 'Booking Reference' },
{ key: 'passenger', label: 'Passenger' },
{ key: 'status', label: 'Status' },
{ key: 'bookingType', label: 'Booking Type' },
{ key: 'passengerCount', label: 'Passenger Count' },
{ key: 'totalMinor', label: 'Amount' },
{ key: 'paymentStatus', label: 'Payment Status' },
{ key: 'createdAt', label: 'Created At' },
].map((col) => (
{BOOKING_COLS.map((col) => (
<label key={col.key} className="flex items-center gap-3 p-2 hover:bg-gray-50 dark:hover:bg-gray-900/50 rounded cursor-pointer">
<input
type="checkbox"
checked={exportColumns[col.key] || false}
<input type="checkbox" checked={exportColumns[col.key] || false}
onChange={(e) => setExportColumns({ ...exportColumns, [col.key]: e.target.checked })}
className="w-4 h-4 rounded border-gray-300"
/>
className="w-4 h-4 rounded border-gray-300" />
<span className="text-sm font-medium">{col.label}</span>
</label>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
<ActionButton onClick={confirmExport}>Export CSV</ActionButton>

View File

@@ -2,7 +2,7 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Download, Eye, Trash2 } from 'lucide-react';
import { Download, Eye, Trash2, ShieldCheck, ShieldOff, Star, Wallet } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import Pagination from '@/components/ui/Pagination';
@@ -13,13 +13,28 @@ import { passengersApi, apiClient } from '@/lib/api';
import { formatDate, formatDateTime } from '@/lib/utils';
import { PassengerFilters } from '@/types';
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
<div className="bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-1">{label}</p>
<p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '—'}</p>
</div>
);
const SectionHeader = ({ title }: { title: string }) => (
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />{title}
</h3>
);
const TIER_COLORS: Record<string, string> = {
BRONZE: 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400 border-orange-200 dark:border-orange-800',
SILVER: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-200 dark:border-gray-600',
GOLD: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800',
PLATINUM: 'bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-400 border-indigo-200 dark:border-indigo-800',
};
export default function PassengersPage() {
const [filters, setFilters] = useState<PassengerFilters>({
page: 1,
pageSize: 20,
search: '',
role: 'PASSENGER',
});
const [filters, setFilters] = useState<PassengerFilters>({ page: 1, pageSize: 20, search: '', role: 'PASSENGER' });
const [selectedPassenger, setSelectedPassenger] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: null });
const [exportModalOpen, setExportModalOpen] = useState(false);
@@ -33,35 +48,23 @@ export default function PassengersPage() {
const deleteMutation = useMutation({
mutationFn: (id: string) => apiClient.delete(`/passengers/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['passengers'] });
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['passengers'] }),
});
const handleDelete = (passenger: any) => {
setDeleteConfirm({ isOpen: true, passenger });
};
const confirmDelete = async () => {
if (deleteConfirm.passenger) {
await deleteMutation.mutateAsync(deleteConfirm.passenger.id);
setDeleteConfirm({ isOpen: false, passenger: null });
}
};
const { data, isLoading, error } = useQuery({
queryKey: ['passengers', filters],
queryFn: () => passengersApi.getAll(filters),
});
if (error) {
console.error('Passengers API Error:', error);
}
const PASSENGER_COLS = [
{ key: 'fullName', label: 'Full Name' }, { key: 'email', label: 'Email' }, { key: 'phone', label: 'Phone' },
{ key: 'dateOfBirth', label: 'Date of Birth' }, { key: 'gender', label: 'Gender' },
{ key: 'nationality', label: 'Nationality' }, { key: 'verified', label: 'Verified' },
];
const confirmExportPassengers = () => {
const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k);
if (cols.length === 0) { alert('Please select at least one column'); return; }
if (!cols.length) { alert('Please select at least one column'); return; }
const exportItems = (data?.items || []).filter((p: any) => {
if (!exportDateFrom && !exportDateTo) return true;
const d = p.createdAt ? new Date(p.createdAt).toISOString().split('T')[0] : null;
@@ -69,26 +72,24 @@ export default function PassengersPage() {
if (exportDateTo && (!d || d > exportDateTo)) return false;
return true;
});
const csv = [
cols.join(','),
...exportItems.map((passenger: any) => {
const values = cols.map(col => {
switch (col) {
case 'fullName': return passenger.fullName;
case 'email': return passenger.email || '';
case 'phone': return passenger.phone || '';
case 'dateOfBirth': return passenger.dateOfBirth ? formatDate(passenger.dateOfBirth) : '';
case 'gender': return passenger.gender || '';
case 'nationality': return passenger.nationality || '';
case 'verified': return passenger.nationalId ? 'Yes' : 'No';
PASSENGER_COLS.map(c => `"${c.label}"`).join(','),
...exportItems.map((p: any) => {
const values = PASSENGER_COLS.filter(c => cols.includes(c.key)).map(({ key }) => {
switch (key) {
case 'fullName': return p.fullName;
case 'email': return p.email || '';
case 'phone': return p.phone || '';
case 'dateOfBirth': return p.dateOfBirth ? formatDate(p.dateOfBirth) : '';
case 'gender': return p.gender || '';
case 'nationality': return p.nationality || '';
case 'verified': return p.nationalId ? 'Yes' : 'No';
default: return '';
}
});
return values.map(v => `"${v}"`).join(',');
}),
].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -99,65 +100,32 @@ export default function PassengersPage() {
};
const columns = [
{
key: 'fullName',
label: 'Name',
sortable: true,
render: (passenger: any) => (
{
key: 'fullName', label: 'Name', sortable: true,
render: (p: any) => (
<div>
<div className="font-medium">{passenger.fullName}</div>
<div className="text-sm text-muted-foreground">{passenger.email}</div>
<div className="font-medium">{p.fullName}</div>
<div className="text-sm text-muted-foreground">{p.email}</div>
</div>
),
},
{
key: 'phone',
label: 'Phone',
sortable: true,
render: (passenger: any) => passenger.phone,
},
{
key: 'gender',
label: 'Gender',
sortable: true,
render: (passenger: any) => passenger.gender || 'N/A',
},
{
key: 'nationality',
label: 'Nationality',
sortable: true,
render: (passenger: any) => passenger.nationality || 'N/A',
},
{
key: 'dateOfBirth',
label: 'Date of Birth',
sortable: true,
render: (passenger: any) => passenger.dateOfBirth ? formatDate(passenger.dateOfBirth) : 'N/A',
},
{
key: 'verified',
label: 'Status',
render: (passenger: any) => (
<Badge variant="status" status={passenger.nationalId ? 'CONFIRMED' : 'PENDING'}>
{passenger.nationalId ? 'Verified' : 'Unverified'}
{ key: 'phone', label: 'Phone', sortable: true, render: (p: any) => p.phone },
{ key: 'gender', label: 'Gender', sortable: true, render: (p: any) => p.gender || 'N/A' },
{ key: 'nationality', label: 'Nationality', sortable: true, render: (p: any) => p.nationality || 'N/A' },
{ key: 'dateOfBirth', label: 'Date of Birth', sortable: true, render: (p: any) => p.dateOfBirth ? formatDate(p.dateOfBirth) : 'N/A' },
{
key: 'verified', label: 'Status',
render: (p: any) => (
<Badge variant="status" status={p.nationalId ? 'CONFIRMED' : 'PENDING'}>
{p.nationalId ? 'Verified' : 'Unverified'}
</Badge>
),
},
];
const actions = [
{
label: 'View Details',
onClick: (passenger: any) => setSelectedPassenger(passenger),
variant: 'secondary' as const,
icon: Eye,
},
{
label: 'Delete',
onClick: handleDelete,
variant: 'danger' as const,
icon: Trash2,
},
{ label: 'View Details', onClick: (p: any) => setSelectedPassenger(p), variant: 'secondary' as const, icon: Eye },
{ label: 'Delete', onClick: (p: any) => setDeleteConfirm({ isOpen: true, passenger: p }), variant: 'danger' as const, icon: Trash2 },
];
return (
@@ -167,9 +135,7 @@ export default function PassengersPage() {
<h1 className="text-2xl font-bold">Passengers</h1>
<p className="text-muted-foreground">Manage passenger profiles and verification</p>
</div>
<div className="flex gap-2">
<ActionButton variant="export" icon={Download} onClick={() => setExportModalOpen(true)}>Export</ActionButton>
</div>
<ActionButton variant="export" icon={Download} onClick={() => setExportModalOpen(true)}>Export</ActionButton>
</div>
<div className="card">
@@ -180,254 +146,218 @@ export default function PassengersPage() {
)}
<div className="mb-4 flex flex-wrap gap-4">
<div className="flex-1">
<input
type="text"
placeholder="Search by name, email, or phone..."
className="input"
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 1 })}
/>
<input type="text" placeholder="Search by name, email, or phone..." className="input"
value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 1 })} />
</div>
<select
className="input w-48"
value={filters.verified?.toString() || ''}
onChange={(e) => setFilters({ ...filters, verified: e.target.value ? e.target.value === 'true' : undefined, page: 1 })}
>
<select className="input w-48" value={filters.verified?.toString() || ''}
onChange={(e) => setFilters({ ...filters, verified: e.target.value ? e.target.value === 'true' : undefined, page: 1 })}>
<option value="">All Passengers</option>
<option value="true">Verified</option>
<option value="false">Unverified</option>
</select>
</div>
<DataTable
data={data?.items || []}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage="No passengers found"
/>
<DataTable data={data?.items || []} columns={columns} actions={actions} loading={isLoading} emptyMessage="No passengers found" />
{data?.meta && (
<Pagination
currentPage={data.meta.page}
totalPages={data.meta.totalPages}
onPageChange={(page) => setFilters({ ...filters, page })}
/>
<Pagination currentPage={data.meta.page} totalPages={data.meta.totalPages}
onPageChange={(page) => setFilters({ ...filters, page })} />
)}
</div>
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, passenger: null })}
onConfirm={confirmDelete}
onConfirm={async () => {
if (deleteConfirm.passenger) {
await deleteMutation.mutateAsync(deleteConfirm.passenger.id);
setDeleteConfirm({ isOpen: false, passenger: null });
}
}}
title="Delete Passenger"
message={`Are you sure you want to delete ${deleteConfirm.passenger?.fullName}?`}
confirmText="Delete"
isDanger={true}
confirmText="Delete" isDanger
warning="This passenger may have active bookings, loyalty points, and wallet balance. Deleting will impact these systems and records."
/>
{/* Passenger Details Modal */}
<Modal
isOpen={!!selectedPassenger}
onClose={() => setSelectedPassenger(null)}
title="Passenger Details"
size="xl"
>
{selectedPassenger && (
<div className="space-y-6">
{/* Personal Information */}
<Modal isOpen={!!selectedPassenger} onClose={() => setSelectedPassenger(null)} title="Passenger Details" size="xl">
{selectedPassenger && (() => {
const p = selectedPassenger;
const isVerified = !!p.faydaVerified || !!p.nationalId;
const tier = p.passenger?.loyalty?.tier || p.loyalty?.tier;
const tierColor = TIER_COLORS[tier] || TIER_COLORS.BRONZE;
return (
<div>
<h3 className="text-lg font-semibold mb-3">Personal Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Full Name</label>
<p className="text-lg font-semibold">{selectedPassenger.fullName}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Date of Birth</label>
<p className="text-lg font-semibold">
{selectedPassenger.dateOfBirth ? formatDate(selectedPassenger.dateOfBirth) : 'N/A'}
</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Gender</label>
<p className="text-lg font-semibold">{selectedPassenger.gender || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Nationality</label>
<p className="text-lg font-semibold">{selectedPassenger.nationality || 'N/A'}</p>
</div>
</div>
</div>
<hr className="border-muted" />
{/* Contact Information */}
<div>
<h3 className="text-lg font-semibold mb-3">Contact Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Email</label>
<p className="text-lg font-semibold">{selectedPassenger.email || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Phone</label>
<p className="text-lg font-semibold">{selectedPassenger.phone || 'N/A'}</p>
</div>
</div>
</div>
<hr className="border-muted" />
{/* Identification */}
<div>
<h3 className="text-lg font-semibold mb-3">Identification</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Passport Number</label>
<p className="text-lg font-mono font-semibold">{selectedPassenger.passportNumber || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Passport Country</label>
<p className="text-lg font-semibold">{selectedPassenger.passportCountry || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Verification Status</label>
<div className="mt-1">
<Badge
variant="status"
status={selectedPassenger.nationalId ? 'CONFIRMED' : 'PENDING'}
>
{selectedPassenger.nationalId ? 'Verified' : 'Unverified'}
</Badge>
{/* Gradient header with avatar */}
<div className="-mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r from-emerald-600 to-emerald-700 rounded-t-lg">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-full bg-white/20 flex items-center justify-center shrink-0">
<span className="text-white text-2xl font-bold">{(p.fullName || p.email || '?')[0].toUpperCase()}</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-white text-xl font-bold truncate">{p.fullName}</p>
<p className="text-emerald-200 text-sm truncate">{p.email}</p>
</div>
<div className="text-right shrink-0 space-y-1">
<div>
<Badge variant="status" status={isVerified ? 'CONFIRMED' : 'PENDING'}>
{isVerified ? '✓ Verified' : 'Unverified'}
</Badge>
</div>
{tier && (
<span className={`inline-flex items-center gap-1 text-xs font-bold px-2.5 py-0.5 rounded-full border ${tierColor}`}>
<Star className="w-3 h-3" />{tier}
</span>
)}
</div>
</div>
</div>
</div>
<hr className="border-muted" />
{/* Account Information */}
<div>
<h3 className="text-lg font-semibold mb-3">Account Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Passenger ID</label>
<p className="text-sm font-mono">{selectedPassenger.id}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">User ID</label>
<p className="text-sm font-mono">{selectedPassenger.userId || 'N/A'}</p>
</div>
</div>
</div>
{/* Loyalty & Wallet (if available) */}
{(selectedPassenger.loyalty || selectedPassenger.wallet) && (
<>
<hr className="border-muted" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{selectedPassenger.loyalty && (
<div>
<h3 className="text-lg font-semibold mb-2">Loyalty Account</h3>
<div className="space-y-2">
<div>
<label className="text-sm font-medium text-muted-foreground">Tier</label>
<p className="text-lg font-semibold">{selectedPassenger.loyalty.tier || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Points Balance</label>
<p className="text-lg font-semibold">{selectedPassenger.loyalty.pointsBalance || 0}</p>
</div>
</div>
{/* Quick stats */}
<div className="mt-4 grid grid-cols-3 gap-3">
{[
{ label: 'Loyalty Points', value: (p.passenger?.loyalty?.pointsBalance ?? p.loyalty?.pointsBalance ?? 0).toLocaleString() },
{ label: 'Wallet Balance', value: p.passenger?.wallet || p.wallet ? `ETB ${((p.passenger?.wallet?.balanceMinor ?? p.wallet?.balanceMinor ?? 0) / 100).toFixed(2)}` : '—' },
{ label: 'Nationality', value: p.nationality || '—' },
].map(({ label, value }) => (
<div key={label} className="bg-white/10 rounded-lg px-3 py-2">
<p className="text-emerald-200 text-xs">{label}</p>
<p className="text-white text-sm font-bold truncate">{value}</p>
</div>
)}
{selectedPassenger.wallet && (
<div>
<h3 className="text-lg font-semibold mb-2">Wallet</h3>
<div className="space-y-2">
<div>
<label className="text-sm font-medium text-muted-foreground">Balance</label>
<p className="text-lg font-semibold">
{(selectedPassenger.wallet.balanceMinor / 100).toFixed(2)} {selectedPassenger.wallet.currency}
</p>
</div>
</div>
</div>
)}
</div>
</>
)}
<hr className="border-muted" />
{/* Timestamps */}
<div>
<h3 className="text-lg font-semibold mb-3">Timestamps</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Created</label>
<p className="text-sm">{selectedPassenger.createdAt ? formatDateTime(selectedPassenger.createdAt) : 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Last Updated</label>
<p className="text-sm">{selectedPassenger.updatedAt ? formatDateTime(selectedPassenger.updatedAt) : 'N/A'}</p>
))}
</div>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton
variant="secondary"
onClick={() => setSelectedPassenger(null)}
>
Close
</ActionButton>
<div className="space-y-6">
{/* Personal */}
<section>
<SectionHeader title="Personal Information" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Full Name" value={p.fullName} />
<Field label="Date of Birth" value={p.dateOfBirth ? formatDate(p.dateOfBirth) : ''} />
<Field label="Gender" value={p.gender} />
<Field label="Nationality" value={p.nationality} />
<Field label="Nationality Code" value={p.nationalityCode} />
<Field label="Preferred Language" value={p.passenger?.preferredLanguage || p.preferredLanguage} />
<Field label="Last Login" value={p.lastLoginAt ? formatDateTime(p.lastLoginAt) : 'Never'} />
<Field label="Role" value={p.role} />
</div>
</section>
{/* Contact */}
<section>
<SectionHeader title="Contact Information" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Email" value={p.email} />
<Field label="Phone" value={p.phone} />
<Field label="Address" value={p.address} />
</div>
</section>
{/* Identification */}
<section>
<SectionHeader title="Identification & Verification" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="bg-muted/40 rounded-lg p-3 col-span-2 md:col-span-1">
<p className="text-xs text-muted-foreground mb-2">Fayda (National ID)</p>
<div className="flex items-center gap-2">
{isVerified
? <ShieldCheck className="w-4 h-4 text-emerald-600 dark:text-emerald-400 shrink-0" />
: <ShieldOff className="w-4 h-4 text-muted-foreground shrink-0" />}
<span className={`text-sm font-semibold ${isVerified ? 'text-emerald-700 dark:text-emerald-400' : 'text-muted-foreground'}`}>
{isVerified ? 'Verified' : 'Not verified'}
</span>
</div>
{p.faydaVerifiedAt && <p className="text-xs text-muted-foreground mt-1">{formatDateTime(p.faydaVerifiedAt)}</p>}
</div>
<Field label="Passport Number" value={p.passportNumber} mono />
<Field label="Passport Country" value={p.passportCountry} />
<Field label="Passport Expiry" value={p.passportExpiryDate ? formatDate(p.passportExpiryDate) : ''} />
</div>
</section>
{/* Loyalty & Wallet */}
{(p.passenger?.loyalty || p.loyalty || p.passenger?.wallet || p.wallet) && (
<section>
<SectionHeader title="Loyalty & Wallet" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{(p.passenger?.loyalty || p.loyalty) && (() => {
const loyalty = p.passenger?.loyalty || p.loyalty;
return (
<>
<div className={`rounded-lg p-3 border ${tierColor}`}>
<p className="text-xs font-medium mb-1 opacity-70">Tier</p>
<div className="flex items-center gap-1.5">
<Star className="w-4 h-4" />
<span className="text-sm font-bold">{loyalty.tier}</span>
</div>
</div>
<Field label="Points Balance" value={(loyalty.pointsBalance ?? 0).toLocaleString()} />
<Field label="Lifetime Points" value={(loyalty.lifetimePoints ?? 0).toLocaleString()} />
</>
);
})()}
{(p.passenger?.wallet || p.wallet) && (() => {
const wallet = p.passenger?.wallet || p.wallet;
return (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800 rounded-lg p-3">
<p className="text-xs text-blue-600 dark:text-blue-400 mb-1 flex items-center gap-1"><Wallet className="w-3 h-3" />Wallet Balance</p>
<p className="text-base font-bold text-blue-800 dark:text-blue-300">
ETB {((wallet.balanceMinor ?? 0) / 100).toFixed(2)}
</p>
</div>
);
})()}
</div>
</section>
)}
{/* Account */}
<section>
<SectionHeader title="Account IDs" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Field label="User ID" value={p.id || p.userId} mono truncate />
<Field label="Passenger ID" value={p.passenger?.id || p.passengerId} mono truncate />
</div>
</section>
{/* Timestamps */}
<section>
<SectionHeader title="Timestamps" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Registered" value={p.createdAt ? formatDateTime(p.createdAt) : ''} />
<Field label="Last Updated" value={p.updatedAt ? formatDateTime(p.updatedAt) : ''} />
<Field label="Fayda Verified At" value={p.faydaVerifiedAt ? formatDateTime(p.faydaVerifiedAt) : 'N/A'} />
</div>
</section>
</div>
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
<ActionButton variant="secondary" onClick={() => setSelectedPassenger(null)}>Close</ActionButton>
</div>
</div>
</div>
)}
);
})()}
</Modal>
{/* Export Modal */}
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Passengers" size="md">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Date From (Registered)</label>
<input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} />
</div>
<div>
<label className="label">Date To (Registered)</label>
<input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} />
</div>
<div><label className="label">Date From (Registered)</label><input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} /></div>
<div><label className="label">Date To (Registered)</label><input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} /></div>
</div>
<div>
<p className="text-sm font-medium mb-2">Select Columns</p>
<div className="space-y-2 max-h-56 overflow-y-auto">
{[
{ key: 'fullName', label: 'Full Name' },
{ key: 'email', label: 'Email' },
{ key: 'phone', label: 'Phone' },
{ key: 'dateOfBirth', label: 'Date of Birth' },
{ key: 'gender', label: 'Gender' },
{ key: 'nationality', label: 'Nationality' },
{ key: 'verified', label: 'Verified' },
].map((col) => (
{PASSENGER_COLS.map((col) => (
<label key={col.key} className="flex items-center gap-3 p-2 hover:bg-gray-50 dark:hover:bg-gray-900/50 rounded cursor-pointer">
<input
type="checkbox"
checked={exportColumns[col.key] || false}
<input type="checkbox" checked={exportColumns[col.key] || false}
onChange={(e) => setExportColumns({ ...exportColumns, [col.key]: e.target.checked })}
className="w-4 h-4 rounded border-gray-300"
/>
className="w-4 h-4 rounded border-gray-300" />
<span className="text-sm font-medium">{col.label}</span>
</label>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
<ActionButton onClick={confirmExportPassengers}>Export CSV</ActionButton>

View File

@@ -28,6 +28,15 @@ export default function PaymentsPage() {
}),
});
const PAYMENT_COLS = [
{ key: 'reference', label: 'Reference' },
{ key: 'booking', label: 'Booking Reference' },
{ key: 'amount', label: 'Amount' },
{ key: 'method', label: 'Payment Method' },
{ key: 'status', label: 'Status' },
{ key: 'createdAt', label: 'Created At' },
];
const confirmExport = () => {
const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k);
if (cols.length === 0) { alert('Please select at least one column'); return; }
@@ -42,16 +51,16 @@ export default function PaymentsPage() {
});
const csv = [
cols.join(','),
PAYMENT_COLS.map(c => `"${c.label}"`).join(','),
...exportItems.map((payment: any) => {
const values = cols.map(col => {
switch (col) {
const values = PAYMENT_COLS.filter(c => cols.includes(c.key)).map(({ key }) => {
switch (key) {
case 'reference': return payment.reference || payment.id?.substring(0, 8) || '';
case 'booking': return payment.booking?.bookingRef || 'N/A';
case 'amount': return formatCurrency(payment.amountMinor, payment.currency);
case 'method': return payment.method || '';
case 'status': return payment.status || '';
case 'createdAt': return payment.createdAt || '';
case 'booking': return payment.booking?.bookingRef || 'N/A';
case 'amount': return formatCurrency(payment.amountMinor, payment.currency);
case 'method': return payment.method || '';
case 'status': return payment.status || '';
case 'createdAt': return payment.createdAt ? new Date(payment.createdAt).toLocaleString() : '';
default: return '';
}
});
@@ -84,7 +93,7 @@ export default function PaymentsPage() {
<h1 className="text-2xl font-bold text-foreground">Payments</h1>
<p className="text-muted-foreground">Manage payment transactions and refunds</p>
</div>
<ActionButton icon={Download} variant="secondary" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
<ActionButton icon={Download} variant="export" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
</div>
<div className="card">

View File

@@ -239,9 +239,9 @@ export default function ReportsPage() {
<Pie
data={[
{ name: 'Confirmed', value: bookings.filter((b: any) => b.status === 'CONFIRMED').length },
{ name: 'Completed', value: bookings.filter((b: any) => b.status === 'COMPLETED').length },
{ name: 'Completed', value: bookings.filter((b: any) => b.status === 'BOARDED').length },
{ name: 'Cancelled', value: bookings.filter((b: any) => b.status === 'CANCELLED').length },
{ name: 'Other', value: bookings.filter((b: any) => !['CONFIRMED', 'COMPLETED', 'CANCELLED'].includes(b.status)).length },
{ name: 'Other', value: bookings.filter((b: any) => !['CONFIRMED', 'BOARDED', 'CANCELLED'].includes(b.status)).length },
].filter(d => d.value > 0)}
cx="50%"
cy="50%"
@@ -306,7 +306,7 @@ export default function ReportsPage() {
</div>
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<p className="text-sm text-muted-foreground">Completed Bookings</p>
<p className="text-xl font-bold mt-2">{bookings.filter((b: any) => b.status === 'COMPLETED').length}</p>
<p className="text-xl font-bold mt-2">{bookings.filter((b: any) => b.status === 'BOARDED').length}</p>
</div>
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<p className="text-sm text-muted-foreground">Cancelled Bookings</p>

View File

@@ -563,7 +563,7 @@ export default function SchedulesPage() {
<option value="">Select Train</option>
{trains.map((train: Train) => (
<option key={train.id} value={train.id}>
{train.name} ({train.number})
{train.number} ({train.name})
</option>
))}
</select>
@@ -580,7 +580,7 @@ export default function SchedulesPage() {
<option value="">Select Route</option>
{routes.map((route: Route) => (
<option key={route.id} value={route.id}>
{route.name} ({route.code})
{route.code} ({route.name})
</option>
))}
</select>

View File

@@ -443,13 +443,12 @@ export default function SeatsPage() {
className="input"
>
<option value="">Select a schedule...</option>
{schedules.map((schedule: any) => {
const trainNumber = schedule.train?.trainNumber || schedule.train?.name || 'N/A';
{schedules.map((schedule: any) => {
const routeName = schedule.route?.name || 'N/A';
const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A';
return (
<option key={schedule.id} value={schedule.id}>
{trainNumber} - {routeName} - {date}
{date} - {routeName}
</option>
);
})}

View File

@@ -72,8 +72,8 @@ export default function StationsPage() {
name: formData.get('name') as string,
city: formData.get('city') as string,
countryCode: formData.get('countryCode') as string,
lat: parseFloat(formData.get('lat') as string) || null,
lng: parseFloat(formData.get('lng') as string) || null,
lat: parseFloat(formData.get('lat') as string) || undefined,
lng: parseFloat(formData.get('lng') as string) || undefined,
timezone: formData.get('timezone') as string,
sequence,
isOperational: formData.get('isOperational') === 'true',
@@ -304,28 +304,6 @@ export default function StationsPage() {
<option value="DJ">Djibouti (DJ)</option>
</select>
</div>
<div>
<label className="label">Latitude</label>
<input
type="number"
name="lat"
className="input"
defaultValue={editingStation?.lat}
step="0.0001"
placeholder="e.g., 9.0320"
/>
</div>
<div>
<label className="label">Longitude</label>
<input
type="number"
name="lng"
className="input"
defaultValue={editingStation?.lng}
step="0.0001"
placeholder="e.g., 38.7469"
/>
</div>
<div>
<label className="label">Timezone *</label>
<select

View File

@@ -2,7 +2,7 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { LogIn, Trash2 } from 'lucide-react';
import { LogIn, ListCollapse, Trash2, Printer } from 'lucide-react';
import { Download } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
@@ -10,7 +10,7 @@ import ActionButton from '@/components/ui/ActionButton';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import Modal from '@/components/ui/Modal';
import { ticketsApi, apiClient, stationsApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
import { formatDateTime, formatCurrency, formatDateTimeShort } from '@/lib/utils';
export default function TicketsPage() {
const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', arrivalDate: '' });
@@ -18,6 +18,8 @@ export default function TicketsPage() {
const [ticketToDelete, setTicketToDelete] = useState<any>(null);
const [boardConfirmOpen, setBoardConfirmOpen] = useState(false);
const [ticketToBoard, setTicketToBoard] = useState<any>(null);
const [printBpModalOpen, setPrintBpModalOpen] = useState(false);
const [ticketToPrint, setTicketToPrint] = useState<any>(null);
const [successMessage, setSuccessMessage] = useState('');
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
const [selectedTicket, setSelectedTicket] = useState<any>(null);
@@ -88,9 +90,75 @@ export default function TicketsPage() {
};
const handleConfirmBoard = async () => {
if (ticketToBoard) {
await boardMutation.mutateAsync({ ticketId: ticketToBoard.id });
}
if (!ticketToBoard) return;
await boardMutation.mutateAsync({ ticketId: ticketToBoard.id });
printBoardingPass(ticketToBoard, 'outbound');
};
const printBoardingPass = (ticket: any, leg: 'outbound' | 'inbound' = 'outbound') => {
const w = window.open('', '_blank', 'width=520,height=460');
if (!w) return;
const isInbound = leg === 'inbound';
const origin = isInbound
? (ticket.booking?.returnOriginStation?.name || ticket.schedule?.destinationStation?.name || 'N/A')
: (ticket.schedule?.originStation?.name || 'N/A');
const dest = isInbound
? (ticket.booking?.returnDestinationStation?.name || ticket.schedule?.originStation?.name || 'N/A')
: (ticket.schedule?.destinationStation?.name || 'N/A');
const date = isInbound
? (ticket.booking?.returnBoardedAt ? new Date(ticket.booking.returnBoardedAt).toLocaleString() : 'N/A')
: (ticket.schedule?.departureAt ? new Date(ticket.schedule.departureAt).toLocaleString() : 'N/A');
const seat = ticket.seat?.seatNumber || 'N/A';
const coach = ticket.seat?.coach?.number || 'N/A';
const bookingRef = ticket.booking?.bookingRef || 'N/A';
const ticketNum = ticket.ticketNumber || 'N/A';
const passenger = ticket.booking?.passenger?.fullName || ticket.booking?.contactEmail || 'Guest';
w.document.write(
'<!DOCTYPE html><html><head><meta charset="utf-8"/><title>Boarding Pass</title><style>' +
'*{box-sizing:border-box;margin:0;padding:0}' +
'body{font-family:"Segoe UI",sans-serif;background:#f0fdf4;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:24px}' +
'.pass{background:#fff;border-radius:16px;overflow:hidden;box-shadow:0 8px 32px rgba(0,0,0,.12);width:460px}' +
'.header{background:linear-gradient(135deg,#10b981,#059669);color:#fff;padding:24px 28px 20px}' +
'.header-top{display:flex;justify-content:space-between;align-items:center;margin-bottom:4px}' +
'.airline{font-size:12px;letter-spacing:2px;text-transform:uppercase;opacity:.85}' +
'.badge{background:rgba(255,255,255,.2);border-radius:20px;padding:3px 12px;font-size:11px;letter-spacing:1px}' +
'.route{display:flex;align-items:center;gap:8px;margin-top:14px}' +
'.city{font-size:24px;font-weight:700}' +
'.arrow{font-size:20px;opacity:.7;flex:1;text-align:center}' +
'.body{padding:24px 28px}' +
'.grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}' +
'.field label{font-size:10px;text-transform:uppercase;letter-spacing:1px;color:#6b7280;font-weight:600}' +
'.field p{font-size:14px;font-weight:600;color:#111827;margin-top:3px}' +
'.divider{border:none;border-top:2px dashed #d1fae5;margin:20px 0}' +
'.footer{display:flex;justify-content:space-between;align-items:center}' +
'.seat-box{background:#f0fdf4;border:2px solid #10b981;border-radius:10px;padding:8px 20px;text-align:center}' +
'.seat-box label{font-size:10px;letter-spacing:1px;text-transform:uppercase;color:#059669;font-weight:700}' +
'.seat-box p{font-size:28px;font-weight:800;color:#065f46}' +
'@media print{body{background:#fff}.pass{box-shadow:none}}' +
'</style></head><body>' +
'<div class="pass">' +
'<div class="header">' +
'<div class="header-top"><span class="airline">EDR &mdash; Ethio-Djibouti Railway</span><span class="badge">BOARDING PASS</span></div>' +
'<div class="route"><span class="city">' + origin + '</span><span class="arrow">&#129122;</span><span class="city">' + dest + '</span></div>' +
'</div>' +
'<div class="body">' +
'<div class="grid">' +
'<div class="field"><label>Booking Ref</label><p>' + bookingRef + '</p></div>' +
'<div class="field"><label>Ticket No.</label><p>' + ticketNum + '</p></div>' +
'<div class="field"><label>Date &amp; Time</label><p>' + date + '</p></div>' +
'<div class="field"><label>Coach</label><p>' + coach + '</p></div>' +
'</div>' +
'<hr class="divider"/>' +
'<div class="footer">' +
'<div class="field"><label>Passenger</label><p>' + passenger + '</p></div>' +
'<div class="seat-box"><label>Seat</label><p>' + seat + '</p></div>' +
'</div>' +
'</div>' +
'</div>' +
'<script>window.onload=function(){window.print();window.onafterprint=function(){window.close()};}<\/script>' +
'</body></html>'
);
w.document.close();
};
const handleDeleteClick = (ticket: any) => {
@@ -104,6 +172,19 @@ export default function TicketsPage() {
}
};
const TICKET_COLS = [
{ key: 'ticketNumber', label: 'Ticket Number' },
{ key: 'booking', label: 'Booking Reference' },
{ key: 'passenger', label: 'Passenger Name' },
{ key: 'trip', label: 'Trip (Origin - Destination)' },
{ key: 'coach', label: 'Coach Number' },
{ key: 'seat', label: 'Seat Number' },
{ key: 'seatClass', label: 'Seat Class' },
{ key: 'amount', label: 'Amount' },
{ key: 'status', label: 'Status' },
{ key: 'boarded', label: 'Boarded' },
];
const confirmExport = () => {
const cols = Object.entries(selectedColumns)
.filter(([, selected]) => selected)
@@ -125,19 +206,20 @@ export default function TicketsPage() {
});
const csv = [
cols.join(','),
TICKET_COLS.map(c => `"${c.label}"`).join(','),
...exportItems.map((ticket: any) => {
const values = cols.map(col => {
switch (col) {
case 'ticketNumber': return ticket.ticketNumber || '';
case 'booking': return ticket.booking?.bookingRef || '';
case 'trip': return `${ticket.schedule?.originStation?.name || ''}-${ticket.schedule?.destinationStation?.name || ''}`;
case 'coach': return ticket.seat?.coach?.number || '';
case 'seat': return ticket.seat?.seatNumber || '';
case 'seatClass': return ticket.seat?.coach?.coachType?.name || '';
case 'amount': return formatCurrency((ticket.booking?.totalMinor || 0), ticket.booking?.currency || 'ETB');
case 'status': return ticket.status || '';
case 'boarded': return ticket.boardedAt ? 'Yes' : 'No';
const values = TICKET_COLS.filter(c => cols.includes(c.key)).map(({ key }) => {
switch (key) {
case 'ticketNumber': return ticket.ticketNumber || 'N/A';
case 'booking': return ticket.booking?.bookingRef || 'N/A';
case 'passenger': return ticket.booking?.passenger?.fullName || ticket.booking?.contactEmail || 'N/A';
case 'trip': return `${ticket.schedule?.originStation?.name || 'N/A'} - ${ticket.schedule?.destinationStation?.name || 'N/A'}`;
case 'coach': return ticket.seat?.coach?.number || 'N/A';
case 'seat': return ticket.seat?.seatNumber || 'N/A';
case 'seatClass': return ticket.seat?.coach?.coachType?.type || 'N/A';
case 'amount': return formatCurrency((ticket.booking?.totalMinor || 0), ticket.booking?.currency || 'ETB');
case 'status': return ticket.status || 'N/A';
case 'boarded': return ticket.boardedAt ? 'Yes' : 'No';
default: return '';
}
});
@@ -160,17 +242,22 @@ export default function TicketsPage() {
label: 'Ticket Number',
sortable: true,
render: (ticket: any) => (
<span className="font-mono font-semibold">{ticket.ticketNumber || 'N/A'}</span>
<div>
<div className="font-mono font-semibold">{ticket.ticketNumber || 'N/A'}</div>
<div className="text-sm text-muted-foreground">
{ticket.booking?.passenger?.fullName || 'N/A'}
</div>
</div>
),
},
{
key: 'booking',
label: 'Booking',
key: 'contact',
label: 'Contact',
render: (ticket: any) => (
<div>
<div className="font-medium">{ticket.booking?.bookingRef || 'N/A'}</div>
<div className="font-medium">{ticket.booking?.contactPhone || ticket.booking?.passenger?.phone || 'N/A'}</div>
<div className="text-sm text-muted-foreground">
{ticket.booking?.passenger?.fullName || ticket.booking?.contactEmail || 'N/A'}
<div className="text-xs text-muted-foreground">{ticket.booking?.contactEmail || ticket.booking?.passenger?.email || 'N/A'}</div>
</div>
</div>
),
@@ -178,16 +265,23 @@ export default function TicketsPage() {
{
key: 'trip',
label: 'Trip',
render: (ticket: any) => (
<div>
<div className="font-medium">
{ticket.schedule?.originStation?.name || 'N/A'} {ticket.schedule?.destinationStation?.name || 'N/A'}
render: (ticket: any) => {
const isRoundTrip = ticket.booking?.bookingType === 'ROUND_TRIP' || ticket.booking?.bookingType === 'ROUND_TRIP_TRANSIT';
const returnArrivalAt = ticket.booking?.returnSchedule?.arrivalAt;
return (
<div>
<div className="font-medium">
{ticket.schedule?.originStation?.name || 'N/A'} {ticket.schedule?.destinationStation?.name || 'N/A'}
</div>
<div className="text-xs text-muted-foreground">
{ticket.schedule?.departureAt ? formatDateTimeShort(ticket.schedule.departureAt) : 'N/A'}
{isRoundTrip && (
<span> {returnArrivalAt ? formatDateTimeShort(returnArrivalAt) : 'N/A'}</span>
)}
</div>
</div>
<div className="text-sm text-muted-foreground">
{ticket.schedule?.departureAt ? formatDateTime(ticket.schedule.departureAt) : 'N/A'}
</div>
</div>
),
);
},
},
{
key: 'seat',
@@ -195,54 +289,29 @@ export default function TicketsPage() {
sortable: true,
render: (ticket: any) => (
<div>
<div className="font-mono font-semibold">{ticket.seat?.coach?.number || 'N/A'} - {ticket.seat?.seatNumber || 'N/A'}</div>
<div className="font-mono font-semibold">{ticket.seat?.coach?.number || 'N/A'}: {ticket.seat?.seatNumber || 'N/A'}</div>
<div className="text-xs text-muted-foreground">{ticket.seat?.coach?.coachType?.type || 'N/A'}</div>
</div>
),
},
{
key: 'amount',
label: 'Amount',
sortable: true,
render: (ticket: any) => formatCurrency(ticket.booking?.totalMinor || 0, ticket.booking?.currency || 'ETB'),
},
{
key: 'status',
label: 'Status',
sortable: true,
render: (ticket: any) => (
<Badge variant="status" status={ticket.status || 'ACTIVE'}>
{ticket.status || 'ACTIVE'}
</Badge>
),
},
{
key: 'boarded',
label: 'Boarded',
render: (ticket: any) => (
ticket.validatedAt ? (
<div className="flex items-center gap-1 text-green-600 dark:text-green-400">
<span className="text-sm">{formatDateTime(ticket.validatedAt)}</span>
</div>
) : (
<span className="text-sm text-muted-foreground">Not boarded</span>
)
),
},
{
key: 'returnLegStatus',
label: 'Return Leg',
key: 'boardingTimes',
label: 'Boarding Times',
render: (ticket: any) => {
const status = ticket.booking?.returnLegStatus;
if (!status || status === 'NOT_APPLICABLE') return <span className="text-xs text-muted-foreground"></span>;
const map: Record<string, { label: string; cls: string }> = {
NEITHER_USED: { label: 'Neither Used', cls: 'edr-badge-warning' },
OUTBOUND_ONLY: { label: 'Outbound Only', cls: 'edr-badge-info' },
INBOUND_ONLY: { label: 'Inbound Only', cls: 'edr-badge-danger' },
BOTH_USED: { label: 'Both Used', cls: 'edr-badge-success' },
};
const entry = map[status] ?? { label: status, cls: 'edr-badge-info' };
return <span className={`edr-badge ${entry.cls}`}>{entry.label}</span>;
const outbound = ticket.booking?.outboundBoardedAt;
const inbound = ticket.booking?.returnBoardedAt;
const hasAny = outbound || inbound;
if (!hasAny) return <span className="text-sm text-muted-foreground">Not boarded</span>;
return (
<div className="flex flex-col gap-0.5 text-sm">
<span className={outbound ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'}>
{outbound ? formatDateTimeShort(outbound) : 'Not boarded'}
</span>
<span className={inbound ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'}>
{inbound ? formatDateTimeShort(inbound) : 'Not boarded'}
</span>
</div>
);
},
},
];
@@ -253,7 +322,25 @@ export default function TicketsPage() {
onClick: handleBoard,
variant: 'primary' as const,
icon: LogIn,
show: (ticket: any) => ticket.status !== 'USED' && !ticket.boardedAt,
show: (ticket: any) => {
const isRoundTrip = ticket.booking?.bookingType === 'ROUND_TRIP' || ticket.booking?.bookingType === 'ROUND_TRIP_TRANSIT';
if (isRoundTrip) {
const inboundBoarded = !!ticket.booking?.returnBoardedAt;
const returnLegStatus = ticket.booking?.returnLegStatus;
return !ticket.validatedAt || (!inboundBoarded && returnLegStatus !== 'BOTH_USED');
}
return !ticket.validatedAt;
},
},
{
label: 'Print BP',
onClick: (ticket: any) => {
setTicketToPrint(ticket);
setPrintBpModalOpen(true);
},
variant: 'secondary' as const,
icon: Printer,
show: (ticket: any) => !!ticket.validatedAt || !!ticket.booking?.outboundBoardedAt || !!ticket.booking?.returnBoardedAt,
},
{
label: 'Details',
@@ -262,6 +349,7 @@ export default function TicketsPage() {
setDetailsModalOpen(true);
},
variant: 'secondary' as const,
icon: ListCollapse,
},
{
label: 'Delete',
@@ -280,7 +368,7 @@ export default function TicketsPage() {
<h1 className="text-2xl font-bold text-foreground">Tickets</h1>
<p className="text-muted-foreground">Manage tickets and validations</p>
</div>
<ActionButton icon={Download} variant="secondary" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
<ActionButton icon={Download} variant="export" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
</div>
{/* Filters */}
@@ -366,17 +454,67 @@ export default function TicketsPage() {
emptyMessage="No tickets found"
/>
{/* Board Confirmation Dialog */}
<ConfirmDialog
{/* Board Confirmation Modal */}
<Modal
isOpen={boardConfirmOpen}
onClose={() => { setBoardConfirmOpen(false); setTicketToBoard(null); }}
onConfirm={handleConfirmBoard}
title="Board Ticket"
message={`Are you sure you want to board ticket ${ticketToBoard?.ticketNumber}? This will mark the ticket as USED.`}
confirmText="Board"
cancelText="Cancel"
isLoading={boardMutation.isPending}
/>
size="sm"
>
<div className="space-y-4">
<div className="space-y-1">
<p className="font-medium">Are you sure you want to board ticket {ticketToBoard?.ticketNumber}?</p>
<p className="text-sm text-muted-foreground">This will mark the ticket as USED.</p>
</div>
<div className="flex justify-end gap-2 pt-2">
<ActionButton variant="secondary" onClick={() => { setBoardConfirmOpen(false); setTicketToBoard(null); }}>Cancel</ActionButton>
<ActionButton icon={LogIn} loading={boardMutation.isPending} onClick={handleConfirmBoard}>Board and Print</ActionButton>
</div>
</div>
</Modal>
{/* Print BP Modal */}
<Modal
isOpen={printBpModalOpen}
onClose={() => { setPrintBpModalOpen(false); setTicketToPrint(null); }}
title="Print Boarding Pass"
size="sm"
>
<div className="space-y-4">
{(() => {
const isRoundTrip = ticketToPrint?.booking?.bookingType === 'ROUND_TRIP' || ticketToPrint?.booking?.bookingType === 'ROUND_TRIP_TRANSIT';
const outboundBoarded = !!ticketToPrint?.booking?.outboundBoardedAt || !!ticketToPrint?.validatedAt;
const inboundBoarded = !!ticketToPrint?.booking?.returnBoardedAt;
if (isRoundTrip && outboundBoarded && inboundBoarded) {
return (
<>
<p className="text-sm text-muted-foreground">Select which leg to print:</p>
<div className="flex flex-col gap-2">
<ActionButton icon={Printer} onClick={() => { printBoardingPass(ticketToPrint, 'outbound'); setPrintBpModalOpen(false); }}>
Outbound
</ActionButton>
<ActionButton icon={Printer} variant="secondary" onClick={() => { printBoardingPass(ticketToPrint, 'inbound'); setPrintBpModalOpen(false); }}>
Inbound (Return)
</ActionButton>
</div>
</>
);
}
const leg = isRoundTrip && inboundBoarded && !outboundBoarded ? 'inbound' : 'outbound';
return (
<>
<p className="text-sm text-muted-foreground">
Print {leg === 'inbound' ? 'inbound (return)' : 'outbound'} boarding pass for ticket {ticketToPrint?.ticketNumber}?
</p>
<div className="flex justify-end gap-2 pt-2">
<ActionButton variant="secondary" onClick={() => { setPrintBpModalOpen(false); setTicketToPrint(null); }}>Cancel</ActionButton>
<ActionButton icon={Printer} onClick={() => { printBoardingPass(ticketToPrint, leg); setPrintBpModalOpen(false); }}>Print</ActionButton>
</div>
</>
);
})()}
</div>
</Modal>
{/* Delete Confirmation Dialog */}
<ConfirmDialog

View File

@@ -22,6 +22,13 @@ export const formatDateTime = (date?: string | Date | null): string => {
return format(d, 'MMM dd, yyyy HH:mm');
};
export const formatDateTimeShort = (date?: string | Date | null): string => {
if (!date) return 'N/A';
const d = new Date(date);
if (isNaN(d.getTime())) return 'N/A';
return format(d, 'dd MMM yy HH:mm');
};
export const formatDateTimeLocal = (date?: string | Date | null): string => {
if (!date) return 'N/A';
const d = new Date(date);
@@ -34,7 +41,7 @@ export const getStatusColor = (status: string): string => {
CONFIRMED: 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400',
PENDING: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400',
CANCELLED: 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400',
COMPLETED: 'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400',
BOARDED: 'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400',
PAID: 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400',
FAILED: 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400',
REFUNDED: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300',

View File

@@ -7,7 +7,7 @@ export interface Booking {
passengerId: string;
passenger?: Passenger.IPassenger;
scheduleId: string;
status: 'DRAFT' | 'PENDING_PAYMENT' | 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW' | 'REFUNDED';
status: 'DRAFT' | 'PENDING_PAYMENT' | 'CONFIRMED' | 'CANCELLED' | 'BOARDED' | 'NO_SHOW' | 'REFUNDED';
currency: string;
totalMinor: number;
adultCount: number;
@@ -33,8 +33,8 @@ export interface Station {
countryCode?: string;
isOperational: boolean;
timezone: string;
lat: number;
lng: number;
lat?: number;
lng?: number;
createdAt: string;
}

View File

@@ -6,7 +6,7 @@ export interface Booking {
passengerId: string;
passenger?: Passenger.IPassenger;
tripId: string;
status: 'PENDING' | 'CONFIRMED' | 'CANCELLED' | 'COMPLETED';
status: 'PENDING' | 'CONFIRMED' | 'CANCELLED' | 'BOARDED';
totalAmount: number;
currency: string;
paymentStatus: Passenger.PaymentStatus;