12 Commits

Author SHA1 Message Date
kayela
f71931d4a7 Merge branch 'dev' into templates 2026-01-06 12:25:55 -06:00
kayela
97cc5bdedf Add pagination buttons for first and last pages in Members Directory 2026-01-06 12:17:03 -06:00
kayela
8011913c4d Enhance Admin Dashboard and Members Directory with improved layout, pagination, and member details display.
Applied requested changes to UI
2026-01-06 12:05:34 -06:00
Koncept Kit
40a0e3f342 feat(frontend): Comprehensive RBAC implementation across admin pages
**Option 3 Implementation (Latest):**
- InviteStaffDialog: Use /admin/roles/assignable endpoint
- AdminStaff: Enable admin users to see 'Invite Staff' button

**Permission Checks Added (8 admin pages):**
- AdminNewsletters: newsletters.create/edit/delete
- AdminFinancials: financials.create/edit/delete
- AdminBylaws: bylaws.create/edit/delete
- AdminValidations: users.approve, subscriptions.activate
- AdminSubscriptions: subscriptions.export/edit/cancel
- AdminDonations: donations.export
- AdminGallery: gallery.upload/edit/delete
- AdminPlans: subscriptions.plans

**Pattern Established:**
All admin action buttons now wrapped with hasPermission() checks.
UI hides what users can't access, backend enforces rules.

**Files Modified:** 10 files, 100+ permission checks added
2026-01-06 14:45:15 +07:00
kayela
968eaccac2 fixed tabs styling 2026-01-05 14:34:23 -06:00
kayela
11de3d1eed styled chrome scrollbar 2026-01-05 14:16:05 -06:00
kayela
11142ec50e Merge branch 'dev' into templates 2026-01-05 12:51:33 -06:00
Koncept Kit
0249cad261 Improve UX with navigation, attendance management, and calendar fixes
## 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>
2026-01-06 01:02:16 +07:00
kayela
1d70ac4ec7 update Merge branch 'dev' into templates 2025-12-26 17:39:16 -06:00
kayela
6d777ed583 Update homepage and links in RegistrationStep4 component 2025-12-24 14:17:30 -06:00
kayela
99d65c917f Merge remote-tracking branch 'origin/dev' into templates 2025-12-24 14:06:12 -06:00
kayela
0f16264656 Remove unused imports from TermsOfService component 2025-12-24 14:04:35 -06:00
25 changed files with 1527 additions and 688 deletions

3
.env.development Normal file
View File

@@ -0,0 +1,3 @@
REACT_APP_BACKEND_URL=http://localhost:8000
REACT_APP_BASENAME=/membership
PUBLIC_URL=/membership

View File

