- 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>
768 lines
31 KiB
TypeScript
768 lines
31 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useEffect, useRef } from 'react';
|
||
import { useParams } from 'next/navigation';
|
||
import Link from 'next/link';
|
||
import { useLanguage } from '@/context/LanguageContext';
|
||
import { ticketsApi, emailsApi, adminApi, paymentsApi, siteSettingsApi, Ticket } from '@/lib/api';
|
||
import { formatDateLong, formatDateCompact, formatTime } from '@/lib/utils';
|
||
import Card from '@/components/ui/Card';
|
||
import Button from '@/components/ui/Button';
|
||
import { Skeleton, TableSkeleton } from '@/components/ui/Skeleton';
|
||
import { Dropdown, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||
import {
|
||
ArrowLeftIcon,
|
||
CalendarIcon,
|
||
MapPinIcon,
|
||
CurrencyDollarIcon,
|
||
UsersIcon,
|
||
TicketIcon,
|
||
CheckCircleIcon,
|
||
EnvelopeIcon,
|
||
PencilIcon,
|
||
EyeIcon,
|
||
UserGroupIcon,
|
||
CreditCardIcon,
|
||
ChevronDownIcon,
|
||
EllipsisVerticalIcon,
|
||
ArrowUturnLeftIcon,
|
||
} from '@heroicons/react/24/outline';
|
||
import toast from 'react-hot-toast';
|
||
import clsx from 'clsx';
|
||
import { usePrivacy } from '@/context/PrivacyContext';
|
||
import type {
|
||
TabType,
|
||
AttendeeStatusFilter,
|
||
TicketStatusFilter,
|
||
RecipientFilter,
|
||
AddTicketType,
|
||
AddTicketFormState,
|
||
PrimaryAction,
|
||
} from './_types';
|
||
import { formatCurrency, downloadBlob } from './_utils/format';
|
||
import { useEventDetailData } from './_hooks/useEventDetailData';
|
||
import { usePaymentOverrides } from './_hooks/usePaymentOverrides';
|
||
import { OverviewTab } from './_tabs/OverviewTab';
|
||
import { AttendeesTab } from './_tabs/AttendeesTab';
|
||
import { TicketsTab } from './_tabs/TicketsTab';
|
||
import { EmailTab } from './_tabs/EmailTab';
|
||
import { PaymentsTab } from './_tabs/PaymentsTab';
|
||
import { EventModals } from './_modals/EventModals';
|
||
import { AddTicketModal } from './_modals/AddTicketModal';
|
||
import EventFormModal from '../_components/EventFormModal';
|
||
|
||
const EMPTY_ADD_TICKET_FORM: AddTicketFormState = {
|
||
type: 'paid',
|
||
firstName: '',
|
||
lastName: '',
|
||
email: '',
|
||
phone: '',
|
||
adminNote: '',
|
||
checkinNow: false,
|
||
};
|
||
|
||
export default function AdminEventDetailPage() {
|
||
const params = useParams();
|
||
const eventId = params.id as string;
|
||
const { locale } = useLanguage();
|
||
|
||
const { loading, event, tickets, templates, loadEventData } = useEventDetailData(eventId);
|
||
const [activeTab, setActiveTab] = useState<TabType>('overview');
|
||
|
||
// Email state
|
||
const [selectedTemplate, setSelectedTemplate] = useState<string>('');
|
||
const [recipientFilter, setRecipientFilter] = useState<RecipientFilter>('confirmed');
|
||
const [customMessage, setCustomMessage] = useState('');
|
||
const [sending, setSending] = useState(false);
|
||
const [previewHtml, setPreviewHtml] = useState<string | null>(null);
|
||
|
||
// Attendees tab state
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [statusFilter, setStatusFilter] = useState<AttendeeStatusFilter>('all');
|
||
const { privacyMode } = usePrivacy();
|
||
const showStats = !privacyMode;
|
||
const [showNoteModal, setShowNoteModal] = useState(false);
|
||
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
|
||
const [noteText, setNoteText] = useState('');
|
||
// Unified Add Ticket modal (paid / unpaid / guest via segmented control)
|
||
const [showAddTicketModal, setShowAddTicketModal] = useState(false);
|
||
const [addTicketForm, setAddTicketForm] = useState<AddTicketFormState>(EMPTY_ADD_TICKET_FORM);
|
||
const [submitting, setSubmitting] = useState(false);
|
||
|
||
const openAddTicket = (type: AddTicketType) => {
|
||
setAddTicketForm({ ...EMPTY_ADD_TICKET_FORM, type });
|
||
setShowAddTicketModal(true);
|
||
};
|
||
|
||
// 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<TicketStatusFilter>('all');
|
||
|
||
// Payment options state + handlers
|
||
const payments = usePaymentOverrides(eventId, locale);
|
||
|
||
// Edit event modal (opens in place instead of redirecting to the list page)
|
||
const [showEditForm, setShowEditForm] = useState(false);
|
||
const [featuredEventId, setFeaturedEventId] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
siteSettingsApi
|
||
.get()
|
||
.then(({ settings }) => setFeaturedEventId(settings.featuredEventId || null))
|
||
.catch(() => {});
|
||
}, []);
|
||
|
||
// 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<HTMLDivElement>(null);
|
||
|
||
useEffect(() => {
|
||
if (activeTab === 'payments') {
|
||
payments.loadPaymentOptions();
|
||
}
|
||
}, [activeTab]);
|
||
|
||
const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
|
||
const formatDateShort = (dateStr: string) => formatDateCompact(dateStr, locale as 'en' | 'es');
|
||
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
||
|
||
const getTicketsByStatus = (status: string) => {
|
||
return tickets.filter(t => t.status === status);
|
||
};
|
||
|
||
const getFilteredRecipientCount = () => {
|
||
if (recipientFilter === 'all') return tickets.length;
|
||
return getTicketsByStatus(recipientFilter).length;
|
||
};
|
||
|
||
const handleMarkPaid = async (ticketId: string) => {
|
||
try {
|
||
await ticketsApi.markPaid(ticketId);
|
||
toast.success('Payment marked as received');
|
||
loadEventData();
|
||
} catch (error: any) {
|
||
toast.error(error.message || 'Failed to mark payment');
|
||
}
|
||
};
|
||
|
||
const handleCheckin = async (ticketId: string) => {
|
||
try {
|
||
await ticketsApi.checkin(ticketId);
|
||
toast.success('Attendee checked in');
|
||
loadEventData();
|
||
} catch (error: any) {
|
||
toast.error(error.message || 'Failed to check in');
|
||
}
|
||
};
|
||
|
||
const handleReactivate = async (ticket: Ticket) => {
|
||
if (!ticket.payment?.id) return;
|
||
try {
|
||
await paymentsApi.reactivate(ticket.payment.id);
|
||
toast.success('Booking reactivated');
|
||
loadEventData();
|
||
} catch (error: any) {
|
||
toast.error(error.message || 'Failed to reactivate booking');
|
||
}
|
||
};
|
||
|
||
const handleRemoveCheckin = async (ticketId: string) => {
|
||
if (!confirm('Are you sure you want to remove the check-in for this attendee?')) return;
|
||
try {
|
||
await ticketsApi.removeCheckin(ticketId);
|
||
toast.success('Check-in removed');
|
||
loadEventData();
|
||
} catch (error: any) {
|
||
toast.error(error.message || 'Failed to remove check-in');
|
||
}
|
||
};
|
||
|
||
const handleOpenNoteModal = (ticket: Ticket) => {
|
||
setSelectedTicket(ticket);
|
||
setNoteText(ticket.adminNote || '');
|
||
setShowNoteModal(true);
|
||
};
|
||
|
||
const handleSaveNote = async () => {
|
||
if (!selectedTicket) return;
|
||
setSubmitting(true);
|
||
try {
|
||
await ticketsApi.updateNote(selectedTicket.id, noteText);
|
||
toast.success('Note saved');
|
||
setShowNoteModal(false);
|
||
setSelectedTicket(null);
|
||
setNoteText('');
|
||
loadEventData();
|
||
} catch (error: any) {
|
||
toast.error(error.message || 'Failed to save note');
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const handleAddTicket = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!event) return;
|
||
setSubmitting(true);
|
||
try {
|
||
const res = await ticketsApi.adminAdd({
|
||
eventId: event.id,
|
||
type: addTicketForm.type,
|
||
firstName: addTicketForm.firstName,
|
||
lastName: addTicketForm.lastName || undefined,
|
||
email: addTicketForm.email || undefined,
|
||
phone: addTicketForm.phone || undefined,
|
||
checkinNow: addTicketForm.checkinNow,
|
||
adminNote: addTicketForm.adminNote || undefined,
|
||
});
|
||
toast.success(res.message || 'Ticket created');
|
||
setShowAddTicketModal(false);
|
||
setAddTicketForm(EMPTY_ADD_TICKET_FORM);
|
||
loadEventData();
|
||
} catch (error: any) {
|
||
toast.error(error.message || 'Failed to add ticket');
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
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', q: searchQuery || undefined });
|
||
downloadBlob(blob, filename);
|
||
toast.success('Export downloaded');
|
||
} catch (error: any) {
|
||
toast.error(error.message || 'Failed to export attendees');
|
||
} finally {
|
||
setExporting(false);
|
||
}
|
||
};
|
||
|
||
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 });
|
||
downloadBlob(blob, filename);
|
||
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) => {
|
||
if (statusFilter !== 'all' && ticket.status !== statusFilter) return false;
|
||
if (searchQuery) {
|
||
const query = searchQuery.toLowerCase();
|
||
const fullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim().toLowerCase();
|
||
return (
|
||
fullName.includes(query) ||
|
||
(ticket.attendeeEmail?.toLowerCase().includes(query) || false) ||
|
||
(ticket.attendeePhone?.toLowerCase().includes(query) || false) ||
|
||
ticket.id.toLowerCase().includes(query)
|
||
);
|
||
}
|
||
return true;
|
||
});
|
||
|
||
// 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) => {
|
||
if (ticketStatusFilter !== 'all' && ticket.status !== ticketStatusFilter) return false;
|
||
if (ticketSearchQuery) {
|
||
const query = ticketSearchQuery.toLowerCase();
|
||
const fullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim().toLowerCase();
|
||
return (
|
||
fullName.includes(query) ||
|
||
ticket.id.toLowerCase().includes(query)
|
||
);
|
||
}
|
||
return true;
|
||
});
|
||
|
||
const handlePreviewEmail = async () => {
|
||
if (!selectedTemplate) {
|
||
toast.error('Please select a template');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const res = await emailsApi.preview({
|
||
templateSlug: selectedTemplate,
|
||
variables: {
|
||
attendeeName: 'John Doe',
|
||
attendeeEmail: 'john@example.com',
|
||
ticketId: 'TKT-PREVIEW',
|
||
eventTitle: event?.title || '',
|
||
eventDate: event ? formatDate(event.startDatetime) : '',
|
||
eventTime: event ? fmtTime(event.startDatetime) : '',
|
||
eventLocation: event?.location || '',
|
||
eventLocationUrl: event?.locationUrl || '',
|
||
eventPrice: event ? formatCurrency(event.price, event.currency) : '',
|
||
customMessage: customMessage || 'Your custom message will appear here.',
|
||
},
|
||
locale,
|
||
});
|
||
setPreviewHtml(res.bodyHtml);
|
||
} catch (error) {
|
||
toast.error('Failed to preview email');
|
||
}
|
||
};
|
||
|
||
const handleSendEmail = async () => {
|
||
if (!selectedTemplate) {
|
||
toast.error('Please select a template');
|
||
return;
|
||
}
|
||
|
||
const recipientCount = getFilteredRecipientCount();
|
||
if (recipientCount === 0) {
|
||
toast.error('No recipients match the selected filter');
|
||
return;
|
||
}
|
||
|
||
if (!confirm(`Send email to ${recipientCount} ${recipientFilter === 'all' ? 'attendee(s)' : `${recipientFilter} attendee(s)`}?`)) {
|
||
return;
|
||
}
|
||
|
||
setSending(true);
|
||
try {
|
||
const res = await emailsApi.sendToEvent(eventId, {
|
||
templateSlug: selectedTemplate,
|
||
recipientFilter,
|
||
customVariables: customMessage ? { customMessage } : undefined,
|
||
});
|
||
|
||
if (res.success) {
|
||
toast.success(`${res.queuedCount} email(s) are being sent in the background.`);
|
||
} else {
|
||
toast.error(res.error || 'Failed to queue emails');
|
||
}
|
||
} catch (error: any) {
|
||
toast.error(error.message || 'Failed to send emails');
|
||
} finally {
|
||
setSending(false);
|
||
}
|
||
};
|
||
|
||
if (loading) {
|
||
return (
|
||
<div>
|
||
<div className="flex items-center justify-between mb-6">
|
||
<Skeleton className="h-8 w-64" />
|
||
<Skeleton className="h-10 w-36 rounded-btn hidden md:block" />
|
||
</div>
|
||
<TableSkeleton rows={5} cols={4} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!event) {
|
||
return (
|
||
<div className="text-center py-12">
|
||
<p className="text-gray-500">Event not found</p>
|
||
<Link href="/admin/events">
|
||
<Button variant="outline" className="mt-4">
|
||
Back to Events
|
||
</Button>
|
||
</Link>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const confirmedCount = getTicketsByStatus('confirmed').length;
|
||
const pendingCount = getTicketsByStatus('pending').length;
|
||
const checkedInCount = getTicketsByStatus('checked_in').length;
|
||
const cancelledCount = getTicketsByStatus('cancelled').length;
|
||
const onHoldCount = getTicketsByStatus('on_hold').length;
|
||
// Revenue counts only settled tickets: unpaid (balance due) and comp (guest)
|
||
// tickets are excluded; legacy rows without paymentStatus fall back to !isGuest
|
||
const isRevenueTicket = (t: Ticket) => (t.paymentStatus ? t.paymentStatus === 'paid' : !t.isGuest);
|
||
const paidConfirmedCount = getTicketsByStatus('confirmed').filter(isRevenueTicket).length;
|
||
const paidCheckedInCount = getTicketsByStatus('checked_in').filter(isRevenueTicket).length;
|
||
const revenue = (paidConfirmedCount + paidCheckedInCount) * 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): PrimaryAction | null => {
|
||
if (ticket.status === 'pending' || ticket.status === 'on_hold') {
|
||
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), variant: 'outline' };
|
||
}
|
||
if (ticket.status === 'confirmed') {
|
||
// Unpaid tickets resolve their balance first; check-in stays available via scanner
|
||
if (ticket.paymentStatus === 'unpaid') {
|
||
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), variant: 'outline' };
|
||
}
|
||
return { label: 'Check In', onClick: () => handleCheckin(ticket.id), variant: 'primary' };
|
||
}
|
||
if (ticket.status === 'checked_in') {
|
||
return { label: 'Undo', onClick: () => handleRemoveCheckin(ticket.id), variant: 'outline', icon: ArrowUturnLeftIcon };
|
||
}
|
||
return null;
|
||
};
|
||
|
||
return (
|
||
<div className="max-w-full overflow-x-hidden">
|
||
{/* ============= HEADER ============= */}
|
||
<div className="flex items-start gap-3 mb-3">
|
||
<Link href="/admin/events" className="mt-1 flex-shrink-0">
|
||
<button className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
|
||
<ArrowLeftIcon className="w-5 h-5" />
|
||
</button>
|
||
</Link>
|
||
<div className="flex-1 min-w-0">
|
||
<h1 className="text-xl md:text-2xl font-bold text-primary-dark truncate">{event.title}</h1>
|
||
<p className="text-sm text-gray-500">{formatDateShort(event.startDatetime)} · {fmtTime(event.startDatetime)}</p>
|
||
</div>
|
||
{/* Desktop header actions */}
|
||
<div className="hidden md:flex items-center gap-2 flex-shrink-0">
|
||
<Link href={`/events/${event.slug}`} target="_blank">
|
||
<Button variant="outline" size="sm">
|
||
<EyeIcon className="w-4 h-4 mr-1.5" />
|
||
View Public
|
||
</Button>
|
||
</Link>
|
||
<Button variant="outline" size="sm" onClick={() => setShowEditForm(true)}>
|
||
<PencilIcon className="w-4 h-4 mr-1.5" />
|
||
Edit
|
||
</Button>
|
||
</div>
|
||
{/* Mobile header overflow menu */}
|
||
<div className="md:hidden flex-shrink-0">
|
||
<Dropdown
|
||
open={mobileHeaderMenuOpen}
|
||
onOpenChange={setMobileHeaderMenuOpen}
|
||
trigger={
|
||
<button className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
|
||
<EllipsisVerticalIcon className="w-5 h-5" />
|
||
</button>
|
||
}
|
||
>
|
||
<DropdownItem onClick={() => { window.open(`/events/${event.slug}`, '_blank'); setMobileHeaderMenuOpen(false); }}>
|
||
<EyeIcon className="w-4 h-4 mr-2" /> View Public
|
||
</DropdownItem>
|
||
<DropdownItem onClick={() => { setShowEditForm(true); setMobileHeaderMenuOpen(false); }}>
|
||
<PencilIcon className="w-4 h-4 mr-2" /> Edit Event
|
||
</DropdownItem>
|
||
</Dropdown>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ============= COMPACT META CHIPS (desktop) ============= */}
|
||
<div className="hidden md:flex flex-wrap items-center gap-2 mb-4 ml-[52px]">
|
||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
|
||
<CalendarIcon className="w-3.5 h-3.5" />
|
||
{formatDateShort(event.startDatetime)} {fmtTime(event.startDatetime)}{event.endDatetime && ` – ${fmtTime(event.endDatetime)}`}
|
||
</span>
|
||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
|
||
<MapPinIcon className="w-3.5 h-3.5" />
|
||
{event.location}
|
||
</span>
|
||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
|
||
<CurrencyDollarIcon className="w-3.5 h-3.5" />
|
||
{event.price === 0 ? 'Free' : formatCurrency(event.price, event.currency)}
|
||
</span>
|
||
{showStats && (
|
||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
|
||
<UsersIcon className="w-3.5 h-3.5" />
|
||
{confirmedCount + checkedInCount}/{event.capacity}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* ============= STATS ROW ============= */}
|
||
{/* Desktop: always-visible compact 4-card row */}
|
||
<div className="hidden md:block mb-4">
|
||
{showStats && (
|
||
<div className="grid grid-cols-4 gap-3">
|
||
{[
|
||
{ 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) => (
|
||
<div key={stat.label} className="flex items-center gap-2.5 bg-white rounded-card shadow-card px-3 py-2.5">
|
||
<div className={clsx('w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0', stat.color)}>
|
||
<stat.icon className="w-4 h-4" />
|
||
</div>
|
||
<div className="min-w-0">
|
||
<p className="text-lg font-bold leading-tight truncate">{stat.value}</p>
|
||
<p className="text-xs text-gray-500">{stat.label}</p>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Mobile: collapsible stats */}
|
||
<div className="md:hidden mb-3">
|
||
{showStats && (
|
||
<div>
|
||
<button
|
||
onClick={() => setMobileStatsExpanded(!mobileStatsExpanded)}
|
||
className="w-full flex items-center justify-between px-3 py-2 bg-white rounded-card shadow-card mb-2 min-h-[44px]"
|
||
>
|
||
<div className="flex items-center gap-3 overflow-x-auto">
|
||
<span className="text-xs font-medium text-gray-500 whitespace-nowrap">Stats</span>
|
||
<span className="text-sm font-semibold whitespace-nowrap">{confirmedCount + checkedInCount}/{event.capacity}</span>
|
||
<span className="text-xs text-gray-400">|</span>
|
||
<span className="text-sm whitespace-nowrap">{checkedInCount} in</span>
|
||
<span className="text-xs text-gray-400">|</span>
|
||
<span className="text-sm whitespace-nowrap">{formatCurrency(revenue, event.currency)}</span>
|
||
</div>
|
||
<ChevronDownIcon className={clsx('w-4 h-4 text-gray-400 transition-transform flex-shrink-0 ml-2', mobileStatsExpanded && 'rotate-180')} />
|
||
</button>
|
||
{mobileStatsExpanded && (
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{[
|
||
{ 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) => (
|
||
<div key={stat.label} className="flex items-center gap-2 bg-white rounded-card shadow-card px-3 py-2">
|
||
<div className={clsx('w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0', stat.color)}>
|
||
<stat.icon className="w-3.5 h-3.5" />
|
||
</div>
|
||
<div className="min-w-0">
|
||
<p className="text-base font-bold leading-tight truncate">{stat.value}</p>
|
||
<p className="text-[10px] text-gray-500">{stat.label}</p>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* ============= TAB BAR ============= */}
|
||
{/* Desktop: tab bar inside a card top-section */}
|
||
<div className="hidden md:block bg-white rounded-t-2xl shadow-card border-b border-gray-200">
|
||
<div ref={tabBarRef} className="sticky top-0 z-30 bg-white rounded-t-2xl px-4">
|
||
<nav className="flex gap-1">
|
||
{tabs.map((tab) => (
|
||
<button
|
||
key={tab.key}
|
||
onClick={() => setActiveTab(tab.key)}
|
||
className={clsx(
|
||
'py-2.5 px-3 font-medium text-sm transition-colors flex items-center gap-1.5 whitespace-nowrap relative',
|
||
activeTab === tab.key
|
||
? 'text-primary-dark'
|
||
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50 rounded-t-lg'
|
||
)}
|
||
>
|
||
<tab.icon className="w-4 h-4" />
|
||
{tab.label}
|
||
{tab.count !== undefined && <span className="text-xs text-gray-400">({tab.count})</span>}
|
||
{activeTab === tab.key && (
|
||
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary-yellow rounded-full" />
|
||
)}
|
||
</button>
|
||
))}
|
||
</nav>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Mobile: segmented tab bar */}
|
||
<div className="md:hidden sticky top-0 z-30 -mx-4 px-3 py-1.5 bg-gray-100">
|
||
<div className="overflow-x-auto scrollbar-hide">
|
||
<div className="flex gap-0.5 min-w-max">
|
||
{tabs.map((tab) => (
|
||
<button
|
||
key={tab.key}
|
||
onClick={() => setActiveTab(tab.key)}
|
||
className={clsx(
|
||
'px-3 py-2 rounded-lg text-xs font-medium transition-all whitespace-nowrap min-h-[36px] flex items-center gap-1',
|
||
activeTab === tab.key
|
||
? 'bg-white shadow-sm text-primary-dark'
|
||
: 'text-gray-500 hover:text-gray-700'
|
||
)}
|
||
>
|
||
<tab.icon className="w-3.5 h-3.5" />
|
||
{tab.label}
|
||
{tab.count !== undefined && <span className="text-[10px] opacity-70">({tab.count})</span>}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ============= TAB CONTENT ============= */}
|
||
<div className="md:bg-white md:rounded-b-2xl md:shadow-card md:p-4 pt-3 md:pt-4">
|
||
{activeTab === 'overview' && (
|
||
<OverviewTab
|
||
event={event}
|
||
formatDate={formatDate}
|
||
fmtTime={fmtTime}
|
||
formatCurrency={formatCurrency}
|
||
confirmedCount={confirmedCount}
|
||
checkedInCount={checkedInCount}
|
||
/>
|
||
)}
|
||
|
||
{activeTab === 'attendees' && (
|
||
<AttendeesTab
|
||
locale={locale}
|
||
tickets={tickets}
|
||
filteredTickets={filteredTickets}
|
||
searchQuery={searchQuery}
|
||
setSearchQuery={setSearchQuery}
|
||
statusFilter={statusFilter}
|
||
setStatusFilter={setStatusFilter}
|
||
pendingCount={pendingCount}
|
||
confirmedCount={confirmedCount}
|
||
checkedInCount={checkedInCount}
|
||
cancelledCount={cancelledCount}
|
||
onHoldCount={onHoldCount}
|
||
exporting={exporting}
|
||
showExportDropdown={showExportDropdown}
|
||
setShowExportDropdown={setShowExportDropdown}
|
||
showAddTicketDropdown={showAddTicketDropdown}
|
||
setShowAddTicketDropdown={setShowAddTicketDropdown}
|
||
handleExportAttendees={handleExportAttendees}
|
||
openAddTicket={openAddTicket}
|
||
setMobileFilterOpen={setMobileFilterOpen}
|
||
setShowExportSheet={setShowExportSheet}
|
||
setShowAddTicketSheet={setShowAddTicketSheet}
|
||
getPrimaryAction={getPrimaryAction}
|
||
handleOpenNoteModal={handleOpenNoteModal}
|
||
handleReactivate={handleReactivate}
|
||
handleMarkPaid={handleMarkPaid}
|
||
handleCheckin={handleCheckin}
|
||
/>
|
||
)}
|
||
|
||
{activeTab === 'tickets' && (
|
||
<TicketsTab
|
||
locale={locale}
|
||
confirmedTickets={confirmedTickets}
|
||
filteredConfirmedTickets={filteredConfirmedTickets}
|
||
ticketSearchQuery={ticketSearchQuery}
|
||
setTicketSearchQuery={setTicketSearchQuery}
|
||
ticketStatusFilter={ticketStatusFilter}
|
||
setTicketStatusFilter={setTicketStatusFilter}
|
||
confirmedCount={confirmedCount}
|
||
checkedInCount={checkedInCount}
|
||
exporting={exporting}
|
||
showTicketExportDropdown={showTicketExportDropdown}
|
||
setShowTicketExportDropdown={setShowTicketExportDropdown}
|
||
handleExportTickets={handleExportTickets}
|
||
handleCheckin={handleCheckin}
|
||
handleRemoveCheckin={handleRemoveCheckin}
|
||
setShowTicketExportSheet={setShowTicketExportSheet}
|
||
/>
|
||
)}
|
||
|
||
{activeTab === 'email' && (
|
||
<EmailTab
|
||
templates={templates}
|
||
selectedTemplate={selectedTemplate}
|
||
setSelectedTemplate={setSelectedTemplate}
|
||
recipientFilter={recipientFilter}
|
||
setRecipientFilter={setRecipientFilter}
|
||
customMessage={customMessage}
|
||
setCustomMessage={setCustomMessage}
|
||
sending={sending}
|
||
handlePreviewEmail={handlePreviewEmail}
|
||
handleSendEmail={handleSendEmail}
|
||
getFilteredRecipientCount={getFilteredRecipientCount}
|
||
ticketsCount={tickets.length}
|
||
confirmedCount={confirmedCount}
|
||
pendingCount={pendingCount}
|
||
checkedInCount={checkedInCount}
|
||
cancelledCount={cancelledCount}
|
||
/>
|
||
)}
|
||
|
||
{activeTab === 'payments' && (
|
||
<PaymentsTab locale={locale} payments={payments} />
|
||
)}
|
||
</div>
|
||
|
||
{/* ============= MODALS ============= */}
|
||
<EventModals
|
||
ticketsCount={tickets.length}
|
||
pendingCount={pendingCount}
|
||
confirmedCount={confirmedCount}
|
||
checkedInCount={checkedInCount}
|
||
cancelledCount={cancelledCount}
|
||
onHoldCount={onHoldCount}
|
||
statusFilter={statusFilter}
|
||
setStatusFilter={setStatusFilter}
|
||
mobileFilterOpen={mobileFilterOpen}
|
||
setMobileFilterOpen={setMobileFilterOpen}
|
||
showAddTicketSheet={showAddTicketSheet}
|
||
setShowAddTicketSheet={setShowAddTicketSheet}
|
||
openAddTicket={openAddTicket}
|
||
showExportSheet={showExportSheet}
|
||
setShowExportSheet={setShowExportSheet}
|
||
handleExportAttendees={handleExportAttendees}
|
||
showTicketExportSheet={showTicketExportSheet}
|
||
setShowTicketExportSheet={setShowTicketExportSheet}
|
||
handleExportTickets={handleExportTickets}
|
||
submitting={submitting}
|
||
showNoteModal={showNoteModal}
|
||
setShowNoteModal={setShowNoteModal}
|
||
selectedTicket={selectedTicket}
|
||
setSelectedTicket={setSelectedTicket}
|
||
noteText={noteText}
|
||
setNoteText={setNoteText}
|
||
handleSaveNote={handleSaveNote}
|
||
previewHtml={previewHtml}
|
||
setPreviewHtml={setPreviewHtml}
|
||
/>
|
||
|
||
<AddTicketModal
|
||
open={showAddTicketModal}
|
||
onClose={() => setShowAddTicketModal(false)}
|
||
form={addTicketForm}
|
||
setForm={setAddTicketForm}
|
||
onSubmit={handleAddTicket}
|
||
submitting={submitting}
|
||
eventPriceLabel={event.price === 0 ? 'Free' : formatCurrency(event.price, event.currency)}
|
||
/>
|
||
|
||
<EventFormModal
|
||
open={showEditForm}
|
||
event={event}
|
||
featuredEventId={featuredEventId}
|
||
onFeaturedChange={setFeaturedEventId}
|
||
onClose={() => setShowEditForm(false)}
|
||
onSaved={() => { setShowEditForm(false); loadEventData(); }}
|
||
/>
|
||
|
||
<AdminMobileStyles />
|
||
</div>
|
||
);
|
||
}
|