Split oversized frontend API client, email service, and admin/booking pages into focused modules while preserving import surfaces, and add Redis-backed queues, stale booking cleanup, stronger auth, and scale deployment configs. Co-authored-by: Cursor <cursoragent@cursor.com>
259 lines
12 KiB
TypeScript
259 lines
12 KiB
TypeScript
import { Ticket } from '@/lib/api';
|
|
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
|
|
import Card from '@/components/ui/Card';
|
|
import Button from '@/components/ui/Button';
|
|
import { Dropdown, DropdownItem, MoreMenu } from '@/components/admin/MobileComponents';
|
|
import {
|
|
MagnifyingGlassIcon,
|
|
ChevronDownIcon,
|
|
ArrowDownTrayIcon,
|
|
ArrowUturnLeftIcon,
|
|
} from '@heroicons/react/24/outline';
|
|
import type { TicketStatusFilter } from '../_types';
|
|
|
|
interface TicketsTabProps {
|
|
locale: string;
|
|
confirmedTickets: Ticket[];
|
|
filteredConfirmedTickets: Ticket[];
|
|
ticketSearchQuery: string;
|
|
setTicketSearchQuery: (value: string) => void;
|
|
ticketStatusFilter: TicketStatusFilter;
|
|
setTicketStatusFilter: (value: TicketStatusFilter) => void;
|
|
confirmedCount: number;
|
|
checkedInCount: number;
|
|
exporting: boolean;
|
|
showTicketExportDropdown: boolean;
|
|
setShowTicketExportDropdown: (value: boolean) => void;
|
|
handleExportTickets: (status: 'confirmed' | 'checked_in' | 'all') => void;
|
|
handleCheckin: (ticketId: string) => void;
|
|
handleRemoveCheckin: (ticketId: string) => void;
|
|
setShowTicketExportSheet: (value: boolean) => void;
|
|
}
|
|
|
|
export function TicketsTab({
|
|
locale,
|
|
confirmedTickets,
|
|
filteredConfirmedTickets,
|
|
ticketSearchQuery,
|
|
setTicketSearchQuery,
|
|
ticketStatusFilter,
|
|
setTicketStatusFilter,
|
|
confirmedCount,
|
|
checkedInCount,
|
|
exporting,
|
|
showTicketExportDropdown,
|
|
setShowTicketExportDropdown,
|
|
handleExportTickets,
|
|
handleCheckin,
|
|
handleRemoveCheckin,
|
|
setShowTicketExportSheet,
|
|
}: TicketsTabProps) {
|
|
return (
|
|
<div className="space-y-3">
|
|
{/* Desktop toolbar */}
|
|
<Card className="p-3 hidden md:block">
|
|
<div className="flex items-center gap-3">
|
|
<div className="relative flex-1 max-w-sm">
|
|
<MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search by name or ticket ID..."
|
|
value={ticketSearchQuery}
|
|
onChange={(e) => setTicketSearchQuery(e.target.value)}
|
|
className="w-full pl-9 pr-3 py-1.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
/>
|
|
</div>
|
|
<select
|
|
value={ticketStatusFilter}
|
|
onChange={(e) => setTicketStatusFilter(e.target.value as TicketStatusFilter)}
|
|
className="px-3 py-1.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
>
|
|
<option value="all">All ({confirmedTickets.length})</option>
|
|
<option value="confirmed">Valid ({confirmedCount})</option>
|
|
<option value="checked_in">Checked In ({checkedInCount})</option>
|
|
</select>
|
|
<div className="flex-1" />
|
|
<Dropdown
|
|
open={showTicketExportDropdown}
|
|
onOpenChange={setShowTicketExportDropdown}
|
|
trigger={
|
|
<Button variant="outline" size="sm" disabled={exporting}>
|
|
<ArrowDownTrayIcon className="w-3.5 h-3.5 mr-1.5" />
|
|
Export
|
|
<ChevronDownIcon className="w-3 h-3 ml-1" />
|
|
</Button>
|
|
}
|
|
>
|
|
<DropdownItem onClick={() => handleExportTickets('all')}>Export All</DropdownItem>
|
|
<DropdownItem onClick={() => handleExportTickets('confirmed')}>Export Valid</DropdownItem>
|
|
<DropdownItem onClick={() => handleExportTickets('checked_in')}>Export Checked-in</DropdownItem>
|
|
<div className="border-t border-gray-100 mx-2" />
|
|
<div className="px-4 py-1.5 text-[10px] text-gray-400">Format: CSV</div>
|
|
</Dropdown>
|
|
</div>
|
|
{(ticketSearchQuery || ticketStatusFilter !== 'all') && (
|
|
<div className="mt-2 text-xs text-gray-500 flex items-center gap-2">
|
|
<span>Showing {filteredConfirmedTickets.length} of {confirmedTickets.length}</span>
|
|
<button onClick={() => { setTicketSearchQuery(''); setTicketStatusFilter('all'); }} className="text-primary-yellow hover:underline">Clear</button>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Mobile toolbar */}
|
|
<div className="md:hidden space-y-2">
|
|
<div className="relative">
|
|
<MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search by name or ticket ID..."
|
|
value={ticketSearchQuery}
|
|
onChange={(e) => setTicketSearchQuery(e.target.value)}
|
|
className="w-full pl-9 pr-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<select
|
|
value={ticketStatusFilter}
|
|
onChange={(e) => setTicketStatusFilter(e.target.value as TicketStatusFilter)}
|
|
className="px-3 py-2 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow min-h-[44px]"
|
|
>
|
|
<option value="all">All ({confirmedTickets.length})</option>
|
|
<option value="confirmed">Valid ({confirmedCount})</option>
|
|
<option value="checked_in">Checked In ({checkedInCount})</option>
|
|
</select>
|
|
<button
|
|
onClick={() => setShowTicketExportSheet(true)}
|
|
disabled={exporting}
|
|
className="flex items-center gap-1.5 px-3 py-2 rounded-btn border border-secondary-light-gray text-sm text-gray-600 min-h-[44px]"
|
|
>
|
|
<ArrowDownTrayIcon className="w-4 h-4" />
|
|
Export
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Desktop: Dense table */}
|
|
<Card className="overflow-hidden hidden md:block">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="bg-gray-50">
|
|
<tr>
|
|
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Attendee</th>
|
|
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
|
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Check-in</th>
|
|
<th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-100">
|
|
{filteredConfirmedTickets.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={4} className="px-4 py-10 text-center text-gray-500 text-sm">
|
|
{confirmedTickets.length === 0 ? 'No confirmed tickets yet' : 'No tickets match the current filters'}
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
filteredConfirmedTickets.map((ticket) => (
|
|
<tr key={ticket.id} className="hover:bg-gray-50/50">
|
|
<td className="px-4 py-2.5">
|
|
<p className="font-medium text-sm">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p>
|
|
{ticket.bookingId && (
|
|
<span className="text-[10px] text-purple-600">Group booking</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-2.5">
|
|
{ticket.status === 'confirmed' ? (
|
|
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-green-100 text-green-800 font-medium">Valid</span>
|
|
) : (
|
|
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-blue-100 text-blue-800 font-medium">Checked In</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-2.5 text-xs text-gray-500">
|
|
{ticket.checkinAt ? (
|
|
parseDate(ticket.checkinAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', {
|
|
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE,
|
|
})
|
|
) : (
|
|
<span className="text-gray-300">—</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-2.5">
|
|
<div className="flex items-center justify-end gap-1">
|
|
{ticket.status === 'confirmed' && (
|
|
<Button size="sm" onClick={() => handleCheckin(ticket.id)} className="text-xs px-2 py-1">
|
|
Check In
|
|
</Button>
|
|
)}
|
|
{ticket.status === 'checked_in' && (
|
|
<Button size="sm" variant="outline" onClick={() => handleRemoveCheckin(ticket.id)} className="text-xs px-2 py-1">
|
|
<ArrowUturnLeftIcon className="w-3 h-3 mr-1" />
|
|
Undo
|
|
</Button>
|
|
)}
|
|
<MoreMenu>
|
|
<div className="px-4 py-1.5 text-[10px] text-gray-400 font-mono" title={ticket.id}>
|
|
ID: {ticket.id.slice(0, 8)}...
|
|
</div>
|
|
{ticket.bookingId && (
|
|
<div className="px-4 py-1.5 text-[10px] text-purple-500 font-mono" title={ticket.bookingId}>
|
|
Booking: {ticket.bookingId.slice(0, 8)}...
|
|
</div>
|
|
)}
|
|
</MoreMenu>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Mobile: Card layout */}
|
|
<div className="md:hidden space-y-2">
|
|
{filteredConfirmedTickets.length === 0 ? (
|
|
<div className="text-center py-10 text-gray-500 text-sm">
|
|
{confirmedTickets.length === 0 ? 'No confirmed tickets yet' : 'No tickets match the current filters'}
|
|
</div>
|
|
) : (
|
|
filteredConfirmedTickets.map((ticket) => (
|
|
<Card key={ticket.id} className="p-3">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="min-w-0 flex-1">
|
|
<p className="font-medium text-sm truncate">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p>
|
|
{ticket.bookingId && <p className="text-[10px] text-purple-600">Group booking</p>}
|
|
</div>
|
|
{ticket.status === 'confirmed' ? (
|
|
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-green-100 text-green-800 font-medium flex-shrink-0">Valid</span>
|
|
) : (
|
|
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-blue-100 text-blue-800 font-medium flex-shrink-0">Checked In</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
|
|
<p className="text-[10px] text-gray-400">
|
|
{ticket.checkinAt
|
|
? `Checked in ${parseDate(ticket.checkinAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE })}`
|
|
: 'Not checked in'}
|
|
</p>
|
|
<div className="flex items-center gap-1">
|
|
{ticket.status === 'confirmed' && (
|
|
<Button size="sm" onClick={() => handleCheckin(ticket.id)} className="text-xs px-2.5 py-1.5 min-h-[36px]">
|
|
Check In
|
|
</Button>
|
|
)}
|
|
{ticket.status === 'checked_in' && (
|
|
<Button size="sm" variant="outline" onClick={() => handleRemoveCheckin(ticket.id)} className="text-xs px-2.5 py-1.5 min-h-[36px]">
|
|
<ArrowUturnLeftIcon className="w-3 h-3 mr-1" />
|
|
Undo
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|