Updated to address requests and UAT feedback

This commit is contained in:
Stephanos A
2026-06-17 22:23:24 +03:00
parent b3904caacb
commit a25ee13339
9 changed files with 450 additions and 211 deletions

View File

@@ -24,6 +24,19 @@ export default function BookingsPage() {
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [bookingToDelete, setBookingToDelete] = useState<any>(null);
const [successMessage, setSuccessMessage] = useState('');
const [exportModalOpen, setExportModalOpen] = useState(false);
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,
});
const queryClient = useQueryClient();
@@ -80,22 +93,23 @@ export default function BookingsPage() {
}
};
const handleExportBookings = async () => {
const selectedColumns = prompt(
'Select columns to export (comma-separated):\n\n' +
'Available: bookingRef, passenger, status, bookingType, passengerCount, totalMinor, paymentStatus, createdAt\n\n' +
'Default: bookingRef, passenger, status, totalMinor, paymentStatus, createdAt',
'bookingRef, passenger, status, totalMinor, paymentStatus, createdAt'
);
if (!selectedColumns) return;
const cols = selectedColumns.split(',').map(c => c.trim());
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; }
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;
if (exportDateFrom && (!d || d < exportDateFrom)) return false;
if (exportDateTo && (!d || d > exportDateTo)) return false;
return true;
});
const csv = [
cols.join(','),
...data?.items?.map((booking: any) => {
...exportItems.map((booking: any) => {
const values = cols.map(col => {
switch(col) {
switch (col) {
case 'bookingRef': return booking.bookingRef;
case 'passenger': return booking.passenger?.fullName || booking.contactEmail || 'Guest';
case 'status': return booking.status;
@@ -108,28 +122,29 @@ export default function BookingsPage() {
}
});
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');
a.href = url;
a.download = `bookings-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
setExportModalOpen(false);
};
const columns = [
{
key: 'bookingRef',
{
key: 'bookingRef',
label: 'Reference',
sortable: true,
render: (booking: any) => (
<span className="font-mono font-semibold">{booking.bookingRef}</span>
),
},
{
key: 'passenger',
{
key: 'passenger',
label: 'Passenger',
render: (booking: any) => (
<div>
@@ -138,32 +153,39 @@ export default function BookingsPage() {
</div>
),
},
{
key: 'bookingType',
{
key: 'bookingType',
label: 'Type',
sortable: true,
render: (booking: any) => booking.bookingType || 'ONE_WAY',
},
{
{
key: 'passengerCount',
label: 'Passengers',
render: (booking: any) => `${(booking.adultCount || 0) + (booking.childCount || 0)}`,
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(' / ');
},
},
{
key: 'status',
{
key: 'status',
label: 'Status',
render: (booking: any) => (
<Badge variant="status" status={booking.status}>{booking.status}</Badge>
),
},
{
key: 'totalMinor',
{
key: 'totalMinor',
label: 'Amount',
sortable: true,
render: (booking: any) => formatCurrency(booking.totalMinor, booking.currency),
},
{
key: 'paymentStatus',
{
key: 'paymentStatus',
label: 'Payment',
render: (booking: any) => (
<Badge variant="status" status={booking.paymentIntent?.status || 'PENDING'}>
@@ -171,8 +193,8 @@ export default function BookingsPage() {
</Badge>
),
},
{
key: 'createdAt',
{
key: 'createdAt',
label: 'Created',
sortable: true,
render: (booking: any) => formatDateTime(booking.createdAt),
@@ -208,7 +230,7 @@ export default function BookingsPage() {
<h1 className="text-2xl font-bold">Bookings</h1>
<p className="text-muted-foreground">Manage all passenger bookings</p>
</div>
<ActionButton variant="export" icon={Download} onClick={handleExportBookings}>Export</ActionButton>
<ActionButton variant="export" icon={Download} onClick={() => setExportModalOpen(true)}>Export</ActionButton>
</div>
<div className="card">
@@ -253,7 +275,7 @@ export default function BookingsPage() {
loading={isLoading}
emptyMessage="No bookings found"
/>
{data?.meta && (
<Pagination
currentPage={data.meta.page}
@@ -264,15 +286,9 @@ export default function BookingsPage() {
</div>
{/* Booking Details Modal */}
<Modal
isOpen={!!selectedBooking}
onClose={() => setSelectedBooking(null)}
title="Booking Details"
size="xl"
>
<Modal isOpen={!!selectedBooking} onClose={() => setSelectedBooking(null)} title="Booking Details" size="xl">
{selectedBooking && (
<div className="space-y-6">
{/* Booking Information */}
<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>
@@ -281,9 +297,7 @@ export default function BookingsPage() {
<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>
<Badge variant="status" status={selectedBooking.status}>{selectedBooking.status}</Badge>
</div>
</div>
<div>
@@ -298,7 +312,6 @@ export default function BookingsPage() {
<hr className="border-muted" />
{/* Passenger Information */}
<div>
<h3 className="text-lg font-semibold mb-3">Passenger Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -323,7 +336,6 @@ export default function BookingsPage() {
<hr className="border-muted" />
{/* Booking Details */}
<div>
<h3 className="text-lg font-semibold mb-3">Journey Details</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -348,7 +360,6 @@ export default function BookingsPage() {
<hr className="border-muted" />
{/* Payment Information */}
<div>
<h3 className="text-lg font-semibold mb-3">Payment Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -377,7 +388,6 @@ export default function BookingsPage() {
<hr className="border-muted" />
{/* Additional Information */}
<div>
<h3 className="text-lg font-semibold mb-3">Additional Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -393,12 +403,7 @@ export default function BookingsPage() {
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton
variant="secondary"
onClick={() => setSelectedBooking(null)}
>
Close
</ActionButton>
<ActionButton variant="secondary" onClick={() => setSelectedBooking(null)}>Close</ActionButton>
</div>
</div>
)}
@@ -407,10 +412,7 @@ export default function BookingsPage() {
{/* Delete Confirmation Dialog */}
<ConfirmDialog
isOpen={deleteConfirmOpen}
onClose={() => {
setDeleteConfirmOpen(false);
setBookingToDelete(null);
}}
onClose={() => { setDeleteConfirmOpen(false); setBookingToDelete(null); }}
onConfirm={handleConfirmDelete}
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.`}
@@ -419,6 +421,53 @@ export default function BookingsPage() {
isLoading={deleteMutation.isPending}
isDanger={true}
/>
{/* 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>
<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) => (
<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}
onChange={(e) => setExportColumns({ ...exportColumns, [col.key]: e.target.checked })}
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>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -22,6 +22,12 @@ export default function PassengersPage() {
});
const [selectedPassenger, setSelectedPassenger] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: null });
const [exportModalOpen, setExportModalOpen] = useState(false);
const [exportDateFrom, setExportDateFrom] = useState('');
const [exportDateTo, setExportDateTo] = useState('');
const [exportColumns, setExportColumns] = useState<Record<string, boolean>>({
fullName: true, email: true, phone: true, gender: true, nationality: true, verified: true,
});
const queryClient = useQueryClient();
@@ -52,22 +58,23 @@ export default function PassengersPage() {
console.error('Passengers API Error:', error);
}
const handleExportPassengers = async () => {
const selectedColumns = prompt(
'Select columns to export (comma-separated):\n\n' +
'Available: fullName, email, phone, dateOfBirth, gender, nationality, verified\n\n' +
'Default: fullName, email, phone, gender, nationality, verified',
'fullName, email, phone, gender, nationality, verified'
);
if (!selectedColumns) return;
const cols = selectedColumns.split(',').map(c => c.trim());
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; }
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;
if (exportDateFrom && (!d || d < exportDateFrom)) return false;
if (exportDateTo && (!d || d > exportDateTo)) return false;
return true;
});
const csv = [
cols.join(','),
...data?.items?.map((passenger: any) => {
...exportItems.map((passenger: any) => {
const values = cols.map(col => {
switch(col) {
switch (col) {
case 'fullName': return passenger.fullName;
case 'email': return passenger.email || '';
case 'phone': return passenger.phone || '';
@@ -79,15 +86,16 @@ export default function PassengersPage() {
}
});
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');
a.href = url;
a.download = `passengers-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
setExportModalOpen(false);
};
const columns = [
@@ -160,7 +168,7 @@ export default function PassengersPage() {
<p className="text-muted-foreground">Manage passenger profiles and verification</p>
</div>
<div className="flex gap-2">
<ActionButton variant="export" icon={Download} onClick={handleExportPassengers}>Export</ActionButton>
<ActionButton variant="export" icon={Download} onClick={() => setExportModalOpen(true)}>Export</ActionButton>
</div>
</div>
@@ -381,6 +389,51 @@ export default function PassengersPage() {
</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>
<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) => (
<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}
onChange={(e) => setExportColumns({ ...exportColumns, [col.key]: e.target.checked })}
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>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -6,27 +6,76 @@ import { Download } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import { paymentsApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
export default function PaymentsPage() {
const [filters, setFilters] = useState({ search: '', status: '', method: '' });
const [exportModalOpen, setExportModalOpen] = useState(false);
const [exportDateFrom, setExportDateFrom] = useState('');
const [exportDateTo, setExportDateTo] = useState('');
const [exportColumns, setExportColumns] = useState<Record<string, boolean>>({
reference: true, booking: true, amount: true, method: true, status: true, createdAt: true,
});
const { data, isLoading } = useQuery({
queryKey: ['payments', filters],
queryFn: () => paymentsApi.getAll(filters),
queryFn: () => paymentsApi.getAll({
search: filters.search || undefined,
status: filters.status || undefined,
method: filters.method || undefined,
}),
});
const columns = [
{ key: 'reference', label: 'Reference', render: (payment: any) => <span className="font-mono">{payment.reference || payment.id?.substring(0, 8)}</span> },
{ key: 'booking', label: 'Booking', render: (payment: any) => payment.booking?.bookingRef || 'N/A' },
{ key: 'amount', label: 'Amount', render: (payment: any) => formatCurrency(payment.amountMinor, payment.currency) },
{ key: 'method', label: 'Method', render: (payment: any) => <Badge>{payment.method}</Badge> },
{ key: 'status', label: 'Status', render: (payment: any) => <Badge variant="status" status={payment.status}>{payment.status}</Badge> },
{ key: 'createdAt', label: 'Created', render: (payment: any) => formatDateTime(payment.createdAt) },
];
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; }
const actions: any[] = [];
const items = ((data as any)?.items || (Array.isArray(data) ? data : [])) as any[];
const exportItems = items.filter((p: any) => {
if (!exportDateFrom && !exportDateTo) return true;
const d = p.createdAt ? new Date(p.createdAt).toISOString().split('T')[0] : null;
if (exportDateFrom && (!d || d < exportDateFrom)) return false;
if (exportDateTo && (!d || d > exportDateTo)) return false;
return true;
});
const csv = [
cols.join(','),
...exportItems.map((payment: any) => {
const values = cols.map(col => {
switch (col) {
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 || '';
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');
a.href = url;
a.download = `payments-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
setExportModalOpen(false);
};
const columns = [
{ key: 'reference', label: 'Reference', render: (payment: any) => <span className="font-mono">{payment.reference || payment.id?.substring(0, 8)}</span> },
{ key: 'booking', label: 'Booking', render: (payment: any) => payment.booking?.bookingRef || 'N/A' },
{ key: 'amount', label: 'Amount', render: (payment: any) => formatCurrency(payment.amountMinor, payment.currency) },
{ key: 'method', label: 'Method', render: (payment: any) => <Badge>{payment.method}</Badge> },
{ key: 'status', label: 'Status', render: (payment: any) => <Badge variant="status" status={payment.status}>{payment.status}</Badge> },
{ key: 'createdAt', label: 'Created', render: (payment: any) => formatDateTime(payment.createdAt) },
];
return (
<div className="space-y-6">
@@ -35,36 +84,91 @@ 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">Export</ActionButton>
<ActionButton icon={Download} variant="secondary" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
</div>
<div className="card">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="label">Search</label>
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
</div>
<div>
<label className="label">Status</label>
<select className="input" value={filters.status} onChange={(e) => setFilters({ ...filters, status: e.target.value })}>
<option value="">All Status</option>
<option value="PENDING">Pending</option>
<option value="COMPLETED">Completed</option>
<option value="FAILED">Failed</option>
</select>
</div>
<div>
<label className="label">Search</label>
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
</div>
<div>
<label className="label">Status</label>
<select className="input" value={filters.status} onChange={(e) => setFilters({ ...filters, status: e.target.value })}>
<option value="">All Status</option>
<option value="PENDING">Pending</option>
<option value="COMPLETED">Completed</option>
<option value="FAILED">Failed</option>
</select>
</div>
<div>
<label className="label">Method</label>
<select className="input" value={filters.method} onChange={(e) => setFilters({ ...filters, method: e.target.value })}>
<option value="">All Methods</option>
<option value="TELEBIRR">Telebirr</option>
<option value="CBE_BIRR">CBE Birr</option>
<option value="EBIRR">eBirr</option>
<option value="CARD">Card</option>
<option value="WALLET">Wallet</option>
<option value="CASH">Cash</option>
</select>
</div>
</div>
</div>
<DataTable
data={(data as any)?.items || (Array.isArray(data) ? data : [])}
columns={columns}
actions={actions}
actions={[]}
loading={isLoading}
emptyMessage="No payments found"
/>
{/* Export Modal */}
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Payments" size="md">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Date From</label>
<input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} />
</div>
<div>
<label className="label">Date To</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">
{[
{ 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' },
].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}
onChange={(e) => setExportColumns({ ...exportColumns, [col.key]: e.target.checked })}
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>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -228,7 +228,7 @@ export default function SeatsPage() {
const hasBedPositionData = validSeats.some((s: any) => s.bedPosition);
if (isBedCoach && hasBedPositionData) {
if (isBedCoach) {
const arrangement = parseSeatArrangement(coach.seatArrangement);
const seatsPerRow = arrangement[0] + (arrangement[1] || 0);
const allSeatsForLayout = [...validSeats, ...removedSeats];

View File

@@ -9,11 +9,11 @@ import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import Modal from '@/components/ui/Modal';
import { ticketsApi, apiClient, schedulesApi, stationsApi } from '@/lib/api';
import { ticketsApi, apiClient, stationsApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
export default function TicketsPage() {
const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', tripDate: '' });
const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', arrivalDate: '' });
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [ticketToDelete, setTicketToDelete] = useState<any>(null);
const [boardConfirmOpen, setBoardConfirmOpen] = useState(false);
@@ -22,6 +22,8 @@ export default function TicketsPage() {
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
const [selectedTicket, setSelectedTicket] = useState<any>(null);
const [exportModalOpen, setExportModalOpen] = useState(false);
const [exportDateFrom, setExportDateFrom] = useState('');
const [exportDateTo, setExportDateTo] = useState('');
const [selectedColumns, setSelectedColumns] = useState<Record<string, boolean>>({
ticketNumber: true,
booking: true,
@@ -35,7 +37,15 @@ export default function TicketsPage() {
const { data, isLoading, error } = useQuery({
queryKey: ['tickets', filters],
queryFn: () => ticketsApi.getAll({ ...filters, skip: 0, take: 50 }),
queryFn: () => ticketsApi.getAll({
search: filters.search || undefined,
status: filters.status || undefined,
originStationId: filters.originStationId || undefined,
destinationStationId: filters.destinationStationId || undefined,
arrivalDate: filters.arrivalDate || undefined,
skip: 0,
take: 50,
}),
});
const { data: stationsData } = useQuery({
@@ -94,25 +104,31 @@ export default function TicketsPage() {
}
};
const handleExportTickets = async () => {
setExportModalOpen(true);
};
const confirmExport = () => {
const cols = Object.entries(selectedColumns)
.filter(([, selected]) => selected)
.map(([col]) => col);
if (cols.length === 0) {
alert('Please select at least one column');
return;
}
const exportItems = (data?.items || []).filter((ticket: any) => {
if (!exportDateFrom && !exportDateTo) return true;
const d = ticket.schedule?.arrivalAt
? new Date(ticket.schedule.arrivalAt).toISOString().split('T')[0]
: null;
if (exportDateFrom && (!d || d < exportDateFrom)) return false;
if (exportDateTo && (!d || d > exportDateTo)) return false;
return true;
});
const csv = [
cols.join(','),
...data?.items?.map((ticket: any) => {
...exportItems.map((ticket: any) => {
const values = cols.map(col => {
switch(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 || ''}`;
@@ -126,9 +142,9 @@ export default function TicketsPage() {
}
});
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');
@@ -175,12 +191,12 @@ export default function TicketsPage() {
},
{
key: 'seat',
label: 'Seat',
label: 'Seat/Bed',
sortable: true,
render: (ticket: any) => (
<div>
<div className="font-mono font-semibold">Coach {ticket.seat?.coach?.number || 'N/A'} - Seat {ticket.seat?.seatNumber || 'N/A'}</div>
<div className="text-xs text-muted-foreground">{ticket.seat?.coach?.coachType?.name || '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>
),
},
@@ -264,7 +280,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={handleExportTickets}>Export</ActionButton>
<ActionButton icon={Download} variant="secondary" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
</div>
{/* Filters */}
@@ -317,12 +333,12 @@ export default function TicketsPage() {
</select>
</div>
<div>
<label className="label">Trip Date</label>
<label className="label">Arrival Date</label>
<input
type="date"
className="input"
value={filters.tripDate}
onChange={(e) => setFilters({ ...filters, tripDate: e.target.value })}
value={filters.arrivalDate}
onChange={(e) => setFilters({ ...filters, arrivalDate: e.target.value })}
/>
</div>
<div>
@@ -353,10 +369,7 @@ export default function TicketsPage() {
{/* Board Confirmation Dialog */}
<ConfirmDialog
isOpen={boardConfirmOpen}
onClose={() => {
setBoardConfirmOpen(false);
setTicketToBoard(null);
}}
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.`}
@@ -368,10 +381,7 @@ export default function TicketsPage() {
{/* Delete Confirmation Dialog */}
<ConfirmDialog
isOpen={deleteConfirmOpen}
onClose={() => {
setDeleteConfirmOpen(false);
setTicketToDelete(null);
}}
onClose={() => { setDeleteConfirmOpen(false); setTicketToDelete(null); }}
onConfirm={handleConfirmDelete}
title="Delete Ticket"
message={`Are you sure you want to permanently delete ticket ${ticketToDelete?.ticketNumber}? This action cannot be undone.`}
@@ -384,10 +394,7 @@ export default function TicketsPage() {
{/* Ticket Details Modal */}
<Modal
isOpen={detailsModalOpen}
onClose={() => {
setDetailsModalOpen(false);
setSelectedTicket(null);
}}
onClose={() => { setDetailsModalOpen(false); setSelectedTicket(null); }}
title="Ticket Details"
size="lg"
>
@@ -488,13 +495,7 @@ export default function TicketsPage() {
)}
<div className="flex justify-end gap-2 pt-4">
<ActionButton
variant="secondary"
onClick={() => {
setDetailsModalOpen(false);
setSelectedTicket(null);
}}
>
<ActionButton variant="secondary" onClick={() => { setDetailsModalOpen(false); setSelectedTicket(null); }}>
Close
</ActionButton>
</div>
@@ -502,49 +503,55 @@ export default function TicketsPage() {
)}
</Modal>
{/* Export Columns Modal */}
{/* Export Modal */}
<Modal
isOpen={exportModalOpen}
onClose={() => setExportModalOpen(false)}
title="Export Tickets - Select Columns"
title="Export Tickets"
size="md"
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">Select which columns to include in the export</p>
<div className="space-y-3 max-h-96 overflow-y-auto">
{[
{ key: 'ticketNumber', label: 'Ticket Number' },
{ key: 'booking', label: 'Booking Reference & Passenger' },
{ 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 Status' },
].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={selectedColumns[col.key] || false}
onChange={(e) =>
setSelectedColumns({ ...selectedColumns, [col.key]: e.target.checked })
}
className="w-4 h-4 rounded border-gray-300"
/>
<span className="text-sm font-medium">{col.label}</span>
</label>
))}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Date From (Arrival)</label>
<input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} />
</div>
<div>
<label className="label">Date To (Arrival)</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: 'ticketNumber', label: 'Ticket Number' },
{ key: 'booking', label: 'Booking Reference & Passenger' },
{ 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 Status' },
].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={selectedColumns[col.key] || false}
onChange={(e) => setSelectedColumns({ ...selectedColumns, [col.key]: e.target.checked })}
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>
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
<ActionButton onClick={confirmExport}>Export CSV</ActionButton>
</div>
</div>
</Modal>