From c3897efd0279d21033e31230ec3e8e5aba579cd3 Mon Sep 17 00:00:00 2001 From: Michilis Date: Sat, 14 Feb 2026 05:27:17 +0000 Subject: [PATCH] feat(admin): add event attendees export (CSV) with status filters Co-authored-by: Cursor --- backend/src/routes/admin.ts | 99 ++++++++++++++++++- frontend/src/app/admin/events/[id]/page.tsx | 103 +++++++++++++++++++- frontend/src/lib/api.ts | 21 ++++ 3 files changed, 220 insertions(+), 3 deletions(-) diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 554b5e0..baf22fb 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -1,6 +1,6 @@ import { Hono } from 'hono'; import { db, dbGet, dbAll, users, events, tickets, payments, contacts, emailSubscribers } from '../db/index.js'; -import { eq, and, gte, sql, desc } from 'drizzle-orm'; +import { eq, and, gte, sql, desc, inArray } from 'drizzle-orm'; import { requireAuth } from '../lib/auth.js'; import { getNow } from '../lib/utils.js'; @@ -222,6 +222,103 @@ 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) => { + 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 + + // 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); + } + + // Build query for tickets belonging to this event + let conditions: any[] = [eq((tickets as any).eventId, eventId)]; + + if (status === 'confirmed') { + conditions.push(eq((tickets as any).status, 'confirmed')); + } else if (status === 'checked_in') { + conditions.push(eq((tickets as any).status, 'checked_in')); + } else if (status === 'confirmed_pending') { + conditions.push(inArray((tickets as any).status, ['confirmed', 'pending'])); + } else { + // "all" — include everything + } + + const ticketList = await dbAll( + (db as any) + .select() + .from(tickets) + .where(conditions.length === 1 ? conditions[0] : and(...conditions)) + .orderBy((tickets as any).createdAt) + ); + + // Enrich each ticket with payment data + const rows = await Promise.all( + ticketList.map(async (ticket: any) => { + const payment = await dbGet( + (db as any) + .select() + .from(payments) + .where(eq((payments as any).ticketId, ticket.id)) + ); + + const fullName = [ticket.attendeeFirstName, ticket.attendeeLastName].filter(Boolean).join(' '); + const isCheckedIn = ticket.status === 'checked_in'; + + return { + 'Ticket ID': ticket.id, + 'Full Name': fullName, + 'Email': ticket.attendeeEmail || '', + 'Status': ticket.status, + 'Checked In': isCheckedIn ? 'true' : 'false', + 'Check-in Time': ticket.checkinAt || '', + 'Payment Status': payment?.status || '', + 'Notes': ticket.adminNote || '', + }; + }) + ); + + // Generate CSV + 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', 'Full Name', 'Email', + 'Status', 'Checked In', 'Check-in Time', 'Payment Status', + 'Notes', + ]; + + const headerLine = columns.map(csvEscape).join(','); + const dataLines = rows.map((row: any) => + columns.map((col) => csvEscape(row[col])).join(',') + ); + + const csvContent = '\uFEFF' + [headerLine, ...dataLines].join('\r\n'); // BOM for UTF-8 + + // Build filename: event-slug-attendees-YYYY-MM-DD.csv + const slug = (event.title || 'event') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, ''); + const dateStr = new Date().toISOString().split('T')[0]; + const filename = `${slug}-attendees-${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 a3efda1..6497e6d 100644 --- a/frontend/src/app/admin/events/[id]/page.tsx +++ b/frontend/src/app/admin/events/[id]/page.tsx @@ -1,10 +1,10 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { useParams, useRouter } from 'next/navigation'; import Link from 'next/link'; import { useLanguage } from '@/context/LanguageContext'; -import { eventsApi, ticketsApi, emailsApi, paymentOptionsApi, Event, Ticket, EmailTemplate, PaymentOptionsConfig } from '@/lib/api'; +import { eventsApi, ticketsApi, emailsApi, paymentOptionsApi, adminApi, Event, Ticket, EmailTemplate, PaymentOptionsConfig } from '@/lib/api'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import { @@ -34,6 +34,8 @@ import { BoltIcon, BuildingLibraryIcon, ArrowPathIcon, + ArrowDownTrayIcon, + ChevronDownIcon, } from '@heroicons/react/24/outline'; import toast from 'react-hot-toast'; import clsx from 'clsx'; @@ -85,6 +87,23 @@ export default function AdminEventDetailPage() { }); 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]); + // Tickets tab state const [ticketSearchQuery, setTicketSearchQuery] = useState(''); const [ticketStatusFilter, setTicketStatusFilter] = useState<'all' | 'confirmed' | 'checked_in'>('all'); @@ -341,6 +360,28 @@ export default function AdminEventDetailPage() { } }; + const handleExport = 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 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 attendees'); + } finally { + setExporting(false); + } + }; + // Filtered tickets for attendees tab const filteredTickets = tickets.filter((ticket) => { // Status filter @@ -710,6 +751,21 @@ export default function AdminEventDetailPage() { {/* Action Buttons */}
+ {/* Export Button */} +
)} + {/* Export Dropdown (rendered outside Card to avoid overflow:hidden clipping) */} + {showExportDropdown && ( + <> +
setShowExportDropdown(false)} + /> +
+ + + + +
+
+ Format: CSV +
+
+ + )} + {/* Add at Door Modal */} {showAddAtDoorModal && (
(`/api/admin/export/financial?${query}`); }, + /** Download attendee export as a file (CSV). Returns a Blob. */ + exportAttendees: async (eventId: string, params?: { status?: string; format?: string }) => { + const query = new URLSearchParams(); + if (params?.status) query.set('status', params.status); + if (params?.format) query.set('format', params.format); + const token = typeof window !== 'undefined' + ? localStorage.getItem('spanglish-token') + : null; + const headers: Record = {}; + if (token) headers['Authorization'] = `Bearer ${token}`; + const res = await fetch(`${API_BASE}/api/admin/events/${eventId}/export?${query}`, { headers }); + if (!res.ok) { + const errorData = await res.json().catch(() => ({ error: 'Export failed' })); + throw new Error(errorData.error || 'Export failed'); + } + const disposition = res.headers.get('Content-Disposition') || ''; + const filenameMatch = disposition.match(/filename="?([^"]+)"?/); + const filename = filenameMatch ? filenameMatch[1] : `attendees-${new Date().toISOString().split('T')[0]}.csv`; + const blob = await res.blob(); + return { blob, filename }; + }, }; // Emails API