diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index baf22fb..911a026 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -222,11 +222,11 @@ adminRouter.get('/export/tickets', requireAuth(['admin']), async (c) => { return c.json({ tickets: enrichedTickets }); }); -// Export attendees for a specific event (admin) — CSV/XLSX download -adminRouter.get('/events/:eventId/export', requireAuth(['admin']), async (c) => { +// Export attendees for a specific event (admin) — CSV download +adminRouter.get('/events/:eventId/attendees/export', requireAuth(['admin']), async (c) => { const eventId = c.req.param('eventId'); const status = c.req.query('status') || 'all'; // confirmed | checked_in | confirmed_pending | all - const format = c.req.query('format') || 'csv'; // csv | xlsx + const q = c.req.query('q') || ''; // Verify event exists const event = await dbGet( @@ -249,14 +249,28 @@ adminRouter.get('/events/:eventId/export', requireAuth(['admin']), async (c) => // "all" — include everything } - const ticketList = await dbAll( + let ticketList = await dbAll( (db as any) .select() .from(tickets) .where(conditions.length === 1 ? conditions[0] : and(...conditions)) - .orderBy((tickets as any).createdAt) + .orderBy(desc((tickets as any).createdAt)) ); + // Apply text search filter in-memory + if (q) { + const query = q.toLowerCase(); + ticketList = ticketList.filter((t: any) => { + const fullName = `${t.attendeeFirstName || ''} ${t.attendeeLastName || ''}`.toLowerCase(); + return ( + fullName.includes(query) || + (t.attendeeEmail || '').toLowerCase().includes(query) || + (t.attendeePhone || '').toLowerCase().includes(query) || + t.id.toLowerCase().includes(query) + ); + }); + } + // Enrich each ticket with payment data const rows = await Promise.all( ticketList.map(async (ticket: any) => { @@ -274,10 +288,12 @@ adminRouter.get('/events/:eventId/export', requireAuth(['admin']), async (c) => 'Ticket ID': ticket.id, 'Full Name': fullName, 'Email': ticket.attendeeEmail || '', + 'Phone': ticket.attendeePhone || '', 'Status': ticket.status, 'Checked In': isCheckedIn ? 'true' : 'false', 'Check-in Time': ticket.checkinAt || '', 'Payment Status': payment?.status || '', + 'Booked At': ticket.createdAt || '', 'Notes': ticket.adminNote || '', }; }) @@ -294,9 +310,9 @@ adminRouter.get('/events/:eventId/export', requireAuth(['admin']), async (c) => }; const columns = [ - 'Ticket ID', 'Full Name', 'Email', + 'Ticket ID', 'Full Name', 'Email', 'Phone', 'Status', 'Checked In', 'Check-in Time', 'Payment Status', - 'Notes', + 'Booked At', 'Notes', ]; const headerLine = columns.map(csvEscape).join(','); @@ -319,6 +335,98 @@ adminRouter.get('/events/:eventId/export', requireAuth(['admin']), async (c) => return c.body(csvContent); }); +// Legacy alias — keep old path working +adminRouter.get('/events/:eventId/export', requireAuth(['admin']), async (c) => { + const newUrl = new URL(c.req.url); + newUrl.pathname = newUrl.pathname.replace('/export', '/attendees/export'); + return c.redirect(newUrl.toString(), 301); +}); + +// Export tickets for a specific event (admin) — CSV download (confirmed/checked_in only) +adminRouter.get('/events/:eventId/tickets/export', requireAuth(['admin']), async (c) => { + const eventId = c.req.param('eventId'); + const status = c.req.query('status') || 'all'; // confirmed | checked_in | all + const q = c.req.query('q') || ''; + + // Verify event exists + const event = await dbGet( + (db as any).select().from(events).where(eq((events as any).id, eventId)) + ); + if (!event) { + return c.json({ error: 'Event not found' }, 404); + } + + // Only confirmed/checked_in for tickets export + let conditions: any[] = [ + eq((tickets as any).eventId, eventId), + inArray((tickets as any).status, ['confirmed', 'checked_in']), + ]; + + if (status === 'confirmed') { + conditions = [eq((tickets as any).eventId, eventId), eq((tickets as any).status, 'confirmed')]; + } else if (status === 'checked_in') { + conditions = [eq((tickets as any).eventId, eventId), eq((tickets as any).status, 'checked_in')]; + } + + let ticketList = await dbAll( + (db as any) + .select() + .from(tickets) + .where(and(...conditions)) + .orderBy(desc((tickets as any).createdAt)) + ); + + // Apply text search filter + if (q) { + const query = q.toLowerCase(); + ticketList = ticketList.filter((t: any) => { + const fullName = `${t.attendeeFirstName || ''} ${t.attendeeLastName || ''}`.toLowerCase(); + return ( + fullName.includes(query) || + t.id.toLowerCase().includes(query) + ); + }); + } + + const csvEscape = (value: string) => { + if (value == null) return ''; + const str = String(value); + if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) { + return '"' + str.replace(/"/g, '""') + '"'; + } + return str; + }; + + const columns = ['Ticket ID', 'Booking ID', 'Attendee Name', 'Status', 'Check-in Time', 'Booked At']; + + const rows = ticketList.map((ticket: any) => ({ + 'Ticket ID': ticket.id, + 'Booking ID': ticket.bookingId || '', + 'Attendee Name': [ticket.attendeeFirstName, ticket.attendeeLastName].filter(Boolean).join(' '), + 'Status': ticket.status, + 'Check-in Time': ticket.checkinAt || '', + 'Booked At': ticket.createdAt || '', + })); + + const headerLine = columns.map(csvEscape).join(','); + const dataLines = rows.map((row: any) => + columns.map((col: string) => csvEscape(row[col])).join(',') + ); + + const csvContent = '\uFEFF' + [headerLine, ...dataLines].join('\r\n'); + + const slug = (event.title || 'event') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, ''); + const dateStr = new Date().toISOString().split('T')[0]; + const filename = `${slug}-tickets-${dateStr}.csv`; + + c.header('Content-Type', 'text/csv; charset=utf-8'); + c.header('Content-Disposition', `attachment; filename="${filename}"`); + return c.body(csvContent); +}); + // Export financial data (admin) adminRouter.get('/export/financial', requireAuth(['admin']), async (c) => { const startDate = c.req.query('startDate'); diff --git a/frontend/src/app/admin/events/[id]/page.tsx b/frontend/src/app/admin/events/[id]/page.tsx index 6497e6d..6fc1e25 100644 --- a/frontend/src/app/admin/events/[id]/page.tsx +++ b/frontend/src/app/admin/events/[id]/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState, useEffect, useRef, useCallback } from 'react'; +import { createPortal } from 'react-dom'; import { useParams, useRouter } from 'next/navigation'; import Link from 'next/link'; import { useLanguage } from '@/context/LanguageContext'; @@ -36,23 +37,178 @@ import { ArrowPathIcon, ArrowDownTrayIcon, ChevronDownIcon, + EllipsisVerticalIcon, } from '@heroicons/react/24/outline'; import toast from 'react-hot-toast'; import clsx from 'clsx'; type TabType = 'overview' | 'attendees' | 'tickets' | 'email' | 'payments'; +// ----- Skeleton loaders ----- +function TableSkeleton({ rows = 5 }: { rows?: number }) { + return ( +
+ {Array.from({ length: rows }).map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+ ); +} + +function CardSkeleton({ count = 3 }: { count?: number }) { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( +
+
+
+
+
+
+
+
+ ))} +
+ ); +} + +// ----- Dropdown component (portal-based to escape overflow:hidden) ----- +function Dropdown({ trigger, children, open, onOpenChange, align = 'right' }: { + trigger: React.ReactNode; + children: React.ReactNode; + open: boolean; + onOpenChange: (open: boolean) => void; + align?: 'left' | 'right'; +}) { + const triggerRef = useRef(null); + const menuRef = useRef(null); + const [pos, setPos] = useState<{ top: number; left: number } | null>(null); + + // Recalculate position when opened + useEffect(() => { + if (open && triggerRef.current) { + const rect = triggerRef.current.getBoundingClientRect(); + const menuWidth = 192; // w-48 = 12rem = 192px + let left = align === 'right' ? rect.right - menuWidth : rect.left; + // Clamp so menu doesn't overflow viewport + left = Math.max(8, Math.min(left, window.innerWidth - menuWidth - 8)); + setPos({ top: rect.bottom + 4, left }); + } + }, [open, align]); + + // Close on outside click + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + const target = e.target as Node; + if ( + triggerRef.current && !triggerRef.current.contains(target) && + menuRef.current && !menuRef.current.contains(target) + ) { + onOpenChange(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [open, onOpenChange]); + + // Close on scroll (the menu position would be stale) + useEffect(() => { + if (!open) return; + const handler = () => onOpenChange(false); + window.addEventListener('scroll', handler, true); + return () => window.removeEventListener('scroll', handler, true); + }, [open, onOpenChange]); + + return ( + <> +
+
onOpenChange(!open)}>{trigger}
+
+ {open && pos && createPortal( +
+ {children} +
, + document.body + )} + + ); +} + +function DropdownItem({ onClick, children, className }: { onClick: () => void; children: React.ReactNode; className?: string }) { + return ( + + ); +} + +// ----- Bottom Sheet (mobile) ----- +function BottomSheet({ open, onClose, title, children }: { + open: boolean; + onClose: () => void; + title: string; + children: React.ReactNode; +}) { + if (!open) return null; + return ( +
+
+
e.stopPropagation()} + > +
+

