513 lines
17 KiB
JavaScript
513 lines
17 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 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,
|
|
} from 'lucide-react';
|
|
|
|
const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
const { user, logout } = useAuth();
|
|
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',
|
|
icon: UserCog,
|
|
path: '/admin/staff',
|
|
disabled: false
|
|
},
|
|
{
|
|
name: 'Members',
|
|
icon: Users,
|
|
path: '/admin/members',
|
|
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: 'Permissions',
|
|
icon: Shield,
|
|
path: '/admin/permissions',
|
|
disabled: false,
|
|
superadminOnly: true
|
|
},
|
|
{
|
|
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={`${process.env.PUBLIC_URL}/loaf-logo.png`}
|
|
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'))}
|
|
|
|
{/* 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 === 'Staff'))}
|
|
{renderNavItem(filteredNavItems.find(item => item.name === 'Members'))}
|
|
{renderNavItem(filteredNavItems.find(item => item.name === 'Validations'))}
|
|
</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;
|