mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 07:38:10 +00:00
998 lines
46 KiB
TypeScript
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 — 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) => {
|
|
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>
|
|
);
|
|
}
|