feat(admin): add event attendees export (CSV) with status filters
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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<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
|
||||
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() {
|
||||
</div>
|
||||
{/* Action Buttons */}
|
||||
<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)}>
|
||||
<EnvelopeIcon className="w-4 h-4 mr-2" />
|
||||
Manual Ticket
|
||||
@@ -988,6 +1044,49 @@ export default function AdminEventDetailPage() {
|
||||
</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 */}
|
||||
{showAddAtDoorModal && (
|
||||
<div
|
||||
|
||||
@@ -372,6 +372,27 @@ export const adminApi = {
|
||||
if (params?.eventId) query.set('eventId', params.eventId);
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user