add editable menu items for companies

This commit is contained in:
root
2026-06-03 20:06:41 -04:00
parent e6110d7faa
commit e0b2239f66
13 changed files with 2881 additions and 5 deletions
@@ -1,7 +1,8 @@
'use client'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { usePathname } from 'next/navigation'
import type { ReactNode } from 'react'
import { useEffect, useLayoutEffect, useState } from 'react'
import {
LayoutDashboard,
@@ -54,6 +55,19 @@ interface EmployeeProfile {
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'
@@ -89,6 +103,23 @@ const NAV_ITEMS = [
{ 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 }
@@ -107,7 +138,6 @@ export default function Sidebar() {
const isRtl = language === 'ar'
const pathname = usePathname()
const appPath = toDashboardAppPath(pathname)
const router = useRouter()
const [brand, setBrand] = useState<BrandSettings | null>(null)
const [user, setUser] = useState<SidebarUser>({
displayName: dict.workspaceUser,
@@ -115,6 +145,7 @@ export default function Sidebar() {
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)
@@ -192,12 +223,16 @@ export default function Sidebar() {
let cancelled = false
apiFetch<{ employee: EmployeeProfile }>('/auth/employee/me')
.then(({ employee }) => {
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.
@@ -224,6 +259,90 @@ export default function Sidebar() {
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_TOKEN_KEY)
localStorage.removeItem(EMPLOYEE_PROFILE_KEY)
@@ -280,7 +399,7 @@ export default function Sidebar() {
</a>
<nav className="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
{NAV_ITEMS.filter((item) => !mounted || hasMinRole(role, item.minRole)).map((item) => {
{menuItems ? renderGeneratedMenu(resolvedMenuItems) : NAV_ITEMS.filter((item) => !mounted || hasMinRole(role, item.minRole)).map((item) => {
const Icon = item.icon
const active = mounted && isActive(item)
return (