544 lines
21 KiB
JavaScript
544 lines
21 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
|
import api from '../../utils/api';
|
|
import { Card } from '../../components/ui/card';
|
|
import { Button } from '../../components/ui/button';
|
|
import { Input } from '../../components/ui/input';
|
|
import { Badge } from '../../components/ui/badge';
|
|
import { Checkbox } from '../../components/ui/checkbox';
|
|
import {
|
|
ArrowLeft,
|
|
Calendar,
|
|
MapPin,
|
|
Download,
|
|
Check,
|
|
X,
|
|
Search,
|
|
Users,
|
|
UserCheck,
|
|
UserX,
|
|
HelpCircle
|
|
} from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import moment from 'moment';
|
|
|
|
const AdminEventAttendance = () => {
|
|
const { eventId } = useParams();
|
|
const navigate = useNavigate();
|
|
|
|
const [event, setEvent] = useState(null);
|
|
const [rsvps, setRsvps] = useState([]);
|
|
const [filteredRsvps, setFilteredRsvps] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
// Filters and search
|
|
const [activeTab, setActiveTab] = useState('all');
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
|
|
// Bulk selection
|
|
const [selectedRsvps, setSelectedRsvps] = useState(new Set());
|
|
const [selectAll, setSelectAll] = useState(false);
|
|
|
|
useEffect(() => {
|
|
fetchEventAndRsvps();
|
|
}, [eventId]);
|
|
|
|
useEffect(() => {
|
|
filterRsvps();
|
|
}, [rsvps, activeTab, searchQuery]);
|
|
|
|
const fetchEventAndRsvps = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const [eventRes, rsvpsRes] = await Promise.all([
|
|
api.get(`/admin/events/${eventId}`),
|
|
api.get(`/admin/events/${eventId}/rsvps`)
|
|
]);
|
|
setEvent(eventRes.data);
|
|
setRsvps(rsvpsRes.data);
|
|
} catch (error) {
|
|
console.error('Failed to fetch event data:', error);
|
|
toast.error('Failed to load event data');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const filterRsvps = () => {
|
|
let filtered = [...rsvps];
|
|
|
|
// Filter by RSVP status tab
|
|
if (activeTab !== 'all') {
|
|
filtered = filtered.filter(rsvp => rsvp.rsvp_status === activeTab);
|
|
}
|
|
|
|
// Filter by search query
|
|
if (searchQuery) {
|
|
const query = searchQuery.toLowerCase();
|
|
filtered = filtered.filter(rsvp =>
|
|
rsvp.user_name?.toLowerCase().includes(query) ||
|
|
rsvp.user_email?.toLowerCase().includes(query)
|
|
);
|
|
}
|
|
|
|
setFilteredRsvps(filtered);
|
|
};
|
|
|
|
const handleSelectAll = () => {
|
|
if (selectAll) {
|
|
setSelectedRsvps(new Set());
|
|
} else {
|
|
setSelectedRsvps(new Set(filteredRsvps.map(rsvp => rsvp.user_id)));
|
|
}
|
|
setSelectAll(!selectAll);
|
|
};
|
|
|
|
const handleSelectRsvp = (userId) => {
|
|
const newSelected = new Set(selectedRsvps);
|
|
if (newSelected.has(userId)) {
|
|
newSelected.delete(userId);
|
|
} else {
|
|
newSelected.add(userId);
|
|
}
|
|
setSelectedRsvps(newSelected);
|
|
setSelectAll(newSelected.size === filteredRsvps.length);
|
|
};
|
|
|
|
const handleBulkAttendance = async (attended) => {
|
|
if (selectedRsvps.size === 0) {
|
|
toast.error('Please select at least one RSVP');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setSaving(true);
|
|
const updates = Array.from(selectedRsvps).map(userId => ({
|
|
user_id: userId,
|
|
attended
|
|
}));
|
|
|
|
await api.put(`/admin/events/${eventId}/attendance`, { updates });
|
|
|
|
toast.success(`Marked ${selectedRsvps.size} ${selectedRsvps.size === 1 ? 'person' : 'people'} as ${attended ? 'attended' : 'not attended'}`);
|
|
|
|
// Refresh data
|
|
await fetchEventAndRsvps();
|
|
|
|
// Clear selection
|
|
setSelectedRsvps(new Set());
|
|
setSelectAll(false);
|
|
} catch (error) {
|
|
console.error('Failed to update attendance:', error);
|
|
toast.error('Failed to update attendance');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleIndividualAttendance = async (userId, attended) => {
|
|
try {
|
|
setSaving(true);
|
|
const updates = [{
|
|
user_id: userId,
|
|
attended
|
|
}];
|
|
|
|
await api.put(`/admin/events/${eventId}/attendance`, { updates });
|
|
|
|
toast.success(`Attendance ${attended ? 'confirmed' : 'removed'}`);
|
|
|
|
// Refresh data
|
|
await fetchEventAndRsvps();
|
|
} catch (error) {
|
|
console.error('Failed to update attendance:', error);
|
|
toast.error('Failed to update attendance');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const exportToCSV = () => {
|
|
if (filteredRsvps.length === 0) {
|
|
toast.error('No RSVPs to export');
|
|
return;
|
|
}
|
|
|
|
// CSV header
|
|
const headers = ['Name', 'Email', 'RSVP Status', 'Attended', 'Attended At'];
|
|
|
|
// CSV rows
|
|
const rows = filteredRsvps.map(rsvp => [
|
|
`"${rsvp.user_name}"`,
|
|
`"${rsvp.user_email}"`,
|
|
`"${rsvp.rsvp_status.toUpperCase()}"`,
|
|
rsvp.attended ? 'Yes' : 'No',
|
|
rsvp.attended_at ? `"${moment(rsvp.attended_at).format('YYYY-MM-DD HH:mm A')}"` : ''
|
|
]);
|
|
|
|
// Combine headers and rows
|
|
const csvContent = [
|
|
headers.join(','),
|
|
...rows.map(row => row.join(','))
|
|
].join('\n');
|
|
|
|
// Create blob and download
|
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
|
const link = document.createElement('a');
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
link.setAttribute('href', url);
|
|
link.setAttribute('download', `${event?.title.replace(/\s+/g, '_')}_RSVPs_${moment().format('YYYY-MM-DD')}.csv`);
|
|
link.style.visibility = 'hidden';
|
|
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
|
|
toast.success('CSV exported successfully');
|
|
};
|
|
|
|
const getStats = () => {
|
|
const total = rsvps.length;
|
|
const yesCount = rsvps.filter(r => r.rsvp_status === 'yes').length;
|
|
const noCount = rsvps.filter(r => r.rsvp_status === 'no').length;
|
|
const maybeCount = rsvps.filter(r => r.rsvp_status === 'maybe').length;
|
|
const attendedCount = rsvps.filter(r => r.attended).length;
|
|
|
|
return { total, yesCount, noCount, maybeCount, attendedCount };
|
|
};
|
|
|
|
const stats = getStats();
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="text-brand-purple ">Loading event data...</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!event) {
|
|
return (
|
|
<div className="text-center py-12">
|
|
<p className="text-brand-purple mb-4">Event not found</p>
|
|
<Button onClick={() => navigate('/admin/events')} className="bg-brand-purple hover:bg-[var(--purple-ink)] text-white rounded-xl">
|
|
Back to Events
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<Button
|
|
onClick={() => navigate('/admin/events')}
|
|
variant="outline"
|
|
className="border-[var(--neutral-800)] text-brand-purple rounded-xl"
|
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
|
>
|
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
|
Back to Events
|
|
</Button>
|
|
<div>
|
|
<h1 className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
Event Attendance
|
|
</h1>
|
|
<p className="text-brand-purple mt-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
Manage RSVPs and track attendance for this event
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
onClick={exportToCSV}
|
|
className="bg-[var(--green-light)] hover:bg-[var(--green-eucalyptus)] dark:bg-[var(--green-forest)] text-white rounded-xl"
|
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
|
>
|
|
<Download className="h-4 w-4 mr-2" />
|
|
Export to CSV
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Event Details Card */}
|
|
<Card className="p-6 bg-background border-[var(--neutral-800)] rounded-xl">
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
{event.title}
|
|
</h2>
|
|
<div className="flex flex-wrap gap-4 text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
<div className="flex items-center gap-2">
|
|
<Calendar className="h-4 w-4" />
|
|
<span>{moment(event.start_at).format('MMMM D, YYYY [at] h:mm A')}</span>
|
|
</div>
|
|
{event.location && (
|
|
<div className="flex items-center gap-2">
|
|
<MapPin className="h-4 w-4" />
|
|
<span>{event.location}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<Badge className={`${event.published ? 'bg-[var(--green-light)] dark:bg-[var(--green-forest)]' : 'bg-[var(--neutral-800)]'} text-white px-3 py-1`}>
|
|
{event.published ? 'Published' : 'Draft'}
|
|
</Badge>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Statistics Cards */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
|
|
<Card className="p-4 bg-background border-[var(--neutral-800)] rounded-xl">
|
|
<div className="flex items-center gap-3">
|
|
<Users className="h-8 w-8 text-brand-purple " />
|
|
<div>
|
|
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total RSVPs</p>
|
|
<p className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.total}</p>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="p-4 bg-background border-[var(--neutral-800)] rounded-xl">
|
|
<div className="flex items-center gap-3">
|
|
<UserCheck className="h-8 w-8 text-[var(--green-light)] dark:text-[var(--green-forest)]" />
|
|
<div>
|
|
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Yes</p>
|
|
<p className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.yesCount}</p>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="p-4 bg-background border-[var(--neutral-800)] rounded-xl">
|
|
<div className="flex items-center gap-3">
|
|
<UserX className="h-8 w-8 text-[var(--orange-soft)]" />
|
|
<div>
|
|
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>No</p>
|
|
<p className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.noCount}</p>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="p-4 bg-background border-[var(--neutral-800)] rounded-xl">
|
|
<div className="flex items-center gap-3">
|
|
<HelpCircle className="h-8 w-8 text-[var(--gold-warm)]" />
|
|
<div>
|
|
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Maybe</p>
|
|
<p className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.maybeCount}</p>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="p-4 bg-background border-[var(--neutral-800)] rounded-xl">
|
|
<div className="flex items-center gap-3">
|
|
<Check className="h-8 w-8 text-brand-purple " />
|
|
<div>
|
|
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Attended</p>
|
|
<p className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.attendedCount}</p>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Filters and Actions */}
|
|
<Card className="p-6 bg-background border-[var(--neutral-800)] rounded-xl">
|
|
<div className="space-y-4">
|
|
{/* Tab Filters */}
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
onClick={() => setActiveTab('all')}
|
|
variant={activeTab === 'all' ? 'default' : 'outline'}
|
|
className={`rounded-xl ${activeTab === 'all'
|
|
? 'bg-brand-purple hover:bg-[var(--purple-ink)] dark:bg-brand-dark-lavender text-white'
|
|
: 'border-[var(--neutral-800)] text-brand-purple hover:bg-[var(--lavender-500)]'
|
|
}`}
|
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
|
>
|
|
All ({stats.total})
|
|
</Button>
|
|
<Button
|
|
onClick={() => setActiveTab('yes')}
|
|
variant={activeTab === 'yes' ? 'default' : 'outline'}
|
|
className={`rounded-xl ${activeTab === 'yes'
|
|
? 'bg-[var(--green-light)] hover:bg-[var(--green-eucalyptus)] dark:bg-[var(--green-forest)] text-white'
|
|
: 'border-[var(--neutral-800)] text-brand-purple hover:bg-[var(--lavender-500)]'
|
|
}`}
|
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
|
>
|
|
Yes ({stats.yesCount})
|
|
</Button>
|
|
<Button
|
|
onClick={() => setActiveTab('no')}
|
|
variant={activeTab === 'no' ? 'default' : 'outline'}
|
|
className={`rounded-xl ${activeTab === 'no'
|
|
? 'bg-[var(--orange-soft)] hover:bg-[var(--orange-rust)] text-white'
|
|
: 'border-[var(--neutral-800)] text-brand-purple hover:bg-[var(--lavender-500)]'
|
|
}`}
|
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
|
>
|
|
No ({stats.noCount})
|
|
</Button>
|
|
<Button
|
|
onClick={() => setActiveTab('maybe')}
|
|
variant={activeTab === 'maybe' ? 'default' : 'outline'}
|
|
className={`rounded-xl ${activeTab === 'maybe'
|
|
? 'bg-[var(--gold-warm)] dark:bg-orange-400 hover:bg-[var(--gold-soft)] text-white'
|
|
: 'border-[var(--neutral-800)] text-brand-purple hover:bg-[var(--lavender-500)]'
|
|
}`}
|
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
|
>
|
|
Maybe ({stats.maybeCount})
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Search and Bulk Actions */}
|
|
<div className="flex flex-wrap gap-3 items-center justify-between">
|
|
<div className="flex-1 min-w-[200px] max-w-md relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-brand-purple " />
|
|
<Input
|
|
type="text"
|
|
placeholder="Search by name or email..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="pl-10 border-[var(--neutral-800)] rounded-xl"
|
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
|
/>
|
|
</div>
|
|
|
|
{selectedRsvps.size > 0 && (
|
|
<div className="flex gap-2">
|
|
<Badge className="bg-brand-purple text-white px-3 py-1">
|
|
{selectedRsvps.size} selected
|
|
</Badge>
|
|
<Button
|
|
onClick={() => handleBulkAttendance(true)}
|
|
disabled={saving}
|
|
className="bg-[var(--green-light)] hover:bg-[var(--green-eucalyptus)] text-white rounded-xl"
|
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
|
>
|
|
<Check className="h-4 w-4 mr-1" />
|
|
Mark Attended
|
|
</Button>
|
|
<Button
|
|
onClick={() => handleBulkAttendance(false)}
|
|
disabled={saving}
|
|
className="bg-[var(--orange-soft)] hover:bg-[var(--orange-rust)] text-white rounded-xl"
|
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
|
>
|
|
<X className="h-4 w-4 mr-1" />
|
|
Mark Not Attended
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* RSVP Table */}
|
|
<Card className="bg-background border-[var(--neutral-800)] rounded-xl overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="bg-[var(--lavender-500)] border-b border-[var(--neutral-800)]">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left">
|
|
<Checkbox
|
|
checked={selectAll}
|
|
onCheckedChange={handleSelectAll}
|
|
/>
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
Name
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
Email
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
RSVP Status
|
|
</th>
|
|
<th className="px-4 py-3 text-center text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
Attendance
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
Attended At
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredRsvps.length > 0 ? (
|
|
filteredRsvps.map((rsvp) => (
|
|
<tr key={rsvp.user_id} className="border-b border-[var(--neutral-800)] hover:bg-[var(--lavender-500)] transition-colors">
|
|
<td className="px-4 py-3">
|
|
<Checkbox
|
|
checked={selectedRsvps.has(rsvp.user_id)}
|
|
onCheckedChange={() => handleSelectRsvp(rsvp.user_id)}
|
|
/>
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
{rsvp.user_name}
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
{rsvp.user_email}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<Badge
|
|
className={`${rsvp.rsvp_status === 'yes'
|
|
? 'bg-[var(--green-light)] dark:bg-[var(--green-forest)]'
|
|
: rsvp.rsvp_status === 'no'
|
|
? 'bg-[var(--orange-soft)]'
|
|
: 'bg-[var(--gold-warm)] text-[var(--purple-ink)]'
|
|
} text-white text-xs px-2 py-1`}
|
|
>
|
|
{rsvp.rsvp_status.toUpperCase()}
|
|
</Badge>
|
|
</td>
|
|
<td className="px-4 py-3 text-center">
|
|
{rsvp.attended ? (
|
|
<Button
|
|
onClick={() => handleIndividualAttendance(rsvp.user_id, false)}
|
|
disabled={saving}
|
|
size="sm"
|
|
className="bg-[var(--green-light)] dark:bg-[var(--green-forest)] hover:bg-[var(--green-eucalyptus)] text-white rounded-lg min-w-[120px]"
|
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
|
>
|
|
<Check className="h-3 w-3 mr-1" />
|
|
Attended
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
onClick={() => handleIndividualAttendance(rsvp.user_id, true)}
|
|
disabled={saving}
|
|
size="sm"
|
|
variant="outline"
|
|
className="border-[var(--neutral-800)] text-brand-purple hover:bg-[var(--green-light)] dark:bg-[var(--green-forest)] hover:text-white hover:border-[var(--green-light)] dark:bg-[var(--green-forest)] rounded-lg min-w-[120px]"
|
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
|
>
|
|
<X className="h-3 w-3 mr-1" />
|
|
Not Attended
|
|
</Button>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
{rsvp.attended_at ? moment(rsvp.attended_at).format('MMM D, YYYY h:mm A') : '-'}
|
|
</td>
|
|
</tr>
|
|
))
|
|
) : (
|
|
<tr>
|
|
<td colSpan="6" className="px-4 py-12 text-center">
|
|
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
{searchQuery ? 'No RSVPs match your search' : 'No RSVPs for this filter'}
|
|
</p>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AdminEventAttendance;
|