Files
Spanglish/frontend/src/app/admin/events/[id]/_tabs/AttendeesTab.tsx
T
MichilisandClaude Fable 5 9b2668f498 Unify admin ticket creation into one modal with first-class payment status.
- 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>
2026-07-26 05:25:00 +00:00

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 &amp; 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>
);
}