{title}

+ +
+
{children}
+
+
+ ); +} + +// ----- More Menu (per-row) ----- +function MoreMenu({ children }: { children: React.ReactNode }) { + const [open, setOpen] = useState(false); + return ( + + + + } + > + {children} + + ); +} + export default function AdminEventDetailPage() { const params = useParams(); const router = useRouter(); const eventId = params.id as string; const { t, locale } = useLanguage(); - + const [loading, setLoading] = useState(true); const [event, setEvent] = useState(null); const [tickets, setTickets] = useState([]); const [activeTab, setActiveTab] = useState('overview'); - + // Email state const [templates, setTemplates] = useState([]); const [selectedTemplate, setSelectedTemplate] = useState(''); @@ -60,7 +216,7 @@ export default function AdminEventDetailPage() { const [customMessage, setCustomMessage] = useState(''); const [sending, setSending] = useState(false); const [previewHtml, setPreviewHtml] = useState(null); - + // Attendees tab state const [searchQuery, setSearchQuery] = useState(''); const [statusFilter, setStatusFilter] = useState<'all' | 'pending' | 'confirmed' | 'checked_in' | 'cancelled'>('all'); @@ -86,28 +242,21 @@ export default function AdminEventDetailPage() { adminNote: '', }); const [submitting, setSubmitting] = useState(false); - - // Export state - const [showExportDropdown, setShowExportDropdown] = useState(false); - const [exporting, setExporting] = useState(false); - const exportBtnRef = useRef(null); - const [exportDropdownPos, setExportDropdownPos] = useState({ top: 0, right: 0 }); - const toggleExportDropdown = useCallback(() => { - if (!showExportDropdown && exportBtnRef.current) { - const rect = exportBtnRef.current.getBoundingClientRect(); - setExportDropdownPos({ - top: rect.bottom + 4, - right: window.innerWidth - rect.right, - }); - } - setShowExportDropdown((v) => !v); - }, [showExportDropdown]); - + // Export state — separate desktop (Dropdown portal) vs mobile (BottomSheet) + const [showExportDropdown, setShowExportDropdown] = useState(false); // desktop dropdown + const [showExportSheet, setShowExportSheet] = useState(false); // mobile bottom sheet + const [showTicketExportDropdown, setShowTicketExportDropdown] = useState(false); // desktop + const [showTicketExportSheet, setShowTicketExportSheet] = useState(false); // mobile + const [exporting, setExporting] = useState(false); + // Add Ticket — separate desktop dropdown vs mobile bottom sheet + const [showAddTicketDropdown, setShowAddTicketDropdown] = useState(false); // desktop + const [showAddTicketSheet, setShowAddTicketSheet] = useState(false); // mobile FAB + // Tickets tab state const [ticketSearchQuery, setTicketSearchQuery] = useState(''); const [ticketStatusFilter, setTicketStatusFilter] = useState<'all' | 'confirmed' | 'checked_in'>('all'); - + // Payment options state const [globalPaymentOptions, setGlobalPaymentOptions] = useState(null); const [paymentOverrides, setPaymentOverrides] = useState>({}); @@ -115,6 +264,14 @@ export default function AdminEventDetailPage() { const [savingPayments, setSavingPayments] = useState(false); const [loadingPayments, setLoadingPayments] = useState(false); + // Mobile-specific state + const [mobileHeaderMenuOpen, setMobileHeaderMenuOpen] = useState(false); + const [mobileFilterOpen, setMobileFilterOpen] = useState(false); + const [mobileStatsExpanded, setMobileStatsExpanded] = useState(false); + + // Tab bar ref for sticky + const tabBarRef = useRef(null); + useEffect(() => { loadEventData(); }, [eventId]); @@ -137,7 +294,7 @@ export default function AdminEventDetailPage() { }; const loadPaymentOptions = async () => { - if (globalPaymentOptions) return; // Already loaded + if (globalPaymentOptions) return; setLoadingPayments(true); try { const [globalRes, overridesRes] = await Promise.all([ @@ -156,7 +313,6 @@ export default function AdminEventDetailPage() { } }; - // Load payment options when switching to payments tab useEffect(() => { if (activeTab === 'payments') { loadPaymentOptions(); @@ -191,7 +347,7 @@ export default function AdminEventDetailPage() { }; const handleResetToGlobal = async () => { - if (!confirm(locale === 'es' + if (!confirm(locale === 'es' ? '¿Resetear a la configuración global? Se eliminarán todas las personalizaciones de este evento.' : 'Reset to global settings? This will remove all customizations for this event.')) { return; @@ -219,6 +375,15 @@ export default function AdminEventDetailPage() { }); }; + const formatDateShort = (dateStr: string) => { + return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + timeZone: 'America/Asuncion', + }); + }; + const formatTime = (dateStr: string) => { return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', { hour: '2-digit', @@ -243,7 +408,7 @@ export default function AdminEventDetailPage() { return getTicketsByStatus(recipientFilter).length; }; - const getStatusBadge = (status: string) => { + const getStatusBadge = (status: string, compact = false) => { const styles: Record = { pending: 'bg-yellow-100 text-yellow-800', confirmed: 'bg-green-100 text-green-800', @@ -251,7 +416,11 @@ export default function AdminEventDetailPage() { checked_in: 'bg-blue-100 text-blue-800', }; return ( - + {status.replace('_', ' ')} ); @@ -360,12 +529,12 @@ export default function AdminEventDetailPage() { } }; - const handleExport = async (status: 'confirmed' | 'checked_in' | 'confirmed_pending' | 'all') => { + const handleExportAttendees = async (status: 'confirmed' | 'checked_in' | 'confirmed_pending' | 'all') => { if (!event) return; setExporting(true); setShowExportDropdown(false); try { - const { blob, filename } = await adminApi.exportAttendees(event.id, { status, format: 'csv' }); + const { blob, filename } = await adminApi.exportAttendees(event.id, { status, format: 'csv', q: searchQuery || undefined }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; @@ -382,13 +551,31 @@ export default function AdminEventDetailPage() { } }; + const handleExportTickets = async (status: 'confirmed' | 'checked_in' | 'all') => { + if (!event) return; + setExporting(true); + setShowTicketExportDropdown(false); + try { + const { blob, filename } = await adminApi.exportTicketsCSV(event.id, { status, q: ticketSearchQuery || undefined }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + toast.success('Export downloaded'); + } catch (error: any) { + toast.error(error.message || 'Failed to export tickets'); + } finally { + setExporting(false); + } + }; + // Filtered tickets for attendees tab const filteredTickets = tickets.filter((ticket) => { - // Status filter - if (statusFilter !== 'all' && ticket.status !== statusFilter) { - return false; - } - // Search filter + if (statusFilter !== 'all' && ticket.status !== statusFilter) return false; if (searchQuery) { const query = searchQuery.toLowerCase(); const fullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim().toLowerCase(); @@ -405,11 +592,7 @@ export default function AdminEventDetailPage() { // Filtered tickets for the Tickets tab (only confirmed/checked_in) const confirmedTickets = tickets.filter(t => ['confirmed', 'checked_in'].includes(t.status)); const filteredConfirmedTickets = confirmedTickets.filter((ticket) => { - // Status filter - if (ticketStatusFilter !== 'all' && ticket.status !== ticketStatusFilter) { - return false; - } - // Search filter + if (ticketStatusFilter !== 'all' && ticket.status !== ticketStatusFilter) return false; if (ticketSearchQuery) { const query = ticketSearchQuery.toLowerCase(); const fullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim().toLowerCase(); @@ -508,683 +691,1418 @@ export default function AdminEventDetailPage() { const pendingCount = getTicketsByStatus('pending').length; const checkedInCount = getTicketsByStatus('checked_in').length; const cancelledCount = getTicketsByStatus('cancelled').length; + const revenue = (confirmedCount + checkedInCount) * event.price; + + const tabs: { key: TabType; label: string; icon: typeof CalendarIcon; count?: number }[] = [ + { key: 'overview', label: 'Overview', icon: CalendarIcon }, + { key: 'attendees', label: 'Attendees', icon: UserGroupIcon, count: tickets.length }, + { key: 'tickets', label: 'Tickets', icon: TicketIcon, count: confirmedTickets.length }, + { key: 'email', label: 'Email', icon: EnvelopeIcon }, + { key: 'payments', label: locale === 'es' ? 'Pagos' : 'Payments', icon: CreditCardIcon }, + ]; + + // ========== Primary action for a ticket ========== + const getPrimaryAction = (ticket: Ticket) => { + if (ticket.status === 'pending') { + return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), variant: 'outline' as const }; + } + if (ticket.status === 'confirmed') { + return { label: 'Check In', onClick: () => handleCheckin(ticket.id), variant: 'primary' as const }; + } + if (ticket.status === 'checked_in') { + return { label: 'Undo', onClick: () => handleRemoveCheckin(ticket.id), variant: 'outline' as const, icon: ArrowUturnLeftIcon }; + } + return null; + }; return ( -
- {/* Header */} -
- - -
-

