365 lines
16 KiB
TypeScript
365 lines
16 KiB
TypeScript
import React, { useMemo, useState } from 'react';
|
|
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
|
import {
|
|
LogOut,
|
|
Zap,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
AlertCircle,
|
|
MoreVertical
|
|
} from 'lucide-react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { PerspectiveSwitcher } from './PerspectiveSwitcher';
|
|
import { NAVIGATION_CONFIG } from '../config/navigation';
|
|
import type { NavItem } from '../config/navigation';
|
|
import { logout } from '../utils/auth';
|
|
|
|
export const Sidebar: React.FC = () => {
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
|
|
|
// Safely parse user data
|
|
const user = useMemo(() => {
|
|
try {
|
|
const stored = localStorage.getItem('teleems_user');
|
|
if (!stored || stored === 'undefined' || stored === 'null') return { roles: [] };
|
|
const parsed = JSON.parse(stored);
|
|
if (parsed && typeof parsed === 'object') {
|
|
// Normalise roles to lowercase_underscore so "Fleet Operator" === "FLEET_OPERATOR"
|
|
parsed.roles = Array.isArray(parsed.roles)
|
|
? parsed.roles.map((r: any) => String(r).toLowerCase().replace(/\s+/g, '_'))
|
|
: [];
|
|
return parsed;
|
|
}
|
|
return { roles: [] };
|
|
} catch (err) {
|
|
console.error('Failed to parse user data from localStorage', err);
|
|
return { roles: [] };
|
|
}
|
|
}, []);
|
|
|
|
const handleLogout = () => {
|
|
logout();
|
|
};
|
|
|
|
const handleRoleSwitch = (role: string) => {
|
|
// Preserve CURESELECT_ADMIN so the admin doesn't lose access to the perspective switcher when switching roles
|
|
const originalStored = localStorage.getItem('teleems_user');
|
|
let originalRoles: string[] = [];
|
|
try {
|
|
if (originalStored) {
|
|
const parsed = JSON.parse(originalStored);
|
|
originalRoles = Array.isArray(parsed?.roles) ? parsed.roles : [];
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
|
|
const hasAdmin = originalRoles.some((r: string) => {
|
|
const normalized = r.toLowerCase().replace(/\s+/g, '_');
|
|
return normalized === 'cureselect_admin' || normalized === 'admin';
|
|
});
|
|
|
|
let newRoles = [role.toUpperCase()];
|
|
if (hasAdmin && role.toUpperCase() !== 'CURESELECT_ADMIN') {
|
|
newRoles.push('CURESELECT_ADMIN');
|
|
}
|
|
|
|
const updatedUser = { ...user, roles: newRoles };
|
|
localStorage.setItem('teleems_user', JSON.stringify(updatedUser));
|
|
window.location.reload();
|
|
};
|
|
|
|
const currentRole = user.roles?.[0] || 'cureselect_admin';
|
|
const displayName = String(user.name || user.username || (currentRole === 'cureselect_admin' ? 'CureSelect Admin' : 'Fleet Operator'));
|
|
const displayId = user.id ? `#${String(user.id).substring(0, 6)}` : '#789022';
|
|
const initials = (displayName.split(' ').map((n: string) => n[0]).join('').toUpperCase().substring(0, 2)) || 'FO';
|
|
|
|
const filteredNavItems = useMemo(() => {
|
|
// The active perspective role being viewed (e.g. 'hospital_admin', 'fleet_operator', 'cureselect_admin')
|
|
const activeRole = currentRole.toLowerCase().replace(/\s+/g, '_');
|
|
|
|
// Check if the active perspective is a platform-wide admin role
|
|
const isAdminPerspective = ['cureselect_admin', 'admin', 'super_admin', 'superadmin'].includes(activeRole);
|
|
|
|
const filterItems = (items: NavItem[]): NavItem[] => {
|
|
return items.filter(item => {
|
|
// If viewing as CureSelect Admin, they can see everything (or we filter as admin)
|
|
if (isAdminPerspective) return true;
|
|
|
|
// Otherwise, filter items so they must match the active role perspective
|
|
return item.roles.some(role =>
|
|
role.toLowerCase().replace(/\s+/g, '_') === activeRole
|
|
);
|
|
}).map(item => ({
|
|
...item,
|
|
subItems: item.subItems ? filterItems(item.subItems) : undefined
|
|
}));
|
|
};
|
|
|
|
return filterItems(NAVIGATION_CONFIG);
|
|
}, [currentRole]);
|
|
|
|
const renderNavItem = (item: NavItem, isSubItem = false) => {
|
|
const Icon = item.icon || AlertCircle;
|
|
const currentUrl = location.pathname + location.search;
|
|
|
|
// Split path into pathname + search to handle query params correctly
|
|
const [itemPathname, itemSearch] = item.path.split('?');
|
|
const itemTo = itemSearch
|
|
? { pathname: itemPathname, search: `?${itemSearch}` }
|
|
: item.path;
|
|
|
|
const isActive = currentUrl === item.path ||
|
|
(itemSearch ? location.pathname === itemPathname && location.search === `?${itemSearch}` : location.pathname === item.path);
|
|
const hasSubItems = item.subItems && item.subItems.length > 0;
|
|
const isParentActive = hasSubItems && (isActive || item.subItems?.some(sub => {
|
|
const [subPath, subSearch] = sub.path.split('?');
|
|
return subSearch
|
|
? location.pathname === subPath && location.search === `?${subSearch}`
|
|
: location.pathname === subPath;
|
|
}));
|
|
|
|
return (
|
|
<div key={item.id} style={{ display: 'flex', flexDirection: 'column' }}>
|
|
<NavLink
|
|
to={itemTo}
|
|
title={isSidebarCollapsed ? item.label : undefined}
|
|
style={({ isActive: linkActive }) => {
|
|
const active = linkActive || isActive;
|
|
return {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: isSidebarCollapsed ? 'center' : 'flex-start',
|
|
gap: isSidebarCollapsed ? '0' : '12px',
|
|
padding: isSidebarCollapsed ? '12px' : isSubItem ? '10px 20px 10px 48px' : '12px 16px',
|
|
margin: isSidebarCollapsed ? '4px 12px' : '2px 12px',
|
|
borderRadius: '12px',
|
|
textDecoration: 'none',
|
|
color: active ? '#fff' : '#94A3B8',
|
|
background: active ? 'linear-gradient(90deg, rgba(6, 182, 212, 0.15), rgba(59, 130, 246, 0.05))' : 'transparent',
|
|
borderLeft: active && !isSubItem && !isSidebarCollapsed ? '3px solid #06B6D4' : '3px solid transparent',
|
|
boxShadow: active && isSidebarCollapsed ? '0 0 0 1px rgba(6,182,212,0.4)' : 'none',
|
|
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
position: 'relative',
|
|
overflow: 'hidden'
|
|
};
|
|
}}
|
|
className={(navData) => `nav-item-link ${navData.isActive || isActive ? 'active' : ''}`}
|
|
>
|
|
{({ isActive: linkActive }) => {
|
|
const active = linkActive || isActive;
|
|
return (
|
|
<>
|
|
<Icon size={20} color={active ? '#06B6D4' : 'currentColor'} style={{ flexShrink: 0 }} />
|
|
<AnimatePresence>
|
|
{!isSidebarCollapsed && (
|
|
<motion.div
|
|
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
|
style={{ flex: 1, display: 'flex', alignItems: 'center', overflow: 'hidden' }}
|
|
>
|
|
<span style={{
|
|
fontWeight: (active || isParentActive) ? 700 : 500,
|
|
fontSize: isSubItem ? '0.8rem' : '0.875rem',
|
|
whiteSpace: 'nowrap',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
letterSpacing: '0.01em',
|
|
flex: 1
|
|
}}>
|
|
{item.label}
|
|
</span>
|
|
{hasSubItems && (
|
|
<div style={{
|
|
transform: isParentActive ? 'rotate(90deg)' : 'rotate(0deg)',
|
|
transition: 'transform 0.3s ease',
|
|
color: '#94A3B8'
|
|
}}>
|
|
<ChevronRight size={16} strokeWidth={2.5} />
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</>
|
|
);
|
|
}}
|
|
</NavLink>
|
|
|
|
<AnimatePresence>
|
|
{hasSubItems && isParentActive && !isSidebarCollapsed && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
|
style={{ overflow: 'hidden' }}
|
|
>
|
|
<div style={{
|
|
borderLeft: '1px solid rgba(255,255,255,0.1)',
|
|
marginLeft: '28px',
|
|
paddingTop: '4px',
|
|
paddingBottom: '8px'
|
|
}}>
|
|
{item.subItems?.map(sub => renderNavItem(sub, true))}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<style>{`
|
|
.nav-item-link:not(.active):hover {
|
|
background: rgba(255,255,255,0.03) !important;
|
|
color: #fff !important;
|
|
transform: translateX(6px);
|
|
}
|
|
`}</style>
|
|
<motion.aside
|
|
initial={{ width: 280 }}
|
|
animate={{ width: isSidebarCollapsed ? 80 : 280 }}
|
|
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
|
style={{
|
|
background: '#040B16', // Deep dark aesthetic
|
|
borderRight: '1px solid rgba(255,255,255,0.05)',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
height: '100vh',
|
|
zIndex: 1100,
|
|
position: 'relative',
|
|
fontFamily: "'Inter', sans-serif"
|
|
}}
|
|
>
|
|
{/* Brand Header */}
|
|
<div style={{
|
|
padding: isSidebarCollapsed ? '24px 16px' : '24px',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: isSidebarCollapsed ? 'center' : 'space-between',
|
|
borderBottom: '1px solid rgba(255,255,255,0.05)',
|
|
flexShrink: 0,
|
|
}}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
|
<div
|
|
onClick={() => { if (isSidebarCollapsed) setIsSidebarCollapsed(false); }}
|
|
title={isSidebarCollapsed ? "Expand Menu" : undefined}
|
|
style={{
|
|
width: '36px',
|
|
height: '36px',
|
|
background: 'linear-gradient(135deg, #06B6D4, #3B82F6)',
|
|
borderRadius: '10px',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
boxShadow: '0 0 20px rgba(6,182,212,0.4)',
|
|
flexShrink: 0,
|
|
cursor: isSidebarCollapsed ? 'pointer' : 'default'
|
|
}}
|
|
>
|
|
<Zap size={20} color="#FFFFFF" strokeWidth={2.5} />
|
|
</div>
|
|
<AnimatePresence>
|
|
{!isSidebarCollapsed && (
|
|
<motion.div initial={{ opacity: 0, width: 0 }} animate={{ opacity: 1, width: 'auto' }} exit={{ opacity: 0, width: 0 }} style={{ overflow: 'hidden', whiteSpace: 'nowrap' }}>
|
|
<h2 style={{ fontSize: '1.25rem', fontWeight: 900, color: '#fff', margin: 0, letterSpacing: '-0.5px', lineHeight: 1.2 }}>CureSelect</h2>
|
|
<span style={{ fontSize: '0.65rem', fontWeight: 700, color: '#06B6D4', textTransform: 'uppercase', letterSpacing: '2px' }}>Platform</span>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
|
|
{/* Minimize/Collapse button near CureSelect title */}
|
|
{!isSidebarCollapsed && (
|
|
<button
|
|
onClick={() => setIsSidebarCollapsed(true)}
|
|
title="Collapse Menu"
|
|
style={{
|
|
background: 'rgba(255,255,255,0.02)',
|
|
border: '1px solid rgba(255,255,255,0.06)',
|
|
borderRadius: '8px',
|
|
padding: '6px',
|
|
color: '#94A3B8',
|
|
cursor: 'pointer',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
transition: 'all 0.2s'
|
|
}}
|
|
onMouseEnter={e => e.currentTarget.style.background = 'rgba(255,255,255,0.08)'}
|
|
onMouseLeave={e => e.currentTarget.style.background = 'rgba(255,255,255,0.02)'}
|
|
>
|
|
<ChevronRight size={14} style={{ transform: 'rotate(180deg)' }} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Navigation Area */}
|
|
<nav style={{ flex: 1, padding: '16px 0', overflowY: 'auto', minHeight: 0 }} className="no-scrollbar">
|
|
<AnimatePresence>
|
|
{!isSidebarCollapsed && (
|
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} style={{ padding: '0 24px', marginBottom: '8px', fontSize: '0.65rem', fontWeight: 700, color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '1px' }}>
|
|
Main Menu
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
{filteredNavItems.map(item => renderNavItem(item))}
|
|
</nav>
|
|
|
|
{/* User Footer Profile */}
|
|
<div style={{ padding: '16px', borderTop: '1px solid rgba(255,255,255,0.05)', display: 'flex', flexDirection: 'column', gap: '12px', flexShrink: 0 }}>
|
|
|
|
<AnimatePresence>
|
|
{!isSidebarCollapsed && (
|
|
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} exit={{ opacity: 0, height: 0 }}>
|
|
{/* Perspective Switcher */}
|
|
{(user.roles?.includes('cureselect_admin') || user.roles?.includes('admin') || user.roles?.includes('CURESELECT_ADMIN') || user.roles?.includes('ADMIN')) && (
|
|
<div style={{ marginBottom: '12px' }}>
|
|
<PerspectiveSwitcher
|
|
currentRole={currentRole}
|
|
onSwitch={handleRoleSwitch}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Premium User Card */}
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px',
|
|
padding: '12px', background: 'rgba(6, 182, 212, 0.05)', border: '1px solid rgba(6, 182, 212, 0.1)',
|
|
borderRadius: '12px', transition: 'all 0.3s ease', cursor: 'pointer'
|
|
}}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', minWidth: 0, overflow: 'hidden' }}>
|
|
<div style={{
|
|
width: '32px', height: '32px', borderRadius: '10px', background: '#040B16',
|
|
border: '1px solid #06B6D4', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
fontSize: '0.75rem', fontWeight: 800, color: '#06B6D4', flexShrink: 0
|
|
}}>{initials}</div>
|
|
<div style={{ minWidth: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
|
<div style={{ fontSize: '0.8rem', fontWeight: 700, color: '#fff', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{displayName}</div>
|
|
<div style={{ fontSize: '0.6rem', fontWeight: 600, color: '#06B6D4', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{currentRole.replace(/_/g, ' ')}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); handleLogout(); }}
|
|
style={{ background: 'transparent', border: 'none', color: '#94A3B8', cursor: 'pointer', padding: '4px', borderRadius: '6px', transition: 'all 0.2s' }}
|
|
onMouseEnter={(e) => { e.currentTarget.style.color = '#EF4444'; e.currentTarget.style.background = 'rgba(239, 68, 68, 0.1)'; }}
|
|
onMouseLeave={(e) => { e.currentTarget.style.color = '#94A3B8'; e.currentTarget.style.background = 'transparent'; }}
|
|
title="Sign Out"
|
|
>
|
|
<LogOut size={16} />
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
</motion.aside>
|
|
</>
|
|
);
|
|
};
|