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