mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 05:25:41 +00:00
260 lines
8.1 KiB
TypeScript
260 lines
8.1 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { Download, RefreshCw, CheckCircle, Trash2 } 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 { ticketsApi, apiClient } from '@/lib/api';
|
|
import { formatDateTime } from '@/lib/utils';
|
|
|
|
export default function TicketsPage() {
|
|
const [filters, setFilters] = useState({ search: '', status: '' });
|
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
|
const [ticketToDelete, setTicketToDelete] = useState<any>(null);
|
|
const [successMessage, setSuccessMessage] = useState('');
|
|
const queryClient = useQueryClient();
|
|
|
|
const { data, isLoading, error } = useQuery({
|
|
queryKey: ['tickets', filters],
|
|
queryFn: () => ticketsApi.getAll({ ...filters, skip: 0, take: 50 }),
|
|
});
|
|
|
|
const regenerateMutation = useMutation({
|
|
mutationFn: ticketsApi.regenerate,
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['tickets'] });
|
|
setSuccessMessage('Ticket regenerated successfully');
|
|
setTimeout(() => setSuccessMessage(''), 3000);
|
|
},
|
|
onError: (error: any) => {
|
|
alert(`Error: ${error.message || 'Failed to regenerate ticket'}`);
|
|
},
|
|
});
|
|
|
|
const validateMutation = useMutation({
|
|
mutationFn: ({ ticketId, data }: any) => ticketsApi.validate(ticketId, data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['tickets'] });
|
|
setSuccessMessage('Ticket validated successfully');
|
|
setTimeout(() => setSuccessMessage(''), 3000);
|
|
},
|
|
onError: (error: any) => {
|
|
alert(`Error: ${error.message || 'Failed to validate 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 handleRegenerate = async (ticket: any) => {
|
|
if (window.confirm(`Regenerate QR code for ticket ${ticket.ticketNumber}?`)) {
|
|
await regenerateMutation.mutateAsync(ticket.id);
|
|
}
|
|
};
|
|
|
|
const handleValidate = async (ticket: any) => {
|
|
await validateMutation.mutateAsync({
|
|
ticketId: ticket.id,
|
|
data: { validatedAt: new Date().toISOString() },
|
|
});
|
|
};
|
|
|
|
const handleDeleteClick = (ticket: any) => {
|
|
setTicketToDelete(ticket);
|
|
setDeleteConfirmOpen(true);
|
|
};
|
|
|
|
const handleConfirmDelete = async () => {
|
|
if (ticketToDelete) {
|
|
await deleteMutation.mutateAsync(ticketToDelete.id);
|
|
}
|
|
};
|
|
|
|
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',
|
|
render: (ticket: any) => (
|
|
<span className="font-mono">{ticket.seat?.seatNumber || 'N/A'}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'status',
|
|
label: 'Status',
|
|
render: (ticket: any) => (
|
|
<Badge variant="status" status={ticket.status || 'PENDING'}>
|
|
{ticket.status || 'PENDING'}
|
|
</Badge>
|
|
),
|
|
},
|
|
{
|
|
key: 'validated',
|
|
label: 'Validated',
|
|
render: (ticket: any) => (
|
|
ticket.validatedAt ? (
|
|
<div className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
|
<CheckCircle className="h-4 w-4" />
|
|
<span className="text-sm">{formatDateTime(ticket.validatedAt)}</span>
|
|
</div>
|
|
) : (
|
|
<span className="text-sm text-muted-foreground">Not validated</span>
|
|
)
|
|
),
|
|
},
|
|
{
|
|
key: 'createdAt',
|
|
label: 'Created',
|
|
sortable: true,
|
|
render: (ticket: any) => formatDateTime(ticket.createdAt),
|
|
},
|
|
];
|
|
|
|
const actions = [
|
|
{
|
|
label: 'Check-in',
|
|
onClick: handleValidate,
|
|
variant: 'primary' as const,
|
|
icon: CheckCircle,
|
|
show: (ticket: any) => !ticket.validatedAt,
|
|
},
|
|
{
|
|
label: 'Regenerate',
|
|
onClick: handleRegenerate,
|
|
variant: 'secondary' as const,
|
|
icon: RefreshCw,
|
|
},
|
|
{
|
|
label: 'Delete',
|
|
onClick: handleDeleteClick,
|
|
variant: 'danger' as const,
|
|
icon: Trash2,
|
|
},
|
|
];
|
|
|
|
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">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 gap-4">
|
|
<div>
|
|
<label className="label">Search</label>
|
|
<input
|
|
type="text"
|
|
placeholder="Search by ticket number or booking ref..."
|
|
className="input"
|
|
value={filters.search}
|
|
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="label">Status</label>
|
|
<select
|
|
className="input"
|
|
value={filters.status}
|
|
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
|
>
|
|
<option value="">All Status</option>
|
|
<option value="ACTIVE">Active</option>
|
|
<option value="USED">Used</option>
|
|
<option value="CANCELLED">Cancelled</option>
|
|
<option value="EXPIRED">Expired</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tickets Table */}
|
|
<DataTable
|
|
data={data?.items || []}
|
|
columns={columns}
|
|
actions={actions}
|
|
loading={isLoading}
|
|
emptyMessage="No tickets found"
|
|
/>
|
|
|
|
{/* 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}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|