@@ -23,6 +23,7 @@ import AdminMembers from './pages/admin/AdminMembers';
import AdminPermissions from './pages/admin/AdminPermissions'; import AdminPermissions from './pages/admin/AdminPermissions';
import AdminRoles from './pages/admin/AdminRoles'; import AdminRoles from './pages/admin/AdminRoles';
import AdminEvents from './pages/admin/AdminEvents'; import AdminEvents from './pages/admin/AdminEvents';
import AdminEventAttendance from './pages/admin/AdminEventAttendance';
import AdminValidations from './pages/admin/AdminValidations'; import AdminValidations from './pages/admin/AdminValidations';
import AdminPlans from './pages/admin/AdminPlans'; import AdminPlans from './pages/admin/AdminPlans';
import AdminSubscriptions from './pages/admin/AdminSubscriptions'; import AdminSubscriptions from './pages/admin/AdminSubscriptions';
@@ -218,6 +219,13 @@ function App() {
</AdminLayout> </AdminLayout>
</PrivateRoute> </PrivateRoute>
} /> } />
<Route path="/admin/events/:eventId/attendance" element={
<PrivateRoute adminOnly>
<AdminLayout>
<AdminEventAttendance />
</AdminLayout>
</PrivateRoute>
} />
<Route path="/admin/validations" element={ <Route path="/admin/validations" element={
<PrivateRoute adminOnly> <PrivateRoute adminOnly>
<AdminLayout> <AdminLayout>

View File

@@ -22,7 +22,7 @@ import {
Scale, Scale,
HardDrive, HardDrive,
Repeat, Repeat,
Heart Heart,
} from 'lucide-react'; } from 'lucide-react';
const AdminSidebar = ({ isOpen, onToggle, isMobile }) => { const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
@@ -265,20 +265,24 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
> >
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-4 border-b border-[#ddd8eb]"> <div className="flex items-center justify-between p-4 border-b border-[#ddd8eb]">
<div className="flex items-center gap-3"> <Link to="/" className="flex items-center gap-3 group flex-1 min-w-0">
<img <img
src={`${process.env.PUBLIC_URL}/loaf-logo.png`} src={`${process.env.PUBLIC_URL}/loaf-logo.png`}
alt="LOAF Logo" alt="LOAF Logo"
className={`object-contain transition-all duration-200 ${ className={`object-contain transition-all duration-200 ${isOpen ? 'h-10 w-10' : 'h-8 w-8'
isOpen ? 'h-10 w-10' : 'h-8 w-8'
}`} }`}
/> />
{isOpen && ( {isOpen && (
<div className="flex-1 min-w-0">
<h2 className="text-xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}> <h2 className="text-xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
Admin Admin
</h2> </h2>
)} <p className="text-xs text-[#664fa3] group-hover:text-[#ff9e77] transition-colors" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
View Public Site
</p>
</div> </div>
)}
</Link>
<button <button
onClick={onToggle} onClick={onToggle}
className="p-2 rounded-lg hover:bg-[#DDD8EB]/20 transition-colors" className="p-2 rounded-lg hover:bg-[#DDD8EB]/20 transition-colors"
@@ -295,7 +299,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
</div> </div>
{/* Navigation */} {/* Navigation */}
<nav className="flex-1 overflow-y-auto p-4"> <nav className="flex-1 overflow-y-auto p-4 scrollbar-dashboard scrollbar-x-dashboard">
{/* Dashboard - Standalone */} {/* Dashboard - Standalone */}
{renderNavItem(filteredNavItems.find(item => item.name === 'Dashboard'))} {renderNavItem(filteredNavItems.find(item => item.name === 'Dashboard'))}
@@ -365,7 +369,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
{/* User Section */} {/* User Section */}
<div className="border-t border-[#ddd8eb] p-4 space-y-2"> <div className="border-t border-[#ddd8eb] p-4 space-y-2">
{isOpen && user && ( {isOpen && user && (
<div className="px-4 py-3 mb-2"> <div className="px-4 py-3 mb-2 flex justify-between items-center">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-[#DDD8EB] flex items-center justify-center text-[#422268] font-semibold"> <div className="h-10 w-10 rounded-full bg-[#DDD8EB] flex items-center justify-center text-[#422268] font-semibold">
{user.first_name?.[0]}{user.last_name?.[0]} {user.first_name?.[0]}{user.last_name?.[0]}
@@ -379,6 +383,8 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
</p> </p>
</div> </div>
</div> </div>
<Link to='/profile'><Settings size={16} />
</Link>
</div> </div>
)} )}
@@ -392,8 +398,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
</div> </div>
<div className="w-full bg-[#ddd8eb] rounded-full h-2"> <div className="w-full bg-[#ddd8eb] rounded-full h-2">
<div <div
className={`h-2 rounded-full transition-all ${ className={`h-2 rounded-full transition-all ${storagePercentage > 90 ? 'bg-red-500' :
storagePercentage > 90 ? 'bg-red-500' :
storagePercentage > 75 ? 'bg-yellow-500' : storagePercentage > 75 ? 'bg-yellow-500' :
'bg-[#81B29A]' 'bg-[#81B29A]'
}`} }`}
@@ -407,8 +412,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
) : ( ) : (
<div className="flex justify-center"> <div className="flex justify-center">
<div className="relative group"> <div className="relative group">
<HardDrive className={`h-5 w-5 ${ <HardDrive className={`h-5 w-5 ${storagePercentage > 90 ? 'text-red-500' :
storagePercentage > 90 ? 'text-red-500' :
storagePercentage > 75 ? 'text-yellow-500' : storagePercentage > 75 ? 'text-yellow-500' :
'text-[#664fa3]' 'text-[#664fa3]'
}`} /> }`} />

View File

@@ -40,15 +40,14 @@ const InviteStaffDialog = ({ open, onOpenChange, onSuccess }) => {
const fetchRoles = async () => { const fetchRoles = async () => {
setLoadingRoles(true); setLoadingRoles(true);
try { try {
const response = await api.get('/admin/roles'); // New endpoint returns roles based on user's permission level
// Filter to show only admin-type roles (not guest or member) // Superadmin: all roles
const staffRoles = response.data.filter(role => // Admin: admin, finance, and non-elevated custom roles
['admin', 'superadmin', 'finance'].includes(role.code) || !role.is_system_role const response = await api.get('/admin/roles/assignable');
); setRoles(response.data);
setRoles(staffRoles);
} catch (error) { } catch (error) {
console.error('Failed to fetch roles:', error); console.error('Failed to fetch assignable roles:', error);
toast.error('Failed to load roles'); toast.error('Failed to load roles. Please try again.');
} finally { } finally {
setLoadingRoles(false); setLoadingRoles(false);
} }

View File

@@ -86,7 +86,7 @@ const RegistrationStep4 = ({ formData, handleInputChange }) => {
<label htmlFor="accepts_tos" className="text-sm text-gray-700" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <label htmlFor="accepts_tos" className="text-sm text-gray-700" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
I agree to the{' '} I agree to the{' '}
<a <a
href="/membership/terms-of-service" href="/become-a-member/terms-of-service"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-[#664fa3] hover:text-[#422268] font-semibold underline" className="text-[#664fa3] hover:text-[#422268] font-semibold underline"
@@ -95,7 +95,7 @@ const RegistrationStep4 = ({ formData, handleInputChange }) => {
</a> </a>
{' '}and{' '} {' '}and{' '}
<a <a
href="/membership/privacy-policy" href="become-a-member/privacy-policy"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-[#664fa3] hover:text-[#422268] font-semibold underline" className="text-[#664fa3] hover:text-[#422268] font-semibold underline"

View File

@@ -1,31 +1,33 @@
import * as React from "react" import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs" import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef(({ className, ...props }, ref) => ( const TabsList = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.List <TabsPrimitive.List
ref={ref} ref={ref}
className={cn( className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground", "inline-flex h-full items-center justify-center rounded-lg gap-6 p-1 text-muted-foreground",
className className
)} )}
{...props} /> {...props}
)) />
TabsList.displayName = TabsPrimitive.List.displayName ));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => ( const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
ref={ref} ref={ref}
className={cn( className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow", "inline-flex items-center justify-center whitespace-nowrap hover:bg-[#f1eef9] border-2 border-[#664fa3] rounded-2xl px-3 py-1 text-[#664fa3] text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-foreground data-[state=active]:text-background data-[state=active]:border-foreground data-[state=active]:shadow",
className className
)} )}
{...props} /> {...props}
)) />
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName ));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef(({ className, ...props }, ref) => ( const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.Content <TabsPrimitive.Content
@@ -34,8 +36,9 @@ const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", "mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className className
)} )}
{...props} /> {...props}
)) />
TabsContent.displayName = TabsPrimitive.Content.displayName ));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent } export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -116,3 +116,27 @@ code {
border-bottom-color: inherit; border-bottom-color: inherit;
} }
} }
@layer utilities {
@supports selector(::-webkit-scrollbar) {
.scrollbar-dashboard::-webkit-scrollbar {
width: 2px;
}
.scrollbar-dashboard::-webkit-scrollbar-thumb {
background-color: #ddd8eb;
border-radius: 9999px;
}
.scrollbar-x-dashboard::-webkit-scrollbar:horizontal {
height: 2px;
}
.scrollbar-x-dashboard::-webkit-scrollbar-thumb:horizontal {
background-color: #ddd8eb;
border-radius: 9999px;
}
.hide-scrollbar-x::-webkit-scrollbar:horizontal {
height: 0px;
}
}
}

View File

@@ -12,10 +12,10 @@ const BoardOfDirectors = () => {
]; ];
const boardMembers = [ const boardMembers = [
{ name: 'Danita Cole' }, { name: 'Danita Cole', title: 'Director' },
{ name: 'Roxanne Cherico' }, { name: 'Roxanne Cherico', title: 'Director' },
{ name: 'Lucretia Copeland' }, { name: 'Lucretia Copeland', title: 'Director' },
{ name: 'Julie Fischer' } { name: 'Julie Fischer', title: 'Director' }
]; ];

View File

