From 2b2f2cc4edf68ee6a613e868b589e54981363174 Mon Sep 17 00:00:00 2001 From: Michilis Date: Sun, 8 Feb 2026 02:44:26 +0000 Subject: [PATCH] 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 --- backend/src/routes/tickets.ts | 148 +++++++++ frontend/src/app/admin/events/[id]/page.tsx | 327 +++++++++++++++++++- frontend/src/lib/api.ts | 14 + 3 files changed, 481 insertions(+), 8 deletions(-) diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index 0288413..96fe952 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -1097,6 +1097,154 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff']) }, 201); }); +// Admin create manual ticket (sends confirmation email + ticket to attendee) +ticketsRouter.post('/admin/manual', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', z.object({ + eventId: z.string(), + firstName: z.string().min(2), + lastName: z.string().optional().or(z.literal('')), + email: z.string().email('Valid email is required for manual tickets'), + phone: z.string().optional().or(z.literal('')), + preferredLanguage: z.enum(['en', 'es']).optional(), + adminNote: z.string().max(1000).optional(), +})), async (c) => { + const data = c.req.valid('json'); + + // Get event + const event = await dbGet( + (db as any).select().from(events).where(eq((events as any).id, data.eventId)) + ); + if (!event) { + return c.json({ error: 'Event not found' }, 404); + } + + // Check capacity + const existingCount = await dbGet( + (db as any) + .select({ count: sql`count(*)` }) + .from(tickets) + .where( + and( + eq((tickets as any).eventId, data.eventId), + sql`${(tickets as any).status} IN ('confirmed', 'checked_in')` + ) + ) + ); + + if ((existingCount?.count || 0) >= event.capacity) { + return c.json({ error: 'Event is at capacity' }, 400); + } + + const now = getNow(); + const attendeeEmail = data.email.trim(); + + // Find or create user + let user = await dbGet( + (db as any).select().from(users).where(eq((users as any).email, attendeeEmail)) + ); + + const fullName = data.lastName && data.lastName.trim() + ? `${data.firstName} ${data.lastName}`.trim() + : data.firstName; + + if (!user) { + const userId = generateId(); + user = { + id: userId, + email: attendeeEmail, + password: '', + name: fullName, + phone: data.phone || null, + role: 'user', + languagePreference: null, + createdAt: now, + updatedAt: now, + }; + await (db as any).insert(users).values(user); + } + + // Check for existing active ticket for this user and event + const existingTicket = await dbGet( + (db as any) + .select() + .from(tickets) + .where( + and( + eq((tickets as any).userId, user.id), + eq((tickets as any).eventId, data.eventId) + ) + ) + ); + + if (existingTicket && existingTicket.status !== 'cancelled') { + return c.json({ error: 'This person already has a ticket for this event' }, 400); + } + + // Create ticket as confirmed + const ticketId = generateId(); + const qrCode = generateTicketCode(); + + const newTicket = { + id: ticketId, + userId: user.id, + eventId: data.eventId, + attendeeFirstName: data.firstName, + attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null, + attendeeEmail: attendeeEmail, + attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null, + preferredLanguage: data.preferredLanguage || null, + status: 'confirmed', + qrCode, + checkinAt: null, + adminNote: data.adminNote || null, + createdAt: now, + }; + + await (db as any).insert(tickets).values(newTicket); + + // Create payment record (marked as paid - manual entry) + const paymentId = generateId(); + const adminUser = (c as any).get('user'); + const newPayment = { + id: paymentId, + ticketId, + provider: 'cash', + amount: event.price, + currency: event.currency, + status: 'paid', + reference: 'Manual ticket', + paidAt: now, + paidByAdminId: adminUser?.id || null, + createdAt: now, + updatedAt: now, + }; + + await (db as any).insert(payments).values(newPayment); + + // Send booking confirmation email + ticket (asynchronously) + emailService.sendBookingConfirmation(ticketId).then(result => { + if (result.success) { + console.log(`[Email] Booking confirmation sent for manual ticket ${ticketId}`); + } else { + console.error(`[Email] Failed to send booking confirmation for manual ticket ${ticketId}:`, result.error); + } + }).catch(err => { + console.error('[Email] Exception sending booking confirmation for manual ticket:', err); + }); + + return c.json({ + ticket: { + ...newTicket, + event: { + title: event.title, + startDatetime: event.startDatetime, + location: event.location, + }, + }, + payment: newPayment, + message: 'Manual ticket created and confirmation email sent', + }, 201); +}); + // Get all tickets (admin) ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => { const eventId = c.req.query('eventId'); diff --git a/frontend/src/app/admin/events/[id]/page.tsx b/frontend/src/app/admin/events/[id]/page.tsx index 9a34843..31d83fc 100644 --- a/frontend/src/app/admin/events/[id]/page.tsx +++ b/frontend/src/app/admin/events/[id]/page.tsx @@ -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(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(null); const [paymentOverrides, setPaymentOverrides] = useState>({}); @@ -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 */}
@@ -629,11 +685,17 @@ export default function AdminEventDetailPage() {
- {/* Add at Door Button */} - + {/* Action Buttons */} +
+ + +
{/* Filter Results Summary */} {(searchQuery || statusFilter !== 'all') && ( @@ -758,6 +820,150 @@ export default function AdminEventDetailPage() { )} + {/* Tickets Tab */} + {activeTab === 'tickets' && ( +
+ {/* Search & Filter Bar */} + +
+
+ {/* Search */} +
+ + 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" + /> +
+ {/* Status Filter */} +
+ + +
+
+
+ {(ticketSearchQuery || ticketStatusFilter !== 'all') && ( +
+ Showing {filteredConfirmedTickets.length} of {confirmedTickets.length} tickets + +
+ )} +
+ + {/* Tickets Table */} + +
+ + + + + + + + + + + + + {filteredConfirmedTickets.length === 0 ? ( + + + + ) : ( + filteredConfirmedTickets.map((ticket) => ( + + + + + + + + + )) + )} + +
Attendee NameTicket IDBooking IDStatusCheck-in TimeActions
+ {confirmedTickets.length === 0 ? 'No confirmed tickets yet' : 'No tickets match the current filters'} +
+

+ {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', + }) + ) : ( + + )} + +
+ {ticket.status === 'confirmed' && ( + + )} + {ticket.status === 'checked_in' && ( + + )} +
+
+
+
+
+ )} + {/* Add at Door Modal */} {showAddAtDoorModal && (
@@ -855,6 +1061,111 @@ export default function AdminEventDetailPage() {
)} + {/* Manual Ticket Modal */} + {showManualTicketModal && ( +
+ +
+
+

Create Manual Ticket

+

Attendee will receive a confirmation email with their ticket

+
+ +
+
+
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+
+ + 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" + /> +

+ Booking confirmation and ticket will be sent to this email +

+
+
+ + 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" + /> +
+
+ +