Admin event page: redesign UI, export endpoints, mobile fixes #9
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user