Files
membership-fe/src/components/AdminSidebar.js

536 lines
18 KiB
JavaScript

import React, { useState, useEffect } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useTheme } from 'next-themes';
import { useAuth } from '../context/AuthContext';
import { useThemeConfig } from '../context/ThemeConfigContext';
import api from '../utils/api';
import { Badge } from './ui/badge';
import {
LayoutDashboard,
UserCog,
Users,
CheckCircle,
CreditCard,
Calendar,
Shield,
Settings,
ChevronLeft,
ChevronRight,
LogOut,
Menu,
Image,
FileText,
DollarSign,
Scale,
HardDrive,
Repeat,
Heart,
Sun,
Moon,
Star,
FileEdit
} from 'lucide-react';
const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
const location = useLocation();
const navigate = useNavigate();
const { user, logout } = useAuth();
const { getLogoUrl } = useThemeConfig();
const { theme, setTheme } = useTheme();
const [pendingCount, setPendingCount] = useState(0);
const [storageUsed, setStorageUsed] = useState(0);
const [storageLimit, setStorageLimit] = useState(0);
const [storagePercentage, setStoragePercentage] = useState(0);
const isDark = theme === 'dark';
// Fetch pending approvals count
useEffect(() => {
const fetchPendingCount = async () => {
try {
const response = await api.get('/admin/users');
const pending = response.data.filter(u =>
['pending_email', 'pending_validation', 'pre_validated', 'payment_pending'].includes(u.status)
);
setPendingCount(pending.length);
} catch (error) {
console.error('Failed to fetch pending count:', error);
}
};
fetchPendingCount();
// Refresh count every 30 seconds
const interval = setInterval(fetchPendingCount, 30000);
return () => clearInterval(interval);
}, []);
// Fetch storage usage
useEffect(() => {
const fetchStorageUsage = async () => {
try {
const response = await api.get('/admin/storage/usage');
setStorageUsed(response.data.total_bytes_used);
setStorageLimit(response.data.max_bytes_allowed);
setStoragePercentage(response.data.percentage);
} catch (error) {
console.error('Failed to fetch storage usage:', error);
}
};
fetchStorageUsage();
// Refresh storage usage every 60 seconds
const interval = setInterval(fetchStorageUsage, 60000);
return () => clearInterval(interval);
}, []);
const formatBytes = (bytes) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const handleLogout = () => {
logout();
navigate('/login');
};
const handleThemeToggle = () => {
setTheme(isDark ? 'light' : 'dark');
};
const navItems = [
{
name: 'Dashboard',
icon: LayoutDashboard,
path: '/admin',
disabled: false
},
{
name: 'Staff & Admins',
icon: UserCog,
path: '/admin/staff',
disabled: false
},
{
name: 'Member Roster',
icon: Users,
path: '/admin/members',
disabled: false
},
{
name: 'Member Tiers',
icon: Star,
path: '/admin/member-tiers',
disabled: false
},
{
name: 'Registration',
icon: FileEdit,
path: '/admin/registration',
disabled: false
},
{
name: 'Validations',
icon: CheckCircle,
path: '/admin/validations',
disabled: false,
badge: pendingCount
},
{
name: 'Plans',
icon: CreditCard,
path: '/admin/plans',
disabled: false
},
{
name: 'Subscriptions',
icon: Repeat,
path: '/admin/subscriptions',
disabled: false
},
{
name: 'Donations',
icon: Heart,
path: '/admin/donations',
disabled: false
},
{
name: 'Events',
icon: Calendar,
path: '/admin/events',
disabled: false
},
{
name: 'Gallery',
icon: Image,
path: '/admin/gallery',
disabled: false
},
{
name: 'Newsletters',
icon: FileText,
path: '/admin/newsletters',
disabled: false
},
{
name: 'Financials',
icon: DollarSign,
path: '/admin/financials',
disabled: false
},
{
name: 'Bylaws',
icon: Scale,
path: '/admin/bylaws',
disabled: false
},
{
name: 'Settings',
icon: Settings,
path: '/admin/settings',
disabled: false,
superadminOnly: true
}
];
// Filter nav items based on user role
const filteredNavItems = navItems.filter(item => {
if (item.superadminOnly && user?.role !== 'superadmin') {
console.log('Filtering out superadmin-only item:', item.name, 'User role:', user?.role);
return false;
}
return true;
});
// Debug: Log filtered items count
console.log('Total nav items:', navItems.length, 'Filtered items:', filteredNavItems.length, 'User role:', user?.role);
const isActive = (path) => {
if (path === '/admin') {
return location.pathname === '/admin';
}
return location.pathname.startsWith(path);
};
const renderNavItem = (item) => {
if (!item) return null;
const Icon = item.icon;
const active = isActive(item.path);
return (
<div key={item.name} className="relative group">
<Link
to={item.disabled ? '#' : item.path}
onClick={(e) => {
if (item.disabled) {
e.preventDefault();
}
}}
className={`
flex items-center gap-3 px-4 py-3 rounded-lg transition-all relative
${item.disabled
? 'opacity-50 cursor-not-allowed text-brand-purple '
: active
? 'bg-[var(--orange-light)]/10 text-[var(--purple-ink)]'
: 'text-[var(--purple-ink)] hover:bg-[var(--neutral-800)]/20'
}
`}
>
{/* Active border */}
{active && !item.disabled && (
<div className="absolute left-0 top-0 bottom-0 w-1 bg-accent rounded-r" />
)}
<Icon className="h-5 w-5 flex-shrink-0" />
{isOpen && (
<>
<span className="flex-1">{item.name}</span>
{item.disabled && (
<Badge className="bg-[var(--neutral-800)] text-[var(--purple-ink)] text-xs px-2 py-0.5">
Soon
</Badge>
)}
{item.badge > 0 && !item.disabled && (
<Badge className="bg-accent foreground text-xs px-2 py-0.5">
{item.badge}
</Badge>
)}
</>
)}
{/* Badge when collapsed */}
{!isOpen && item.badge > 0 && !item.disabled && (
<div className="absolute -top-1 -right-1 bg-accent text-white foreground text-xs rounded-full h-5 w-5 flex items-center justify-center font-medium">
{item.badge}
</div>
)}
</Link>
{/* Tooltip when collapsed */}
{!isOpen && (
<div className="absolute left-full ml-2 top-1/2 -translate-y-1/2 px-3 py-2 bg-primary foreground text-sm rounded-lg opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap z-50">
{item.name}
{item.badge > 0 && ` (${item.badge})`}
</div>
)}
</div>
);
};
return (
<>
{/* Sidebar */}
<aside
className={`
bg-background border-r border-[var(--neutral-800)] transition-all duration-300 ease-out
${isMobile ? 'fixed inset-y-0 left-0 z-40' : 'relative'}
${isOpen ? 'w-64' : 'w-16'}
${isMobile && !isOpen ? '-translate-x-full' : 'translate-x-0'}
flex flex-col
`}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-[var(--neutral-800)]">
<Link to="/" className="flex items-center gap-3 group flex-1 min-w-0">
<img
src={getLogoUrl()}
alt="LOAF Logo"
className={`object-contain transition-all duration-200 ${isOpen ? 'h-10 w-10' : 'h-8 w-8'
}`}
/>
{isOpen && (
<div className="flex-1 min-w-0">
<h2 className="text-xl font-semibold text-primary dark:text-brand-light-lavender " style={{ fontFamily: "'Inter', sans-serif" }}>
Admin
</h2>
</div>
)}
</Link>
<button
onClick={onToggle}
className="p-2 rounded-lg hover:bg-[var(--neutral-800)]/20 transition-colors"
aria-label={isOpen ? 'Collapse sidebar' : 'Expand sidebar'}
>
{isMobile ? (
<Menu className="h-5 w-5 text-primary" />
) : isOpen ? (
<ChevronLeft className="h-5 w-5 text-primary" />
) : (
<ChevronRight className="h-5 w-5 text-primary" />
)}
</button>
</div>
{/* Navigation */}
<nav className="flex-1 overflow-y-auto p-4 scrollbar-dashboard scrollbar-x-dashboard">
{/* Dashboard - Standalone */}
{renderNavItem(filteredNavItems.find(item => item.name === 'Dashboard'))}
{/* Onboarding Section */}
{isOpen && (
<div className="px-4 py-2 mt-6">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Onboarding
</h3>
</div>
)}
<div className="space-y-1">
{renderNavItem(filteredNavItems.find(item => item.name === 'Registration'))}
{renderNavItem(filteredNavItems.find(item => item.name === 'Validations'))}
</div>
{/* MEMBERSHIP Section */}
{isOpen && (
<div className="px-4 py-2 mt-6">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Membership
</h3>
</div>
)}
<div className="space-y-1">
{renderNavItem(filteredNavItems.find(item => item.name === 'Member Roster'))}
{renderNavItem(filteredNavItems.find(item => item.name === 'Member Tiers'))}
{renderNavItem(filteredNavItems.find(item => item.name === 'Staff & Admins'))}
</div>
{/* FINANCIALS Section */}
{isOpen && (
<div className="px-4 py-2 mt-6">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Financials
</h3>
</div>
)}
<div className="space-y-1">
{renderNavItem(filteredNavItems.find(item => item.name === 'Plans'))}
{renderNavItem(filteredNavItems.find(item => item.name === 'Subscriptions'))}
{renderNavItem(filteredNavItems.find(item => item.name === 'Donations'))}
</div>
{/* EVENTS & MEDIA Section */}
{isOpen && (
<div className="px-4 py-2 mt-6">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Events & Media
</h3>
</div>
)}
<div className="space-y-1">
{renderNavItem(filteredNavItems.find(item => item.name === 'Events'))}
{renderNavItem(filteredNavItems.find(item => item.name === 'Gallery'))}
</div>
{/* DOCUMENTATION Section */}
{isOpen && (
<div className="px-4 py-2 mt-6">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Documentation
</h3>
</div>
)}
<div className="space-y-1">
{renderNavItem(filteredNavItems.find(item => item.name === 'Newsletters'))}
{renderNavItem(filteredNavItems.find(item => item.name === 'Financials'))}
{renderNavItem(filteredNavItems.find(item => item.name === 'Bylaws'))}
</div>
{/* SYSTEM Section - Superadmin only */}
{user?.role === 'superadmin' && (
<>
{isOpen && (
<div className="px-4 py-2 mt-6">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
System
</h3>
</div>
)}
<div className="space-y-1">
{renderNavItem(filteredNavItems.find(item => item.name === 'Permissions'))}
{renderNavItem(filteredNavItems.find(item => item.name === 'Settings'))}
</div>
</>
)}
</nav>
{/* User Section */}
<div className="border-t border-[var(--neutral-800)] p-4 space-y-2">
{isOpen && user && (
<div className="px-4 py-3 mb-2 flex justify-between items-center">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-[var(--neutral-800)] flex items-center justify-center text-[var(--purple-ink)] font-semibold">
{user.first_name?.[0]}{user.last_name?.[0]}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-primary dark:text-brand-light-lavender truncate" style={{ fontFamily: "'Inter', sans-serif" }}>
{user.first_name} {user.last_name}
</p>
<p className="text-xs text-muted-foreground capitalize truncate" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{user.role}
</p>
</div>
</div>
<Link className='dark:text-brand-lavender ' to='/profile'><Settings size={16} />
</Link>
</div>
)}
{/* Theme Toggle */}
<div className="relative group">
<button
type="button"
onClick={handleThemeToggle}
aria-pressed={isDark}
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
className={`
flex items-center gap-3 px-4 py-3 rounded-lg w-full
text-primary dark:text-brand-lavender hover:bg-muted/20 transition-colors
${!isOpen && 'justify-center'}
`}
>
{isDark ? (
<Sun className="h-5 w-5 flex-shrink-0 " />
) : (
<Moon className="h-5 w-5 flex-shrink-0" />
)}
{isOpen && <span >{isDark ? 'Light mode' : 'Dark mode'}</span>}
</button>
{!isOpen && (
<div className="absolute left-full ml-2 top-1/2 -translate-y-1/2 px-3 py-2 bg-primary foreground text-sm rounded-lg opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap z-50">
{isDark ? 'Light mode' : 'Dark mode'}
</div>
)}
</div>
{/* Storage Usage Widget */}
<div className="mb-2">
{isOpen ? (
<div className="px-4 py-3 bg-[var(--lavender-500)] rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-primary dark:text-brand-light-lavender ">Storage Usage</span>
<span className="text-xs text-muted-foreground">{storagePercentage}%</span>
</div>
<div className="w-full bg-[var(--neutral-800)] rounded-full h-2">
<div
className={`h-2 rounded-full transition-all ${storagePercentage > 90 ? 'bg-red-500' :
storagePercentage > 75 ? 'bg-yellow-500' :
'bg-[var(--green-light)]'
}`}
style={{ width: `${storagePercentage}%` }}
/>
</div>
<p className="text-xs text-muted-foreground mt-1">
{formatBytes(storageUsed)} / {formatBytes(storageLimit)}
</p>
</div>
) : (
<div className="flex justify-center">
<div className="relative group">
<HardDrive className={`h-5 w-5 ${storagePercentage > 90 ? 'text-red-500' :
storagePercentage > 75 ? 'text-yellow-500' :
'text-muted-foreground'
}`} />
{storagePercentage > 75 && (
<div className="absolute -top-1 -right-1 bg-red-500 h-2 w-2 rounded-full" />
)}
{/* Tooltip */}
<div className="absolute left-full ml-2 top-1/2 -translate-y-1/2 px-3 py-2 bg-primary foreground text-sm rounded-lg opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap z-50">
Storage: {storagePercentage}%
</div>
</div>
</div>
)}
</div>
{/* Logout Button */}
<button
onClick={handleLogout}
className={`
flex items-center gap-3 px-4 py-3 rounded-lg w-full
text-accent hover:bg-accent/10 transition-colors
${!isOpen && 'justify-center'}
`}
>
<LogOut className="h-5 w-5 flex-shrink-0" />
{isOpen && <span>Logout</span>}
</button>
{/* Logout tooltip when collapsed */}
{!isOpen && (
<div className="relative group">
<div className="absolute left-full ml-2 bottom-0 px-3 py-2 bg-primary foreground text-sm rounded-lg opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap z-50">
Logout
</div>
</div>
)}
</div>
</aside>
</>
);
};
export default AdminSidebar;