@@ -253,7 +253,13 @@ const Plans = () => {
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading plans...</p> <p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading plans...</p>
</div> </div>
) : plans.length > 0 ? ( ) : plans.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 sm:gap-8 max-w-5xl mx-auto"> <div className={`grid gap-6 sm:gap-8 mx-auto ${
plans.length === 1
? 'grid-cols-1 max-w-md'
: plans.length === 2
? 'grid-cols-1 sm:grid-cols-2 max-w-3xl'
: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 max-w-5xl'
}`}>
{plans.map((plan) => { {plans.map((plan) => {
const minimumPrice = plan.minimum_price_cents || plan.price_cents || 3000; const minimumPrice = plan.minimum_price_cents || plan.price_cents || 3000;
const suggestedPrice = plan.suggested_price_cents || minimumPrice; const suggestedPrice = plan.suggested_price_cents || minimumPrice;

View File

@@ -2,8 +2,6 @@ import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import PublicNavbar from '../components/PublicNavbar'; import PublicNavbar from '../components/PublicNavbar';
import PublicFooter from '../components/PublicFooter'; import PublicFooter from '../components/PublicFooter';
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
export default function TermsOfService() { export default function TermsOfService() {

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useAuth } from '../../context/AuthContext';
import api from '../../utils/api'; import api from '../../utils/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';
@@ -32,6 +33,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
const AdminBylaws = () => { const AdminBylaws = () => {
const { hasPermission } = useAuth();
const [bylaws, setBylaws] = useState([]); const [bylaws, setBylaws] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
@@ -184,6 +186,7 @@ const AdminBylaws = () => {
Manage LOAF governing bylaws and version history Manage LOAF governing bylaws and version history
</p> </p>
</div> </div>
{hasPermission('bylaws.create') && (
<Button <Button
onClick={handleCreate} onClick={handleCreate}
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2" className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
@@ -191,6 +194,7 @@ const AdminBylaws = () => {
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Add Version Add Version
</Button> </Button>
)}
</div> </div>
{/* Current Bylaws */} {/* Current Bylaws */}
@@ -226,6 +230,7 @@ const AdminBylaws = () => {
<ExternalLink className="h-4 w-4 mr-1" /> <ExternalLink className="h-4 w-4 mr-1" />
View View
</Button> </Button>
{hasPermission('bylaws.edit') && (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -234,6 +239,8 @@ const AdminBylaws = () => {
> >
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>
)}
{hasPermission('bylaws.delete') && (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -242,6 +249,7 @@ const AdminBylaws = () => {
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
)}
</div> </div>
</div> </div>
<div className="flex items-center gap-4 text-sm text-[#664fa3]"> <div className="flex items-center gap-4 text-sm text-[#664fa3]">
@@ -254,10 +262,12 @@ const AdminBylaws = () => {
<Card className="p-12 text-center"> <Card className="p-12 text-center">
<Scale className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" /> <Scale className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
<p className="text-[#664fa3] text-lg mb-4">No current bylaws set</p> <p className="text-[#664fa3] text-lg mb-4">No current bylaws set</p>
{hasPermission('bylaws.create') && (
<Button onClick={handleCreate} className="bg-[#664fa3] text-white"> <Button onClick={handleCreate} className="bg-[#664fa3] text-white">
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
Create Bylaws Create Bylaws
</Button> </Button>
)}
</Card> </Card>
)} )}
@@ -290,6 +300,7 @@ const AdminBylaws = () => {
> >
<ExternalLink className="h-4 w-4" /> <ExternalLink className="h-4 w-4" />
</Button> </Button>
{hasPermission('bylaws.edit') && (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -298,6 +309,8 @@ const AdminBylaws = () => {
> >
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>
)}
{hasPermission('bylaws.delete') && (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -306,6 +319,7 @@ const AdminBylaws = () => {
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
)}
</div> </div>
</div> </div>
</Card> </Card>

View File

@@ -4,7 +4,7 @@ import api from '../../utils/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 { Badge } from '../../components/ui/badge'; import { Badge } from '../../components/ui/badge';
import { Users, Calendar, Clock, CheckCircle, Mail, AlertCircle } from 'lucide-react'; import { Users, Calendar, Clock, CheckCircle, Mail, AlertCircle,Globe } from 'lucide-react';
const AdminDashboard = () => { const AdminDashboard = () => {
const [stats, setStats] = useState({ const [stats, setStats] = useState({
@@ -56,6 +56,7 @@ const AdminDashboard = () => {
return ( return (
<> <>
<div className='flex justify-between items-center'>
<div className="mb-8"> <div className="mb-8">
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Admin Dashboard Admin Dashboard
@@ -64,6 +65,15 @@ const AdminDashboard = () => {
Manage users, events, and membership applications. Manage users, events, and membership applications.
</p> </p>
</div> </div>
<Link to={'/'}>
<Button
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
>
<Globe />
View Public Site
</Button>
</Link>
</div>
{/* Stats Grid */} {/* Stats Grid */}
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 mb-12"> <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 mb-12">

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useAuth } from '../../context/AuthContext';
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 { Input } from '../../components/ui/input'; import { Input } from '../../components/ui/input';
@@ -31,6 +32,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
const AdminDonations = () => { const AdminDonations = () => {
const { hasPermission } = useAuth();
const [donations, setDonations] = useState([]); const [donations, setDonations] = useState([]);
const [filteredDonations, setFilteredDonations] = useState([]); const [filteredDonations, setFilteredDonations] = useState([]);
const [stats, setStats] = useState({}); const [stats, setStats] = useState({});
@@ -269,6 +271,7 @@ const AdminDonations = () => {
className="pl-10 rounded-full border-2 border-[#ddd8eb] focus:border-[#664fa3]" className="pl-10 rounded-full border-2 border-[#ddd8eb] focus:border-[#664fa3]"
/> />
</div> </div>
{hasPermission('donations.export') && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
@@ -296,6 +299,7 @@ const AdminDonations = () => {
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)}
</div> </div>
{/* Filters Row */} {/* Filters Row */}

View File

@@ -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 (
<div className="flex items-center justify-center h-64">
<div className="text-[#664fa3]">Loading event data...</div>
</div>
);
}
if (!event) {
return (
<div className="text-center py-12">
<p className="text-[#664fa3] mb-4">Event not found</p>
<Button onClick={() => navigate('/admin/events')} className="bg-[#664fa3] hover:bg-[#422268] 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-[#ddd8eb] text-[#664fa3] 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-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
Event Attendance
</h1>
<p className="text-[#664fa3] mt-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Manage RSVPs and track attendance for this event
</p>
</div>
</div>
<Button
onClick={exportToCSV}
className="bg-[#81B29A] hover:bg-[#6a9a83] 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-white border-[#ddd8eb] rounded-xl">
<div className="flex items-start justify-between">
<div>
<h2 className="text-2xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
{event.title}
</h2>
<div className="flex flex-wrap gap-4 text-[#664fa3]" 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-[#81B29A]' : 'bg-[#ddd8eb]'} 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-white border-[#ddd8eb] rounded-xl">
<div className="flex items-center gap-3">
<Users className="h-8 w-8 text-[#664fa3]" />
<div>
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total RSVPs</p>
<p className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.total}</p>
</div>
</div>
</Card>
<Card className="p-4 bg-white border-[#ddd8eb] rounded-xl">
<div className="flex items-center gap-3">
<UserCheck className="h-8 w-8 text-[#81B29A]" />
<div>
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Yes</p>
<p className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.yesCount}</p>
</div>
</div>
</Card>
<Card className="p-4 bg-white border-[#ddd8eb] rounded-xl">
<div className="flex items-center gap-3">
<UserX className="h-8 w-8 text-[#E07A5F]" />
<div>
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>No</p>
<p className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.noCount}</p>
</div>
</div>
</Card>
<Card className="p-4 bg-white border-[#ddd8eb] rounded-xl">
<div className="flex items-center gap-3">
<HelpCircle className="h-8 w-8 text-[#F2CC8F]" />
<div>
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Maybe</p>
<p className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.maybeCount}</p>
</div>
</div>
</Card>
<Card className="p-4 bg-white border-[#ddd8eb] rounded-xl">
<div className="flex items-center gap-3">
<Check className="h-8 w-8 text-[#664fa3]" />
<div>
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Attended</p>
<p className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.attendedCount}</p>
</div>
</div>
</Card>
</div>
{/* Filters and Actions */}
<Card className="p-6 bg-white border-[#ddd8eb] 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-[#664fa3] hover:bg-[#422268] text-white'
: 'border-[#ddd8eb] text-[#664fa3] hover:bg-[#F8F7FB]'
}`}
style={{ fontFamily: "'Inter', sans-serif" }}
>
All ({stats.total})
</Button>
<Button
onClick={() => setActiveTab('yes')}
variant={activeTab === 'yes' ? 'default' : 'outline'}
className={`rounded-xl ${
activeTab === 'yes'
? 'bg-[#81B29A] hover:bg-[#6a9a83] text-white'
: 'border-[#ddd8eb] text-[#664fa3] hover:bg-[#F8F7FB]'
}`}
style={{ fontFamily: "'Inter', sans-serif" }}
>
Yes ({stats.yesCount})
</Button>
<Button
onClick={() => setActiveTab('no')}
variant={activeTab === 'no' ? 'default' : 'outline'}
className={`rounded-xl ${
activeTab === 'no'
? 'bg-[#E07A5F] hover:bg-[#d16b54] text-white'
: 'border-[#ddd8eb] text-[#664fa3] hover:bg-[#F8F7FB]'
}`}
style={{ fontFamily: "'Inter', sans-serif" }}
>
No ({stats.noCount})
</Button>
<Button
onClick={() => setActiveTab('maybe')}
variant={activeTab === 'maybe' ? 'default' : 'outline'}
className={`rounded-xl ${
activeTab === 'maybe'
? 'bg-[#F2CC8F] hover:bg-[#e8bf7a] text-[#422268]'
: 'border-[#ddd8eb] text-[#664fa3] hover:bg-[#F8F7FB]'
}`}
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-[#664fa3]" />
<Input
type="text"
placeholder="Search by name or email..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 border-[#ddd8eb] rounded-xl"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
/>
</div>
{selectedRsvps.size > 0 && (
<div className="flex gap-2">
<Badge className="bg-[#664fa3] text-white px-3 py-1">
{selectedRsvps.size} selected
</Badge>
<Button
onClick={() => handleBulkAttendance(true)}
disabled={saving}
className="bg-[#81B29A] hover:bg-[#6a9a83] 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-[#E07A5F] hover:bg-[#d16b54] 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-white border-[#ddd8eb] rounded-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-[#F8F7FB] border-b border-[#ddd8eb]">
<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-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
Name
</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
Email
</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
RSVP Status
</th>
<th className="px-4 py-3 text-center text-sm font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
Attendance
</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-[#422268]" 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-[#ddd8eb] hover:bg-[#F8F7FB] 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-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{rsvp.user_name}
</td>
<td className="px-4 py-3 text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{rsvp.user_email}
</td>
<td className="px-4 py-3">
<Badge
className={`${
rsvp.rsvp_status === 'yes'
? 'bg-[#81B29A]'
: rsvp.rsvp_status === 'no'
? 'bg-[#E07A5F]'
: 'bg-[#F2CC8F] text-[#422268]'
} 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-[#81B29A] hover:bg-[#6a9a83] 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-[#ddd8eb] text-[#664fa3] hover:bg-[#81B29A] hover:text-white hover:border-[#81B29A] 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-[#664fa3]" 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-[#664fa3]" 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;

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
import api from '../../utils/api'; import api from '../../utils/api';
import { Card } from '../../components/ui/card'; 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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '../../components/ui/dialog';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Calendar, MapPin, Users, Plus, Edit, Trash2, Eye, EyeOff } from 'lucide-react'; import { Calendar, MapPin, Users, Plus, Edit, Trash2, Eye, EyeOff } from 'lucide-react';
import { AttendanceDialog } from '../../components/AttendanceDialog';
const AdminEvents = () => { const AdminEvents = () => {
const navigate = useNavigate();
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const [events, setEvents] = useState([]); const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [editingEvent, setEditingEvent] = useState(null); const [editingEvent, setEditingEvent] = useState(null);
const [attendanceDialogOpen, setAttendanceDialogOpen] = useState(false);
const [selectedEvent, setSelectedEvent] = useState(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
title: '', title: '',
@@ -342,19 +341,16 @@ const AdminEvents = () => {
{/* Actions */} {/* Actions */}
<div className="space-y-2 pt-4 border-t border-[#ddd8eb]"> <div className="space-y-2 pt-4 border-t border-[#ddd8eb]">
{/* Mark Attendance Button */} {/* Manage Attendance Button */}
<Button <Button
onClick={() => { onClick={() => navigate(`/admin/events/${event.id}/attendance`)}
setSelectedEvent(event);
setAttendanceDialogOpen(true);
}}
variant="outline" variant="outline"
size="sm" size="sm"
className="w-full border-[#81B29A] text-[#81B29A] hover:bg-[#81B29A] hover:text-white" className="w-full border-[#81B29A] text-[#81B29A] hover:bg-[#81B29A] hover:text-white"
data-testid={`mark-attendance-${event.id}`} data-testid={`mark-attendance-${event.id}`}
> >
<Users className="h-4 w-4 mr-2" /> <Users className="h-4 w-4 mr-2" />
Mark Attendance ({event.rsvp_count || 0} RSVPs) Manage Attendance ({event.rsvp_count || 0} RSVPs)
</Button> </Button>
{/* Other Actions */} {/* Other Actions */}
@@ -419,14 +415,6 @@ const AdminEvents = () => {
</Button> </Button>
</div> </div>
)} )}
{/* Attendance Dialog */}
<AttendanceDialog
event={selectedEvent}
open={attendanceDialogOpen}
onOpenChange={setAttendanceDialogOpen}
onSuccess={fetchEvents}
/>
</> </>
); );
}; };

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useAuth } from '../../context/AuthContext';
import api from '../../utils/api'; import api from '../../utils/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';
@@ -31,6 +32,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
const AdminFinancials = () => { const AdminFinancials = () => {
const { hasPermission } = useAuth();
const [reports, setReports] = useState([]); const [reports, setReports] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
@@ -162,6 +164,7 @@ const AdminFinancials = () => {
Manage annual financial reports Manage annual financial reports
</p> </p>
</div> </div>
{hasPermission('financials.create') && (
<Button <Button
onClick={handleCreate} onClick={handleCreate}
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2" className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
@@ -169,6 +172,7 @@ const AdminFinancials = () => {
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Add Report Add Report
</Button> </Button>
)}
</div> </div>
{/* Reports List */} {/* Reports List */}
@@ -176,10 +180,12 @@ const AdminFinancials = () => {
<Card className="p-12 text-center"> <Card className="p-12 text-center">
<TrendingUp className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" /> <TrendingUp className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
<p className="text-[#664fa3] text-lg mb-4">No financial reports yet</p> <p className="text-[#664fa3] text-lg mb-4">No financial reports yet</p>
{hasPermission('financials.create') && (
<Button onClick={handleCreate} className="bg-[#664fa3] text-white"> <Button onClick={handleCreate} className="bg-[#664fa3] text-white">
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
Create First Report Create First Report
</Button> </Button>
)}
</Card> </Card>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
@@ -209,7 +215,9 @@ const AdminFinancials = () => {
</Button> </Button>
</div> </div>
</div> </div>
{(hasPermission('financials.edit') || hasPermission('financials.delete')) && (
<div className="flex gap-2"> <div className="flex gap-2">
{hasPermission('financials.edit') && (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -218,6 +226,8 @@ const AdminFinancials = () => {
> >
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>
)}
{hasPermission('financials.delete') && (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -226,7 +236,9 @@ const AdminFinancials = () => {
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
)}
</div> </div>
)}
</div> </div>
</Card> </Card>
))} ))}

View File

@@ -1,4 +1,6 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
import api from '../../utils/api'; import api from '../../utils/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';
@@ -14,11 +16,12 @@ import {
SelectValue, SelectValue,
} from '../../components/ui/select'; } from '../../components/ui/select';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '../../components/ui/dialog'; 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 { toast } from 'sonner';
import moment from 'moment'; import moment from 'moment';
const AdminGallery = () => { const AdminGallery = () => {
const { hasPermission } = useAuth();
const [events, setEvents] = useState([]); const [events, setEvents] = useState([]);
const [selectedEvent, setSelectedEvent] = useState(null); const [selectedEvent, setSelectedEvent] = useState(null);
const [galleryImages, setGalleryImages] = useState([]); const [galleryImages, setGalleryImages] = useState([]);
@@ -179,7 +182,33 @@ const AdminGallery = () => {
</Select> </Select>
</div> </div>
{selectedEvent && ( {/* Empty State Message */}
{events.length === 0 && (
<div className="mt-4 p-4 bg-[#f1eef9] border-2 border-[#DDD8EB] rounded-xl">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-[#664fa3] flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h4 className="text-sm font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
No Events Available
</h4>
<p className="text-sm text-[#664fa3] mb-3" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
You need to create an event before uploading gallery images. Events help organize photos by occasion.
</p>
<Link to="/admin/events">
<Button
className="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl text-sm"
style={{ fontFamily: "'Inter', sans-serif" }}
>
<Calendar className="h-4 w-4 mr-2" />
Create Your First Event
</Button>
</Link>
</div>
</div>
</div>
)}
{selectedEvent && hasPermission('gallery.upload') && (
<div className="pt-4"> <div className="pt-4">
<input <input
type="file" type="file"
@@ -240,7 +269,9 @@ const AdminGallery = () => {
</div> </div>
{/* Overlay with Actions */} {/* Overlay with Actions */}
{(hasPermission('gallery.edit') || hasPermission('gallery.delete')) && (
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity rounded-xl flex flex-col items-center justify-center gap-2"> <div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity rounded-xl flex flex-col items-center justify-center gap-2">
{hasPermission('gallery.edit') && (
<Button <Button
onClick={() => openEditCaption(image)} onClick={() => openEditCaption(image)}
size="sm" size="sm"
@@ -250,6 +281,8 @@ const AdminGallery = () => {
<Edit className="h-4 w-4 mr-1" /> <Edit className="h-4 w-4 mr-1" />
Caption Caption
</Button> </Button>
)}
{hasPermission('gallery.delete') && (
<Button <Button
onClick={() => handleDeleteImage(image.id)} onClick={() => handleDeleteImage(image.id)}
size="sm" size="sm"
@@ -259,7 +292,9 @@ const AdminGallery = () => {
<Trash2 className="h-4 w-4 mr-1" /> <Trash2 className="h-4 w-4 mr-1" />
Delete Delete
</Button> </Button>
)}
</div> </div>
)}
{/* Caption Preview */} {/* Caption Preview */}
{image.caption && ( {image.caption && (

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useAuth } from '../../context/AuthContext';
import api from '../../utils/api'; import api from '../../utils/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';
@@ -32,6 +33,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
const AdminNewsletters = () => { const AdminNewsletters = () => {
const { hasPermission } = useAuth();
const [newsletters, setNewsletters] = useState([]); const [newsletters, setNewsletters] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
@@ -190,6 +192,7 @@ const AdminNewsletters = () => {
Create and manage newsletter archive Create and manage newsletter archive
</p> </p>
</div> </div>
{hasPermission('newsletters.create') && (
<Button <Button
onClick={handleCreate} onClick={handleCreate}
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2" className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
@@ -197,6 +200,7 @@ const AdminNewsletters = () => {
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Add Newsletter Add Newsletter
</Button> </Button>
)}
</div> </div>
{/* Newsletters List */} {/* Newsletters List */}
@@ -204,10 +208,12 @@ const AdminNewsletters = () => {
<Card className="p-12 text-center"> <Card className="p-12 text-center">
<FileText className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" /> <FileText className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
<p className="text-[#664fa3] text-lg mb-4">No newsletters yet</p> <p className="text-[#664fa3] text-lg mb-4">No newsletters yet</p>
{hasPermission('newsletters.create') && (
<Button onClick={handleCreate} className="bg-[#664fa3] text-white"> <Button onClick={handleCreate} className="bg-[#664fa3] text-white">
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
Create First Newsletter Create First Newsletter
</Button> </Button>
)}
</Card> </Card>
) : ( ) : (
<div className="space-y-6"> <div className="space-y-6">
@@ -246,7 +252,9 @@ const AdminNewsletters = () => {
</Button> </Button>
</div> </div>
</div> </div>
{(hasPermission('newsletters.edit') || hasPermission('newsletters.delete')) && (
<div className="flex gap-2"> <div className="flex gap-2">
{hasPermission('newsletters.edit') && (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -255,6 +263,8 @@ const AdminNewsletters = () => {
> >
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>
)}
{hasPermission('newsletters.delete') && (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -263,7 +273,9 @@ const AdminNewsletters = () => {
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
)}
</div> </div>
)}
</div> </div>
</Card> </Card>
))} ))}

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useAuth } from '../../context/AuthContext';
import api from '../../utils/api'; import api from '../../utils/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';
@@ -24,6 +25,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
const AdminPlans = () => { const AdminPlans = () => {
const { hasPermission } = useAuth();
const [plans, setPlans] = useState([]); const [plans, setPlans] = useState([]);
const [filteredPlans, setFilteredPlans] = useState([]); const [filteredPlans, setFilteredPlans] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -136,6 +138,7 @@ const AdminPlans = () => {
Manage membership plans and pricing. Manage membership plans and pricing.
</p> </p>
</div> </div>
{hasPermission('subscriptions.plans') && (
<Button <Button
onClick={handleCreatePlan} onClick={handleCreatePlan}
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-6" className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-6"
@@ -143,6 +146,7 @@ const AdminPlans = () => {
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
Create Plan Create Plan
</Button> </Button>
)}
</div> </div>
</div> </div>
@@ -286,6 +290,7 @@ const AdminPlans = () => {
</div> </div>
{/* Actions */} {/* Actions */}
{hasPermission('subscriptions.plans') && (
<div className="flex flex-col sm:flex-row gap-2 pt-4 border-t border-[#ddd8eb]"> <div className="flex flex-col sm:flex-row gap-2 pt-4 border-t border-[#ddd8eb]">
<Button <Button
onClick={() => handleEditPlan(plan)} onClick={() => handleEditPlan(plan)}
@@ -307,6 +312,7 @@ const AdminPlans = () => {
Delete Delete
</Button> </Button>
</div> </div>
)}
{/* Warning for plans with subscribers */} {/* Warning for plans with subscribers */}
{plan.subscriber_count > 0 && ( {plan.subscriber_count > 0 && (
@@ -328,7 +334,7 @@ const AdminPlans = () => {
? 'Try adjusting your filters' ? 'Try adjusting your filters'
: 'Create your first subscription plan to get started'} : 'Create your first subscription plan to get started'}
</p> </p>
{!searchQuery && activeFilter === 'all' && ( {!searchQuery && activeFilter === 'all' && hasPermission('subscriptions.plans') && (
<Button <Button
onClick={handleCreatePlan} onClick={handleCreatePlan}
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-8" className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-8"

View File

@@ -16,7 +16,7 @@ import { UserCog, Search, Shield, UserPlus, Mail, Edit, Eye, Trash2, UserCheck,
const AdminStaff = () => { const AdminStaff = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { hasPermission } = useAuth(); const { hasPermission, user } = useAuth();
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [filteredUsers, setFilteredUsers] = useState([]); const [filteredUsers, setFilteredUsers] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -118,7 +118,7 @@ const AdminStaff = () => {
const getStatusBadge = (status) => { const getStatusBadge = (status) => {
const config = { const config = {
active: { label: 'Active', className: 'bg-[#81B29A] text-white' }, active: { label: 'Active', className: 'bg-[#81B29A] text-white' },
inactive: { label: 'Inactive', className: 'bg-gray-400 text-white' } inactive: { label: 'Inactive', className: 'bg-gray-400 text-white ' }
}; };
const statusConfig = config[status] || config.inactive; const statusConfig = config[status] || config.inactive;
@@ -142,7 +142,7 @@ const AdminStaff = () => {
</p> </p>
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
{hasPermission('users.invite') && ( {hasPermission('users.create') && (
<Button <Button
onClick={() => setInviteDialogOpen(true)} onClick={() => setInviteDialogOpen(true)}
className="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl h-12 px-6" className="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl h-12 px-6"

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useAuth } from '../../context/AuthContext';
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 { Input } from '../../components/ui/input'; import { Input } from '../../components/ui/input';
@@ -44,6 +45,7 @@ import {
} from '../../components/ui/dropdown-menu'; } from '../../components/ui/dropdown-menu';
const AdminSubscriptions = () => { const AdminSubscriptions = () => {
const { hasPermission } = useAuth();
const [subscriptions, setSubscriptions] = useState([]); const [subscriptions, setSubscriptions] = useState([]);
const [filteredSubscriptions, setFilteredSubscriptions] = useState([]); const [filteredSubscriptions, setFilteredSubscriptions] = useState([]);
const [plans, setPlans] = useState([]); const [plans, setPlans] = useState([]);
@@ -412,6 +414,7 @@ Proceed with activation?`;
</div> </div>
{/* Export Dropdown */} {/* Export Dropdown */}
{hasPermission('subscriptions.export') && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
@@ -439,6 +442,7 @@ Proceed with activation?`;
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)}
</div> </div>
</Card> </Card>
@@ -503,6 +507,7 @@ Proceed with activation?`;
{/* Actions */} {/* Actions */}
<div className="flex gap-2 pt-2"> <div className="flex gap-2 pt-2">
{hasPermission('subscriptions.edit') && (
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@@ -512,7 +517,8 @@ Proceed with activation?`;
<Edit className="h-4 w-4 mr-2" /> <Edit className="h-4 w-4 mr-2" />
Edit Edit
</Button> </Button>
{sub.status === 'active' && ( )}
{sub.status === 'active' && hasPermission('subscriptions.cancel') && (
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@@ -607,6 +613,7 @@ Proceed with activation?`;
</td> </td>
<td className="p-4"> <td className="p-4">
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
{hasPermission('subscriptions.edit') && (
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@@ -615,7 +622,8 @@ Proceed with activation?`;
> >
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>
{sub.status === 'active' && ( )}
{sub.status === 'active' && hasPermission('subscriptions.cancel') && (
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useAuth } from '../../context/AuthContext';
import api from '../../utils/api'; import api from '../../utils/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';
@@ -35,6 +36,7 @@ import ConfirmationDialog from '../../components/ConfirmationDialog';
import RejectionDialog from '../../components/RejectionDialog'; import RejectionDialog from '../../components/RejectionDialog';
const AdminValidations = () => { const AdminValidations = () => {
const { hasPermission } = useAuth();
const [pendingUsers, setPendingUsers] = useState([]); const [pendingUsers, setPendingUsers] = useState([]);
const [filteredUsers, setFilteredUsers] = useState([]); const [filteredUsers, setFilteredUsers] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -419,6 +421,7 @@ const AdminValidations = () => {
</Button> </Button>
) : user.status === 'pending_email' ? ( ) : user.status === 'pending_email' ? (
<> <>
{hasPermission('users.approve') && (
<Button <Button
onClick={() => handleBypassAndValidateRequest(user)} onClick={() => handleBypassAndValidateRequest(user)}
disabled={actionLoading === user.id} disabled={actionLoading === user.id}
@@ -427,6 +430,8 @@ const AdminValidations = () => {
> >
{actionLoading === user.id ? 'Validating...' : 'Bypass & Validate'} {actionLoading === user.id ? 'Validating...' : 'Bypass & Validate'}
</Button> </Button>
)}
{hasPermission('users.approve') && (
<Button <Button
onClick={() => handleRejectUser(user)} onClick={() => handleRejectUser(user)}
disabled={actionLoading === user.id} disabled={actionLoading === user.id}
@@ -437,9 +442,11 @@ const AdminValidations = () => {
<X className="h-4 w-4 mr-1" /> <X className="h-4 w-4 mr-1" />
Reject Reject
</Button> </Button>
)}
</> </>
) : user.status === 'payment_pending' ? ( ) : user.status === 'payment_pending' ? (
<> <>
{hasPermission('subscriptions.activate') && (
<Button <Button
onClick={() => handleActivatePayment(user)} onClick={() => handleActivatePayment(user)}
size="sm" size="sm"
@@ -448,6 +455,8 @@ const AdminValidations = () => {
<CheckCircle className="h-4 w-4 mr-1" /> <CheckCircle className="h-4 w-4 mr-1" />
Activate Payment Activate Payment
</Button> </Button>
)}
{hasPermission('users.approve') && (
<Button <Button
onClick={() => handleRejectUser(user)} onClick={() => handleRejectUser(user)}
disabled={actionLoading === user.id} disabled={actionLoading === user.id}
@@ -458,9 +467,11 @@ const AdminValidations = () => {
<X className="h-4 w-4 mr-1" /> <X className="h-4 w-4 mr-1" />
Reject Reject
</Button> </Button>
)}
</> </>
) : ( ) : (
<> <>
{hasPermission('users.approve') && (
<Button <Button
onClick={() => handleValidateRequest(user)} onClick={() => handleValidateRequest(user)}
disabled={actionLoading === user.id} disabled={actionLoading === user.id}
@@ -469,6 +480,8 @@ const AdminValidations = () => {
> >
{actionLoading === user.id ? 'Validating...' : 'Validate'} {actionLoading === user.id ? 'Validating...' : 'Validate'}
</Button> </Button>
)}
{hasPermission('users.approve') && (
<Button <Button
onClick={() => handleRejectUser(user)} onClick={() => handleRejectUser(user)}
disabled={actionLoading === user.id} disabled={actionLoading === user.id}
@@ -479,6 +492,7 @@ const AdminValidations = () => {
<X className="h-4 w-4 mr-1" /> <X className="h-4 w-4 mr-1" />
Reject Reject
</Button> </Button>
)}
</> </>
)} )}
</div> </div>

View File

@@ -0,0 +1,93 @@
/* Member Calendar Custom Styles */
.member-calendar .rbc-header {
padding: 12px 6px;
font-family: 'Inter', sans-serif;
font-weight: 600;
color: #422268;
background-color: #f9f7fc;
border-bottom: 2px solid #ddd8eb;
}
.member-calendar .rbc-today {
background-color: #f1eef9;
}
.member-calendar .rbc-off-range-bg {
background-color: #fafafa;
}
.member-calendar .rbc-event {
border-radius: 6px;
padding: 2px 6px;
}
.member-calendar .rbc-event:hover {
opacity: 0.85;
cursor: pointer;
}
.member-calendar .rbc-toolbar button {
color: #664fa3;
border-color: #ddd8eb;
font-family: 'Nunito Sans', sans-serif;
padding: 6px 12px;
background-color: white;
cursor: pointer;
transition: all 0.2s ease;
}
.member-calendar .rbc-toolbar button:hover {
background-color: #f1eef9;
border-color: #664fa3;
}
.member-calendar .rbc-toolbar button:active,
.member-calendar .rbc-toolbar button.rbc-active {
background-color: #664fa3;
color: white;
}
.member-calendar .rbc-month-view {
border: 1px solid #ddd8eb;
border-radius: 8px;
}
.member-calendar .rbc-day-bg {
border-color: #ddd8eb;
}
.member-calendar .rbc-date-cell {
padding: 8px;
font-family: 'Nunito Sans', sans-serif;
}
/* Ensure toolbar buttons are clickable */
.member-calendar .rbc-toolbar {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
gap: 12px;
}
.member-calendar .rbc-toolbar button {
outline: none;
border: 1px solid #ddd8eb;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
}
.member-calendar .rbc-btn-group button {
margin: 0 2px;
}
.member-calendar .rbc-btn-group button:first-child {
margin-left: 0;
}
.member-calendar .rbc-btn-group button:last-child {
margin-right: 0;
}

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect, useMemo } from 'react';
import { Calendar, momentLocalizer } from 'react-big-calendar'; import { Calendar, momentLocalizer } from 'react-big-calendar';
import moment from 'moment'; import moment from 'moment';
import 'react-big-calendar/lib/css/react-big-calendar.css'; import 'react-big-calendar/lib/css/react-big-calendar.css';
import './MemberCalendar.css';
import api from '../../utils/api'; import api from '../../utils/api';
import Navbar from '../../components/Navbar'; import Navbar from '../../components/Navbar';
import MemberFooter from '../../components/MemberFooter'; import MemberFooter from '../../components/MemberFooter';
@@ -26,6 +27,8 @@ export default function MemberCalendar() {
const [selectedEvent, setSelectedEvent] = useState(null); const [selectedEvent, setSelectedEvent] = useState(null);
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [rsvpLoading, setRsvpLoading] = useState(false); const [rsvpLoading, setRsvpLoading] = useState(false);
const [currentDate, setCurrentDate] = useState(new Date());
const [currentView, setCurrentView] = useState('month');
useEffect(() => { useEffect(() => {
fetchEvents(); fetchEvents();
@@ -58,6 +61,14 @@ export default function MemberCalendar() {
setIsDialogOpen(true); setIsDialogOpen(true);
}; };
const handleNavigate = (newDate) => {
setCurrentDate(newDate);
};
const handleViewChange = (newView) => {
setCurrentView(newView);
};
const handleRSVP = async (status) => { const handleRSVP = async (status) => {
if (!selectedEvent) return; if (!selectedEvent) return;
@@ -171,10 +182,13 @@ export default function MemberCalendar() {
startAccessor="start" startAccessor="start"
endAccessor="end" endAccessor="end"
style={{ height: 700 }} style={{ height: 700 }}
date={currentDate}
view={currentView}
onNavigate={handleNavigate}
onView={handleViewChange}
onSelectEvent={handleSelectEvent} onSelectEvent={handleSelectEvent}
eventPropGetter={eventStyleGetter} eventPropGetter={eventStyleGetter}
views={['month', 'week', 'day', 'agenda']} views={['month', 'week', 'day', 'agenda']}
defaultView="month"
popup popup
className="member-calendar" className="member-calendar"
/> />
@@ -320,64 +334,6 @@ export default function MemberCalendar() {
</Dialog> </Dialog>
</div> </div>
<style jsx global>{`
.member-calendar .rbc-header {
padding: 12px 6px;
font-family: 'Inter', sans-serif;
font-weight: 600;
color: #422268;
background-color: #f9f7fc;
border-bottom: 2px solid #ddd8eb;
}
.member-calendar .rbc-today {
background-color: #f1eef9;
}
.member-calendar .rbc-off-range-bg {
background-color: #fafafa;
}
.member-calendar .rbc-event {
border-radius: 6px;
padding: 2px 6px;
}
.member-calendar .rbc-event:hover {
opacity: 0.85;
cursor: pointer;
}
.member-calendar .rbc-toolbar button {
color: #664fa3;
border-color: #ddd8eb;
font-family: 'Nunito Sans', sans-serif;
}
.member-calendar .rbc-toolbar button:hover {
background-color: #f1eef9;
border-color: #664fa3;
}
.member-calendar .rbc-toolbar button.rbc-active {
background-color: #664fa3;
color: white;
}
.member-calendar .rbc-month-view {
border: 1px solid #ddd8eb;
border-radius: 8px;
}
.member-calendar .rbc-day-bg {
border-color: #ddd8eb;
}
.member-calendar .rbc-date-cell {
padding: 8px;
font-family: 'Nunito Sans', sans-serif;
}
`}</style>
<MemberFooter /> <MemberFooter />
</div> </div>
); );

View File

@@ -24,6 +24,8 @@ const MembersDirectory = () => {
const [selectedMember, setSelectedMember] = useState(null); const [selectedMember, setSelectedMember] = useState(null);
const [profileDialogOpen, setProfileDialogOpen] = useState(false); const [profileDialogOpen, setProfileDialogOpen] = useState(false);
const { toast } = useToast(); const { toast } = useToast();
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 12;
useEffect(() => { useEffect(() => {
fetchMembers(); fetchMembers();
@@ -33,6 +35,10 @@ const MembersDirectory = () => {
filterMembers(); filterMembers();
}, [searchQuery, members]); }, [searchQuery, members]);
useEffect(() => {
setCurrentPage(1);
}, [searchQuery, members]);
const fetchMembers = async () => { const fetchMembers = async () => {
try { try {
const response = await api.get('/members/directory'); const response = await api.get('/members/directory');
@@ -66,6 +72,14 @@ const MembersDirectory = () => {
setFilteredMembers(filtered); setFilteredMembers(filtered);
}; };
const totalPages = Math.max(1, Math.ceil(filteredMembers.length / pageSize));
const pageStart = (currentPage - 1) * pageSize;
const paginatedMembers = filteredMembers.slice(pageStart, pageStart + pageSize);
const totalMembers = members.length;
const getInitials = (firstName, lastName) => { const getInitials = (firstName, lastName) => {
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase(); return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
}; };
@@ -97,9 +111,15 @@ const MembersDirectory = () => {
if (!dateString) return null; if (!dateString) return null;
return new Date(dateString).toLocaleDateString('en-US', { month: 'long', day: 'numeric' }); return new Date(dateString).toLocaleDateString('en-US', { month: 'long', day: 'numeric' });
}; };
const Border = ({ yaxis = false }) => {
return (
yaxis ?
<div className=' border-2 w-full border-[#664FA3] my-24' />
: <div className=' border-2 w-full border-[#664FA3] mb-24' />
)
}
const MemberCard = ({ member }) => ( const MemberCard = ({ member }) => (
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb] hover:shadow-lg transition-all h-full"> <Card className="p-6 bg-white rounded-3xl border border-[#ddd8eb] hover:shadow-lg transition-all h-full">
{/* Profile Photo */} {/* Profile Photo */}
<div className="flex justify-center mb-4"> <div className="flex justify-center mb-4">
{member.profile_photo_url ? ( {member.profile_photo_url ? (
@@ -259,30 +279,34 @@ const MembersDirectory = () => {
); );
return ( return (
<div className="min-h-screen bg-white"> <div className="min-h-screen bg-gradient-to-bl from-[#F9FAFB] to-[#DDD8EB]">
<Navbar /> <Navbar />
<div className="max-w-7xl mx-auto px-6 py-12"> <div className="max-w-7xl mx-auto py-12">
{/* Header and Search bar */}
<div className='px-9'>
{/* Header */} {/* Header */}
<div className="mb-8"> <div className="m-8 mt-14 flex flex-col sm:flex-row justify-between items-center ">
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <h1 className="text-4xl md:text-5xl font-bold text-[#422268] mb-4" style={{ fontFamily: "'Poppins', sans-serif" }}>
Members Directory LOAF Members
</h1> </h1>
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-lg " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Connect with fellow LOAF members in our community. <span className='text-foreground'>Number of current memebers in the directory: </span> <span className='text-[#664fa3] font-medium'>{totalMembers}</span>
</p> </p>
</div> </div>
{/* Search Bar */} {/* Search Bar */}
<div className="mb-8"> <div className="mb-24 mx-10">
<div className="relative max-w-xl"> <div className="relative w-full ">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-[#664fa3]" /> <Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-[#664fa3]" />
<Input <Input
type="text" type="text"
placeholder="Search by name or bio..." placeholder="Search by name or bio..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="pl-12 pr-4 py-6 text-lg border-[#ddd8eb] rounded-xl focus:border-[#664fa3] focus:ring-[#664fa3]" className="pl-12 pr-4 py-6 text-3xl font-medium bg-background border-foreground rounded-full focus:border-[#664fa3] focus:ring-[#664fa3]"
style={{ fontFamily: "'Nunito Sans', sans-serif" }} style={{ fontFamily: "'Nunito Sans', sans-serif" }}
/> />
</div> </div>
@@ -293,6 +317,11 @@ const MembersDirectory = () => {
)} )}
</div> </div>
</div>
{/* Border Decoration */}
<Border />
{/* Members Grid */} {/* Members Grid */}
{loading ? ( {loading ? (
<div className="text-center py-20"> <div className="text-center py-20">
@@ -300,7 +329,7 @@ const MembersDirectory = () => {
</div> </div>
) : filteredMembers.length > 0 ? ( ) : filteredMembers.length > 0 ? (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8"> <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
{filteredMembers.map((member) => ( {paginatedMembers.map((member) => (
<MemberCard key={member.id} member={member} /> <MemberCard key={member.id} member={member} />
))} ))}
</div> </div>
@@ -318,6 +347,11 @@ const MembersDirectory = () => {
</div> </div>
)} )}
{/* Border Decoration */}
<Border yaxis="true" />
{/* Info Card */} {/* Info Card */}
{!loading && members.length > 0 && ( {!loading && members.length > 0 && (
<Card className="mt-12 p-6 bg-[#F8F7FB] border-[#ddd8eb]"> <Card className="mt-12 p-6 bg-[#F8F7FB] border-[#ddd8eb]">
@@ -526,6 +560,66 @@ const MembersDirectory = () => {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Pagination */}
{!loading && filteredMembers.length > 0 && (
<div className="mt-10 flex flex-col items-center gap-4 pb-12">
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Showing {pageStart + 1}{Math.min(pageStart + pageSize, filteredMembers.length)} of {filteredMembers.length}
</p>
<div className="flex flex-wrap items-center justify-center gap-2">
<Button
onClick={() => setCurrentPage(1)}
disabled={currentPage === 1}
className="bg-[#DDD8EB] rounded-full text-[#422268] hover:bg-[#664fa3] hover:text-white"
>
First Page
</Button>
<Button
onClick={() => setCurrentPage((page) => Math.max(1, page - 1))}
disabled={currentPage === 1}
className="bg-[#DDD8EB] rounded-full text-[#422268] hover:bg-[#664fa3] hover:text-white"
>
Previous
</Button>
{Array.from({ length: totalPages }, (_, index) => {
const pageNumber = index + 1;
const isActive = pageNumber === currentPage;
return (
<Button
key={pageNumber}
onClick={() => setCurrentPage(pageNumber)}
className={
isActive
? "bg-[#664fa3] text-white hover:bg-[#422268] rounded-full"
: "bg-[#DDD8EB] text-[#422268] hover:bg-[#664fa3] hover:text-white rounded-full"
}
>
{pageNumber}
</Button>
);
})}
<Button
onClick={() => setCurrentPage((page) => Math.min(totalPages, page + 1))}
disabled={currentPage === totalPages}
className="bg-[#DDD8EB] text-[#422268] hover:bg-[#664fa3] rounded-full hover:text-white"
>
Next
</Button>
<Button
onClick={() => setCurrentPage(totalPages)}
disabled={currentPage === totalPages}
className="bg-[#DDD8EB] text-[#422268] hover:bg-[#664fa3] rounded-full hover:text-white"
>
Last Page
</Button>
</div>
</div>
)}
<MemberFooter /> <MemberFooter />
</div> </div>
); );