From 0249cad261530214545b1ecaaa173f9f544a63a7 Mon Sep 17 00:00:00 2001 From: Koncept Kit <63216427+konceptkit@users.noreply.github.com> Date: Tue, 6 Jan 2026 01:02:16 +0700 Subject: [PATCH] Improve UX with navigation, attendance management, and calendar fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- src/App.js | 8 + src/components/AdminSidebar.js | 15 +- src/pages/Plans.js | 8 +- src/pages/admin/AdminEventAttendance.js | 548 ++++++++++++++++++++++++ src/pages/admin/AdminEvents.js | 22 +- src/pages/admin/AdminGallery.js | 29 +- src/pages/members/MemberCalendar.css | 93 ++++ src/pages/members/MemberCalendar.js | 74 +--- 8 files changed, 714 insertions(+), 83 deletions(-) create mode 100644 src/pages/admin/AdminEventAttendance.js create mode 100644 src/pages/members/MemberCalendar.css diff --git a/src/App.js b/src/App.js index 9aa68b8..d706f13 100644 --- a/src/App.js +++ b/src/App.js @@ -23,6 +23,7 @@ import AdminMembers from './pages/admin/AdminMembers'; import AdminPermissions from './pages/admin/AdminPermissions'; import AdminRoles from './pages/admin/AdminRoles'; import AdminEvents from './pages/admin/AdminEvents'; +import AdminEventAttendance from './pages/admin/AdminEventAttendance'; import AdminValidations from './pages/admin/AdminValidations'; import AdminPlans from './pages/admin/AdminPlans'; import AdminSubscriptions from './pages/admin/AdminSubscriptions'; @@ -218,6 +219,13 @@ function App() { } /> + + + + + + } /> diff --git a/src/components/AdminSidebar.js b/src/components/AdminSidebar.js index f22f44d..07757aa 100644 --- a/src/components/AdminSidebar.js +++ b/src/components/AdminSidebar.js @@ -265,7 +265,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => { > {/* Header */}
-
+ LOAF Logo { }`} /> {isOpen && ( -

- Admin -

+
+

+ Admin +

+

+ View Public Site +

+
)} -
+
) : plans.length > 0 ? ( -
+
{plans.map((plan) => { const minimumPrice = plan.minimum_price_cents || plan.price_cents || 3000; const suggestedPrice = plan.suggested_price_cents || minimumPrice; diff --git a/src/pages/admin/AdminEventAttendance.js b/src/pages/admin/AdminEventAttendance.js new file mode 100644 index 0000000..5fa5c09 --- /dev/null +++ b/src/pages/admin/AdminEventAttendance.js @@ -0,0 +1,548 @@ +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 ( +
+
Loading event data...
+
+ ); + } + + if (!event) { + return ( +
+

Event not found

+ +
+ ); + } + + return ( +
+ {/* Header */} +
+
+ +
+

+ Event Attendance +

+

+ Manage RSVPs and track attendance for this event +

+
+
+ +
+ + {/* Event Details Card */} + +
+
+

+ {event.title} +

+
+
+ + {moment(event.start_at).format('MMMM D, YYYY [at] h:mm A')} +
+ {event.location && ( +
+ + {event.location} +
+ )} +
+
+ + {event.published ? 'Published' : 'Draft'} + +
+
+ + {/* Statistics Cards */} +
+ +
+ +
+

Total RSVPs

+

{stats.total}

+
+
+
+ + +
+ +
+

Yes

+

{stats.yesCount}

+
+
+
+ + +
+ +
+

No

+

{stats.noCount}

+
+
+
+ + +
+ +
+

Maybe

+

{stats.maybeCount}

+
+
+
+ + +
+ +
+

Attended

+

{stats.attendedCount}

+
+
+
+
+ + {/* Filters and Actions */} + +
+ {/* Tab Filters */} +
+ + + + +
+ + {/* Search and Bulk Actions */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-10 border-[#ddd8eb] rounded-xl" + style={{ fontFamily: "'Nunito Sans', sans-serif" }} + /> +
+ + {selectedRsvps.size > 0 && ( +
+ + {selectedRsvps.size} selected + + + +
+ )} +
+
+
+ + {/* RSVP Table */} + +
+ + + + + + + + + + + + + {filteredRsvps.length > 0 ? ( + filteredRsvps.map((rsvp) => ( + + + + + + + + + )) + ) : ( + + + + )} + +
+ + + Name + + Email + + RSVP Status + + Attendance + + Attended At +
+ handleSelectRsvp(rsvp.user_id)} + /> + + {rsvp.user_name} + + {rsvp.user_email} + + + {rsvp.rsvp_status.toUpperCase()} + + + {rsvp.attended ? ( + + ) : ( + + )} + + {rsvp.attended_at ? moment(rsvp.attended_at).format('MMM D, YYYY h:mm A') : '-'} +
+

+ {searchQuery ? 'No RSVPs match your search' : 'No RSVPs for this filter'} +

+
+
+
+
+ ); +}; + +export default AdminEventAttendance; diff --git a/src/pages/admin/AdminEvents.js b/src/pages/admin/AdminEvents.js index 9c8b89c..8d25af7 100644 --- a/src/pages/admin/AdminEvents.js +++ b/src/pages/admin/AdminEvents.js @@ -1,4 +1,5 @@ import React, { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { useAuth } from '../../context/AuthContext'; import api from '../../utils/api'; import { Card } from '../../components/ui/card'; @@ -8,16 +9,14 @@ import { Input } from '../../components/ui/input'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '../../components/ui/dialog'; import { toast } from 'sonner'; import { Calendar, MapPin, Users, Plus, Edit, Trash2, Eye, EyeOff } from 'lucide-react'; -import { AttendanceDialog } from '../../components/AttendanceDialog'; const AdminEvents = () => { + const navigate = useNavigate(); const { hasPermission } = useAuth(); const [events, setEvents] = useState([]); const [loading, setLoading] = useState(true); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [editingEvent, setEditingEvent] = useState(null); - const [attendanceDialogOpen, setAttendanceDialogOpen] = useState(false); - const [selectedEvent, setSelectedEvent] = useState(null); const [formData, setFormData] = useState({ title: '', @@ -342,19 +341,16 @@ const AdminEvents = () => { {/* Actions */}
- {/* Mark Attendance Button */} + {/* Manage Attendance Button */} {/* Other Actions */} @@ -419,14 +415,6 @@ const AdminEvents = () => {
)} - - {/* Attendance Dialog */} - ); }; diff --git a/src/pages/admin/AdminGallery.js b/src/pages/admin/AdminGallery.js index 723518b..e7aac7d 100644 --- a/src/pages/admin/AdminGallery.js +++ b/src/pages/admin/AdminGallery.js @@ -1,4 +1,5 @@ import React, { useState, useEffect, useRef } from 'react'; +import { Link } from 'react-router-dom'; import api from '../../utils/api'; import { Card } from '../../components/ui/card'; import { Button } from '../../components/ui/button'; @@ -14,7 +15,7 @@ import { SelectValue, } from '../../components/ui/select'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '../../components/ui/dialog'; -import { Upload, Trash2, Edit, X, ImageIcon, Calendar, MapPin } from 'lucide-react'; +import { Upload, Trash2, Edit, X, ImageIcon, Calendar, MapPin, AlertCircle } from 'lucide-react'; import { toast } from 'sonner'; import moment from 'moment'; @@ -179,6 +180,32 @@ const AdminGallery = () => {
+ {/* Empty State Message */} + {events.length === 0 && ( +
+
+ +
+

+ No Events Available +

+

+ You need to create an event before uploading gallery images. Events help organize photos by occasion. +

+ + + +
+
+
+ )} + {selectedEvent && (
{ fetchEvents(); @@ -58,6 +61,14 @@ export default function MemberCalendar() { setIsDialogOpen(true); }; + const handleNavigate = (newDate) => { + setCurrentDate(newDate); + }; + + const handleViewChange = (newView) => { + setCurrentView(newView); + }; + const handleRSVP = async (status) => { if (!selectedEvent) return; @@ -171,10 +182,13 @@ export default function MemberCalendar() { startAccessor="start" endAccessor="end" style={{ height: 700 }} + date={currentDate} + view={currentView} + onNavigate={handleNavigate} + onView={handleViewChange} onSelectEvent={handleSelectEvent} eventPropGetter={eventStyleGetter} views={['month', 'week', 'day', 'agenda']} - defaultView="month" popup className="member-calendar" /> @@ -320,64 +334,6 @@ export default function MemberCalendar() {
-
);