Files
edr-platform/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx
2026-06-29 12:42:37 +03:00

998 lines
46 KiB
TypeScript

'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { LogIn, ListCollapse, Trash2, Printer, Package } 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, excessBaggageApi } from '@/lib/api';
import { formatDateTime, formatCurrency, formatDateTimeShort } from '@/lib/utils';
import { useAuthStore } from '@/lib/auth-store';
export default function TicketsPage() {
const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', arrivalDate: '', dateFrom: '', dateTo: '', coachId: '' });
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [ticketToDelete, setTicketToDelete] = useState<any>(null);
const [deleteError, setDeleteError] = useState<string | null>(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);
const [showExtraFilters, setShowExtraFilters] = useState(false);
const [exportFormat, setExportFormat] = useState<'csv' | 'excel' | 'pdf'>('csv');
const { user } = useAuthStore();
const [excessModalOpen, setExcessModalOpen] = useState(false);
const [excessTicket, setExcessTicket] = useState<any>(null);
const [excessKg, setExcessKg] = useState('');
const [excessCollectCash, setExcessCollectCash] = useState(false);
const [excessError, setExcessError] = useState<string | null>(null);
const [excessResult, setExcessResult] = useState<any>(null);
const { data: agentData } = useQuery({
queryKey: ['agent-me'],
queryFn: () => apiClient.get<any>('/agents/me'),
enabled: !!user,
retry: false,
});
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 [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,
dateFrom: filters.dateFrom || undefined,
dateTo: filters.dateTo || undefined,
coachId: filters.coachId || undefined,
skip: 0,
take: 50,
}),
});
const { data: stationsData } = useQuery({
queryKey: ['stations'],
queryFn: () => stationsApi.getAll(),
});
const { data: coachesData } = useQuery({
queryKey: ['coaches'],
queryFn: () => apiClient.get('/fleet/coaches'),
});
const boardMutation = useMutation({
mutationFn: ({ ticketId, leg }: { ticketId: string; leg?: 'outbound' | 'inbound' }) =>
ticketsApi.validate(ticketId, { status: 'USED', boardedAt: new Date().toISOString(), leg: leg === 'inbound' ? 'RETURN' : 'OUTBOUND' }),
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 excessMutation = useMutation({
mutationFn: (data: any) => excessBaggageApi.logCharge(data),
onSuccess: (result) => {
setExcessResult(result);
setExcessError(null);
},
onError: (e: any) => setExcessError(e?.response?.data?.message || e?.message || 'Failed to log charge'),
});
const openExcessModal = (ticket: any) => {
setExcessTicket(ticket);
setExcessKg('');
setExcessCollectCash(false);
setExcessError(null);
setExcessResult(null);
setExcessModalOpen(true);
};
const handleExcessSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!excessTicket) return;
const agentId = agentData?.id;
if (!agentId) { setExcessError('No agent profile found for your account'); return; }
await excessMutation.mutateAsync({
bookingId: excessTicket.booking?.id ?? excessTicket.bookingId,
agentId,
excessWeightKg: parseInt(excessKg),
collectCash: excessCollectCash,
});
};
const deleteMutation = useMutation({
mutationFn: (id: string) => apiClient.delete(`/tickets/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tickets'] });
setDeleteConfirmOpen(false);
setTicketToDelete(null);
setDeleteError(null);
setSuccessMessage('Ticket deleted successfully');
setTimeout(() => setSuccessMessage(''), 3000);
},
onError: (error: any) => {
setDeleteError(error?.response?.data?.message || error?.message || 'Failed to delete ticket');
},
});
const restoreMutation = useMutation({
mutationFn: (id: string) => apiClient.patch(`/tickets/${id}/restore`, {}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tickets'] });
setSuccessMessage('Ticket restored successfully');
setTimeout(() => setSuccessMessage(''), 3000);
},
onError: (error: any) => alert(error?.response?.data?.message || error?.message || 'Failed to restore ticket'),
});
const handleBoard = (ticket: any) => {
setTicketToBoard(ticket);
setBoardConfirmOpen(true);
};
const handleConfirmBoard = async () => {
if (!ticketToBoard) return;
const isRoundTrip = ticketToBoard.booking?.bookingType === 'ROUND_TRIP' || ticketToBoard.booking?.bookingType === 'ROUND_TRIP_TRANSIT';
const outboundDone = !!ticketToBoard.validatedAt || !!ticketToBoard.booking?.outboundBoardedAt;
const leg: 'outbound' | 'inbound' = isRoundTrip && outboundDone ? 'inbound' : 'outbound';
await boardMutation.mutateAsync({ ticketId: ticketToBoard.id, leg });
printBoardingPass(ticketToBoard, leg);
};
const printBoardingPass = (ticket: any, leg: 'outbound' | 'inbound' = 'outbound') => {
const w = window.open('', '_blank', 'width=520,height=460');
if (!w) return;
const isInbound = leg === 'inbound';
const origin = isInbound
? (ticket.booking?.returnOriginStation?.name || ticket.schedule?.destinationStation?.name || 'N/A')
: (ticket.schedule?.originStation?.name || 'N/A');
const dest = isInbound
? (ticket.booking?.returnDestinationStation?.name || ticket.schedule?.originStation?.name || 'N/A')
: (ticket.schedule?.destinationStation?.name || 'N/A');
const date = isInbound
? (ticket.booking?.returnBoardedAt ? new Date(ticket.booking.returnBoardedAt).toLocaleString() : 'N/A')
: (ticket.schedule?.departureAt ? new Date(ticket.schedule.departureAt).toLocaleString() : 'N/A');
const seat = ticket.seat?.seatNumber || 'N/A';
const coach = ticket.seat?.coach?.number || 'N/A';
const bookingRef = ticket.booking?.bookingRef || 'N/A';
const ticketNum = ticket.ticketNumber || 'N/A';
const passenger = ticket.booking?.passenger?.fullName || ticket.booking?.contactEmail || 'Guest';
w.document.write(
'<!DOCTYPE html><html><head><meta charset="utf-8"/><title>Boarding Pass</title><style>' +
'*{box-sizing:border-box;margin:0;padding:0}' +
'body{font-family:"Segoe UI",sans-serif;background:#f0fdf4;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:24px}' +
'.pass{background:#fff;border-radius:16px;overflow:hidden;box-shadow:0 8px 32px rgba(0,0,0,.12);width:460px}' +
'.header{background:linear-gradient(135deg,#10b981,#059669);color:#fff;padding:24px 28px 20px}' +
'.header-top{display:flex;justify-content:space-between;align-items:center;margin-bottom:4px}' +
'.airline{font-size:12px;letter-spacing:2px;text-transform:uppercase;opacity:.85}' +
'.badge{background:rgba(255,255,255,.2);border-radius:20px;padding:3px 12px;font-size:11px;letter-spacing:1px}' +
'.route{display:flex;align-items:center;gap:8px;margin-top:14px}' +
'.city{font-size:24px;font-weight:700}' +
'.arrow{font-size:20px;opacity:.7;flex:1;text-align:center}' +
'.body{padding:24px 28px}' +
'.grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}' +
'.field label{font-size:10px;text-transform:uppercase;letter-spacing:1px;color:#6b7280;font-weight:600}' +
'.field p{font-size:14px;font-weight:600;color:#111827;margin-top:3px}' +
'.divider{border:none;border-top:2px dashed #d1fae5;margin:20px 0}' +
'.footer{display:flex;justify-content:space-between;align-items:center}' +
'.seat-box{background:#f0fdf4;border:2px solid #10b981;border-radius:10px;padding:8px 20px;text-align:center}' +
'.seat-box label{font-size:10px;letter-spacing:1px;text-transform:uppercase;color:#059669;font-weight:700}' +
'.seat-box p{font-size:28px;font-weight:800;color:#065f46}' +
'@media print{body{background:#fff}.pass{box-shadow:none}}' +
'</style></head><body>' +
'<div class="pass">' +
'<div class="header">' +
'<div class="header-top"><span class="airline">EDR &mdash; Ethio-Djibouti Railway</span><span class="badge">BOARDING PASS</span></div>' +
'<div class="route"><span class="city">' + origin + '</span><span class="arrow">&#129122;</span><span class="city">' + dest + '</span></div>' +
'</div>' +
'<div class="body">' +
'<div class="grid">' +
'<div class="field"><label>Booking Ref</label><p>' + bookingRef + '</p></div>' +
'<div class="field"><label>Ticket No.</label><p>' + ticketNum + '</p></div>' +
'<div class="field"><label>Date &amp; Time</label><p>' + date + '</p></div>' +
'<div class="field"><label>Coach</label><p>' + coach + '</p></div>' +
'</div>' +
'<hr class="divider"/>' +
'<div class="footer">' +
'<div class="field"><label>Passenger</label><p>' + passenger + '</p></div>' +
'<div class="seat-box"><label>Seat</label><p>' + seat + '</p></div>' +
'</div>' +
'</div>' +
'</div>' +
'<script>window.onload=function(){window.print();window.onafterprint=function(){window.close()};}<\/script>' +
'</body></html>'
);
w.document.close();
};
const handleDeleteClick = (ticket: any) => {
setTicketToDelete(ticket);
setDeleteError(null);
setDeleteConfirmOpen(true);
};
const handleConfirmDelete = async () => {
if (ticketToDelete) {
await deleteMutation.mutateAsync(ticketToDelete.id);
}
};
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 = async () => {
const cols = Object.entries(selectedColumns).filter(([, v]) => v).map(([k]) => k);
if (!cols.length) { alert('Please select at least one column'); return; }
const allData = await ticketsApi.getAll({ search: filters.search || undefined, status: filters.status || undefined, skip: 0, take: 9999 });
const exportItems = (allData?.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 headers = TICKET_COLS.filter(c => cols.includes(c.key)).map(c => c.label);
const rows = exportItems.map((ticket: any) =>
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.passengerName || ticket.booking?.seats?.[0]?.passengerName || ticket.booking?.passenger?.fullName || ticket.booking?.contactEmail || 'Guest';
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 '';
}
})
);
const dateStr = new Date().toISOString().split('T')[0];
if (exportFormat === 'pdf') {
const w = window.open('', '_blank')!;
w.document.write('<!DOCTYPE html><html><head><title>Tickets Export</title><style>body{font-family:sans-serif;font-size:11px}table{border-collapse:collapse;width:100%}th,td{border:1px solid #ccc;padding:4px 8px}th{background:#10b981;color:#fff}</style></head><body>');
w.document.write('<h2>Tickets Export - ' + dateStr + '</h2><table><thead><tr>' + headers.map(h => '<th>' + h + '</th>').join('') + '</tr></thead><tbody>');
rows.forEach((r: string[]) => { w.document.write('<tr>' + r.map((v: string) => '<td>' + v + '</td>').join('') + '</tr>'); });
w.document.write('</tbody></table></body></html>');
w.document.close(); w.print();
} else if (exportFormat === 'excel') {
const tsv = [headers.join(' '), ...rows.map((r: string[]) => r.join(' '))].join('\n');
const blob = new Blob([tsv], { type: 'application/vnd.ms-excel' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = 'tickets-' + dateStr + '.xls'; a.click();
} else {
const csv = [headers.map((h: string) => '"' + h + '"').join(','), ...rows.map((r: string[]) => r.map((v: string) => '"' + 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-' + dateStr + '.csv'; a.click();
}
setExportModalOpen(false);
};
const columns = [
{
key: 'ticketNumber',
label: 'Ticket Number',
sortable: true,
render: (ticket: any) => {
const passengerName = ticket.passengerName ||
ticket.booking?.seats?.[0]?.passengerName ||
ticket.booking?.passenger?.fullName ||
ticket.booking?.contactEmail ||
'Guest';
return (
<div>
<div className="font-mono font-semibold">{ticket.ticketNumber || 'N/A'}</div>
<div className="text-sm text-muted-foreground">
{passengerName}
</div>
</div>
);
},
},
{
key: 'contact',
label: 'Contact',
render: (ticket: any) => {
const phone = ticket.booking?.passenger?.phone || 'N/A';
const email = ticket.booking?.passenger?.email || 'N/A';
return (
<div>
<div className="font-medium">{phone}</div>
<div className="text-sm text-muted-foreground">
<div className="text-xs text-muted-foreground truncate" title={email}>{email}</div>
</div>
</div>
);
},
},
{
key: 'trip',
label: 'Trip',
render: (ticket: any) => {
const isRoundTrip = ticket.booking?.bookingType === 'ROUND_TRIP' || ticket.booking?.bookingType === 'ROUND_TRIP_TRANSIT';
const returnDeparture = ticket.booking?.returnSchedule?.departureAt;
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">
{!isRoundTrip ? (
<span>{ticket.schedule?.departureAt ? formatDateTimeShort(ticket.schedule.departureAt) : 'N/A'}</span>
) : (
<span>
{ticket.schedule?.departureAt ? formatDateTimeShort(ticket.schedule.departureAt) : 'N/A'} ·
{returnDeparture ? formatDateTimeShort(returnDeparture) : 'N/A'}
</span>
)}
</div>
</div>
);
},
},
{
key: 'seat',
label: 'Seat/Bed',
sortable: true,
render: (ticket: any) => {
const isRoundTrip = ticket.booking?.bookingType === 'ROUND_TRIP' || ticket.booking?.bookingType === 'ROUND_TRIP_TRANSIT';
if (isRoundTrip) {
// Find seats for THIS specific passenger based on passengerName
const passengerSeats = ticket.booking?.seats?.filter((s: any) => s.passengerName === ticket.passengerName) || [];
const outboundSeat = passengerSeats.find((s: any) => s.leg === 1);
const returnSeat = passengerSeats.find((s: any) => s.leg === 2);
return (
<div className="space-y-1">
<div className="font-mono font-semibold text-sm">
{outboundSeat?.seat?.coach?.number || 'N/A'}: {outboundSeat?.seat?.seatNumber || 'N/A'}
</div>
<div className="font-mono text-sm text-muted-foreground">
{returnSeat?.seat?.coach?.number || 'N/A'}: {returnSeat?.seat?.seatNumber || 'N/A'}
</div>
</div>
);
}
// For one-way, show the ticket's primary seat
return (
<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: 'arrivalDate',
label: 'Arrival Date',
render: (ticket: any) => {
const isRoundTrip = ticket.booking?.bookingType === 'ROUND_TRIP' || ticket.booking?.bookingType === 'ROUND_TRIP_TRANSIT';
const outboundArrival = ticket.schedule?.arrivalAt;
const returnArrival = ticket.booking?.returnSchedule?.arrivalAt;
if (!isRoundTrip) {
return (
<span className="text-sm">
{outboundArrival ? new Date(outboundArrival).toLocaleDateString() : '—'}
</span>
);
}
return (
<div className="flex flex-col gap-0.5 text-sm">
<span> {outboundArrival ? new Date(outboundArrival).toLocaleDateString() : '—'}</span>
<span> {returnArrival ? new Date(returnArrival).toLocaleDateString() : '—'}</span>
</div>
);
},
},
{
key: 'boardingTimes',
label: 'Boarding Times',
render: (ticket: any) => {
const isRoundTrip = ticket.booking?.bookingType === 'ROUND_TRIP' || ticket.booking?.bookingType === 'ROUND_TRIP_TRANSIT';
const outbound = ticket.booking?.outboundBoardedAt || ticket.validatedAt;
const inbound = ticket.booking?.returnBoardedAt;
if (!isRoundTrip) {
// One-way tickets: only show outbound status
return (
<span className={`text-sm ${outbound ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'}`}>
{outbound ? formatDateTimeShort(outbound) : 'Not boarded'}
</span>
);
}
// Round-trip tickets: show both legs
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>
);
},
},
];
const actions = [
{
label: 'Baggage',
onClick: openExcessModal,
variant: 'secondary' as const,
icon: Package,
show: (ticket: any) => !!ticket.booking && ['CONFIRMED', 'BOARDED'].includes(ticket.booking?.status ?? ticket.status),
},
{
label: 'Board',
onClick: handleBoard,
variant: 'primary' as const,
icon: LogIn,
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',
onClick: (ticket: any) => {
setSelectedTicket(ticket);
setDetailsModalOpen(true);
},
variant: 'secondary' as const,
icon: ListCollapse,
},
{
label: 'Restore',
onClick: (ticket: any) => restoreMutation.mutate(ticket.id),
variant: 'secondary' as const,
icon: ListCollapse,
show: (ticket: any) => ticket.status === 'CANCELLED',
},
{
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="export" 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-3 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 className="flex items-end">
<button type="button" className="input w-full px-4 text-sm font-medium text-primary border-primary/40"
onClick={() => setShowExtraFilters(v => !v)}>
{showExtraFilters ? 'Hide Filters ▲' : 'More Filters ▼'}
</button>
</div>
</div>
{showExtraFilters && (
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3 mt-3">
<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>
<label className="label">Coach</label>
<select
className="input"
value={filters.coachId}
onChange={(e) => setFilters({ ...filters, coachId: e.target.value })}
>
<option value="">All Coaches</option>
{(Array.isArray(coachesData) ? coachesData : (coachesData as any)?.data || []).map((coach: any) => (
<option key={coach.id} value={coach.id}>{coach.number}</option>
))}
</select>
</div>
<div>
<label className="label">Issued From</label>
<input type="date" className="input" value={filters.dateFrom}
onChange={(e) => setFilters({ ...filters, dateFrom: e.target.value })} />
</div>
<div>
<label className="label">Issued To</label>
<input type="date" className="input" value={filters.dateTo}
onChange={(e) => setFilters({ ...filters, dateTo: e.target.value })} />
</div>
</div>
)}
</div>
{/* Tickets Table */}
<DataTable
data={data?.items || []}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage="No tickets found"
/>
{/* Board Confirmation Modal */}
<Modal
isOpen={boardConfirmOpen}
onClose={() => { setBoardConfirmOpen(false); setTicketToBoard(null); }}
title="Board Ticket"
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
isOpen={deleteConfirmOpen}
onClose={() => { setDeleteConfirmOpen(false); setTicketToDelete(null); setDeleteError(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}
error={deleteError ?? undefined}
/>
{/* Ticket Details Modal */}
<Modal
isOpen={detailsModalOpen}
onClose={() => { setDetailsModalOpen(false); setSelectedTicket(null); }}
title="Ticket Details"
size="xl"
>
{selectedTicket && (() => {
const t = selectedTicket;
const b = t.booking;
const isRoundTrip = b?.bookingType === 'ROUND_TRIP' || b?.bookingType === 'ROUND_TRIP_TRANSIT';
const passengerName = b?.seats?.[0]?.passengerName || b?.passenger?.fullName || b?.contactEmail || 'Guest';
return (
<div>
{/* 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">Ticket Number</p>
<p className="text-white text-3xl font-mono font-bold tracking-wider">{t.ticketNumber || '—'}</p>
</div>
<div className="text-right shrink-0">
<Badge variant="status" status={t.status || 'ACTIVE'}>{t.status || 'ACTIVE'}</Badge>
{t.validatedAt && <p className="text-emerald-200 text-xs mt-1">Validated {formatDateTime(t.validatedAt)}</p>}
</div>
</div>
<div className="mt-4 grid grid-cols-3 gap-3">
{[
{ label: 'Passenger', value: passengerName },
{ label: 'Route', value: `${t.schedule?.originStation?.name || '?'}${t.schedule?.destinationStation?.name || '?'}` },
{ label: 'Amount', value: formatCurrency(b?.totalMinor || 0, b?.currency || 'ETB') },
].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>
))}
</div>
</div>
<div className="space-y-6">
{/* Booking */}
<section>
<SectionHeader title="Booking Information" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Booking Ref" value={b?.bookingRef} mono />
<Field label="Booking Type" value={(b?.bookingType || 'ONE_WAY').replace(/_/g, ' ')} />
<Field label="Payment Status" value={b?.paymentIntent?.status || 'N/A'} />
<Field label="Contact Phone" value={b?.contactPhone || b?.passenger?.phone} />
<Field label="Contact Email" value={b?.contactEmail || b?.passenger?.email} truncate />
<Field label="Adults" value={String(b?.adultCount ?? 0)} />
<Field label="Children" value={String(b?.childCount ?? 0)} />
<Field label="Booking ID" value={b?.id} mono truncate />
</div>
</section>
{/* Trip */}
<section>
<SectionHeader title="Trip Information" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Origin" value={t.schedule?.originStation?.name} />
<Field label="Destination" value={t.schedule?.destinationStation?.name} />
<Field label="Departure" value={t.schedule?.departureAt ? formatDateTime(t.schedule.departureAt) : ''} />
<Field label="Arrival" value={t.schedule?.arrivalAt ? formatDateTime(t.schedule.arrivalAt) : ''} />
<Field label="Train" value={t.schedule?.train?.name || t.schedule?.train?.number} />
<Field label="Schedule ID" value={t.scheduleId} mono truncate />
</div>
</section>
{/* Seat */}
<section>
<SectionHeader title="Seat Information" />
<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 md:col-span-1 flex flex-col items-center justify-center">
<p className="text-xs text-emerald-700 dark:text-emerald-400 mb-1">Seat</p>
<p className="text-2xl font-mono font-bold text-emerald-800 dark:text-emerald-300">{t.seat?.seatNumber || '—'}</p>
</div>
<Field label="Coach" value={t.seat?.coach?.number} mono />
<Field label="Class" value={t.seat?.coach?.coachType?.name || t.seat?.coach?.coachType?.type} />
<Field label="Seat ID" value={t.seatId} mono truncate />
</div>
</section>
{/* Round-trip */}
{isRoundTrip && (
<section>
<SectionHeader title="Round-Trip Legs" />
<div className="grid grid-cols-2 md:grid-cols-3 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'} />
</div>
</section>
)}
{/* Validation */}
<section>
<SectionHeader title="Validation & Timestamps" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Validated At" value={t.validatedAt ? formatDateTime(t.validatedAt) : 'Not validated'} />
<Field label="Boarded At" value={t.boardedAt ? formatDateTime(t.boardedAt) : 'Not boarded'} />
<Field label="QR Code" value={t.qrCode ? 'Generated' : 'N/A'} />
<Field label="Created" value={formatDateTime(t.createdAt)} />
<Field label="Last Updated" value={formatDateTime(t.updatedAt)} />
<Field label="Ticket ID" value={t.id} mono truncate />
</div>
</section>
</div>
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
<ActionButton variant="secondary" onClick={() => { setDetailsModalOpen(false); setSelectedTicket(null); }}>Close</ActionButton>
</div>
</div>
);
})()}
</Modal>
{/* Excess Baggage Modal */}
<Modal
isOpen={excessModalOpen}
onClose={() => { setExcessModalOpen(false); setExcessTicket(null); setExcessResult(null); }}
title="Log Excess Baggage"
size="sm"
>
{excessResult ? (
<div className="space-y-4">
<div className="rounded-lg bg-green-50 dark:bg-green-900/20 p-4 text-sm text-green-800 dark:text-green-200">
{excessResult.status === 'CASH_COLLECTED'
? '✓ Cash collected and charge recorded.'
: `✓ Payment link sent to passenger. Charge: ${formatCurrency(excessResult.totalMinor, excessResult.currency)}`}
</div>
<div className="text-sm space-y-1">
<div className="flex justify-between"><span className="text-muted-foreground">Excess weight</span><span className="font-medium">{excessResult.excessWeightKg} kg</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">Amount due</span><span className="font-semibold">{formatCurrency(excessResult.totalMinor, excessResult.currency)}</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">Status</span><span className="font-medium">{excessResult.status}</span></div>
</div>
<div className="flex justify-end pt-2">
<ActionButton variant="secondary" onClick={() => { setExcessModalOpen(false); setExcessResult(null); }}>Close</ActionButton>
</div>
</div>
) : (
<form onSubmit={handleExcessSubmit} className="space-y-4">
<div className="text-sm text-muted-foreground">
Booking: <span className="font-semibold text-foreground">{excessTicket?.booking?.bookingRef}</span>
</div>
{agentData && (
<div className="text-sm text-muted-foreground">
Agent: <span className="font-semibold text-foreground">{agentData.agentCode}</span>
</div>
)}
{!agentData && (
<div className="text-sm text-amber-600 dark:text-amber-400">
No agent profile linked to your account.
</div>
)}
<div>
<label className="label">Excess weight (kg)</label>
<input
type="number"
min="1"
className="input"
placeholder="e.g. 7"
value={excessKg}
onChange={(e) => setExcessKg(e.target.value)}
required
/>
</div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={excessCollectCash}
onChange={(e) => setExcessCollectCash(e.target.checked)}
className="w-4 h-4 rounded border-gray-300"
/>
<span className="text-sm">Collect cash now (no payment link sent)</span>
</label>
{excessError && (
<p className="text-sm text-red-600 dark:text-red-400">{excessError}</p>
)}
<div className="flex justify-end gap-2 pt-2">
<ActionButton variant="secondary" onClick={() => setExcessModalOpen(false)}>Cancel</ActionButton>
<ActionButton icon={Package} loading={excessMutation.isPending} type="submit">
{excessCollectCash ? 'Collect Cash' : 'Send Payment Link'}
</ActionButton>
</div>
</form>
)}
</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>
<p className="text-sm font-medium mb-2">Export Format</p>
<div className="flex gap-4">
{(['csv', 'excel', 'pdf'] as const).map(fmt => (
<label key={fmt} className="flex items-center gap-2 cursor-pointer">
<input type="radio" name="ticketExportFmt" value={fmt} checked={exportFormat === fmt} onChange={() => setExportFormat(fmt)} className="w-4 h-4" />
<span className="text-sm font-medium">{fmt === 'excel' ? 'Excel (.xls)' : fmt === 'pdf' ? 'PDF (Print)' : 'CSV'}</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</ActionButton>
</div>
</div>
</Modal>
</div>
);
}