- Replace the three Attendees-tab modals (Manual Ticket / Add at Door / Invite Guest) with a single Add Ticket modal: Paid/Unpaid/Guest segmented control, shared fields, "Check in now" for all types, and a live "what happens" preview, backed by one POST /api/tickets/admin/add. - Add tickets.payment_status (paid | unpaid | comp) with a backfill migration; keep it in sync on every payment-settlement path (mark-paid, admin approval, Lightning, free bookings, hold recovery). - Show Paid/Unpaid/Comp badges in the attendee list, count only paid tickets toward revenue, let unpaid tickets be resolved via Mark Paid, and flag unpaid tickets with their balance due in the door scanner. - Replace the per-page useStatsPrivacy hook with an admin-wide PrivacyContext + SensitiveValue mask, toggled from the admin layout. - Add server-side pagination with page-size options to the users page. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
392 lines
18 KiB
TypeScript
392 lines
18 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 clsx from 'clsx';
|
|
import {
|
|
MagnifyingGlassIcon,
|
|
ChevronDownIcon,
|
|
ArrowDownTrayIcon,
|
|
PlusIcon,
|
|
EnvelopeIcon,
|
|
StarIcon,
|
|
FunnelIcon,
|
|
ChatBubbleLeftIcon,
|
|
ArrowPathIcon,
|
|
BanknotesIcon,
|
|
CheckCircleIcon,
|
|
} from '@heroicons/react/24/outline';
|
|
import { StatusBadge, PaymentBadge } from '../_components/StatusBadge';
|
|
import type { AttendeeStatusFilter, AddTicketType, PrimaryAction } from '../_types';
|
|
|
|
interface AttendeesTabProps {
|
|
locale: string;
|
|
tickets: Ticket[];
|
|
filteredTickets: Ticket[];
|
|
searchQuery: string;
|
|
setSearchQuery: (value: string) => void;
|
|
statusFilter: AttendeeStatusFilter;
|
|
setStatusFilter: (value: AttendeeStatusFilter) => void;
|
|
pendingCount: number;
|
|
confirmedCount: number;
|
|
checkedInCount: number;
|
|
cancelledCount: number;
|
|
onHoldCount: number;
|
|
exporting: boolean;
|
|
showExportDropdown: boolean;
|
|
setShowExportDropdown: (value: boolean) => void;
|
|
showAddTicketDropdown: boolean;
|
|
setShowAddTicketDropdown: (value: boolean) => void;
|
|
handleExportAttendees: (status: 'confirmed' | 'checked_in' | 'confirmed_pending' | 'all') => void;
|
|
openAddTicket: (type: AddTicketType) => void;
|
|
setMobileFilterOpen: (value: boolean) => void;
|
|
setShowExportSheet: (value: boolean) => void;
|
|
setShowAddTicketSheet: (value: boolean) => void;
|
|
getPrimaryAction: (ticket: Ticket) => PrimaryAction | null;
|
|
handleOpenNoteModal: (ticket: Ticket) => void;
|
|
handleReactivate: (ticket: Ticket) => void;
|
|
handleMarkPaid: (ticketId: string) => void;
|
|
handleCheckin: (ticketId: string) => void;
|
|
}
|
|
|
|
export function AttendeesTab({
|
|
locale,
|
|
tickets,
|
|
filteredTickets,
|
|
searchQuery,
|
|
setSearchQuery,
|
|
statusFilter,
|
|
setStatusFilter,
|
|
pendingCount,
|
|
confirmedCount,
|
|
checkedInCount,
|
|
cancelledCount,
|
|
onHoldCount,
|
|
exporting,
|
|
showExportDropdown,
|
|
setShowExportDropdown,
|
|
showAddTicketDropdown,
|
|
setShowAddTicketDropdown,
|
|
handleExportAttendees,
|
|
openAddTicket,
|
|
setMobileFilterOpen,
|
|
setShowExportSheet,
|
|
setShowAddTicketSheet,
|
|
getPrimaryAction,
|
|
handleOpenNoteModal,
|
|
handleReactivate,
|
|
handleMarkPaid,
|
|
handleCheckin,
|
|
}: AttendeesTabProps) {
|
|
return (
|
|
<div className="space-y-3">
|
|
{/* Desktop toolbar */}
|
|
<Card className="p-3 hidden md:block">
|
|
<div className="flex items-center gap-3">
|
|
{/* Left: Search + Status */}
|
|
<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 name, email, phone..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(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={statusFilter}
|
|
onChange={(e) => setStatusFilter(e.target.value as AttendeeStatusFilter)}
|
|
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 ({tickets.length})</option>
|
|
<option value="pending">Pending ({pendingCount})</option>
|
|
<option value="confirmed">Confirmed ({confirmedCount})</option>
|
|
<option value="checked_in">Checked In ({checkedInCount})</option>
|
|
<option value="cancelled">Cancelled ({cancelledCount})</option>
|
|
<option value="on_hold">On Hold ({onHoldCount})</option>
|
|
</select>
|
|
|
|
<div className="flex-1" />
|
|
|
|
{/* Right: Export + Add Ticket dropdown */}
|
|
<Dropdown
|
|
open={showExportDropdown}
|
|
onOpenChange={setShowExportDropdown}
|
|
trigger={
|
|
<Button variant="outline" size="sm" disabled={exporting}>
|
|
{exporting ? (
|
|
<div className="w-3.5 h-3.5 mr-1.5 border-2 border-gray-400 border-t-transparent rounded-full animate-spin" />
|
|
) : (
|
|
<ArrowDownTrayIcon className="w-3.5 h-3.5 mr-1.5" />
|
|
)}
|
|
Export
|
|
<ChevronDownIcon className="w-3 h-3 ml-1" />
|
|
</Button>
|
|
}
|
|
>
|
|
<DropdownItem onClick={() => handleExportAttendees('all')}>Export All</DropdownItem>
|
|
<DropdownItem onClick={() => handleExportAttendees('confirmed')}>Export Confirmed</DropdownItem>
|
|
<DropdownItem onClick={() => handleExportAttendees('checked_in')}>Export Checked-in</DropdownItem>
|
|
<DropdownItem onClick={() => handleExportAttendees('confirmed_pending')}>Confirmed & Pending</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>
|
|
|
|
<Dropdown
|
|
open={showAddTicketDropdown}
|
|
onOpenChange={setShowAddTicketDropdown}
|
|
trigger={
|
|
<Button size="sm">
|
|
<PlusIcon className="w-3.5 h-3.5 mr-1.5" />
|
|
Add Ticket
|
|
<ChevronDownIcon className="w-3 h-3 ml-1" />
|
|
</Button>
|
|
}
|
|
>
|
|
<DropdownItem onClick={() => { openAddTicket('paid'); setShowAddTicketDropdown(false); }}>
|
|
<EnvelopeIcon className="w-4 h-4 mr-2" /> Paid Ticket
|
|
</DropdownItem>
|
|
<DropdownItem onClick={() => { openAddTicket('unpaid'); setShowAddTicketDropdown(false); }}>
|
|
<BanknotesIcon className="w-4 h-4 mr-2" /> Unpaid Ticket
|
|
</DropdownItem>
|
|
<DropdownItem onClick={() => { openAddTicket('guest'); setShowAddTicketDropdown(false); }}>
|
|
<StarIcon className="w-4 h-4 mr-2" /> Invite Guest
|
|
</DropdownItem>
|
|
</Dropdown>
|
|
</div>
|
|
{(searchQuery || statusFilter !== 'all') && (
|
|
<div className="mt-2 text-xs text-gray-500 flex items-center gap-2">
|
|
<span>Showing {filteredTickets.length} of {tickets.length}</span>
|
|
<button onClick={() => { setSearchQuery(''); setStatusFilter('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 name, email, phone..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(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">
|
|
<button
|
|
onClick={() => setMobileFilterOpen(true)}
|
|
className={clsx(
|
|
'flex items-center gap-1.5 px-3 py-2 rounded-btn border text-sm min-h-[44px]',
|
|
statusFilter !== 'all'
|
|
? 'border-primary-yellow bg-yellow-50 text-primary-dark'
|
|
: 'border-secondary-light-gray text-gray-600'
|
|
)}
|
|
>
|
|
<FunnelIcon className="w-4 h-4" />
|
|
{statusFilter === 'all' ? 'Filter' : statusFilter.replace('_', ' ')}
|
|
</button>
|
|
<button
|
|
onClick={() => setShowExportSheet(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>
|
|
{(searchQuery || statusFilter !== 'all') && (
|
|
<button
|
|
onClick={() => { setSearchQuery(''); setStatusFilter('all'); }}
|
|
className="text-xs text-primary-yellow ml-auto min-h-[44px] flex items-center"
|
|
>
|
|
Clear
|
|
</button>
|
|
)}
|
|
</div>
|
|
{(searchQuery || statusFilter !== 'all') && (
|
|
<p className="text-xs text-gray-500">Showing {filteredTickets.length} of {tickets.length}</p>
|
|
)}
|
|
</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">Contact</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">Booked</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">
|
|
{filteredTickets.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={5} className="px-4 py-10 text-center text-gray-500 text-sm">
|
|
{tickets.length === 0 ? 'No attendees yet' : 'No attendees match the current filters'}
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
filteredTickets.map((ticket) => {
|
|
const primary = getPrimaryAction(ticket);
|
|
return (
|
|
<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" title={`Booking: ${ticket.bookingId}`}>
|
|
Group booking
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-2.5">
|
|
<p className="text-sm text-gray-600 truncate max-w-[200px]">{ticket.attendeeEmail}</p>
|
|
{ticket.attendeePhone && <p className="text-xs text-gray-400">{ticket.attendeePhone}</p>}
|
|
{ticket.attendeeRuc && <p className="text-xs text-gray-400">RUC: {ticket.attendeeRuc}</p>}
|
|
</td>
|
|
<td className="px-4 py-2.5">
|
|
<div className="flex items-center gap-1 flex-wrap">
|
|
<StatusBadge status={ticket.status} compact />
|
|
<PaymentBadge paymentStatus={ticket.paymentStatus} compact />
|
|
</div>
|
|
{ticket.checkinAt && (
|
|
<p className="text-[10px] text-gray-400 mt-0.5">
|
|
{parseDate(ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE })}
|
|
</p>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-2.5 text-xs text-gray-500">
|
|
{parseDate(ticket.createdAt).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: EVENT_TIMEZONE })}
|
|
</td>
|
|
<td className="px-4 py-2.5">
|
|
<div className="flex items-center justify-end gap-1">
|
|
{primary && (
|
|
<Button size="sm" variant={primary.variant} onClick={primary.onClick} className="text-xs px-2 py-1">
|
|
{primary.icon && <primary.icon className="w-3 h-3 mr-1" />}
|
|
{primary.label}
|
|
</Button>
|
|
)}
|
|
<MoreMenu>
|
|
{ticket.status === 'on_hold' && (
|
|
<DropdownItem onClick={() => handleReactivate(ticket)}>
|
|
<ArrowPathIcon className="w-4 h-4 mr-2" /> Reactivate
|
|
</DropdownItem>
|
|
)}
|
|
{ticket.paymentStatus === 'unpaid' && ticket.status === 'checked_in' && (
|
|
<DropdownItem onClick={() => handleMarkPaid(ticket.id)}>
|
|
<BanknotesIcon className="w-4 h-4 mr-2" /> Mark as Paid
|
|
</DropdownItem>
|
|
)}
|
|
{ticket.paymentStatus === 'unpaid' && ticket.status === 'confirmed' && (
|
|
<DropdownItem onClick={() => handleCheckin(ticket.id)}>
|
|
<CheckCircleIcon className="w-4 h-4 mr-2" /> Check In (unpaid)
|
|
</DropdownItem>
|
|
)}
|
|
<DropdownItem onClick={() => handleOpenNoteModal(ticket)}>
|
|
<ChatBubbleLeftIcon className="w-4 h-4 mr-2" />
|
|
{ticket.adminNote ? 'Edit Note' : 'Add Note'}
|
|
</DropdownItem>
|
|
{ticket.adminNote && (
|
|
<div className="px-4 py-1.5 text-[10px] text-gray-400 truncate max-w-[180px]">
|
|
Note: {ticket.adminNote}
|
|
</div>
|
|
)}
|
|
<div className="px-4 py-1.5 text-[10px] text-gray-400 font-mono" title={ticket.id}>
|
|
ID: {ticket.id.slice(0, 8)}...
|
|
</div>
|
|
</MoreMenu>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Mobile: Card layout */}
|
|
<div className="md:hidden space-y-2">
|
|
{filteredTickets.length === 0 ? (
|
|
<div className="text-center py-10 text-gray-500 text-sm">
|
|
{tickets.length === 0 ? 'No attendees yet' : 'No attendees match the current filters'}
|
|
</div>
|
|
) : (
|
|
filteredTickets.map((ticket) => {
|
|
const primary = getPrimaryAction(ticket);
|
|
return (
|
|
<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>
|
|
<p className="text-xs text-gray-500 truncate">{ticket.attendeeEmail}</p>
|
|
{ticket.attendeePhone && <p className="text-[10px] text-gray-400">{ticket.attendeePhone}</p>}
|
|
{ticket.attendeeRuc && <p className="text-[10px] text-gray-400">RUC: {ticket.attendeeRuc}</p>}
|
|
</div>
|
|
<div className="flex items-center gap-1.5 flex-shrink-0 flex-wrap justify-end">
|
|
<StatusBadge status={ticket.status} compact />
|
|
<PaymentBadge paymentStatus={ticket.paymentStatus} compact />
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
|
|
<p className="text-[10px] text-gray-400">
|
|
{parseDate(ticket.createdAt).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: EVENT_TIMEZONE })}
|
|
{ticket.checkinAt && ` · Checked in ${parseDate(ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE })}`}
|
|
</p>
|
|
<div className="flex items-center gap-1">
|
|
{primary && (
|
|
<Button size="sm" variant={primary.variant} onClick={primary.onClick} className="text-xs px-2.5 py-1.5 min-h-[36px]">
|
|
{primary.icon && <primary.icon className="w-3 h-3 mr-1" />}
|
|
{primary.label}
|
|
</Button>
|
|
)}
|
|
<MoreMenu>
|
|
{ticket.status === 'on_hold' && (
|
|
<DropdownItem onClick={() => handleReactivate(ticket)}>
|
|
<ArrowPathIcon className="w-4 h-4 mr-2" /> Reactivate
|
|
</DropdownItem>
|
|
)}
|
|
{ticket.paymentStatus === 'unpaid' && ticket.status === 'checked_in' && (
|
|
<DropdownItem onClick={() => handleMarkPaid(ticket.id)}>
|
|
<BanknotesIcon className="w-4 h-4 mr-2" /> Mark as Paid
|
|
</DropdownItem>
|
|
)}
|
|
{ticket.paymentStatus === 'unpaid' && ticket.status === 'confirmed' && (
|
|
<DropdownItem onClick={() => handleCheckin(ticket.id)}>
|
|
<CheckCircleIcon className="w-4 h-4 mr-2" /> Check In (unpaid)
|
|
</DropdownItem>
|
|
)}
|
|
<DropdownItem onClick={() => handleOpenNoteModal(ticket)}>
|
|
<ChatBubbleLeftIcon className="w-4 h-4 mr-2" />
|
|
{ticket.adminNote ? 'Edit Note' : 'Add Note'}
|
|
</DropdownItem>
|
|
</MoreMenu>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
|
|
{/* Mobile FAB */}
|
|
<div className="md:hidden fixed bottom-6 right-6 z-40">
|
|
<button
|
|
onClick={() => setShowAddTicketSheet(true)}
|
|
className="w-14 h-14 bg-primary-yellow text-primary-dark rounded-full shadow-lg flex items-center justify-center hover:bg-yellow-400 active:scale-95 transition-transform"
|
|
>
|
|
<PlusIcon className="w-6 h-6" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|