mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 10:58:14 +00:00
561 lines
22 KiB
TypeScript
561 lines
22 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { LogIn, Trash2 } from 'lucide-react';
|
|
import { Download } from 'lucide-react';
|
|
import DataTable from '@/components/ui/DataTable';
|
|
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, stationsApi } from '@/lib/api';
|
|
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
|
|
|
export default function TicketsPage() {
|
|
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);
|
|
const [ticketToBoard, setTicketToBoard] = useState<any>(null);
|
|
const [successMessage, setSuccessMessage] = useState('');
|
|
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,
|
|
trip: true,
|
|
seat: true,
|
|
seatClass: true,
|
|
amount: true,
|
|
status: true,
|
|
});
|
|
const queryClient = useQueryClient();
|
|
|
|
const { data, isLoading, error } = useQuery({
|
|
queryKey: ['tickets', filters],
|
|
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({
|
|
queryKey: ['stations'],
|
|
queryFn: () => stationsApi.getAll(),
|
|
});
|
|
|
|
const boardMutation = useMutation({
|
|
mutationFn: ({ ticketId }: any) => ticketsApi.validate(ticketId, { status: 'USED', boardedAt: new Date().toISOString() }),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['tickets'] });
|
|
setBoardConfirmOpen(false);
|
|
setTicketToBoard(null);
|
|
setSuccessMessage('Ticket boarded successfully');
|
|
setTimeout(() => setSuccessMessage(''), 3000);
|
|
},
|
|
onError: (error: any) => {
|
|
alert(`Error: ${error.message || 'Failed to board ticket'}`);
|
|
},
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (id: string) => apiClient.delete(`/tickets/${id}`),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['tickets'] });
|
|
setDeleteConfirmOpen(false);
|
|
setTicketToDelete(null);
|
|
setSuccessMessage('Ticket deleted successfully');
|
|
setTimeout(() => setSuccessMessage(''), 3000);
|
|
},
|
|
onError: (error: any) => {
|
|
setDeleteConfirmOpen(false);
|
|
alert(`Error: ${error.message || 'Failed to delete ticket'}`);
|
|
},
|
|
});
|
|
|
|
const handleBoard = (ticket: any) => {
|
|
setTicketToBoard(ticket);
|
|
setBoardConfirmOpen(true);
|
|
};
|
|
|
|
const handleConfirmBoard = async () => {
|
|
if (ticketToBoard) {
|
|
await boardMutation.mutateAsync({ ticketId: ticketToBoard.id });
|
|
}
|
|
};
|
|
|
|
const handleDeleteClick = (ticket: any) => {
|
|
setTicketToDelete(ticket);
|
|
setDeleteConfirmOpen(true);
|
|
};
|
|
|
|
const handleConfirmDelete = async () => {
|
|
if (ticketToDelete) {
|
|
await deleteMutation.mutateAsync(ticketToDelete.id);
|
|
}
|
|
};
|
|
|
|
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(','),
|
|
...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';
|
|
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 = `tickets-${new Date().toISOString().split('T')[0]}.csv`;
|
|
a.click();
|
|
setExportModalOpen(false);
|
|
};
|
|
|
|
const columns = [
|
|
{
|
|
key: 'ticketNumber',
|
|
label: 'Ticket Number',
|
|
sortable: true,
|
|
render: (ticket: any) => (
|
|
<span className="font-mono font-semibold">{ticket.ticketNumber || 'N/A'}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'booking',
|
|
label: 'Booking',
|
|
render: (ticket: any) => (
|
|
<div>
|
|
<div className="font-medium">{ticket.booking?.bookingRef || 'N/A'}</div>
|
|
<div className="text-sm text-muted-foreground">
|
|
{ticket.booking?.passenger?.fullName || ticket.booking?.contactEmail || 'N/A'}
|
|
</div>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'trip',
|
|
label: 'Trip',
|
|
render: (ticket: any) => (
|
|
<div>
|
|
<div className="font-medium">
|
|
{ticket.schedule?.originStation?.name || 'N/A'} → {ticket.schedule?.destinationStation?.name || 'N/A'}
|
|
</div>
|
|
<div className="text-sm text-muted-foreground">
|
|
{ticket.schedule?.departureAt ? formatDateTime(ticket.schedule.departureAt) : 'N/A'}
|
|
</div>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'seat',
|
|
label: 'Seat/Bed',
|
|
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="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',
|
|
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 actions = [
|
|
{
|
|
label: 'Board',
|
|
onClick: handleBoard,
|
|
variant: 'primary' as const,
|
|
icon: LogIn,
|
|
show: (ticket: any) => ticket.status !== 'USED' && !ticket.boardedAt,
|
|
},
|
|
{
|
|
label: 'Details',
|
|
onClick: (ticket: any) => {
|
|
setSelectedTicket(ticket);
|
|
setDetailsModalOpen(true);
|
|
},
|
|
variant: 'secondary' as const,
|
|
},
|
|
{
|
|
label: 'Delete',
|
|
onClick: handleDeleteClick,
|
|
variant: 'danger' as const,
|
|
icon: Trash2,
|
|
},
|
|
];
|
|
|
|
const stations = stationsData?.items || [];
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<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>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<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>
|
|
)}
|
|
{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">
|
|
Error loading tickets: {error instanceof Error ? error.message : 'Unknown error'}
|
|
</div>
|
|
)}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
|
<div>
|
|
<label className="label">Search</label>
|
|
<input
|
|
type="text"
|
|
placeholder="Search by ticket number..."
|
|
className="input"
|
|
value={filters.search}
|
|
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="label">Origin</label>
|
|
<select
|
|
className="input"
|
|
value={filters.originStationId}
|
|
onChange={(e) => setFilters({ ...filters, originStationId: e.target.value })}
|
|
>
|
|
<option value="">All Origins</option>
|
|
{stations.map((station: any) => (
|
|
<option key={station.id} value={station.id}>{station.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="label">Destination</label>
|
|
<select
|
|
className="input"
|
|
value={filters.destinationStationId}
|
|
onChange={(e) => setFilters({ ...filters, destinationStationId: e.target.value })}
|
|
>
|
|
<option value="">All Destinations</option>
|
|
{stations.map((station: any) => (
|
|
<option key={station.id} value={station.id}>{station.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="label">Arrival Date</label>
|
|
<input
|
|
type="date"
|
|
className="input"
|
|
value={filters.arrivalDate}
|
|
onChange={(e) => setFilters({ ...filters, arrivalDate: 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="ACTIVE">Active</option>
|
|
<option value="USED">Used</option>
|
|
<option value="CANCELLED">Cancelled</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tickets Table */}
|
|
<DataTable
|
|
data={data?.items || []}
|
|
columns={columns}
|
|
actions={actions}
|
|
loading={isLoading}
|
|
emptyMessage="No tickets found"
|
|
/>
|
|
|
|
{/* Board Confirmation Dialog */}
|
|
<ConfirmDialog
|
|
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}
|
|
/>
|
|
|
|
{/* Delete Confirmation Dialog */}
|
|
<ConfirmDialog
|
|
isOpen={deleteConfirmOpen}
|
|
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.`}
|
|
confirmText="Delete"
|
|
cancelText="Cancel"
|
|
isLoading={deleteMutation.isPending}
|
|
isDanger={true}
|
|
/>
|
|
|
|
{/* Ticket Details Modal */}
|
|
<Modal
|
|
isOpen={detailsModalOpen}
|
|
onClose={() => { setDetailsModalOpen(false); setSelectedTicket(null); }}
|
|
title="Ticket Details"
|
|
size="lg"
|
|
>
|
|
{selectedTicket && (
|
|
<div className="space-y-6">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Ticket Number</p>
|
|
<p className="font-mono font-semibold text-lg">{selectedTicket.ticketNumber}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Status</p>
|
|
<div className="mt-1">
|
|
<Badge variant="status" status={selectedTicket.status || 'ACTIVE'}>
|
|
{selectedTicket.status || 'ACTIVE'}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t pt-4">
|
|
<h3 className="font-semibold mb-3">Booking Information</h3>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Booking Reference</p>
|
|
<p className="font-medium">{selectedTicket.booking?.bookingRef || 'N/A'}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Passenger</p>
|
|
<p className="font-medium">{selectedTicket.booking?.passenger?.fullName || selectedTicket.booking?.contactEmail || 'N/A'}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Amount</p>
|
|
<p className="font-medium">{formatCurrency(selectedTicket.booking?.totalMinor || 0, selectedTicket.booking?.currency || 'ETB')}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t pt-4">
|
|
<h3 className="font-semibold mb-3">Trip Information</h3>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Route</p>
|
|
<p className="font-medium">
|
|
{selectedTicket.schedule?.originStation?.name || 'N/A'} → {selectedTicket.schedule?.destinationStation?.name || 'N/A'}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Departure</p>
|
|
<p className="font-medium">{selectedTicket.schedule?.departureAt ? formatDateTime(selectedTicket.schedule.departureAt) : 'N/A'}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t pt-4">
|
|
<h3 className="font-semibold mb-3">Seat Information</h3>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Coach</p>
|
|
<p className="font-mono font-semibold">{selectedTicket.seat?.coach?.number || 'N/A'}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Seat Number</p>
|
|
<p className="font-mono font-semibold">{selectedTicket.seat?.seatNumber || 'N/A'}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Class</p>
|
|
<p className="font-medium">{selectedTicket.seat?.coach?.coachType?.name || 'N/A'}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{selectedTicket.validatedAt && (
|
|
<div className="border-t pt-4 bg-green-50 dark:bg-green-900/20 rounded-lg p-4">
|
|
<p className="text-sm text-muted-foreground">Validated At</p>
|
|
<p className="font-medium text-green-700 dark:text-green-400">{formatDateTime(selectedTicket.validatedAt)}</p>
|
|
</div>
|
|
)}
|
|
|
|
{selectedTicket.booking?.returnLegStatus && selectedTicket.booking.returnLegStatus !== 'NOT_APPLICABLE' && (
|
|
<div className="border-t pt-4">
|
|
<h3 className="font-semibold mb-3">Round-Trip Leg Status</h3>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Leg Status</p>
|
|
<p className="font-medium">{selectedTicket.booking.returnLegStatus.replace(/_/g, ' ')}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Outbound Boarded</p>
|
|
<p className="font-medium">{selectedTicket.booking.outboundBoardedAt ? formatDateTime(selectedTicket.booking.outboundBoardedAt) : '—'}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Return Boarded</p>
|
|
<p className="font-medium">{selectedTicket.booking.returnBoardedAt ? formatDateTime(selectedTicket.booking.returnBoardedAt) : '—'}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex justify-end gap-2 pt-4">
|
|
<ActionButton variant="secondary" onClick={() => { setDetailsModalOpen(false); setSelectedTicket(null); }}>
|
|
Close
|
|
</ActionButton>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
|
|
{/* Export Modal */}
|
|
<Modal
|
|
isOpen={exportModalOpen}
|
|
onClose={() => setExportModalOpen(false)}
|
|
title="Export Tickets"
|
|
size="md"
|
|
>
|
|
<div className="space-y-4">
|
|
<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>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|