{event.title}

-

{formatDate(event.startDatetime)}

+
+

{event.title}

+

{formatDateShort(event.startDatetime)} · {formatTime(event.startDatetime)}

-
+ {/* Desktop header actions */} +
-
- - {/* Stats Cards */} -
- {showStats ? ( -
- -
-
- -
-
-

{event.capacity}

-

Capacity

-
-
-
- -
-
- -
-
-

{confirmedCount}

-

Confirmed

-
-
-
- -
-
- -
-
-

{pendingCount}

-

Pending

-
-
-
- -
-
- -
-
-

{checkedInCount}

-

Checked In

-
-
-
- -
-
- -
-
-

{formatCurrency(confirmedCount * event.price, event.currency)}

-

Revenue

-
-
-
-
- ) : null} - -
- - {/* Tabs */} -
- -
- - {/* Overview Tab */} - {activeTab === 'overview' && ( -
- -

Event Information

-
-
- -
-

Date & Time

-

{formatDate(event.startDatetime)}

-

{formatTime(event.startDatetime)}{event.endDatetime && ` - ${formatTime(event.endDatetime)}`}

-
-
-
- -
-

Location

-

{event.location}

- {event.locationUrl && ( - - View on Map - - )} -
-
-
- -
-

Price

-

{event.price === 0 ? 'Free' : formatCurrency(event.price, event.currency)}

-
-
-
- -
-

Capacity

-

{confirmedCount + checkedInCount} / {event.capacity} spots filled

-

{Math.max(0, event.capacity - confirmedCount - checkedInCount)} spots remaining

-
-
-
-
- - -

Description

-
-

{event.description}

- {event.descriptionEs && ( - <> -

Spanish:

-

{event.descriptionEs}

- - )} -
-
- - {event.bannerUrl && ( - -

Event Banner

- {event.title} -
- )} + {/* Mobile header overflow menu */} +
+ + + + } + > + { window.open(`/events/${event.id}`, '_blank'); setMobileHeaderMenuOpen(false); }}> + View Public + + { router.push(`/admin/events?edit=${event.id}`); setMobileHeaderMenuOpen(false); }}> + Edit Event + + { setShowStats(v => !v); setMobileHeaderMenuOpen(false); }}> + {showStats ? : } + {showStats ? 'Hide Stats' : 'Show Stats'} + +
- )} +
- {/* Attendees Tab */} - {activeTab === 'attendees' && ( -
- {/* Filters & Actions Bar */} - -
-
- {/* Search */} -
- + {/* ============= COMPACT META CHIPS (desktop) ============= */} +
+ + + {formatDateShort(event.startDatetime)} {formatTime(event.startDatetime)}{event.endDatetime && ` – ${formatTime(event.endDatetime)}`} + + + + {event.location} + + + + {event.price === 0 ? 'Free' : formatCurrency(event.price, event.currency)} + + + + {confirmedCount + checkedInCount}/{event.capacity} + +
+ + {/* ============= STATS ROW ============= */} + {/* Desktop: always-visible compact 4-card row */} +
+ {showStats && ( +
+ {[ + { label: 'Capacity', value: `${confirmedCount + checkedInCount}/${event.capacity}`, icon: UsersIcon, color: 'bg-blue-50 text-blue-600' }, + { label: 'Confirmed', value: confirmedCount, icon: CheckCircleIcon, color: 'bg-green-50 text-green-600' }, + { label: 'Checked In', value: checkedInCount, icon: TicketIcon, color: 'bg-purple-50 text-purple-600' }, + { label: 'Revenue', value: formatCurrency(revenue, event.currency), icon: CurrencyDollarIcon, color: 'bg-gray-50 text-gray-600' }, + ].map((stat) => ( +
+
+ +
+
+

{stat.value}

+

{stat.label}

+
+
+ ))} +
+ )} +
+ + {/* Mobile: collapsible stats */} +
+ {showStats && ( +
+ + {mobileStatsExpanded && ( +
+ {[ + { label: 'Capacity', value: `${confirmedCount + checkedInCount}/${event.capacity}`, icon: UsersIcon, color: 'text-blue-600 bg-blue-50' }, + { label: 'Confirmed', value: confirmedCount, icon: CheckCircleIcon, color: 'text-green-600 bg-green-50' }, + { label: 'Checked In', value: checkedInCount, icon: TicketIcon, color: 'text-purple-600 bg-purple-50' }, + { label: 'Revenue', value: formatCurrency(revenue, event.currency), icon: CurrencyDollarIcon, color: 'text-gray-600 bg-gray-50' }, + ].map((stat) => ( +
+
+ +
+
+

{stat.value}

+

{stat.label}

+
+
+ ))} +
+ )} +
+ )} +
+ + {/* ============= TABS + CONTENT ============= */} + {/* Unified container: tabs are visually connected to the content below */} + + {/* ============= TAB BAR ============= */} + {/* Desktop: tab bar inside a card top-section */} +
+
+ +
+
+ + {/* Mobile: segmented tab bar */} +
+
+
+ {tabs.map((tab) => ( + + ))} +
+
+
+ + {/* ============= TAB CONTENT ============= */} + {/* Desktop: content panel continues from the tab bar card (no top radius) */} + {/* Mobile: content flows directly below the segmented control */} +
+ {/* ============= OVERVIEW TAB ============= */} + {activeTab === 'overview' && ( +
+ +

Event Information

+
+
+ +
+

Date & Time

+

{formatDate(event.startDatetime)}

+

{formatTime(event.startDatetime)}{event.endDatetime && ` - ${formatTime(event.endDatetime)}`}

+
+
+
+ +
+

Location

+

{event.location}

+ {event.locationUrl && ( + + View on Map + + )} +
+
+
+ +
+

Price

+

{event.price === 0 ? 'Free' : formatCurrency(event.price, event.currency)}

+
+
+
+ +
+

Capacity

+

{confirmedCount + checkedInCount} / {event.capacity} spots filled

+

{Math.max(0, event.capacity - confirmedCount - checkedInCount)} spots remaining

+
+
+
+
+ + +

Description

+
+

{event.description}

+ {event.descriptionEs && ( + <> +

Spanish:

+

{event.descriptionEs}

+ + )} +
+
+ + {event.bannerUrl && ( + +

Event Banner

+ {event.title} +
+ )} +
+ )} + + {/* ============= ATTENDEES TAB ============= */} + {activeTab === 'attendees' && ( +
+ {/* Desktop toolbar */} + +
+ {/* Left: Search + Status */} +
+ setSearchQuery(e.target.value)} - className="w-full pl-10 pr-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow" + 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" />
- {/* Status Filter */} -
- - -
-
- {/* Action Buttons */} -
- {/* Export Button */} - - - + + + + + + + +
+ + {/* Right: Export + Add Ticket dropdown */} + + {exporting ? ( +
+ ) : ( + + )} + Export + + + } + > + handleExportAttendees('all')}>Export All + handleExportAttendees('confirmed')}>Export Confirmed + handleExportAttendees('checked_in')}>Export Checked-in + handleExportAttendees('confirmed_pending')}>Confirmed & Pending +
+
Format: CSV
+ + + + + Add Ticket + + + } + > + { setShowManualTicketModal(true); setShowAddTicketDropdown(false); }}> + Manual Ticket + + { setShowAddAtDoorModal(true); setShowAddTicketDropdown(false); }}> + Add at Door + +
-
- {/* Filter Results Summary */} - {(searchQuery || statusFilter !== 'all') && ( -
- Showing {filteredTickets.length} of {tickets.length} attendees + {(searchQuery || statusFilter !== 'all') && ( +
+ Showing {filteredTickets.length} of {tickets.length} + +
+ )} + + + {/* Mobile toolbar */} +
+
+ + 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" + /> +
+
+ + {(searchQuery || statusFilter !== 'all') && ( )}
- )} - + {(searchQuery || statusFilter !== 'all') && ( +

Showing {filteredTickets.length} of {tickets.length}

+ )} +
- {/* Attendees Table */} - -
- - - - - - - - - - - - - {filteredTickets.length === 0 ? ( + {/* Desktop: Dense table */} + +
+
AttendeeContactStatusNoteBookedActions
+ - + + + + + - ) : ( - filteredTickets.map((ticket) => ( - - - - - - - + + {filteredTickets.length === 0 ? ( + + - )) - )} - -
- {tickets.length === 0 ? 'No attendees yet' : 'No attendees match the current filters'} - AttendeeContactStatusBookedActions
-

{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}

-

ID: {ticket.id.slice(0, 8)}...

- {ticket.bookingId && ( -

- 📦 {locale === 'es' ? 'Reserva grupal' : 'Group booking'} -

- )} -
-

{ticket.attendeeEmail}

-

{ticket.attendeePhone}

-
- {getStatusBadge(ticket.status)} - {ticket.checkinAt && ( -

- {new Date(ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion' })} -

- )} -
- {ticket.adminNote ? ( -

- {ticket.adminNote} -

- ) : ( - - - )} -
- {new Date(ticket.createdAt).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' })} - -
- {/* Note button */} - - - {ticket.status === 'pending' && ( - - )} - {ticket.status === 'confirmed' && ( - - )} - {ticket.status === 'checked_in' && ( - - )} -
+
+ {tickets.length === 0 ? 'No attendees yet' : 'No attendees match the current filters'}
-
-
-
- )} + ) : ( + filteredTickets.map((ticket) => { + const primary = getPrimaryAction(ticket); + return ( + + +

{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}

+ {ticket.bookingId && ( + + Group booking + + )} + + +

{ticket.attendeeEmail}

+ {ticket.attendeePhone &&

{ticket.attendeePhone}

} + + + {getStatusBadge(ticket.status, true)} + {ticket.checkinAt && ( +

+ {new Date(ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion' })} +

+ )} + + + {new Date(ticket.createdAt).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' })} + + +
+ {primary && ( + + )} + + handleOpenNoteModal(ticket)}> + + {ticket.adminNote ? 'Edit Note' : 'Add Note'} + + {ticket.adminNote && ( +
+ Note: {ticket.adminNote} +
+ )} +
+ ID: {ticket.id.slice(0, 8)}... +
+
+
+ + + ); + }) + )} + + +
+ - {/* Tickets Tab */} - {activeTab === 'tickets' && ( -
- {/* Search & Filter Bar */} - -
-
- {/* Search */} -
- + {/* Mobile: Card layout */} +
+ {filteredTickets.length === 0 ? ( +
+ {tickets.length === 0 ? 'No attendees yet' : 'No attendees match the current filters'} +
+ ) : ( + filteredTickets.map((ticket) => { + const primary = getPrimaryAction(ticket); + return ( + +
+
+

{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}

+

{ticket.attendeeEmail}

+ {ticket.attendeePhone &&

{ticket.attendeePhone}

} +
+
+ {getStatusBadge(ticket.status, true)} +
+
+
+

+ {new Date(ticket.createdAt).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' })} + {ticket.checkinAt && ` · Checked in ${new Date(ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion' })}`} +

+
+ {primary && ( + + )} + + handleOpenNoteModal(ticket)}> + + {ticket.adminNote ? 'Edit Note' : 'Add Note'} + + +
+
+
+ ); + }) + )} +
+ + {/* Mobile FAB */} +
+ +
+
+ )} + + {/* ============= TICKETS TAB ============= */} + {activeTab === 'tickets' && ( +
+ {/* Desktop toolbar */} + +
+
+ setTicketSearchQuery(e.target.value)} - className="w-full pl-10 pr-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow" + 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" />
- {/* Status Filter */} -
- - -
-
-
- {(ticketSearchQuery || ticketStatusFilter !== 'all') && ( -
- Showing {filteredConfirmedTickets.length} of {confirmedTickets.length} tickets - + } + > + handleExportTickets('all')}>Export All + handleExportTickets('confirmed')}>Export Valid + handleExportTickets('checked_in')}>Export Checked-in +
+
Format: CSV
+ +
+ {(ticketSearchQuery || ticketStatusFilter !== 'all') && ( +
+ Showing {filteredConfirmedTickets.length} of {confirmedTickets.length} + +
+ )} + + + {/* Mobile toolbar */} +
+
+ + 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" + /> +
+
+ +
- )} - +
- {/* Tickets Table */} - -
- - - - - - - - - - - - - {filteredConfirmedTickets.length === 0 ? ( + {/* Desktop: Dense table */} + +
+
Attendee NameTicket IDBooking IDStatusCheck-in TimeActions
+ - + + + + - ) : ( - filteredConfirmedTickets.map((ticket) => ( - - - - - - - + + {filteredConfirmedTickets.length === 0 ? ( + + - )) - )} - -
- {confirmedTickets.length === 0 ? 'No confirmed tickets yet' : 'No tickets match the current filters'} - AttendeeStatusCheck-inActions
-

- {ticket.attendeeFirstName} {ticket.attendeeLastName || ''} -

-
- - {ticket.id.slice(0, 8)}... - - - {ticket.bookingId ? ( - - {ticket.bookingId.slice(0, 8)}... - - ) : ( - - )} - - {ticket.status === 'confirmed' ? ( - - Valid - - ) : ( - - Checked In - - )} - - {ticket.checkinAt ? ( - new Date(ticket.checkinAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - timeZone: 'America/Asuncion', - }) - ) : ( - - )} - -
- {ticket.status === 'confirmed' && ( - - )} - {ticket.status === 'checked_in' && ( - - )} -
+
+ {confirmedTickets.length === 0 ? 'No confirmed tickets yet' : 'No tickets match the current filters'}
-
-
-
- )} + ) : ( + filteredConfirmedTickets.map((ticket) => ( + + +

{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}

+ {ticket.bookingId && ( + Group booking + )} + + + {ticket.status === 'confirmed' ? ( + Valid + ) : ( + Checked In + )} + + + {ticket.checkinAt ? ( + new Date(ticket.checkinAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', { + month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion', + }) + ) : ( + + )} + + +
+ {ticket.status === 'confirmed' && ( + + )} + {ticket.status === 'checked_in' && ( + + )} + +
+ ID: {ticket.id.slice(0, 8)}... +
+ {ticket.bookingId && ( +
+ Booking: {ticket.bookingId.slice(0, 8)}... +
+ )} +
+
+ + + )) + )} + + +
+ - {/* Export Dropdown (rendered outside Card to avoid overflow:hidden clipping) */} - {showExportDropdown && ( - <> -
setShowExportDropdown(false)} - /> -
- - - - -
-
- Format: CSV + {/* Mobile: Card layout */} +
+ {filteredConfirmedTickets.length === 0 ? ( +
+ {confirmedTickets.length === 0 ? 'No confirmed tickets yet' : 'No tickets match the current filters'} +
+ ) : ( + filteredConfirmedTickets.map((ticket) => ( + +
+
+

{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}

+ {ticket.bookingId &&

Group booking

} +
+ {ticket.status === 'confirmed' ? ( + Valid + ) : ( + Checked In + )} +
+
+

+ {ticket.checkinAt + ? `Checked in ${new Date(ticket.checkinAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion' })}` + : 'Not checked in'} +

+
+ {ticket.status === 'confirmed' && ( + + )} + {ticket.status === 'checked_in' && ( + + )} +
+
+
+ )) + )}
- - )} + )} + + {/* ============= EMAIL TAB ============= */} + {activeTab === 'email' && ( +
+ +

Send Email to Attendees

+ +
+
+ + +
+ +
+ + +
+ +
+ +