Dealer_Onboard_Frontend/src/components/layout/Sidebar.tsx

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