Compare commits
2 Commits
b5f14335c4
...
dcfefc8371
| Author | SHA1 | Date | |
|---|---|---|---|
| dcfefc8371 | |||
|
|
c3897efd02 |
@@ -1,6 +1,6 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { db, dbGet, dbAll, users, events, tickets, payments, contacts, emailSubscribers } from '../db/index.js';
|
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 { requireAuth } from '../lib/auth.js';
|
||||||
import { getNow } from '../lib/utils.js';
|
import { getNow } from '../lib/utils.js';
|
||||||
|
|
||||||
@@ -222,6 +222,103 @@ 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
|
||||||
|
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<any>(
|
||||||
|
(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<any>(
|
||||||
|
(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<any>(
|
||||||
|
(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)
|
// 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');
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
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 Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import {
|
import {
|
||||||
@@ -34,6 +34,8 @@ import {
|
|||||||
BoltIcon,
|
BoltIcon,
|
||||||
BuildingLibraryIcon,
|
BuildingLibraryIcon,
|
||||||
ArrowPathIcon,
|
ArrowPathIcon,
|
||||||
|
ArrowDownTrayIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
@@ -85,6 +87,23 @@ export default function AdminEventDetailPage() {
|
|||||||
});
|
});
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Export state
|
||||||
|
const [showExportDropdown, setShowExportDropdown] = useState(false);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const exportBtnRef = useRef<HTMLButtonElement>(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
|
// Tickets tab state
|
||||||
const [ticketSearchQuery, setTicketSearchQuery] = useState('');
|
const [ticketSearchQuery, setTicketSearchQuery] = useState('');
|
||||||
const [ticketStatusFilter, setTicketStatusFilter] = useState<'all' | 'confirmed' | 'checked_in'>('all');
|
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
|
// Filtered tickets for attendees tab
|
||||||
const filteredTickets = tickets.filter((ticket) => {
|
const filteredTickets = tickets.filter((ticket) => {
|
||||||
// Status filter
|
// Status filter
|
||||||
@@ -710,6 +751,21 @@ export default function AdminEventDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Export Button */}
|
||||||
|
<Button
|
||||||
|
ref={exportBtnRef}
|
||||||
|
variant="outline"
|
||||||
|
onClick={toggleExportDropdown}
|
||||||
|
disabled={exporting}
|
||||||
|
>
|
||||||
|
{exporting ? (
|
||||||
|
<div className="w-4 h-4 mr-2 border-2 border-gray-400 border-t-transparent rounded-full animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ArrowDownTrayIcon className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
Export
|
||||||
|
<ChevronDownIcon className="w-3 h-3 ml-1" />
|
||||||
|
</Button>
|
||||||
<Button variant="outline" onClick={() => setShowManualTicketModal(true)}>
|
<Button variant="outline" onClick={() => setShowManualTicketModal(true)}>
|
||||||
<EnvelopeIcon className="w-4 h-4 mr-2" />
|
<EnvelopeIcon className="w-4 h-4 mr-2" />
|
||||||
Manual Ticket
|
Manual Ticket
|
||||||
@@ -988,6 +1044,49 @@ export default function AdminEventDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Export Dropdown (rendered outside Card to avoid overflow:hidden clipping) */}
|
||||||
|
{showExportDropdown && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40"
|
||||||
|
onClick={() => setShowExportDropdown(false)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="fixed w-48 bg-white border border-secondary-light-gray rounded-btn shadow-lg z-50"
|
||||||
|
style={{ top: exportDropdownPos.top, right: exportDropdownPos.right }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => handleExport('confirmed')}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm hover:bg-gray-50 rounded-t-btn"
|
||||||
|
>
|
||||||
|
Export Confirmed
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleExport('checked_in')}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Export Checked-in
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleExport('confirmed_pending')}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Export Confirmed & Pending
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleExport('all')}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Export All
|
||||||
|
</button>
|
||||||
|
<div className="border-t border-secondary-light-gray" />
|
||||||
|
<div className="px-4 py-2 text-xs text-gray-400 rounded-b-btn">
|
||||||
|
Format: CSV
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Add at Door Modal */}
|
{/* Add at Door Modal */}
|
||||||
{showAddAtDoorModal && (
|
{showAddAtDoorModal && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -372,6 +372,27 @@ export const adminApi = {
|
|||||||
if (params?.eventId) query.set('eventId', params.eventId);
|
if (params?.eventId) query.set('eventId', params.eventId);
|
||||||
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. */
|
||||||
|
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<string, string> = {};
|
||||||
|
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
|
// Emails API
|
||||||
|
|||||||
Reference in New Issue
Block a user