## Quick Wins - **AdminSidebar**: Move "View Public Site" to clickable logo area - **Plans**: Fix layout to center single plan, dynamic grid for multiple - **AdminGallery**: Add empty state message with "Create Event" button ## Event Attendance Enhancement - **NEW: AdminEventAttendance page** with full-featured table view: - Tab filters (All/Yes/No/Maybe RSVPs) - Search by name/email - Bulk selection with Select All - Individual attendance toggle buttons (merged column) - CSV export functionality (client requirement) - Summary statistics cards - **AdminEvents**: Navigate to new attendance page instead of dialog - **App.js**: Add /admin/events/:eventId/attendance route ## Calendar Fixes - **MemberCalendar**: Add state management for navigation (date/view) - Fix non-functional buttons (Today/Back/Next/Month/Week/Day/Agenda) - Add onNavigate and onView handlers - **NEW: MemberCalendar.css**: Extract styles from broken jsx syntax - Fix toolbar button styling and interactivity 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
341 lines
13 KiB
JavaScript
341 lines
13 KiB
JavaScript
import React, { useState, useEffect, useMemo } from 'react';
|
|
import { Calendar, momentLocalizer } from 'react-big-calendar';
|
|
import moment from 'moment';
|
|
import 'react-big-calendar/lib/css/react-big-calendar.css';
|
|
import './MemberCalendar.css';
|
|
import api from '../../utils/api';
|
|
import Navbar from '../../components/Navbar';
|
|
import MemberFooter from '../../components/MemberFooter';
|
|
import { Card } from '../../components/ui/card';
|
|
import { Button } from '../../components/ui/button';
|
|
import { Badge } from '../../components/ui/badge';
|
|
import AddToCalendarButton from '../../components/AddToCalendarButton';
|
|
import { toast } from 'sonner';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '../../components/ui/dialog';
|
|
import { Calendar as CalendarIcon, MapPin, Users, Clock, X, Check, HelpCircle } from 'lucide-react';
|
|
|
|
const localizer = momentLocalizer(moment);
|
|
|
|
export default function MemberCalendar() {
|
|
const [events, setEvents] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [selectedEvent, setSelectedEvent] = useState(null);
|
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
|
const [rsvpLoading, setRsvpLoading] = useState(false);
|
|
const [currentDate, setCurrentDate] = useState(new Date());
|
|
const [currentView, setCurrentView] = useState('month');
|
|
|
|
useEffect(() => {
|
|
fetchEvents();
|
|
}, []);
|
|
|
|
const fetchEvents = async () => {
|
|
try {
|
|
const response = await api.get('/events');
|
|
setEvents(response.data);
|
|
} catch (error) {
|
|
toast.error('Failed to load events');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// Transform events for react-big-calendar
|
|
const calendarEvents = useMemo(() => {
|
|
return events.map(event => ({
|
|
id: event.id,
|
|
title: event.title,
|
|
start: new Date(event.start_at),
|
|
end: new Date(event.end_at),
|
|
resource: event,
|
|
}));
|
|
}, [events]);
|
|
|
|
const handleSelectEvent = (event) => {
|
|
setSelectedEvent(event.resource);
|
|
setIsDialogOpen(true);
|
|
};
|
|
|
|
const handleNavigate = (newDate) => {
|
|
setCurrentDate(newDate);
|
|
};
|
|
|
|
const handleViewChange = (newView) => {
|
|
setCurrentView(newView);
|
|
};
|
|
|
|
const handleRSVP = async (status) => {
|
|
if (!selectedEvent) return;
|
|
|
|
setRsvpLoading(true);
|
|
try {
|
|
await api.post(`/events/${selectedEvent.id}/rsvp`, { rsvp_status: status });
|
|
toast.success(`RSVP updated to: ${status}`);
|
|
|
|
await fetchEvents();
|
|
|
|
const updatedEvent = events.find(e => e.id === selectedEvent.id);
|
|
if (updatedEvent) {
|
|
setSelectedEvent({ ...updatedEvent, user_rsvp_status: status });
|
|
}
|
|
} catch (error) {
|
|
toast.error('Failed to update RSVP');
|
|
} finally {
|
|
setRsvpLoading(false);
|
|
}
|
|
};
|
|
|
|
const eventStyleGetter = (event) => {
|
|
const rsvpStatus = event.resource?.user_rsvp_status;
|
|
|
|
let backgroundColor = '#DDD8EB';
|
|
let borderColor = '#664fa3';
|
|
|
|
if (rsvpStatus === 'yes') {
|
|
backgroundColor = '#81B29A';
|
|
borderColor = '#66927e';
|
|
} else if (rsvpStatus === 'no') {
|
|
backgroundColor = '#9ca3af';
|
|
borderColor = '#6b7280';
|
|
} else if (rsvpStatus === 'maybe') {
|
|
backgroundColor = '#fb923c';
|
|
borderColor = '#ea580c';
|
|
}
|
|
|
|
return {
|
|
style: {
|
|
backgroundColor,
|
|
borderColor,
|
|
borderWidth: '2px',
|
|
borderStyle: 'solid',
|
|
borderRadius: '6px',
|
|
color: 'white',
|
|
fontWeight: '500',
|
|
fontSize: '0.875rem',
|
|
padding: '2px 6px',
|
|
}
|
|
};
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="min-h-screen bg-white">
|
|
<Navbar />
|
|
<div className="flex items-center justify-center min-h-[60vh]">
|
|
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
Loading calendar...
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-white">
|
|
<Navbar />
|
|
|
|
<div className="max-w-7xl mx-auto px-6 py-12">
|
|
<div className="mb-8">
|
|
<h1 className="text-4xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
Event Calendar
|
|
</h1>
|
|
<p className="text-[#664fa3] mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
View and manage your event RSVPs. Click on any event to see details and update your RSVP.
|
|
</p>
|
|
|
|
<div className="flex gap-3 flex-wrap items-center">
|
|
<AddToCalendarButton
|
|
showSubscribe={true}
|
|
variant="default"
|
|
/>
|
|
|
|
<div className="flex gap-4 ml-auto">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-4 h-4 rounded bg-[#81B29A]"></div>
|
|
<span className="text-sm text-[#664fa3]">Going</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-4 h-4 rounded bg-[#fb923c]"></div>
|
|
<span className="text-sm text-[#664fa3]">Maybe</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-4 h-4 rounded bg-[#9ca3af]"></div>
|
|
<span className="text-sm text-[#664fa3]">Not Going</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-4 h-4 rounded bg-[#DDD8EB]"></div>
|
|
<span className="text-sm text-[#664fa3]">No RSVP</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb] shadow-lg">
|
|
<Calendar
|
|
localizer={localizer}
|
|
events={calendarEvents}
|
|
startAccessor="start"
|
|
endAccessor="end"
|
|
style={{ height: 700 }}
|
|
date={currentDate}
|
|
view={currentView}
|
|
onNavigate={handleNavigate}
|
|
onView={handleViewChange}
|
|
onSelectEvent={handleSelectEvent}
|
|
eventPropGetter={eventStyleGetter}
|
|
views={['month', 'week', 'day', 'agenda']}
|
|
popup
|
|
className="member-calendar"
|
|
/>
|
|
</Card>
|
|
|
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
|
{selectedEvent && (
|
|
<>
|
|
<DialogHeader>
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<div className="bg-[#DDD8EB]/20 p-3 rounded-lg">
|
|
<CalendarIcon className="h-6 w-6 text-[#664fa3]" />
|
|
</div>
|
|
{selectedEvent.user_rsvp_status && (
|
|
<Badge
|
|
className={`px-3 py-1 rounded-full text-sm ${
|
|
selectedEvent.user_rsvp_status === 'yes'
|
|
? 'bg-[#81B29A] text-white'
|
|
: selectedEvent.user_rsvp_status === 'no'
|
|
? 'bg-gray-400 text-white'
|
|
: 'bg-orange-400 text-white'
|
|
}`}
|
|
>
|
|
{selectedEvent.user_rsvp_status === 'yes' && 'Going'}
|
|
{selectedEvent.user_rsvp_status === 'no' && 'Not Going'}
|
|
{selectedEvent.user_rsvp_status === 'maybe' && 'Maybe'}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<DialogTitle className="text-2xl text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
{selectedEvent.title}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4 mt-4">
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-3 text-[#664fa3]">
|
|
<CalendarIcon className="h-5 w-5" />
|
|
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
{new Date(selectedEvent.start_at).toLocaleDateString('en-US', {
|
|
weekday: 'long',
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric'
|
|
})}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-3 text-[#664fa3]">
|
|
<Clock className="h-5 w-5" />
|
|
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
{new Date(selectedEvent.start_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - {new Date(selectedEvent.end_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-3 text-[#664fa3]">
|
|
<MapPin className="h-5 w-5" />
|
|
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{selectedEvent.location}</span>
|
|
</div>
|
|
<div className="flex items-center gap-3 text-[#664fa3]">
|
|
<Users className="h-5 w-5" />
|
|
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
{selectedEvent.rsvp_count || 0} {selectedEvent.rsvp_count === 1 ? 'person' : 'people'} attending
|
|
{selectedEvent.capacity && ` (Capacity: ${selectedEvent.capacity})`}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{selectedEvent.description && (
|
|
<div className="pt-4 border-t border-[#ddd8eb]">
|
|
<h3 className="text-lg font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
About This Event
|
|
</h3>
|
|
<p className="text-[#664fa3] leading-relaxed whitespace-pre-line" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
{selectedEvent.description}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="pt-4 border-t border-[#ddd8eb]">
|
|
<h3 className="text-lg font-semibold text-[#422268] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
Your RSVP
|
|
</h3>
|
|
<div className="flex gap-3 flex-wrap">
|
|
<Button
|
|
onClick={() => handleRSVP('yes')}
|
|
disabled={rsvpLoading}
|
|
size="sm"
|
|
className={`rounded-full px-6 flex items-center gap-2 ${
|
|
selectedEvent.user_rsvp_status === 'yes'
|
|
? 'bg-[#81B29A] text-white hover:bg-[#66927e]'
|
|
: 'bg-[#DDD8EB] text-[#422268] hover:bg-[#c4bed8]'
|
|
}`}
|
|
>
|
|
<Check className="h-4 w-4" />
|
|
I'm Going
|
|
</Button>
|
|
<Button
|
|
onClick={() => handleRSVP('maybe')}
|
|
disabled={rsvpLoading}
|
|
size="sm"
|
|
variant="outline"
|
|
className={`rounded-full px-6 flex items-center gap-2 border-2 ${
|
|
selectedEvent.user_rsvp_status === 'maybe'
|
|
? 'border-orange-400 bg-orange-100 text-orange-700'
|
|
: 'border-[#664fa3] text-[#664fa3] hover:bg-[#f1eef9]'
|
|
}`}
|
|
>
|
|
<HelpCircle className="h-4 w-4" />
|
|
Maybe
|
|
</Button>
|
|
<Button
|
|
onClick={() => handleRSVP('no')}
|
|
disabled={rsvpLoading}
|
|
size="sm"
|
|
variant="outline"
|
|
className={`rounded-full px-6 flex items-center gap-2 border-2 ${
|
|
selectedEvent.user_rsvp_status === 'no'
|
|
? 'border-gray-400 bg-gray-100 text-gray-700'
|
|
: 'border-gray-400 text-gray-600 hover:bg-gray-50'
|
|
}`}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
Can't Attend
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="pt-4 border-t border-[#ddd8eb]">
|
|
<h3 className="text-lg font-semibold text-[#422268] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
Add to Your Calendar
|
|
</h3>
|
|
<AddToCalendarButton
|
|
event={selectedEvent}
|
|
showSubscribe={false}
|
|
variant="outline"
|
|
size="sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
|
|
<MemberFooter />
|
|
</div>
|
|
);
|
|
}
|