Admin event: Manual ticket + Tickets tab
- Backend: POST /api/tickets/admin/manual - creates ticket and sends confirmation + ticket email - Frontend: Manual Ticket button and modal (email required, sends confirmation + ticket) - New Tickets tab between Attendees and Send Email: confirmed tickets table with search (name/ticket ID), status filter, check-in actions Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -37,7 +37,7 @@ import {
|
||||
import toast from 'react-hot-toast';
|
||||
import clsx from 'clsx';
|
||||
|
||||
type TabType = 'overview' | 'attendees' | 'email' | 'payments';
|
||||
type TabType = 'overview' | 'attendees' | 'tickets' | 'email' | 'payments';
|
||||
|
||||
export default function AdminEventDetailPage() {
|
||||
const params = useParams();
|
||||
@@ -62,6 +62,7 @@ export default function AdminEventDetailPage() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'pending' | 'confirmed' | 'checked_in' | 'cancelled'>('all');
|
||||
const [showAddAtDoorModal, setShowAddAtDoorModal] = useState(false);
|
||||
const [showManualTicketModal, setShowManualTicketModal] = useState(false);
|
||||
const [showNoteModal, setShowNoteModal] = useState(false);
|
||||
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
|
||||
const [noteText, setNoteText] = useState('');
|
||||
@@ -73,8 +74,19 @@ export default function AdminEventDetailPage() {
|
||||
autoCheckin: true,
|
||||
adminNote: '',
|
||||
});
|
||||
const [manualTicketForm, setManualTicketForm] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
adminNote: '',
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Tickets tab state
|
||||
const [ticketSearchQuery, setTicketSearchQuery] = useState('');
|
||||
const [ticketStatusFilter, setTicketStatusFilter] = useState<'all' | 'confirmed' | 'checked_in'>('all');
|
||||
|
||||
// Payment options state
|
||||
const [globalPaymentOptions, setGlobalPaymentOptions] = useState<PaymentOptionsConfig | null>(null);
|
||||
const [paymentOverrides, setPaymentOverrides] = useState<Partial<PaymentOptionsConfig>>({});
|
||||
@@ -301,6 +313,30 @@ export default function AdminEventDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleManualTicket = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!event) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await ticketsApi.manualCreate({
|
||||
eventId: event.id,
|
||||
firstName: manualTicketForm.firstName,
|
||||
lastName: manualTicketForm.lastName || undefined,
|
||||
email: manualTicketForm.email,
|
||||
phone: manualTicketForm.phone || undefined,
|
||||
adminNote: manualTicketForm.adminNote || undefined,
|
||||
});
|
||||
toast.success('Manual ticket created — confirmation email sent');
|
||||
setShowManualTicketModal(false);
|
||||
setManualTicketForm({ firstName: '', lastName: '', email: '', phone: '', adminNote: '' });
|
||||
loadEventData();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to create manual ticket');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Filtered tickets for attendees tab
|
||||
const filteredTickets = tickets.filter((ticket) => {
|
||||
// Status filter
|
||||
@@ -321,6 +357,25 @@ export default function AdminEventDetailPage() {
|
||||
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) => {
|
||||
// Status filter
|
||||
if (ticketStatusFilter !== 'all' && ticket.status !== ticketStatusFilter) {
|
||||
return false;
|
||||
}
|
||||
// Search filter
|
||||
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');
|
||||
@@ -503,7 +558,7 @@ export default function AdminEventDetailPage() {
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-secondary-light-gray mb-6">
|
||||
<nav className="flex gap-6">
|
||||
{(['overview', 'attendees', 'email', 'payments'] as TabType[]).map((tab) => (
|
||||
{(['overview', 'attendees', 'tickets', 'email', 'payments'] as TabType[]).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
@@ -517,9 +572,10 @@ export default function AdminEventDetailPage() {
|
||||
>
|
||||
{tab === 'overview' && <CalendarIcon className="w-4 h-4" />}
|
||||
{tab === 'attendees' && <UserGroupIcon className="w-4 h-4" />}
|
||||
{tab === 'tickets' && <TicketIcon className="w-4 h-4" />}
|
||||
{tab === 'email' && <EnvelopeIcon className="w-4 h-4" />}
|
||||
{tab === 'payments' && <CreditCardIcon className="w-4 h-4" />}
|
||||
{tab === 'overview' ? 'Overview' : tab === 'attendees' ? `Attendees (${tickets.length})` : tab === 'email' ? 'Send Email' : (locale === 'es' ? 'Pagos' : 'Payments')}
|
||||
{tab === 'overview' ? 'Overview' : tab === 'attendees' ? `Attendees (${tickets.length})` : tab === 'tickets' ? `Tickets (${confirmedTickets.length})` : tab === 'email' ? 'Send Email' : (locale === 'es' ? 'Pagos' : 'Payments')}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
@@ -629,11 +685,17 @@ export default function AdminEventDetailPage() {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{/* Add at Door Button */}
|
||||
<Button onClick={() => setShowAddAtDoorModal(true)}>
|
||||
<PlusIcon className="w-4 h-4 mr-2" />
|
||||
Add at Door
|
||||
</Button>
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => setShowManualTicketModal(true)}>
|
||||
<EnvelopeIcon className="w-4 h-4 mr-2" />
|
||||
Manual Ticket
|
||||
</Button>
|
||||
<Button onClick={() => setShowAddAtDoorModal(true)}>
|
||||
<PlusIcon className="w-4 h-4 mr-2" />
|
||||
Add at Door
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Filter Results Summary */}
|
||||
{(searchQuery || statusFilter !== 'all') && (
|
||||
@@ -758,6 +820,150 @@ export default function AdminEventDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tickets Tab */}
|
||||
{activeTab === 'tickets' && (
|
||||
<div className="space-y-4">
|
||||
{/* Search & Filter Bar */}
|
||||
<Card className="p-4">
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
||||
<div className="flex flex-col sm:flex-row gap-3 flex-1 w-full sm:w-auto">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<MagnifyingGlassIcon className="w-5 h-5 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name or ticket ID..."
|
||||
value={ticketSearchQuery}
|
||||
onChange={(e) => setTicketSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
/>
|
||||
</div>
|
||||
{/* Status Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<FunnelIcon className="w-5 h-5 text-gray-400" />
|
||||
<select
|
||||
value={ticketStatusFilter}
|
||||
onChange={(e) => setTicketStatusFilter(e.target.value as any)}
|
||||
className="px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
>
|
||||
<option value="all">All ({confirmedTickets.length})</option>
|
||||
<option value="confirmed">Valid ({getTicketsByStatus('confirmed').length})</option>
|
||||
<option value="checked_in">Checked In ({getTicketsByStatus('checked_in').length})</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{(ticketSearchQuery || ticketStatusFilter !== 'all') && (
|
||||
<div className="mt-3 text-sm text-gray-500 flex items-center gap-2">
|
||||
<span>Showing {filteredConfirmedTickets.length} of {confirmedTickets.length} tickets</span>
|
||||
<button
|
||||
onClick={() => { setTicketSearchQuery(''); setTicketStatusFilter('all'); }}
|
||||
className="text-primary-yellow hover:underline"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Tickets Table */}
|
||||
<Card className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-secondary-gray">
|
||||
<tr>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Attendee Name</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Ticket ID</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Booking ID</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Status</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Check-in Time</th>
|
||||
<th className="text-right px-6 py-3 text-sm font-medium text-gray-600">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-secondary-light-gray">
|
||||
{filteredConfirmedTickets.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-12 text-center text-gray-500">
|
||||
{confirmedTickets.length === 0 ? 'No confirmed tickets yet' : 'No tickets match the current filters'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredConfirmedTickets.map((ticket) => (
|
||||
<tr key={ticket.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
<p className="font-medium">
|
||||
{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm bg-gray-100 px-2 py-1 rounded" title={ticket.id}>
|
||||
{ticket.id.slice(0, 8)}...
|
||||
</code>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{ticket.bookingId ? (
|
||||
<code className="text-sm bg-purple-50 text-purple-700 px-2 py-1 rounded" title={ticket.bookingId}>
|
||||
{ticket.bookingId.slice(0, 8)}...
|
||||
</code>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{ticket.status === 'confirmed' ? (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800">
|
||||
Valid
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800">
|
||||
Checked In
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
{ticket.checkinAt ? (
|
||||
new Date(ticket.checkinAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
) : (
|
||||
<span className="text-gray-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{ticket.status === 'confirmed' && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleCheckin(ticket.id)}
|
||||
>
|
||||
Check In
|
||||
</Button>
|
||||
)}
|
||||
{ticket.status === 'checked_in' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleRemoveCheckin(ticket.id)}
|
||||
>
|
||||
<ArrowUturnLeftIcon className="w-4 h-4 mr-1" />
|
||||
Undo
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add at Door Modal */}
|
||||
{showAddAtDoorModal && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
@@ -855,6 +1061,111 @@ export default function AdminEventDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manual Ticket Modal */}
|
||||
{showManualTicketModal && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold">Create Manual Ticket</h2>
|
||||
<p className="text-sm text-gray-500">Attendee will receive a confirmation email with their ticket</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowManualTicketModal(false)}
|
||||
className="p-2 hover:bg-gray-100 rounded-btn"
|
||||
>
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleManualTicket} className="p-4 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">First Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={manualTicketForm.firstName}
|
||||
onChange={(e) => setManualTicketForm({ ...manualTicketForm, firstName: e.target.value })}
|
||||
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
placeholder="First name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Last Name (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={manualTicketForm.lastName}
|
||||
onChange={(e) => setManualTicketForm({ ...manualTicketForm, lastName: e.target.value })}
|
||||
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
placeholder="Last name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Email *</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={manualTicketForm.email}
|
||||
onChange={(e) => setManualTicketForm({ ...manualTicketForm, email: e.target.value })}
|
||||
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Booking confirmation and ticket will be sent to this email
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Phone (optional)</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={manualTicketForm.phone}
|
||||
onChange={(e) => setManualTicketForm({ ...manualTicketForm, phone: e.target.value })}
|
||||
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
placeholder="+595 981 123456"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Admin Note (optional)</label>
|
||||
<textarea
|
||||
value={manualTicketForm.adminNote}
|
||||
onChange={(e) => setManualTicketForm({ ...manualTicketForm, adminNote: e.target.value })}
|
||||
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
rows={2}
|
||||
placeholder="Internal note about this attendee..."
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<EnvelopeIcon className="w-5 h-5 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm text-blue-800">
|
||||
<p className="font-medium">This will send:</p>
|
||||
<ul className="list-disc ml-4 mt-1 space-y-0.5">
|
||||
<li>Booking confirmation email</li>
|
||||
<li>Ticket with QR code</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setShowManualTicketModal(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" isLoading={submitting} className="flex-1">
|
||||
<EnvelopeIcon className="w-4 h-4 mr-2" />
|
||||
Create & Send
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note Modal */}
|
||||
{showNoteModal && selectedTicket && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
|
||||
Reference in New Issue
Block a user