Files
TeleEms-Dashboard/src/components/Sidebar.tsx
2026-05-06 17:09:54 +05:30

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>
</>
);
};