Files
carmanagement/apps/dashboard/src/components/layout/Sidebar.tsx
T
2026-06-10 00:40:19 -04:00

445 lines
16 KiB
TypeScript

'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import type { ReactNode } from 'react'
import { useEffect, useLayoutEffect, useState } from 'react'
import {
LayoutDashboard,
Car,
Calendar,
Globe,
Users,
Tag,
UserPlus,
BarChart2,
CreditCard,
Bell,
Settings,
LogOut,
FileText,
ChevronLeft,
ChevronRight,
Star,
AlertTriangle,
} from 'lucide-react'
import { DashboardLanguageSwitcher, DashboardThemeSwitcher, useDashboardI18n } from '@/components/I18nProvider'
import { marketplaceUrl } from '@/lib/urls'
import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
import { toDashboardAppPath } from '@/lib/dashboardPaths'
import { SHARED_LANGUAGE_KEY, readCurrentUserScopedPreference } from '@/lib/preferences'
interface BrandSettings {
displayName: string
logoUrl: string | null
}
interface CompanySummary {
name: string
brand?: {
displayName?: string | null
logoUrl?: string | null
} | null
}
interface SidebarUser {
displayName: string
subtitle: string
initials: string
}
interface EmployeeProfile {
email: string
firstName: string
lastName: string
role: string
preferredLanguage?: string
}
interface GeneratedMenuItem {
id: string
systemKey: string | null
label: string
itemType: 'INTERNAL_PAGE' | 'EXTERNAL_LINK' | 'PARENT_MENU' | 'SECTION_LABEL' | 'DIVIDER'
routeOrUrl: string | null
icon: string | null
parentId: string | null
openInNewTab: boolean
displayOrder: number
children: GeneratedMenuItem[]
}
function computeInitials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean)
if (parts.length === 0) return 'U'
if (parts.length === 1) return parts[0].slice(0, 1).toUpperCase()
return `${parts[0].slice(0, 1)}${parts[1].slice(0, 1)}`.toUpperCase()
}
function toSidebarUser(profile: Partial<EmployeeProfile>, fallbackName: string, fallbackSubtitle: string): SidebarUser {
const fullName = `${profile.firstName ?? ''} ${profile.lastName ?? ''}`.trim()
const email = profile.email ?? ''
const role = profile.role ?? ''
const displayName = fullName || (email ? email.split('@')[0] : fallbackName)
const subtitle = email || role || fallbackSubtitle
const initials = computeInitials(fullName || email || fallbackName)
return { displayName, subtitle, initials }
}
const NAV_ITEMS = [
{ href: '/', key: 'dashboard', icon: LayoutDashboard, exact: true, minRole: 'AGENT' },
{ href: '/fleet', key: 'fleet', icon: Car, minRole: 'AGENT' },
{ href: '/reservations', key: 'reservations', icon: Calendar, minRole: 'AGENT' },
{ href: '/online-reservations', key: 'onlineReservations', icon: Globe, minRole: 'AGENT' },
{ href: '/customers', key: 'customers', icon: Users, minRole: 'AGENT' },
{ href: '/offers', key: 'offers', icon: Tag, minRole: 'MANAGER' },
{ href: '/team', key: 'team', icon: UserPlus, minRole: 'AGENT' },
{ href: '/reports', key: 'reports', icon: BarChart2, minRole: 'MANAGER' },
{ href: '/subscription', key: 'subscription', icon: CreditCard, minRole: 'OWNER' },
{ href: '/billing', key: 'billing', icon: CreditCard, minRole: 'MANAGER' },
{ href: '/contracts', key: 'contracts', icon: FileText, minRole: 'AGENT' },
{ href: '/reviews', key: 'reviews', icon: Star, minRole: 'AGENT' },
{ href: '/complaints', key: 'complaints', icon: AlertTriangle, minRole: 'AGENT' },
{ href: '/notifications', key: 'notifications', icon: Bell, minRole: 'AGENT' },
{ href: '/settings', key: 'settings', icon: Settings, minRole: 'OWNER' },
] as const
const ICON_MAP = {
LayoutDashboard,
Car,
Calendar,
Globe,
Users,
Tag,
UserPlus,
BarChart2,
CreditCard,
Bell,
Settings,
FileText,
Star,
AlertTriangle,
} as const
const SIDEBAR_BRAND_KEY = 'dashboard_brand'
const ROLE_RANK: Record<string, number> = { OWNER: 3, MANAGER: 2, AGENT: 1 }
function hasMinRole(employeeRole: string, minRole: string): boolean {
return (ROLE_RANK[employeeRole] ?? 0) >= (ROLE_RANK[minRole] ?? 0)
}
function notifyParent(message: Record<string, unknown>) {
if (typeof window === 'undefined' || window.parent === window) return
window.parent.postMessage(message, '*')
}
export default function Sidebar() {
const { dict, language, setLanguage, theme } = useDashboardI18n()
const isRtl = language === 'ar'
const pathname = usePathname()
const appPath = toDashboardAppPath(pathname)
const [brand, setBrand] = useState<BrandSettings | null>(null)
const [user, setUser] = useState<SidebarUser>({
displayName: dict.workspaceUser,
subtitle: dict.localAuth,
initials: 'W',
})
const [role, setRole] = useState<string>('AGENT')
const [menuItems, setMenuItems] = useState<GeneratedMenuItem[] | null>(null)
const [open, setOpen] = useState(false)
const [mounted, setMounted] = useState(false)
useEffect(() => { setMounted(true) }, [])
useLayoutEffect(() => {
try {
const cached = window.localStorage.getItem(SIDEBAR_BRAND_KEY)
if (!cached) return
const parsed = JSON.parse(cached) as Partial<BrandSettings>
if (typeof parsed.displayName === 'string' && parsed.displayName.trim()) {
setBrand({
displayName: parsed.displayName,
logoUrl: typeof parsed.logoUrl === 'string' ? parsed.logoUrl : null,
})
}
} catch {}
}, [])
useEffect(() => {
let cancelled = false
async function loadBrand() {
try {
const brandData = await apiFetch<BrandSettings | null>('/companies/me/brand')
if (cancelled) return
if (brandData?.displayName?.trim()) {
setBrand(brandData)
window.localStorage.setItem(SIDEBAR_BRAND_KEY, JSON.stringify(brandData))
return
}
} catch {}
try {
const company = await apiFetch<CompanySummary>('/companies/me')
if (cancelled) return
const resolvedBrand: BrandSettings = {
displayName: company.brand?.displayName?.trim() || company.name,
logoUrl: company.brand?.logoUrl ?? null,
}
setBrand(resolvedBrand)
window.localStorage.setItem(SIDEBAR_BRAND_KEY, JSON.stringify(resolvedBrand))
} catch {}
}
loadBrand()
return () => { cancelled = true }
}, [])
useEffect(() => {
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)
if (cached) {
try {
const profile = JSON.parse(cached) as Partial<EmployeeProfile>
setUser(toSidebarUser(profile, dict.workspaceUser, dict.localAuth))
if (profile.role) setRole(profile.role)
} catch {}
}
let cancelled = false
Promise.all([
apiFetch<{ employee: EmployeeProfile }>('/auth/employee/me'),
apiFetch<{ items: GeneratedMenuItem[] }>('/auth/employee/menu').catch(() => null),
])
.then(([{ employee }, menu]) => {
if (cancelled) return
window.localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(employee))
setUser(toSidebarUser(employee, dict.workspaceUser, dict.localAuth))
if (employee.role) setRole(employee.role)
if (menu?.items) setMenuItems(menu.items)
if (employee.preferredLanguage === 'en' || employee.preferredLanguage === 'fr' || employee.preferredLanguage === 'ar') {
// Only apply the server-side preference when the employee has no local preference
// stored yet — avoids overriding a language the user manually switched to.
const existingPref = readCurrentUserScopedPreference(SHARED_LANGUAGE_KEY)
if (!existingPref) {
setLanguage(employee.preferredLanguage)
document.cookie = `rentaldrivego-language=${employee.preferredLanguage}; path=/; max-age=31536000; samesite=lax`
}
}
})
.catch(() => {
if (cancelled) return
if (!cached) setUser(toSidebarUser({}, dict.workspaceUser, dict.localAuth))
})
return () => { cancelled = true }
}, [dict.localAuth, dict.workspaceUser])
// Close sidebar when navigating on mobile
useEffect(() => { setOpen(false) }, [pathname])
const isActive = (item: typeof NAV_ITEMS[number]) => {
if ('exact' in item && item.exact) return appPath === item.href
return appPath.startsWith(item.href)
}
const isGeneratedItemActive = (item: GeneratedMenuItem) => {
if (!item.routeOrUrl || item.itemType !== 'INTERNAL_PAGE') return false
if (item.routeOrUrl === '/') return appPath === '/'
return appPath.startsWith(item.routeOrUrl)
}
const fallbackMenuItems = NAV_ITEMS
.filter((item) => !mounted || hasMinRole(role, item.minRole))
.map((item) => ({
id: item.href,
systemKey: item.key,
label: dict.nav[item.key] ?? item.key,
itemType: 'INTERNAL_PAGE' as const,
routeOrUrl: item.href,
icon: item.icon.name,
parentId: null,
openInNewTab: false,
displayOrder: 0,
children: [],
}))
const resolvedMenuItems = menuItems ?? fallbackMenuItems
function renderGeneratedMenu(items: GeneratedMenuItem[], depth = 0): ReactNode {
return items.map((item) => {
const label = item.systemKey ? (dict.nav[item.systemKey] ?? item.label) : item.label
const paddingClass = depth > 0 ? 'pl-6' : ''
const Icon = item.icon && item.icon in ICON_MAP ? ICON_MAP[item.icon as keyof typeof ICON_MAP] : null
if (item.itemType === 'DIVIDER') {
return <div key={item.id} className="my-3 border-t border-blue-900/50" />
}
if (item.itemType === 'SECTION_LABEL') {
return (
<p key={item.id} className="px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">
{label}
</p>
)
}
if (item.itemType === 'PARENT_MENU') {
return (
<div key={item.id} className="space-y-1">
<div className={`flex items-center gap-3 rounded-2xl px-3 py-2 text-sm font-medium text-slate-300 ${paddingClass}`}>
{Icon ? <Icon className="h-4 w-4 flex-shrink-0" /> : null}
{label}
</div>
<div className="space-y-0.5">{renderGeneratedMenu(item.children, depth + 1)}</div>
</div>
)
}
const active = mounted && isGeneratedItemActive(item)
const className = [
'flex items-center gap-3 rounded-2xl px-3 py-2.5 text-sm font-medium transition-colors',
paddingClass,
active ? 'bg-orange-500 text-white' : 'text-slate-400 hover:bg-blue-900/40 hover:text-white',
].join(' ')
if (item.itemType === 'EXTERNAL_LINK' && item.routeOrUrl) {
return (
<a
key={item.id}
href={item.routeOrUrl}
target={item.openInNewTab ? '_blank' : '_self'}
rel={item.openInNewTab ? 'noreferrer noopener' : undefined}
className={className}
>
{Icon ? <Icon className="h-4 w-4 flex-shrink-0" /> : null}
{label}
</a>
)
}
return (
<Link key={item.id} href={item.routeOrUrl || '/'} className={className}>
{Icon ? <Icon className="h-4 w-4 flex-shrink-0" /> : null}
{label}
</Link>
)
})
}
function signOut() {
localStorage.removeItem(EMPLOYEE_PROFILE_KEY)
void fetch('/dashboard/api/v1/auth/employee/logout', { method: 'POST', credentials: 'include' })
window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed'))
notifyParent({ type: 'rentaldrivego:employee-logout' })
window.location.href = marketplaceUrl
}
return (
<>
{/* Mobile backdrop */}
{open && (
<div
className="fixed inset-0 z-30 bg-blue-950/50 lg:hidden"
onClick={() => setOpen(false)}
/>
)}
{/* Tab to open sidebar — only visible when sidebar is closed on mobile */}
{!open && (
<button
onClick={() => setOpen(true)}
aria-label="Open sidebar"
className={[
'fixed top-1/2 z-50 flex h-14 w-6 -translate-y-1/2 items-center justify-center bg-[#0a1128] text-white shadow-lg lg:hidden',
isRtl ? 'right-0 rounded-l-lg' : 'left-0 rounded-r-lg',
].join(' ')}
>
{isRtl ? <ChevronLeft className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</button>
)}
<aside
className={[
'fixed inset-y-0 z-40 flex w-64 flex-col border-r border-blue-900/50 bg-[#0a1128]/94 backdrop-blur-xl transition-transform duration-300',
isRtl ? 'right-0' : 'left-0',
'lg:translate-x-0',
open ? 'translate-x-0' : (isRtl ? 'translate-x-full' : '-translate-x-full'),
].join(' ')}
>
<a href={marketplaceUrl} target="_top" className="flex items-center gap-3 border-b border-blue-900/50 px-6 py-5">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-lg bg-orange-400">
{brand?.logoUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={brand.logoUrl} alt={brand.displayName} className="h-8 w-8 object-cover" />
) : (
<Car className="h-4 w-4 text-blue-950" />
)}
</div>
<span className={`truncate text-sm font-bold tracking-wide ${theme === 'light' ? 'text-blue-600' : 'text-white'}`}>
{brand?.displayName ?? 'RentalDriveGo'}
</span>
</a>
<nav className="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
{menuItems ? renderGeneratedMenu(resolvedMenuItems) : NAV_ITEMS.filter((item) => !mounted || hasMinRole(role, item.minRole)).map((item) => {
const Icon = item.icon
const active = mounted && isActive(item)
return (
<Link
key={item.href}
href={item.href}
className={[
'flex items-center gap-3 rounded-2xl px-3 py-2.5 text-sm font-medium transition-colors',
active ? 'bg-orange-500 text-white' : 'text-slate-400 hover:bg-blue-900/40 hover:text-white',
].join(' ')}
>
<Icon className="h-4 w-4 flex-shrink-0" />
{dict.nav[item.key]}
</Link>
)
})}
</nav>
<div className="border-t border-blue-900/50 px-3 py-4">
<div className="flex items-center gap-3 rounded-2xl px-3 py-2">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-orange-500 text-xs font-semibold text-white">
{user.initials}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-white">{user.displayName}</p>
<p className="truncate text-xs text-slate-400">{user.subtitle}</p>
</div>
</div>
<button
onClick={signOut}
className="mt-2 flex w-full items-center gap-3 rounded-2xl px-3 py-2 text-sm text-slate-400 transition-colors hover:bg-blue-900/40 hover:text-white"
>
<LogOut className="h-4 w-4" />
{dict.signOut}
</button>
<div className="mt-3 flex items-center gap-2 border-t border-blue-900/50 pt-3">
<DashboardLanguageSwitcher />
<DashboardThemeSwitcher />
</div>
</div>
{/* Arrow tab to collapse sidebar (mobile only, sticks to outer edge) */}
<button
onClick={() => setOpen(false)}
aria-label="Close sidebar"
className={[
'absolute top-1/2 z-10 flex h-14 w-5 -translate-y-1/2 items-center justify-center bg-[#0a1128] text-white shadow-lg lg:hidden',
isRtl ? '-left-5 rounded-l-lg' : '-right-5 rounded-r-lg',
].join(' ')}
>
{isRtl ? <ChevronRight className="h-3.5 w-3.5" /> : <ChevronLeft className="h-3.5 w-3.5" />}
</button>
</aside>
</>
)
}