Admin event page: redesign UI, export endpoints, mobile fixes

- Backend: Add /events/:eventId/attendees/export and /events/:eventId/tickets/export with q/status; legacy redirect for old export path
- API: exportAttendees q param, new exportTicketsCSV for tickets CSV
- Admin event page: unified tabs+content container, portal dropdowns to fix clipping, separate mobile export/add-ticket sheets (fix double menu), responsive tab bar and card layout

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Michilis
2026-02-14 18:27:27 +00:00
parent c3897efd02
commit 6bc7e13e78
3 changed files with 1745 additions and 1295 deletions

View File

@@ -222,11 +222,11 @@ adminRouter.get('/export/tickets', requireAuth(['admin']), async (c) => {
return c.json({ tickets: enrichedTickets }); return c.json({ tickets: enrichedTickets });
}); });
// Export attendees for a specific event (admin) — CSV/XLSX download // Export attendees for a specific event (admin) — CSV download
adminRouter.get('/events/:eventId/export', requireAuth(['admin']), async (c) => { adminRouter.get('/events/:eventId/attendees/export', requireAuth(['admin']), async (c) => {
const eventId = c.req.param('eventId'); const eventId = c.req.param('eventId');
const status = c.req.query('status') || 'all'; // confirmed | checked_in | confirmed_pending | all 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 // Verify event exists
const event = await dbGet<any>( const event = await dbGet<any>(
@@ -249,14 +249,28 @@ adminRouter.get('/events/:eventId/export', requireAuth(['admin']), async (c) =>
// "all" — include everything // "all" — include everything
} }
const ticketList = await dbAll<any>( let ticketList = await dbAll<any>(
(db as any) (db as any)
.select() .select()
.from(tickets) .from(tickets)
.where(conditions.length === 1 ? conditions[0] : and(...conditions)) .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 // Enrich each ticket with payment data
const rows = await Promise.all( const rows = await Promise.all(
ticketList.map(async (ticket: any) => { ticketList.map(async (ticket: any) => {
@@ -274,10 +288,12 @@ adminRouter.get('/events/:eventId/export', requireAuth(['admin']), async (c) =>
'Ticket ID': ticket.id, 'Ticket ID': ticket.id,
'Full Name': fullName, 'Full Name': fullName,
'Email': ticket.attendeeEmail || '', 'Email': ticket.attendeeEmail || '',
'Phone': ticket.attendeePhone || '',
'Status': ticket.status, 'Status': ticket.status,
'Checked In': isCheckedIn ? 'true' : 'false', 'Checked In': isCheckedIn ? 'true' : 'false',
'Check-in Time': ticket.checkinAt || '', 'Check-in Time': ticket.checkinAt || '',
'Payment Status': payment?.status || '', 'Payment Status': payment?.status || '',
'Booked At': ticket.createdAt || '',
'Notes': ticket.adminNote || '', 'Notes': ticket.adminNote || '',
}; };
}) })
@@ -294,9 +310,9 @@ adminRouter.get('/events/:eventId/export', requireAuth(['admin']), async (c) =>
}; };
const columns = [ const columns = [
'Ticket ID', 'Full Name', 'Email', 'Ticket ID', 'Full Name', 'Email', 'Phone',
'Status', 'Checked In', 'Check-in Time', 'Payment Status', 'Status', 'Checked In', 'Check-in Time', 'Payment Status',
'Notes', 'Booked At', 'Notes',
]; ];
const headerLine = columns.map(csvEscape).join(','); const headerLine = columns.map(csvEscape).join(',');
@@ -319,6 +335,98 @@ adminRouter.get('/events/:eventId/export', requireAuth(['admin']), async (c) =>
return c.body(csvContent); 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<any>(
(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<any>(
(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) // Export financial data (admin)
adminRouter.get('/export/financial', requireAuth(['admin']), async (c) => { adminRouter.get('/export/financial', requireAuth(['admin']), async (c) => {
const startDate = c.req.query('startDate'); const startDate = c.req.query('startDate');

File diff suppressed because it is too large Load Diff

View File

@@ -373,16 +373,17 @@ export const adminApi = {
return fetchApi<{ payments: ExportedPayment[]; summary: FinancialSummary }>(`/api/admin/export/financial?${query}`); return fetchApi<{ payments: ExportedPayment[]; summary: FinancialSummary }>(`/api/admin/export/financial?${query}`);
}, },
/** Download attendee export as a file (CSV). Returns a Blob. */ /** Download attendee export as a file (CSV). Returns a Blob. */
exportAttendees: async (eventId: string, params?: { status?: string; format?: string }) => { exportAttendees: async (eventId: string, params?: { status?: string; format?: string; q?: string }) => {
const query = new URLSearchParams(); const query = new URLSearchParams();
if (params?.status) query.set('status', params.status); if (params?.status) query.set('status', params.status);
if (params?.format) query.set('format', params.format); if (params?.format) query.set('format', params.format);
if (params?.q) query.set('q', params.q);
const token = typeof window !== 'undefined' const token = typeof window !== 'undefined'
? localStorage.getItem('spanglish-token') ? localStorage.getItem('spanglish-token')
: null; : null;
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`; if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${API_BASE}/api/admin/events/${eventId}/export?${query}`, { headers }); const res = await fetch(`${API_BASE}/api/admin/events/${eventId}/attendees/export?${query}`, { headers });
if (!res.ok) { if (!res.ok) {
const errorData = await res.json().catch(() => ({ error: 'Export failed' })); const errorData = await res.json().catch(() => ({ error: 'Export failed' }));
throw new Error(errorData.error || 'Export failed'); throw new Error(errorData.error || 'Export failed');
@@ -393,6 +394,27 @@ export const adminApi = {
const blob = await res.blob(); const blob = await res.blob();
return { blob, filename }; return { blob, filename };
}, },
/** Download tickets export as CSV. Returns a Blob. */
exportTicketsCSV: async (eventId: string, params?: { status?: string; q?: string }) => {
const query = new URLSearchParams();
if (params?.status) query.set('status', params.status);
if (params?.q) query.set('q', params.q);
const token = typeof window !== 'undefined'
? localStorage.getItem('spanglish-token')
: null;
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${API_BASE}/api/admin/events/${eventId}/tickets/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] : `tickets-${new Date().toISOString().split('T')[0]}.csv`;
const blob = await res.blob();
return { blob, filename };
},
}; };
// Emails API // Emails API