375 lines
15 KiB
TypeScript
375 lines
15 KiB
TypeScript
import {
|
|
LayoutDashboard,
|
|
FileText,
|
|
LogOut,
|
|
Users,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
Search,
|
|
UserMinus,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
FolderOpen,
|
|
Settings,
|
|
RefreshCcw,
|
|
MapPin,
|
|
ClipboardList
|
|
} from 'lucide-react';
|
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
|
import ReactDOM from 'react-dom';
|
|
import { useNavigate, useLocation } from 'react-router-dom';
|
|
import { useSelector } from 'react-redux';
|
|
import { RootState } from '../../store';
|
|
import { Input } from '../ui/input';
|
|
import { Button } from '../ui/button';
|
|
|
|
interface SidebarProps {
|
|
onLogout: () => void;
|
|
}
|
|
|
|
interface FlyoutState {
|
|
submenuKey: string;
|
|
top: number;
|
|
left: number;
|
|
}
|
|
|
|
export function Sidebar({ onLogout }: SidebarProps) {
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const activeView = location.pathname.substring(1) || 'dashboard';
|
|
const { user: currentUser } = useSelector((state: RootState) => state.auth);
|
|
const [collapsed, setCollapsed] = useState(false);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [offboardingExpanded, setOffboardingExpanded] = useState(false);
|
|
const [allRequestsExpanded, setAllRequestsExpanded] = useState(false);
|
|
const [flyout, setFlyout] = useState<FlyoutState | null>(null);
|
|
const hoverTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
const currentRole = currentUser?.role || currentUser?.roleCode || '';
|
|
const normalizedRole = String(currentRole).trim().toLowerCase();
|
|
const hasRole = (roles: string[]) => roles.map((r) => r.toLowerCase()).includes(normalizedRole);
|
|
|
|
const resignationRoles = ['DD Admin', 'ASM', 'RBM', 'DD Lead', 'ZBH', 'NBH', 'Legal', 'Legal Admin', 'Super Admin'];
|
|
const terminationRoles = ['ASM', 'RBM', 'ZBH', 'DD Lead', 'DD Head', 'NBH', 'Legal Admin', 'Legal', 'DD Admin', 'CCO', 'CEO', 'Super Admin'];
|
|
const fnfRoles = ['DD Admin', 'DD Lead', 'NBH', 'Finance', 'Finance Admin', 'Super Admin'];
|
|
const canSeeResignation = hasRole(resignationRoles);
|
|
const canSeeTermination = hasRole(terminationRoles);
|
|
const canSeeFnF = hasRole(fnfRoles);
|
|
const offboardingSubmenu = [
|
|
canSeeResignation ? { id: 'resignation', label: 'Resignation' } : null,
|
|
canSeeTermination ? { id: 'termination', label: 'Termination' } : null,
|
|
canSeeFnF ? { id: 'fnf', label: 'F&F' } : null
|
|
].filter(Boolean) as { id: string; label: string }[];
|
|
|
|
const menuItems = hasRole(['Finance', 'Finance Admin']) ? [
|
|
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
|
{ id: 'finance-onboarding', label: 'Onboarding', icon: FileText },
|
|
{ id: 'finance-fnf', label: 'F&F', icon: UserMinus },
|
|
] : hasRole(['Dealer']) ? [
|
|
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
|
{ id: 'dealer-resignation', label: 'My Resignations', icon: UserMinus },
|
|
{ id: 'dealer-constitutional', label: 'Constitutional Change', icon: RefreshCcw },
|
|
{ id: 'dealer-relocation', label: 'Relocation Requests', icon: MapPin },
|
|
] : hasRole(['FDD']) ? [
|
|
{ id: 'fdd-dashboard', label: 'FDD Dashboard', icon: LayoutDashboard },
|
|
] : [
|
|
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
|
{ id: 'applications', label: 'Dealership Requests', icon: FileText },
|
|
...(offboardingSubmenu.length > 0 ? [{
|
|
id: 'offboarding',
|
|
label: 'Offboarding',
|
|
icon: UserMinus,
|
|
hasSubmenu: true,
|
|
submenuKey: 'offboarding',
|
|
submenu: offboardingSubmenu
|
|
}] : []),
|
|
{ id: 'constitutional-change', label: 'Constitutional Change', icon: RefreshCcw },
|
|
{ id: 'relocation-requests', label: 'Relocation Requests', icon: MapPin },
|
|
];
|
|
|
|
if (hasRole(['DD Lead', 'DD Admin', 'Super Admin'])) {
|
|
menuItems.splice(1, 0, {
|
|
id: 'all-requests',
|
|
label: 'All Requests',
|
|
icon: FolderOpen,
|
|
hasSubmenu: true,
|
|
submenuKey: 'allRequests',
|
|
submenu: [
|
|
{ id: 'opportunity-requests', label: 'Opportunity Requests' },
|
|
{ id: 'non-opportunities', label: 'Non-opportunities' }
|
|
]
|
|
});
|
|
}
|
|
|
|
if (hasRole(['Super Admin', 'DD Admin', 'DD Lead'])) {
|
|
menuItems.push({ id: 'master', label: 'Master', icon: Settings });
|
|
menuItems.push({ id: 'sla-configurations', label: 'SLA Matrix', icon: RefreshCcw });
|
|
}
|
|
|
|
if (hasRole(['Super Admin'])) {
|
|
menuItems.push({ id: 'users', label: 'User Management', icon: Users });
|
|
menuItems.push({ id: 'questionnaires', label: 'Questionnaire Templates', icon: ClipboardList });
|
|
}
|
|
|
|
const handleSearch = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (searchQuery.trim()) navigate('/applications');
|
|
};
|
|
|
|
const openFlyout = useCallback((submenuKey: string, triggerEl: HTMLElement) => {
|
|
if (hoverTimeout.current) clearTimeout(hoverTimeout.current);
|
|
const rect = triggerEl.getBoundingClientRect();
|
|
setFlyout({ submenuKey, top: rect.top, left: rect.right + 8 });
|
|
}, []);
|
|
|
|
const closeFlyout = useCallback((immediate = false) => {
|
|
if (immediate) {
|
|
if (hoverTimeout.current) clearTimeout(hoverTimeout.current);
|
|
setFlyout(null);
|
|
} else {
|
|
hoverTimeout.current = setTimeout(() => setFlyout(null), 150);
|
|
}
|
|
}, []);
|
|
|
|
const keepFlyoutOpen = useCallback(() => {
|
|
if (hoverTimeout.current) clearTimeout(hoverTimeout.current);
|
|
}, []);
|
|
|
|
// Close flyout when sidebar expands
|
|
useEffect(() => {
|
|
if (!collapsed) setFlyout(null);
|
|
}, [collapsed]);
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
className={`bg-black text-white h-screen flex flex-col transition-all duration-300 overflow-hidden relative flex-shrink-0 ${
|
|
collapsed ? 'w-20' : 'w-64'
|
|
}`}
|
|
>
|
|
{/* Header with Logo */}
|
|
<div className="border-b border-white/10">
|
|
{collapsed ? (
|
|
/* Collapsed header: logo + toggle stacked, centered */
|
|
<div className="flex flex-col items-center py-3 gap-3">
|
|
<div className="w-10 h-10 rounded-lg bg-white flex items-center justify-center p-1.5 shadow-md">
|
|
<img
|
|
src="/assets/images/Re_Logo.png"
|
|
alt="RE"
|
|
className="w-full h-full object-contain"
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={() => setCollapsed(false)}
|
|
className="p-1.5 hover:bg-white/10 rounded-lg transition-colors text-slate-400 hover:text-white"
|
|
title="Expand sidebar"
|
|
>
|
|
<ChevronRight className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
/* Expanded header: logo + subtitle + collapse toggle */
|
|
<div className="flex items-center justify-between px-4 py-4">
|
|
<div className="flex flex-col min-w-0">
|
|
<img src="/assets/images/Re_Logo.png" alt="Royal Enfield" className="h-8 w-auto" />
|
|
<span className="text-[10px] uppercase tracking-[0.2em] font-bold text-slate-400 mt-1 whitespace-nowrap">
|
|
Dealer Onboarding
|
|
</span>
|
|
</div>
|
|
<button
|
|
onClick={() => setCollapsed(true)}
|
|
className="p-1.5 hover:bg-white/10 rounded-lg transition-colors text-slate-400 hover:text-white flex-shrink-0 ml-2"
|
|
title="Collapse sidebar"
|
|
>
|
|
<ChevronLeft className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Search Bar (expanded only) */}
|
|
{!collapsed && (
|
|
<div className="p-4 border-b border-white/10">
|
|
<form onSubmit={handleSearch} className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
|
<Input
|
|
type="text"
|
|
placeholder="Search applications..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="w-full pl-10 bg-white/5 border-white/10 text-white placeholder:text-slate-500"
|
|
/>
|
|
</form>
|
|
</div>
|
|
)}
|
|
|
|
{/* Menu Items */}
|
|
<nav className="flex-1 p-3 space-y-1 overflow-y-auto custom-scrollbar">
|
|
{menuItems.map((item) => {
|
|
const Icon = item.icon;
|
|
const isActive = activeView === item.id;
|
|
const hasSubmenu = !!item.hasSubmenu;
|
|
const isSubmenuActive = hasSubmenu && item.submenu?.some(sub => activeView === sub.id);
|
|
|
|
const submenuKey = (item as any).submenuKey as string | undefined;
|
|
const isExpanded = submenuKey === 'offboarding' ? offboardingExpanded :
|
|
submenuKey === 'allRequests' ? allRequestsExpanded : false;
|
|
|
|
return (
|
|
<div key={item.id}>
|
|
<button
|
|
onMouseEnter={(e) => {
|
|
if (collapsed && hasSubmenu && submenuKey) {
|
|
openFlyout(submenuKey, e.currentTarget);
|
|
}
|
|
}}
|
|
onMouseLeave={() => {
|
|
if (collapsed && hasSubmenu) closeFlyout();
|
|
}}
|
|
onClick={() => {
|
|
if (hasSubmenu) {
|
|
if (collapsed) {
|
|
// Expand sidebar and open submenu
|
|
setCollapsed(false);
|
|
if (submenuKey === 'offboarding') setOffboardingExpanded(true);
|
|
else if (submenuKey === 'allRequests') setAllRequestsExpanded(true);
|
|
} else {
|
|
if (submenuKey === 'offboarding') setOffboardingExpanded(!offboardingExpanded);
|
|
else if (submenuKey === 'allRequests') setAllRequestsExpanded(!allRequestsExpanded);
|
|
}
|
|
} else {
|
|
navigate(`/${item.id}`);
|
|
}
|
|
}}
|
|
className={`w-full flex items-center gap-3 px-3 py-3 rounded-lg transition-colors ${
|
|
collapsed ? 'justify-center' : ''
|
|
} ${
|
|
isActive || isSubmenuActive
|
|
? 'bg-re-red text-white shadow-lg shadow-re-red/20'
|
|
: 'text-slate-400 hover:bg-white/5 hover:text-white'
|
|
}`}
|
|
title={collapsed ? item.label : undefined}
|
|
>
|
|
<Icon className="w-5 h-5 flex-shrink-0" />
|
|
{!collapsed && (
|
|
<>
|
|
<span className="flex-1 text-left text-sm">{item.label}</span>
|
|
{hasSubmenu && (
|
|
isExpanded
|
|
? <ChevronUp className="w-4 h-4 flex-shrink-0" />
|
|
: <ChevronDown className="w-4 h-4 flex-shrink-0" />
|
|
)}
|
|
</>
|
|
)}
|
|
</button>
|
|
|
|
{/* Expanded inline submenu (non-collapsed) */}
|
|
{hasSubmenu && isExpanded && !collapsed && (
|
|
<div className="ml-3 mt-1 space-y-1 border-l border-white/10 pl-3">
|
|
{item.submenu?.map((subItem) => {
|
|
const isSubActive = activeView === subItem.id;
|
|
return (
|
|
<button
|
|
key={subItem.id}
|
|
onClick={() => navigate(`/${subItem.id}`)}
|
|
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg transition-colors text-sm ${
|
|
isSubActive
|
|
? 'bg-re-red/20 text-re-red font-semibold'
|
|
: 'text-slate-500 hover:bg-white/5 hover:text-white'
|
|
}`}
|
|
>
|
|
<span className="w-1.5 h-1.5 rounded-full bg-current flex-shrink-0" />
|
|
<span>{subItem.label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</nav>
|
|
|
|
{/* User Profile & Logout */}
|
|
<div className="p-4 border-t border-white/10 space-y-2">
|
|
{!collapsed && currentUser && (
|
|
<div className="px-4 py-2 bg-white/5 rounded-lg mb-2">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 bg-re-red rounded-full flex items-center justify-center font-bold flex-shrink-0">
|
|
<span>{currentUser.name.charAt(0)}</span>
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="truncate text-sm font-semibold">{currentUser.name}</p>
|
|
<p className="text-slate-500 truncate text-[11px] uppercase tracking-wider">{currentUser.role}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{collapsed && currentUser && (
|
|
<div className="flex justify-center mb-2">
|
|
<div
|
|
className="w-9 h-9 bg-re-red rounded-full flex items-center justify-center font-bold text-sm"
|
|
title={currentUser.name}
|
|
>
|
|
<span>{currentUser.name.charAt(0)}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<Button
|
|
onClick={onLogout}
|
|
variant="ghost"
|
|
className={`w-full ${collapsed ? 'px-2 justify-center' : 'justify-start'} text-slate-400 hover:bg-white/5 hover:text-white`}
|
|
title={collapsed ? 'Logout' : undefined}
|
|
>
|
|
<LogOut className="w-5 h-5 flex-shrink-0" />
|
|
{!collapsed && <span className="ml-3">Logout</span>}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Flyout submenu portal — rendered outside sidebar to bypass overflow:hidden */}
|
|
{flyout && collapsed && (() => {
|
|
const flyoutItem = menuItems.find(
|
|
(m) => (m as any).submenuKey === flyout.submenuKey
|
|
);
|
|
if (!flyoutItem || !flyoutItem.submenu) return null;
|
|
return ReactDOM.createPortal(
|
|
<div
|
|
style={{ top: flyout.top, left: flyout.left }}
|
|
className="fixed z-[9999] min-w-[200px] bg-gray-900 border border-white/10 rounded-xl shadow-2xl py-2"
|
|
onMouseEnter={keepFlyoutOpen}
|
|
onMouseLeave={() => closeFlyout()}
|
|
>
|
|
<div className="px-4 py-1.5 text-xs font-bold uppercase tracking-widest text-slate-400 border-b border-white/10 mb-1">
|
|
{flyoutItem.label}
|
|
</div>
|
|
{flyoutItem.submenu.map((subItem) => {
|
|
const isSubActive = activeView === subItem.id;
|
|
return (
|
|
<button
|
|
key={subItem.id}
|
|
onClick={() => {
|
|
navigate(`/${subItem.id}`);
|
|
closeFlyout(true);
|
|
}}
|
|
className={`w-full flex items-center gap-2.5 px-4 py-2.5 text-sm transition-colors ${
|
|
isSubActive
|
|
? 'bg-re-red/20 text-re-red font-semibold'
|
|
: 'text-slate-300 hover:bg-white/10 hover:text-white'
|
|
}`}
|
|
>
|
|
<span className="w-1.5 h-1.5 rounded-full bg-current flex-shrink-0" />
|
|
<span>{subItem.label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>,
|
|
document.body
|
|
);
|
|
})()}
|
|
</>
|
|
);
|
|
}
|