mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +00:00
Merge branch 'alpha' into passenger/feat/iam
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 — Ethio-Djibouti Railway</span><span class="badge">BOARDING PASS</span></div>' +
|
||||
'<div class="route"><span class="city">' + origin + '</span><span class="arrow">🡢</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 & 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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user