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
View File
@@ -26,6 +26,7 @@ const navLinks = [
{ href: '/dashboard/renters', key: 'renters', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z' },
{ href: '/dashboard/audit-logs', key: 'auditLogs', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
{ href: '/dashboard/notifications', key: 'notifications', icon: 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9' },
{ href: '/dashboard/menu-management', key: 'menuManagement', icon: 'M4 6h16M4 12h16M4 18h10' },
{ href: '/dashboard/admin-users', key: 'adminUsers', icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z' },
{ href: '/dashboard/billing', key: 'billing', icon: 'M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z' },
{ href: '/dashboard/pricing', key: 'pricing', icon: 'M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z' },
@@ -0,0 +1,700 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { ADMIN_API_BASE } from '@/lib/api'
type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT'
type Plan = 'STARTER' | 'GROWTH' | 'PRO'
type MenuItemType = 'INTERNAL_PAGE' | 'EXTERNAL_LINK' | 'PARENT_MENU' | 'SECTION_LABEL' | 'DIVIDER'
type CompanyOption = {
id: string
name: string
subscription?: { plan?: string | null } | null
status: string
}
type MenuItem = {
id: string
systemKey: string | null
label: string
itemType: MenuItemType
routeOrUrl: string | null
icon: string | null
parentId: string | null
displayOrder: number
openInNewTab: boolean
isRequired: boolean
isActive: boolean
parent?: { id: string; label: string } | null
roleVisibilities: Array<{ role: EmployeeRole }>
subscriptionAssignments: Array<{ plan: Plan; displayOrder: number; isActive: boolean }>
companyAssignments: Array<{
companyId: string
displayOrder: number
isActive: boolean
company: { id: string; name: string }
}>
}
type AuditLog = {
id: string
action: string
resource: string
resourceId: string | null
createdAt: string
adminUser: { firstName: string; lastName: string; email: string } | null
}
type PreviewItem = {
id: string
label: string
systemKey: string | null
itemType: MenuItemType
routeOrUrl: string | null
displayOrder: number
visible: boolean
source: 'subscription' | 'company' | 'none'
reasons: string[]
}
type PreviewResponse = {
company: { id: string; name: string; status: string; subscription: { plan: Plan; status: string } | null }
role: EmployeeRole
subscriptionPlan: Plan | null
items: PreviewItem[]
}
type FormState = {
label: string
systemKey: string
itemType: MenuItemType
routeOrUrl: string
icon: string
parentId: string
displayOrder: string
openInNewTab: boolean
isRequired: boolean
isActive: boolean
roles: EmployeeRole[]
plans: Plan[]
companyIds: string[]
}
const ROLES: EmployeeRole[] = ['OWNER', 'MANAGER', 'AGENT']
const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO']
const ITEM_TYPES: MenuItemType[] = ['INTERNAL_PAGE', 'EXTERNAL_LINK', 'PARENT_MENU', 'SECTION_LABEL', 'DIVIDER']
const INPUT =
'mt-1 w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-blue-950 outline-none focus:border-orange-500 dark:border-blue-800 dark:bg-[#07101e] dark:text-white'
const LABEL = 'text-xs font-semibold uppercase tracking-[0.14em] text-stone-500 dark:text-slate-400'
function getToken() {
return typeof window === 'undefined' ? '' : localStorage.getItem('admin_token') ?? ''
}
async function api<T>(path: string, options?: RequestInit): Promise<T> {
const token = getToken()
const res = await fetch(`${ADMIN_API_BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
...(options?.headers ?? {}),
},
})
const json = await res.json()
if (!res.ok) throw new Error(json?.message ?? 'Request failed')
return (json?.data ?? json) as T
}
function emptyForm(): FormState {
return {
label: '',
systemKey: '',
itemType: 'INTERNAL_PAGE',
routeOrUrl: '',
icon: '',
parentId: '',
displayOrder: '0',
openInNewTab: false,
isRequired: false,
isActive: true,
roles: ['OWNER', 'MANAGER', 'AGENT'],
plans: ['STARTER', 'GROWTH', 'PRO'],
companyIds: [],
}
}
function formFromItem(item: MenuItem): FormState {
return {
label: item.label,
systemKey: item.systemKey ?? '',
itemType: item.itemType,
routeOrUrl: item.routeOrUrl ?? '',
icon: item.icon ?? '',
parentId: item.parentId ?? '',
displayOrder: String(item.displayOrder),
openInNewTab: item.openInNewTab,
isRequired: item.isRequired,
isActive: item.isActive,
roles: item.roleVisibilities.map((entry) => entry.role),
plans: item.subscriptionAssignments.map((entry) => entry.plan),
companyIds: item.companyAssignments.map((entry) => entry.companyId),
}
}
function chip(text: string, tone = 'default') {
const toneClass =
tone === 'success'
? 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-900/20 dark:text-emerald-300'
: tone === 'warn'
? 'border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900/60 dark:bg-orange-900/20 dark:text-orange-300'
: 'border-stone-200 bg-stone-50 text-stone-700 dark:border-blue-800 dark:bg-[#07101e] dark:text-slate-300'
return (
<span className={`inline-flex rounded-full border px-2.5 py-1 text-[11px] font-medium ${toneClass}`}>
{text}
</span>
)
}
export default function MenuManagementPage() {
const [items, setItems] = useState<MenuItem[]>([])
const [companies, setCompanies] = useState<CompanyOption[]>([])
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([])
const [preview, setPreview] = useState<PreviewResponse | null>(null)
const [previewCompanyId, setPreviewCompanyId] = useState('')
const [previewRole, setPreviewRole] = useState<EmployeeRole>('OWNER')
const [editingId, setEditingId] = useState<string | null>(null)
const [form, setForm] = useState<FormState>(emptyForm())
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [previewing, setPreviewing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const parentOptions = useMemo(
() => items.filter((item) => item.itemType === 'PARENT_MENU' && item.id !== editingId),
[items, editingId],
)
useEffect(() => {
async function load() {
try {
setLoading(true)
setError(null)
const [menuItems, companiesPage, auditPage] = await Promise.all([
api<MenuItem[]>('/admin/menu-items'),
api<{ data: CompanyOption[] }>('/admin/companies?page=1&pageSize=100'),
api<{ data: AuditLog[] }>('/admin/menu-audit-logs?page=1&pageSize=20'),
])
setItems(menuItems)
setCompanies(companiesPage.data)
setAuditLogs(auditPage.data)
if (!previewCompanyId && companiesPage.data[0]?.id) {
setPreviewCompanyId(companiesPage.data[0].id)
}
} catch (err: any) {
setError(err.message ?? 'Failed to load menu management data.')
} finally {
setLoading(false)
}
}
load()
}, [])
function updateForm<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((prev) => ({ ...prev, [key]: value }))
}
function toggleArrayValue<K extends 'roles' | 'plans' | 'companyIds'>(key: K, value: FormState[K][number]) {
setForm((prev) => {
const values = prev[key] as string[]
return {
...prev,
[key]: values.includes(value as string)
? values.filter((entry) => entry !== value)
: [...values, value as string],
}
})
}
async function refreshLists() {
const [menuItems, auditPage] = await Promise.all([
api<MenuItem[]>('/admin/menu-items'),
api<{ data: AuditLog[] }>('/admin/menu-audit-logs?page=1&pageSize=20'),
])
setItems(menuItems)
setAuditLogs(auditPage.data)
}
async function submitForm(event: React.FormEvent) {
event.preventDefault()
try {
setSaving(true)
setError(null)
setSuccess(null)
const payload = {
label: form.label,
systemKey: form.systemKey || null,
itemType: form.itemType,
routeOrUrl: form.routeOrUrl || null,
icon: form.icon || null,
parentId: form.parentId || null,
displayOrder: Number(form.displayOrder || 0),
openInNewTab: form.openInNewTab,
isRequired: form.isRequired,
isActive: form.isActive,
roles: form.roles,
subscriptionPlans: form.plans.map((plan) => ({
plan,
displayOrder: Number(form.displayOrder || 0),
isActive: true,
})),
companyAssignments: form.companyIds.map((companyId) => ({
companyId,
displayOrder: Number(form.displayOrder || 0),
isActive: true,
})),
}
if (editingId) {
await api(`/admin/menu-items/${editingId}`, {
method: 'PUT',
body: JSON.stringify(payload),
})
setSuccess('Menu item updated.')
} else {
await api('/admin/menu-items', {
method: 'POST',
body: JSON.stringify(payload),
})
setSuccess('Menu item created.')
}
setEditingId(null)
setForm(emptyForm())
await refreshLists()
} catch (err: any) {
setError(err.message ?? 'Failed to save menu item.')
} finally {
setSaving(false)
}
}
function startCreate() {
setEditingId(null)
setForm(emptyForm())
setSuccess(null)
setError(null)
}
function startEdit(item: MenuItem) {
setEditingId(item.id)
setForm(formFromItem(item))
setSuccess(null)
setError(null)
}
async function toggleStatus(item: MenuItem) {
try {
setError(null)
await api(`/admin/menu-items/${item.id}/status`, {
method: 'PATCH',
body: JSON.stringify({ isActive: !item.isActive }),
})
await refreshLists()
} catch (err: any) {
setError(err.message ?? 'Failed to update status.')
}
}
async function removeItem(item: MenuItem) {
if (!window.confirm(`Delete "${item.label}"?`)) return
try {
setError(null)
await api(`/admin/menu-items/${item.id}`, { method: 'DELETE' })
if (editingId === item.id) {
setEditingId(null)
setForm(emptyForm())
}
await refreshLists()
} catch (err: any) {
setError(err.message ?? 'Failed to delete menu item.')
}
}
async function runPreview() {
if (!previewCompanyId) return
try {
setPreviewing(true)
setError(null)
const result = await api<PreviewResponse>('/admin/menu-preview', {
method: 'POST',
body: JSON.stringify({ companyId: previewCompanyId, role: previewRole }),
})
setPreview(result)
} catch (err: any) {
setError(err.message ?? 'Failed to load menu preview.')
} finally {
setPreviewing(false)
}
}
if (loading) {
return (
<div className="px-8 py-10">
<div className="rounded-3xl border border-stone-200/80 bg-white/80 p-8 text-sm text-stone-500 shadow-sm dark:border-blue-900 dark:bg-[#0d1b38]/70 dark:text-slate-400">
Loading menu management
</div>
</div>
)
}
return (
<div className="space-y-8 px-8 py-10 text-stone-900 dark:text-white">
<section className="rounded-[2rem] border border-stone-200/80 bg-white/85 p-8 shadow-sm dark:border-blue-900 dark:bg-[#0d1b38]/78">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-orange-600 dark:text-orange-300">Platform Navigation</p>
<h1 className="mt-2 text-3xl font-semibold text-blue-950 dark:text-white">Menu Management</h1>
<p className="mt-2 max-w-3xl text-sm text-stone-500 dark:text-slate-400">
Control company dashboard navigation by plan, role, and company-specific exceptions from one admin surface.
</p>
</div>
<button
type="button"
onClick={startCreate}
className="rounded-full bg-orange-600 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-orange-500"
>
Create menu item
</button>
</div>
</section>
{(error || success) && (
<section className="space-y-3">
{error && <div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900/50 dark:bg-red-900/20 dark:text-red-300">{error}</div>}
{success && <div className="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700 dark:border-emerald-900/50 dark:bg-emerald-900/20 dark:text-emerald-300">{success}</div>}
</section>
)}
<div className="grid gap-8 xl:grid-cols-[1.4fr,0.95fr]">
<section className="rounded-[2rem] border border-stone-200/80 bg-white/85 p-6 shadow-sm dark:border-blue-900 dark:bg-[#0d1b38]/78">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-blue-950 dark:text-white">Menu items</h2>
<p className="mt-1 text-sm text-stone-500 dark:text-slate-400">{items.length} configured items</p>
</div>
</div>
<div className="mt-6 overflow-x-auto">
<table className="min-w-full text-left text-sm">
<thead>
<tr className="border-b border-stone-200 dark:border-blue-900">
<th className="px-3 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-stone-500 dark:text-slate-400">Item</th>
<th className="px-3 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-stone-500 dark:text-slate-400">Type</th>
<th className="px-3 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-stone-500 dark:text-slate-400">Assignments</th>
<th className="px-3 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-stone-500 dark:text-slate-400">Status</th>
<th className="px-3 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-stone-500 dark:text-slate-400">Actions</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id} className="border-b border-stone-100 align-top last:border-b-0 dark:border-blue-900/60">
<td className="px-3 py-4">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-blue-950 dark:text-white">{item.label}</span>
{item.isRequired && chip('Required', 'warn')}
{item.systemKey && chip(item.systemKey)}
</div>
<p className="text-xs text-stone-500 dark:text-slate-400">
{item.routeOrUrl || 'No route'} order {item.displayOrder}
{item.parent ? ` • child of ${item.parent.label}` : ''}
</p>
</div>
</td>
<td className="px-3 py-4 text-xs text-stone-600 dark:text-slate-300">{item.itemType}</td>
<td className="px-3 py-4">
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
{item.subscriptionAssignments.length > 0
? item.subscriptionAssignments.map((assignment) => (
<span key={`${item.id}-${assignment.plan}`} className="inline-flex rounded-full border border-stone-200 bg-stone-50 px-2.5 py-1 text-[11px] font-medium text-stone-700 dark:border-blue-800 dark:bg-[#07101e] dark:text-slate-300">
{assignment.plan}
</span>
))
: <span className="text-xs text-stone-400 dark:text-slate-500">No plan assignments</span>}
</div>
<div className="flex flex-wrap gap-2">
{item.companyAssignments.slice(0, 3).map((assignment) => (
<span key={`${item.id}-${assignment.companyId}`} className="inline-flex rounded-full border border-stone-200 bg-stone-50 px-2.5 py-1 text-[11px] font-medium text-stone-700 dark:border-blue-800 dark:bg-[#07101e] dark:text-slate-300">
{assignment.company.name}
</span>
))}
{item.companyAssignments.length > 3 && chip(`+${item.companyAssignments.length - 3} more`)}
</div>
<div className="flex flex-wrap gap-2">
{item.roleVisibilities.map((entry) => (
<span key={`${item.id}-${entry.role}`} className="inline-flex rounded-full border border-stone-200 bg-stone-50 px-2.5 py-1 text-[11px] font-medium text-stone-700 dark:border-blue-800 dark:bg-[#07101e] dark:text-slate-300">
{entry.role}
</span>
))}
</div>
</div>
</td>
<td className="px-3 py-4">
{item.isActive ? chip('Active', 'success') : chip('Inactive')}
</td>
<td className="px-3 py-4">
<div className="flex flex-wrap gap-2">
<button type="button" onClick={() => startEdit(item)} className="rounded-full border border-stone-200 px-3 py-1.5 text-xs font-medium text-stone-700 hover:border-orange-400 hover:text-orange-600 dark:border-blue-800 dark:text-slate-300">
Edit
</button>
<button type="button" onClick={() => toggleStatus(item)} className="rounded-full border border-stone-200 px-3 py-1.5 text-xs font-medium text-stone-700 hover:border-orange-400 hover:text-orange-600 dark:border-blue-800 dark:text-slate-300">
{item.isActive ? 'Disable' : 'Enable'}
</button>
<button type="button" onClick={() => removeItem(item)} className="rounded-full border border-red-200 px-3 py-1.5 text-xs font-medium text-red-700 hover:bg-red-50 dark:border-red-900/60 dark:text-red-300 dark:hover:bg-red-900/20">
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section className="rounded-[2rem] border border-stone-200/80 bg-white/85 p-6 shadow-sm dark:border-blue-900 dark:bg-[#0d1b38]/78">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-blue-950 dark:text-white">{editingId ? 'Edit menu item' : 'New menu item'}</h2>
<p className="mt-1 text-sm text-stone-500 dark:text-slate-400">
Configure menu metadata, role visibility, plan defaults, and company-specific overrides.
</p>
</div>
{editingId && (
<button type="button" onClick={startCreate} className="text-sm font-medium text-orange-600 hover:text-orange-500">
Clear
</button>
)}
</div>
<form className="mt-6 space-y-5" onSubmit={submitForm}>
<div className="grid gap-4 sm:grid-cols-2">
<label>
<span className={LABEL}>Label</span>
<input className={INPUT} value={form.label} onChange={(e) => updateForm('label', e.target.value)} />
</label>
<label>
<span className={LABEL}>System key</span>
<input className={INPUT} value={form.systemKey} onChange={(e) => updateForm('systemKey', e.target.value)} placeholder="dashboard" />
</label>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<label>
<span className={LABEL}>Type</span>
<select className={INPUT} value={form.itemType} onChange={(e) => updateForm('itemType', e.target.value as MenuItemType)}>
{ITEM_TYPES.map((type) => <option key={type} value={type}>{type}</option>)}
</select>
</label>
<label>
<span className={LABEL}>Icon</span>
<input className={INPUT} value={form.icon} onChange={(e) => updateForm('icon', e.target.value)} placeholder="LayoutDashboard" />
</label>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<label>
<span className={LABEL}>Route or URL</span>
<input className={INPUT} value={form.routeOrUrl} onChange={(e) => updateForm('routeOrUrl', e.target.value)} placeholder="/reports or https://…" />
</label>
<label>
<span className={LABEL}>Parent menu</span>
<select className={INPUT} value={form.parentId} onChange={(e) => updateForm('parentId', e.target.value)}>
<option value="">No parent</option>
{parentOptions.map((item) => (
<option key={item.id} value={item.id}>{item.label}</option>
))}
</select>
</label>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<label>
<span className={LABEL}>Display order</span>
<input className={INPUT} type="number" min="0" value={form.displayOrder} onChange={(e) => updateForm('displayOrder', e.target.value)} />
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label className="flex items-center gap-2 rounded-xl border border-stone-200 px-3 py-2 text-sm dark:border-blue-800">
<input type="checkbox" checked={form.isActive} onChange={(e) => updateForm('isActive', e.target.checked)} />
Active
</label>
<label className="flex items-center gap-2 rounded-xl border border-stone-200 px-3 py-2 text-sm dark:border-blue-800">
<input type="checkbox" checked={form.openInNewTab} onChange={(e) => updateForm('openInNewTab', e.target.checked)} />
New tab
</label>
</div>
</div>
<label className="flex items-center gap-2 rounded-xl border border-stone-200 px-3 py-2 text-sm dark:border-blue-800">
<input type="checkbox" checked={form.isRequired} onChange={(e) => updateForm('isRequired', e.target.checked)} />
Required system item
</label>
<div>
<p className={LABEL}>Role visibility</p>
<div className="mt-2 flex flex-wrap gap-2">
{ROLES.map((role) => (
<label key={role} className="flex items-center gap-2 rounded-full border border-stone-200 px-3 py-2 text-sm dark:border-blue-800">
<input type="checkbox" checked={form.roles.includes(role)} onChange={() => toggleArrayValue('roles', role)} />
{role}
</label>
))}
</div>
</div>
<div>
<p className={LABEL}>Subscription plans</p>
<div className="mt-2 flex flex-wrap gap-2">
{PLANS.map((plan) => (
<label key={plan} className="flex items-center gap-2 rounded-full border border-stone-200 px-3 py-2 text-sm dark:border-blue-800">
<input type="checkbox" checked={form.plans.includes(plan)} onChange={() => toggleArrayValue('plans', plan)} />
{plan}
</label>
))}
</div>
</div>
<div>
<p className={LABEL}>Company-specific assignments</p>
<div className="mt-2 max-h-56 space-y-2 overflow-y-auto rounded-2xl border border-stone-200 p-3 dark:border-blue-800">
{companies.map((company) => (
<label key={company.id} className="flex items-center justify-between gap-3 rounded-xl px-2 py-2 text-sm hover:bg-stone-50 dark:hover:bg-[#07101e]">
<span className="flex items-center gap-2">
<input type="checkbox" checked={form.companyIds.includes(company.id)} onChange={() => toggleArrayValue('companyIds', company.id)} />
<span>{company.name}</span>
</span>
<span className="text-xs text-stone-400 dark:text-slate-500">
{company.subscription?.plan ?? 'No plan'} {company.status}
</span>
</label>
))}
</div>
</div>
<button
type="submit"
disabled={saving}
className="w-full rounded-full bg-orange-600 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-orange-500 disabled:opacity-60"
>
{saving ? 'Saving…' : editingId ? 'Update menu item' : 'Create menu item'}
</button>
</form>
</section>
</div>
<div className="grid gap-8 xl:grid-cols-[1.1fr,0.9fr]">
<section className="rounded-[2rem] border border-stone-200/80 bg-white/85 p-6 shadow-sm dark:border-blue-900 dark:bg-[#0d1b38]/78">
<div className="flex flex-wrap items-end gap-4">
<div className="flex-1">
<h2 className="text-xl font-semibold text-blue-950 dark:text-white">Preview company menu</h2>
<p className="mt-1 text-sm text-stone-500 dark:text-slate-400">
Test the generated menu for a company and role before troubleshooting or saving follow-up changes.
</p>
</div>
<div className="min-w-[220px]">
<label className={LABEL}>Company</label>
<select className={INPUT} value={previewCompanyId} onChange={(e) => setPreviewCompanyId(e.target.value)}>
{companies.map((company) => (
<option key={company.id} value={company.id}>{company.name}</option>
))}
</select>
</div>
<div className="min-w-[160px]">
<label className={LABEL}>Role</label>
<select className={INPUT} value={previewRole} onChange={(e) => setPreviewRole(e.target.value as EmployeeRole)}>
{ROLES.map((role) => <option key={role} value={role}>{role}</option>)}
</select>
</div>
<button
type="button"
onClick={runPreview}
disabled={previewing || !previewCompanyId}
className="rounded-full bg-blue-950 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-blue-900 disabled:opacity-60 dark:bg-orange-600 dark:hover:bg-orange-500"
>
{previewing ? 'Previewing…' : 'Run preview'}
</button>
</div>
{preview && (
<div className="mt-6 space-y-4">
<div className="rounded-2xl border border-stone-200 p-4 dark:border-blue-800">
<p className="text-sm font-semibold text-blue-950 dark:text-white">{preview.company.name}</p>
<p className="mt-1 text-xs text-stone-500 dark:text-slate-400">
Plan: {preview.subscriptionPlan ?? 'None'} Role: {preview.role} Company status: {preview.company.status}
</p>
</div>
<div className="space-y-3">
{preview.items.map((item) => (
<div key={item.id} className="rounded-2xl border border-stone-200 p-4 dark:border-blue-800">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-blue-950 dark:text-white">{item.label}</span>
{item.visible ? chip('Visible', 'success') : chip('Hidden')}
{chip(item.source === 'none' ? 'unassigned' : item.source)}
</div>
<p className="mt-1 text-xs text-stone-500 dark:text-slate-400">
{item.routeOrUrl || 'No route'} order {item.displayOrder}
</p>
<ul className="mt-3 space-y-1 text-sm text-stone-600 dark:text-slate-300">
{item.reasons.map((reason, index) => (
<li key={`${item.id}-${index}`}>{reason}</li>
))}
</ul>
</div>
))}
</div>
</div>
)}
</section>
<section className="rounded-[2rem] border border-stone-200/80 bg-white/85 p-6 shadow-sm dark:border-blue-900 dark:bg-[#0d1b38]/78">
<h2 className="text-xl font-semibold text-blue-950 dark:text-white">Recent menu audit activity</h2>
<p className="mt-1 text-sm text-stone-500 dark:text-slate-400">
Recent platform-side changes to menu items and assignments.
</p>
<div className="mt-6 space-y-3">
{auditLogs.map((entry) => (
<div key={entry.id} className="rounded-2xl border border-stone-200 p-4 dark:border-blue-800">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-semibold text-blue-950 dark:text-white">{entry.action}</p>
<span className="text-xs text-stone-400 dark:text-slate-500">
{new Date(entry.createdAt).toLocaleString()}
</span>
</div>
<p className="mt-1 text-xs text-stone-500 dark:text-slate-400">
{entry.adminUser
? `${entry.adminUser.firstName} ${entry.adminUser.lastName}${entry.adminUser.email}`
: 'Unknown admin'}
</p>
</div>
))}
</div>
</section>
</div>
</div>
)
}
@@ -30,6 +30,7 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
billing: 'Billing',
pricing: 'Pricing',
notifications: 'Notifications',
menuManagement: 'Menu Management',
},
logout: 'Logout',
language: 'Language',
@@ -52,6 +53,7 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
billing: 'Facturation',
pricing: 'Tarification',
notifications: 'Notifications',
menuManagement: 'Gestion du menu',
},
logout: 'Déconnexion',
language: 'Langue',
@@ -74,6 +76,7 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
billing: 'الفوترة',
pricing: 'الأسعار',
notifications: 'الإشعارات',
menuManagement: 'إدارة القوائم',
},
logout: 'تسجيل الخروج',
language: 'اللغة',
@@ -4,6 +4,7 @@ import { parseBody, parseQuery, parseParams } from '../../http/validate'
import { ok, created } from '../../http/respond'
import * as service from './admin.service'
import * as subService from '../subscriptions/subscription.service'
import * as menuService from '../menu/menu.service'
import { presentAdminUser } from './admin.presenter'
import {
loginSchema, forgotPasswordSchema, resetPasswordSchema, totpVerifySchema,
@@ -16,6 +17,8 @@ import {
promotionCreateSchema, promotionUpdateSchema, promotionIdParamSchema,
billingAccountUpdateSchema, createBillingInvoiceSchema, payBillingInvoiceSchema,
retryBillingInvoiceSchema, billingReasonSchema, billingCreditNoteSchema, billingRefundSchema,
menuItemSchema, menuItemStatusSchema, menuPlanAssignmentsSchema, menuCompanyAssignmentsSchema,
menuPreviewSchema, menuAuditLogQuerySchema, menuPlanParamSchema, menuCompanyParamSchema,
} from './admin.schemas'
import { z } from 'zod'
@@ -127,6 +130,92 @@ router.post('/companies/:id/impersonate', requireAdminAuth, requireAdminRole('AD
} catch (err) { next(err) }
})
// ─── Menu management ──────────────────────────────────────────
router.get('/menu-items', requireAdminAuth, requireAdminRole('SUPPORT'), async (_req, res, next) => {
try {
ok(res, await menuService.listMenuItems())
} catch (err) { next(err) }
})
router.post('/menu-items', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
try {
created(res, { data: await menuService.createMenuItem(parseBody(menuItemSchema, req), req.admin) })
} catch (err) { next(err) }
})
router.get('/menu-items/:id', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
ok(res, await menuService.getMenuItem(id))
} catch (err) { next(err) }
})
router.put('/menu-items/:id', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
ok(res, await menuService.updateMenuItem(id, parseBody(menuItemSchema, req), req.admin))
} catch (err) { next(err) }
})
router.patch('/menu-items/:id/status', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { isActive } = parseBody(menuItemStatusSchema, req)
ok(res, await menuService.setMenuItemStatus(id, isActive, req.admin))
} catch (err) { next(err) }
})
router.delete('/menu-items/:id', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
await menuService.deleteMenuItem(id, req.admin)
ok(res, { success: true })
} catch (err) { next(err) }
})
router.post('/menu-items/:id/subscriptions', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { assignments } = parseBody(menuPlanAssignmentsSchema, req)
ok(res, await menuService.assignMenuItemToPlans(id, assignments, req.admin))
} catch (err) { next(err) }
})
router.delete('/menu-items/:id/subscriptions/:plan', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
try {
const { id, plan } = parseParams(menuPlanParamSchema, req)
ok(res, await menuService.removeMenuItemFromPlan(id, plan, req.admin))
} catch (err) { next(err) }
})
router.post('/menu-items/:id/companies', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { assignments } = parseBody(menuCompanyAssignmentsSchema, req)
ok(res, await menuService.assignMenuItemToCompanies(id, assignments, req.admin))
} catch (err) { next(err) }
})
router.delete('/menu-items/:id/companies/:companyId', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
try {
const { id, companyId } = parseParams(menuCompanyParamSchema, req)
ok(res, await menuService.removeMenuItemFromCompany(id, companyId, req.admin))
} catch (err) { next(err) }
})
router.post('/menu-preview', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
try {
ok(res, await menuService.previewCompanyMenu(parseBody(menuPreviewSchema, req)))
} catch (err) { next(err) }
})
router.get('/menu-audit-logs', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
try {
ok(res, await menuService.listMenuAuditLogs(parseQuery(menuAuditLogQuerySchema, req)))
} catch (err) { next(err) }
})
// ─── Renters ───────────────────────────────────────────────────
router.get('/renters', requireAdminAuth, async (req, res, next) => {
@@ -332,3 +332,75 @@ export const promotionUpdateSchema = promotionCreateSchema.partial()
export const planFeatureIdParamSchema = z.object({ featureId: z.string().min(1) })
export const promotionIdParamSchema = z.object({ promotionId: z.string().min(1) })
const employeeRoleSchema = z.enum(['OWNER', 'MANAGER', 'AGENT'])
const planSchema = z.enum(['STARTER', 'GROWTH', 'PRO'])
const menuItemTypeSchema = z.enum(['INTERNAL_PAGE', 'EXTERNAL_LINK', 'PARENT_MENU', 'SECTION_LABEL', 'DIVIDER'])
export const menuItemSchema = z.object({
systemKey: z.string().trim().min(1).max(120).optional().nullable(),
label: z.string().trim().min(1).max(120),
itemType: menuItemTypeSchema,
routeOrUrl: z.string().trim().max(400).optional().nullable(),
icon: z.string().trim().max(120).optional().nullable(),
parentId: z.string().trim().min(1).optional().nullable(),
displayOrder: z.number().int().min(0).default(0),
openInNewTab: z.boolean().default(false),
isRequired: z.boolean().default(false),
isActive: z.boolean().default(true),
roles: z.array(employeeRoleSchema).default([]),
subscriptionPlans: z.array(z.object({
plan: planSchema,
displayOrder: z.number().int().min(0).optional(),
isActive: z.boolean().optional(),
})).default([]),
companyAssignments: z.array(z.object({
companyId: z.string().trim().min(1),
displayOrder: z.number().int().min(0).optional(),
isActive: z.boolean().optional(),
})).default([]),
})
export const menuItemStatusSchema = z.object({
isActive: z.boolean(),
})
export const menuPlanAssignmentsSchema = z.object({
assignments: z.array(z.object({
plan: planSchema,
displayOrder: z.number().int().min(0).optional(),
isActive: z.boolean().optional(),
})).min(1),
})
export const menuCompanyAssignmentsSchema = z.object({
assignments: z.array(z.object({
companyId: z.string().trim().min(1),
displayOrder: z.number().int().min(0).optional(),
isActive: z.boolean().optional(),
})).min(1),
})
export const menuPreviewSchema = z.object({
companyId: z.string().trim().min(1),
role: employeeRoleSchema,
})
export const menuAuditLogQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
})
export const menuItemIdParamSchema = z.object({
id: z.string().min(1),
})
export const menuPlanParamSchema = z.object({
id: z.string().min(1),
plan: planSchema,
})
export const menuCompanyParamSchema = z.object({
id: z.string().min(1),
companyId: z.string().min(1),
})
@@ -2,6 +2,7 @@ import { Router } from 'express'
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { parseBody } from '../../http/validate'
import { ok } from '../../http/respond'
import { getEmployeeMenu } from '../menu/menu.service'
import {
employeeForgotPasswordSchema,
employeeLanguageSchema,
@@ -18,6 +19,12 @@ router.get('/me', requireCompanyAuth, async (req, res, next) => {
} catch (err) { next(err) }
})
router.get('/menu', requireCompanyAuth, async (req, res, next) => {
try {
ok(res, await getEmployeeMenu(req.employee.id))
} catch (err) { next(err) }
})
router.post('/login', async (req, res, next) => {
try {
const body = parseBody(employeeLoginSchema, req)
@@ -0,0 +1,201 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../lib/prisma', () => ({
prisma: {
menuItem: {
findMany: vi.fn(),
},
employee: {
findUniqueOrThrow: vi.fn(),
},
company: {
findUniqueOrThrow: vi.fn(),
},
auditLog: {
create: vi.fn(),
},
$transaction: vi.fn(),
},
}))
import { prisma } from '../../lib/prisma'
import { getEmployeeMenu, previewCompanyMenu } from './menu.service'
describe('menu.service', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('builds the employee menu from subscription defaults and company overrides', async () => {
vi.mocked(prisma.employee.findUniqueOrThrow).mockResolvedValue({
id: 'employee_1',
role: 'MANAGER',
isActive: true,
companyId: 'company_1',
} as never)
vi.mocked(prisma.company.findUniqueOrThrow).mockResolvedValue({
id: 'company_1',
name: 'Atlas Cars',
status: 'ACTIVE',
subscription: { plan: 'GROWTH', status: 'ACTIVE' },
} as never)
vi.mocked(prisma.menuItem.findMany).mockResolvedValue([
{
id: 'item_dashboard',
systemKey: 'dashboard',
label: 'Dashboard',
itemType: 'INTERNAL_PAGE',
routeOrUrl: '/',
icon: 'LayoutDashboard',
parentId: null,
openInNewTab: false,
isRequired: true,
isActive: true,
displayOrder: 1,
roleVisibilities: [{ role: 'MANAGER' }],
subscriptionAssignments: [],
companyAssignments: [],
},
{
id: 'item_reports',
systemKey: 'reports',
label: 'Reports',
itemType: 'INTERNAL_PAGE',
routeOrUrl: '/reports',
icon: 'BarChart2',
parentId: null,
openInNewTab: false,
isRequired: false,
isActive: true,
displayOrder: 80,
roleVisibilities: [{ role: 'MANAGER' }],
subscriptionAssignments: [{ plan: 'GROWTH', displayOrder: 80, isActive: true }],
companyAssignments: [],
},
{
id: 'item_finance',
systemKey: null,
label: 'Finance Portal',
itemType: 'EXTERNAL_LINK',
routeOrUrl: 'https://finance.example.com',
icon: null,
parentId: null,
openInNewTab: true,
isRequired: false,
isActive: true,
displayOrder: 200,
roleVisibilities: [{ role: 'MANAGER' }],
subscriptionAssignments: [],
companyAssignments: [{ companyId: 'company_1', displayOrder: 5, isActive: true }],
},
{
id: 'item_owner_only',
systemKey: 'settings',
label: 'Settings',
itemType: 'INTERNAL_PAGE',
routeOrUrl: '/settings',
icon: 'Settings',
parentId: null,
openInNewTab: false,
isRequired: false,
isActive: true,
displayOrder: 150,
roleVisibilities: [{ role: 'OWNER' }],
subscriptionAssignments: [{ plan: 'GROWTH', displayOrder: 150, isActive: true }],
companyAssignments: [],
},
] as never)
const result = await getEmployeeMenu('employee_1')
expect(result.items).toHaveLength(3)
expect(result.items.map((item) => item.label)).toEqual(['Dashboard', 'Finance Portal', 'Reports'])
expect(result.items[0]).toMatchObject({
label: 'Dashboard',
itemType: 'INTERNAL_PAGE',
})
expect(result.items[1]).toMatchObject({
label: 'Finance Portal',
itemType: 'EXTERNAL_LINK',
openInNewTab: true,
})
})
it('explains hidden items in preview results', async () => {
vi.mocked(prisma.company.findUniqueOrThrow).mockResolvedValue({
id: 'company_1',
name: 'Atlas Cars',
status: 'ACTIVE',
subscription: { plan: 'STARTER', status: 'ACTIVE' },
} as never)
vi.mocked(prisma.menuItem.findMany).mockResolvedValue([
{
id: 'item_dashboard',
systemKey: 'dashboard',
label: 'Dashboard',
itemType: 'INTERNAL_PAGE',
routeOrUrl: '/',
icon: 'LayoutDashboard',
parentId: null,
openInNewTab: false,
isRequired: true,
isActive: true,
displayOrder: 1,
roleVisibilities: [{ role: 'MANAGER' }],
subscriptionAssignments: [],
companyAssignments: [],
},
{
id: 'item_reports',
systemKey: 'reports',
label: 'Reports',
itemType: 'INTERNAL_PAGE',
routeOrUrl: '/reports',
icon: 'BarChart2',
parentId: null,
openInNewTab: false,
isRequired: false,
isActive: true,
displayOrder: 80,
roleVisibilities: [{ role: 'MANAGER' }],
subscriptionAssignments: [{ plan: 'GROWTH', displayOrder: 80, isActive: true }],
companyAssignments: [],
},
{
id: 'item_direct',
systemKey: null,
label: 'Finance Portal',
itemType: 'EXTERNAL_LINK',
routeOrUrl: 'https://finance.example.com',
icon: null,
parentId: null,
openInNewTab: true,
isRequired: false,
isActive: true,
displayOrder: 20,
roleVisibilities: [{ role: 'MANAGER' }],
subscriptionAssignments: [],
companyAssignments: [{ companyId: 'company_1', displayOrder: 10, isActive: true }],
},
] as never)
const result = await previewCompanyMenu({ companyId: 'company_1', role: 'MANAGER' })
expect(result.items.find((item) => item.label === 'Dashboard')).toMatchObject({
visible: true,
source: 'subscription',
})
expect(result.items.find((item) => item.label === 'Dashboard')?.reasons.join(' ')).toContain('defaults to all subscription plans')
expect(result.items.find((item) => item.label === 'Finance Portal')).toMatchObject({
visible: true,
source: 'company',
})
expect(result.items.find((item) => item.label === 'Reports')).toMatchObject({
visible: false,
})
expect(result.items.find((item) => item.label === 'Reports')?.reasons.join(' ')).toContain('STARTER does not include this menu item')
})
})
+642
View File
@@ -0,0 +1,642 @@
import { AppError } from '../../http/errors'
import { prisma } from '../../lib/prisma'
type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT'
type Plan = 'STARTER' | 'GROWTH' | 'PRO'
type MenuItemType = 'INTERNAL_PAGE' | 'EXTERNAL_LINK' | 'PARENT_MENU' | 'SECTION_LABEL' | 'DIVIDER'
type AdminActor = {
id: string
role: string
}
type MenuItemInput = {
systemKey?: string | null
label: string
itemType: MenuItemType
routeOrUrl?: string | null
icon?: string | null
parentId?: string | null
displayOrder?: number
openInNewTab?: boolean
isRequired?: boolean
isActive?: boolean
roles?: EmployeeRole[]
subscriptionPlans?: Array<{
plan: Plan
displayOrder?: number
isActive?: boolean
}>
companyAssignments?: Array<{
companyId: string
displayOrder?: number
isActive?: boolean
}>
}
type MenuPreviewInput = {
companyId: string
role: EmployeeRole
}
type MenuAuditQuery = {
page: number
pageSize: number
}
const EMPLOYEE_ROLES: EmployeeRole[] = ['OWNER', 'MANAGER', 'AGENT']
const MENU_AUDIT_RESOURCES = ['MenuItem', 'MenuSubscriptionAssignment', 'MenuCompanyAssignment', 'MenuRoleVisibility']
const MENU_ITEM_TYPES_WITHOUT_ROUTE = new Set([
'PARENT_MENU',
'SECTION_LABEL',
'DIVIDER',
])
function normalizeNullableString(value?: string | null) {
if (typeof value !== 'string') return null
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
}
function sortByDisplayOrder<T extends { displayOrder: number; label?: string | null }>(items: T[]) {
return [...items].sort((a, b) => {
if (a.displayOrder !== b.displayOrder) return a.displayOrder - b.displayOrder
return (a.label ?? '').localeCompare(b.label ?? '')
})
}
function toPlanValue(plan: Plan) {
return plan
}
function toItemTypeValue(itemType: MenuItemType) {
return itemType
}
function describeEntitlement(
plan: Plan | null,
hasSubscriptionAssignment: boolean,
hasCompanyAssignment: boolean,
defaultsToAllSubscriptions: boolean,
) {
if (hasCompanyAssignment) return 'Visible because it is assigned directly to this company.'
if (defaultsToAllSubscriptions) return 'Visible because this menu item defaults to all subscription plans.'
if (hasSubscriptionAssignment && plan) return `Visible because ${plan} includes this menu item.`
if (plan) return `Hidden because ${plan} does not include this menu item.`
return 'Hidden because the company does not have an active subscription assignment for this item.'
}
async function createAuditLog(admin: AdminActor, action: string, resource: string, resourceId: string | null, before?: unknown, after?: unknown) {
await prisma.auditLog.create({
data: {
adminUserId: admin.id,
action,
resource,
resourceId,
before: before === undefined ? undefined : JSON.parse(JSON.stringify(before)),
after: after === undefined ? undefined : JSON.parse(JSON.stringify(after)),
},
})
}
async function assertMenuItemPayload(input: MenuItemInput, menuItemId?: string) {
const label = input.label.trim()
if (!label) throw new AppError('Menu label is required.', 400, 'validation_error')
const itemType = toItemTypeValue(input.itemType)
const routeOrUrl = normalizeNullableString(input.routeOrUrl)
const parentId = normalizeNullableString(input.parentId)
if (MENU_ITEM_TYPES_WITHOUT_ROUTE.has(itemType)) {
if (routeOrUrl) {
throw new AppError('This menu item type cannot define a route or URL.', 400, 'validation_error')
}
} else if (!routeOrUrl) {
throw new AppError('Route or URL is required for this menu item type.', 400, 'validation_error')
}
if (routeOrUrl && itemType === 'EXTERNAL_LINK') {
if (!routeOrUrl.startsWith('https://')) {
throw new AppError('External links must use HTTPS.', 400, 'validation_error')
}
}
if (routeOrUrl && itemType === 'INTERNAL_PAGE' && !routeOrUrl.startsWith('/')) {
throw new AppError('Internal page routes must start with "/".', 400, 'validation_error')
}
if (parentId) {
if (menuItemId && parentId === menuItemId) {
throw new AppError('A menu item cannot be its own parent.', 400, 'validation_error')
}
const parent = await prisma.menuItem.findUnique({ where: { id: parentId } })
if (!parent) throw new AppError('Parent menu item was not found.', 400, 'validation_error')
if (parent.itemType !== 'PARENT_MENU') {
throw new AppError('Only parent menu items can contain children.', 400, 'validation_error')
}
}
if (routeOrUrl && !MENU_ITEM_TYPES_WITHOUT_ROUTE.has(itemType)) {
const duplicate = await prisma.menuItem.findFirst({
where: {
routeOrUrl,
parentId,
...(menuItemId ? { id: { not: menuItemId } } : {}),
},
select: { id: true },
})
if (duplicate) {
throw new AppError('Duplicate route under the same parent is not allowed.', 409, 'duplicate_menu_route')
}
}
const roles = Array.from(new Set(input.roles ?? []))
if (roles.some((role) => !EMPLOYEE_ROLES.includes(role))) {
throw new AppError('One or more role visibility values are invalid.', 400, 'validation_error')
}
const companyIds = Array.from(new Set((input.companyAssignments ?? []).map((assignment) => assignment.companyId)))
if (companyIds.length > 0) {
const companies = await prisma.company.findMany({
where: { id: { in: companyIds }, status: { not: 'CANCELLED' } },
select: { id: true },
})
if (companies.length !== companyIds.length) {
throw new AppError('Menu items can only be assigned to active companies.', 400, 'validation_error')
}
}
}
async function syncMenuAssignments(
tx: Omit<typeof prisma, '$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends'>,
menuItemId: string,
input: MenuItemInput,
) {
await tx.menuItemRoleVisibility.deleteMany({ where: { menuItemId } })
if ((input.roles ?? []).length > 0) {
await tx.menuItemRoleVisibility.createMany({
data: Array.from(new Set(input.roles)).map((role) => ({ menuItemId, role })),
})
}
await tx.subscriptionMenuItem.deleteMany({ where: { menuItemId } })
if ((input.subscriptionPlans ?? []).length > 0) {
await tx.subscriptionMenuItem.createMany({
data: input.subscriptionPlans!.map((assignment) => ({
menuItemId,
plan: toPlanValue(assignment.plan),
displayOrder: assignment.displayOrder ?? input.displayOrder ?? 0,
isActive: assignment.isActive ?? true,
})),
})
}
await tx.companyMenuItem.deleteMany({ where: { menuItemId } })
if ((input.companyAssignments ?? []).length > 0) {
await tx.companyMenuItem.createMany({
data: input.companyAssignments!.map((assignment) => ({
menuItemId,
companyId: assignment.companyId,
displayOrder: assignment.displayOrder ?? input.displayOrder ?? 0,
isActive: assignment.isActive ?? true,
})),
})
}
}
async function loadMenuItemDetail(id: string) {
return prisma.menuItem.findUniqueOrThrow({
where: { id },
include: {
parent: { select: { id: true, label: true } },
children: { select: { id: true, label: true } },
roleVisibilities: { orderBy: { role: 'asc' } },
subscriptionAssignments: { orderBy: [{ plan: 'asc' }, { displayOrder: 'asc' }] },
companyAssignments: {
include: { company: { select: { id: true, name: true } } },
orderBy: [{ displayOrder: 'asc' }, { companyId: 'asc' }],
},
},
})
}
export async function listMenuItems() {
return prisma.menuItem.findMany({
include: {
parent: { select: { id: true, label: true } },
roleVisibilities: { orderBy: { role: 'asc' } },
subscriptionAssignments: { orderBy: [{ plan: 'asc' }, { displayOrder: 'asc' }] },
companyAssignments: {
include: { company: { select: { id: true, name: true } } },
orderBy: [{ displayOrder: 'asc' }, { companyId: 'asc' }],
},
},
orderBy: [{ displayOrder: 'asc' }, { createdAt: 'asc' }],
})
}
export async function getMenuItem(id: string) {
return loadMenuItemDetail(id)
}
export async function createMenuItem(input: MenuItemInput, admin: AdminActor) {
await assertMenuItemPayload(input)
const created = await prisma.$transaction(async (tx) => {
const menuItem = await tx.menuItem.create({
data: {
systemKey: normalizeNullableString(input.systemKey) ?? undefined,
label: input.label.trim(),
itemType: toItemTypeValue(input.itemType),
routeOrUrl: normalizeNullableString(input.routeOrUrl) ?? undefined,
icon: normalizeNullableString(input.icon) ?? undefined,
parentId: normalizeNullableString(input.parentId) ?? undefined,
displayOrder: input.displayOrder ?? 0,
openInNewTab: input.openInNewTab ?? false,
isRequired: input.isRequired ?? false,
isActive: input.isActive ?? true,
createdBy: admin.id,
updatedBy: admin.id,
},
})
await syncMenuAssignments(tx as any, menuItem.id, input)
return menuItem
})
const detail = await loadMenuItemDetail(created.id)
await createAuditLog(admin, 'CREATE_MENU_ITEM', 'MenuItem', created.id, undefined, detail)
return detail
}
export async function updateMenuItem(id: string, input: MenuItemInput, admin: AdminActor) {
const before = await loadMenuItemDetail(id)
await assertMenuItemPayload(input, id)
const updated = await prisma.$transaction(async (tx) => {
const menuItem = await tx.menuItem.update({
where: { id },
data: {
systemKey: normalizeNullableString(input.systemKey) ?? undefined,
label: input.label.trim(),
itemType: toItemTypeValue(input.itemType),
routeOrUrl: normalizeNullableString(input.routeOrUrl) ?? undefined,
icon: normalizeNullableString(input.icon) ?? undefined,
parentId: normalizeNullableString(input.parentId) ?? undefined,
displayOrder: input.displayOrder ?? 0,
openInNewTab: input.openInNewTab ?? false,
isRequired: input.isRequired ?? false,
isActive: input.isActive ?? true,
updatedBy: admin.id,
},
})
await syncMenuAssignments(tx as any, id, input)
return menuItem
})
const detail = await loadMenuItemDetail(updated.id)
await createAuditLog(admin, 'UPDATE_MENU_ITEM', 'MenuItem', id, before, detail)
return detail
}
export async function setMenuItemStatus(id: string, isActive: boolean, admin: AdminActor) {
const before = await prisma.menuItem.findUniqueOrThrow({ where: { id } })
if (!isActive && before.isRequired && admin.role !== 'SUPER_ADMIN') {
throw new AppError('Required system items can only be disabled by a Super Admin.', 403, 'forbidden')
}
const updated = await prisma.menuItem.update({
where: { id },
data: { isActive, updatedBy: admin.id },
})
await createAuditLog(
admin,
isActive ? 'ENABLE_MENU_ITEM' : 'DISABLE_MENU_ITEM',
'MenuItem',
id,
{ isActive: before.isActive },
{ isActive: updated.isActive },
)
return updated
}
export async function deleteMenuItem(id: string, admin: AdminActor) {
const before = await loadMenuItemDetail(id)
if (before.isRequired && admin.role !== 'SUPER_ADMIN') {
throw new AppError('Required system items can only be deleted by a Super Admin.', 403, 'forbidden')
}
await prisma.menuItem.delete({ where: { id } })
await createAuditLog(admin, 'DELETE_MENU_ITEM', 'MenuItem', id, before, undefined)
}
export async function assignMenuItemToPlans(
id: string,
assignments: Array<{ plan: Plan; displayOrder?: number; isActive?: boolean }>,
admin: AdminActor,
) {
await prisma.menuItem.findUniqueOrThrow({ where: { id } })
const payload = assignments.map((assignment) => ({
menuItemId: id,
plan: toPlanValue(assignment.plan),
displayOrder: assignment.displayOrder ?? 0,
isActive: assignment.isActive ?? true,
}))
await prisma.subscriptionMenuItem.deleteMany({
where: { menuItemId: id, plan: { in: payload.map((assignment) => assignment.plan) } },
})
if (payload.length > 0) {
await prisma.subscriptionMenuItem.createMany({ data: payload })
}
await createAuditLog(admin, 'ASSIGN_MENU_ITEM_TO_SUBSCRIPTIONS', 'MenuSubscriptionAssignment', id, undefined, payload)
return loadMenuItemDetail(id)
}
export async function removeMenuItemFromPlan(id: string, plan: Plan, admin: AdminActor) {
await prisma.subscriptionMenuItem.deleteMany({
where: {
menuItemId: id,
plan: toPlanValue(plan),
},
})
await createAuditLog(admin, 'REMOVE_MENU_ITEM_FROM_SUBSCRIPTION', 'MenuSubscriptionAssignment', id, { plan }, undefined)
return loadMenuItemDetail(id)
}
export async function assignMenuItemToCompanies(
id: string,
assignments: Array<{ companyId: string; displayOrder?: number; isActive?: boolean }>,
admin: AdminActor,
) {
await prisma.menuItem.findUniqueOrThrow({ where: { id } })
const companyIds = assignments.map((assignment) => assignment.companyId)
const companies = await prisma.company.findMany({
where: { id: { in: companyIds }, status: { not: 'CANCELLED' } },
select: { id: true },
})
if (companies.length !== new Set(companyIds).size) {
throw new AppError('Menu items can only be assigned to active companies.', 400, 'validation_error')
}
await prisma.companyMenuItem.deleteMany({
where: {
menuItemId: id,
companyId: { in: companyIds },
},
})
if (assignments.length > 0) {
await prisma.companyMenuItem.createMany({
data: assignments.map((assignment) => ({
menuItemId: id,
companyId: assignment.companyId,
displayOrder: assignment.displayOrder ?? 0,
isActive: assignment.isActive ?? true,
})),
})
}
await createAuditLog(admin, 'ASSIGN_MENU_ITEM_TO_COMPANIES', 'MenuCompanyAssignment', id, undefined, assignments)
return loadMenuItemDetail(id)
}
export async function removeMenuItemFromCompany(id: string, companyId: string, admin: AdminActor) {
await prisma.companyMenuItem.deleteMany({
where: { menuItemId: id, companyId },
})
await createAuditLog(admin, 'REMOVE_MENU_ITEM_FROM_COMPANY', 'MenuCompanyAssignment', id, { companyId }, undefined)
return loadMenuItemDetail(id)
}
async function getMenuEvaluationContext(companyId: string, role: EmployeeRole, employeeIsActive = true) {
const company = await prisma.company.findUniqueOrThrow({
where: { id: companyId },
select: {
id: true,
name: true,
status: true,
subscription: {
select: {
plan: true,
status: true,
},
},
},
})
const menuItems = await prisma.menuItem.findMany({
include: {
roleVisibilities: true,
subscriptionAssignments: true,
companyAssignments: {
where: { companyId },
},
},
orderBy: [{ displayOrder: 'asc' }, { createdAt: 'asc' }],
})
const currentPlan = company.subscription?.plan ?? null
const currentPlanName = currentPlan as Plan | null
const evaluated = menuItems.map((item) => {
const defaultsToAllSubscriptions = item.subscriptionAssignments.length === 0
const subscriptionAssignment = item.subscriptionAssignments.find(
(assignment) => assignment.isActive && (!currentPlan || assignment.plan === currentPlan),
)
const companyAssignment = item.companyAssignments.find((assignment) => assignment.isActive)
const roleAllowed = item.roleVisibilities.length === 0 || item.roleVisibilities.some((visibility) => visibility.role === role)
const subscriptionEntitled = Boolean(subscriptionAssignment || (defaultsToAllSubscriptions && currentPlan))
const entitled = Boolean(subscriptionEntitled || companyAssignment)
const visible = Boolean(item.isActive && entitled && roleAllowed && employeeIsActive)
const displayOrder = companyAssignment?.displayOrder ?? subscriptionAssignment?.displayOrder ?? item.displayOrder
const reasons: string[] = []
if (!item.isActive) reasons.push('Hidden because the menu item is inactive.')
if (!employeeIsActive) reasons.push('Hidden because the user account is inactive.')
if (!entitled) reasons.push(describeEntitlement(currentPlanName, false, false, false))
if (entitled) {
reasons.push(
describeEntitlement(
currentPlanName,
Boolean(subscriptionAssignment),
Boolean(companyAssignment),
Boolean(defaultsToAllSubscriptions && !companyAssignment),
),
)
}
if (!roleAllowed) reasons.push(`Hidden because the ${role} role is not allowed to see this item.`)
return {
id: item.id,
systemKey: item.systemKey,
label: item.label,
itemType: item.itemType,
routeOrUrl: item.routeOrUrl,
icon: item.icon,
parentId: item.parentId,
openInNewTab: item.openInNewTab,
isRequired: item.isRequired,
isActive: item.isActive,
displayOrder,
source: companyAssignment ? 'company' : subscriptionEntitled ? 'subscription' : 'none',
visible,
reasons,
}
})
return {
company,
role,
currentPlan: currentPlanName,
items: sortByDisplayOrder(evaluated),
}
}
function buildMenuTree(items: Array<{
id: string
systemKey: string | null
label: string
itemType: MenuItemType
routeOrUrl: string | null
icon: string | null
parentId: string | null
openInNewTab: boolean
displayOrder: number
}>) {
const byId = new Map<string, any>()
const roots: any[] = []
for (const item of items) {
byId.set(item.id, {
id: item.id,
systemKey: item.systemKey,
label: item.label,
itemType: item.itemType,
routeOrUrl: item.routeOrUrl,
icon: item.icon,
parentId: item.parentId,
openInNewTab: item.openInNewTab,
displayOrder: item.displayOrder,
children: [],
})
}
for (const item of items) {
const node = byId.get(item.id)
if (item.parentId && byId.has(item.parentId)) {
byId.get(item.parentId).children.push(node)
} else {
roots.push(node)
}
}
const sortTree = (nodes: any[]) => {
nodes.sort((a, b) => {
if (a.displayOrder !== b.displayOrder) return a.displayOrder - b.displayOrder
return a.label.localeCompare(b.label)
})
nodes.forEach((node) => sortTree(node.children))
}
sortTree(roots)
return roots
}
export async function previewCompanyMenu(input: MenuPreviewInput) {
const context = await getMenuEvaluationContext(input.companyId, input.role)
return {
company: context.company,
role: context.role,
subscriptionPlan: context.currentPlan,
items: context.items,
finalMenu: buildMenuTree(
context.items
.filter((item) => item.visible)
.map((item) => ({
id: item.id,
systemKey: item.systemKey,
label: item.label,
itemType: item.itemType,
routeOrUrl: item.routeOrUrl,
icon: item.icon,
parentId: item.parentId,
openInNewTab: item.openInNewTab,
displayOrder: item.displayOrder,
})),
),
}
}
export async function getEmployeeMenu(employeeId: string) {
const employee = await prisma.employee.findUniqueOrThrow({
where: { id: employeeId },
select: {
id: true,
role: true,
isActive: true,
companyId: true,
},
})
const context = await getMenuEvaluationContext(employee.companyId, employee.role, employee.isActive)
const visibleItems = context.items.filter((item) => item.visible)
return {
companyId: employee.companyId,
role: employee.role,
subscriptionPlan: context.currentPlan,
items: buildMenuTree(
visibleItems.map((item) => ({
id: item.id,
systemKey: item.systemKey,
label: item.label,
itemType: item.itemType,
routeOrUrl: item.routeOrUrl,
icon: item.icon,
parentId: item.parentId,
openInNewTab: item.openInNewTab,
displayOrder: item.displayOrder,
})),
),
}
}
export async function listMenuAuditLogs(query: MenuAuditQuery) {
const where = {
resource: {
in: MENU_AUDIT_RESOURCES,
},
} as const
const [data, total] = await Promise.all([
prisma.auditLog.findMany({
where,
include: { adminUser: { select: { firstName: true, lastName: true, email: true } } },
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
orderBy: { createdAt: 'desc' },
}),
prisma.auditLog.count({ where }),
])
return {
data,
total,
page: query.page,
pageSize: query.pageSize,
totalPages: Math.ceil(total / query.pageSize),
}
}
@@ -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 (
+615
View File
@@ -0,0 +1,615 @@
# Website Admin Menu Management Plan for Company Users
## 1. Goal
Build a website admin system that allows platform administrators to manage menu items shown to company users.
The menu should be controlled centrally by website admins. Companies should automatically receive default menu items based on their subscription plan. Website admins should also be able to add extra menu items for individual companies when needed.
Company admins should not manage menus unless this is introduced later as a separate feature.
---
## 2. Core Concept
The final menu shown to a company user should come from two sources:
```text
Final User Menu = Subscription Default Menu Items + Company-Specific Menu Items
```
Where:
```text
Subscription Default Menu Items:
Managed by website admins and assigned based on subscription plan.
Company-Specific Menu Items:
Managed by website admins and assigned to one company or selected companies.
```
This creates centralized control and avoids letting every company modify the navigation structure independently.
---
## 3. Admin Roles
The system should support website-level admin roles.
Suggested roles:
```text
Super Admin:
- Full access to all menu management features
- Can create, edit, delete, enable, and disable menu items
- Can manage subscription menu templates
- Can assign custom menu items to specific companies
Website Admin:
- Can manage menu items
- Can assign menu items to companies
- Cannot change billing or subscription rules unless allowed
Support Admin:
- Can view company menus
- Can preview menus for troubleshooting
- Cannot create or edit menu items
Company User:
- Can only see the final menu generated for their company and role
```
---
## 4. Subscription-Based Default Menus
Each subscription plan should have a menu template managed by website admins.
Example:
```text
Basic Plan:
- Dashboard
- Profile
- Reports
Pro Plan:
- Dashboard
- Profile
- Reports
- Team Management
- Advanced Analytics
Enterprise Plan:
- Dashboard
- Profile
- Reports
- Team Management
- Advanced Analytics
- Integrations
- Audit Logs
```
When a company is assigned to a subscription plan, the system automatically uses that plans menu template.
Website admins should be able to:
```text
- Create subscription menu templates
- Edit default menu items for a subscription
- Reorder default menu items
- Enable or disable menu items
- Mark menu items as required
- Assign menu items to one or more subscription plans
```
---
## 5. Company-Specific Menu Items
Website admins should be able to add menu items for a specific company without changing the global subscription template.
Example:
```text
Company A has Pro Plan:
- Gets all Pro default menu items
- Also gets custom item: Finance Portal
Company B has Pro Plan:
- Gets all Pro default menu items
- Does not get Finance Portal
```
This allows exceptions without changing the subscription plan for every company.
---
## 6. Menu Item Types
The system should support these menu item types:
```text
Internal Page:
Links to a page inside the application.
External Link:
Links to an outside tool or website.
Parent Menu:
A dropdown or folder that contains child items.
Section Label:
A visual grouping label.
Divider:
A visual separator.
```
Each menu item should include:
```text
- Name / label
- Type
- Route or URL
- Icon
- Parent menu item
- Display order
- Status: Active or Inactive
- Open behavior: Same tab or new tab
- Required or optional
- Subscription availability
- Company assignment
- Role visibility
- Created by
- Updated by
- Created date
- Updated date
```
---
## 7. Website Admin Features
The website admin panel should include a **Menu Management** section.
Website admins should be able to:
```text
- View all menu items
- Create new menu items
- Edit existing menu items
- Enable or disable menu items
- Reorder menu items
- Assign menu items to subscription plans
- Assign menu items to individual companies
- Assign menu items to user roles
- Preview a company users menu
- View menu history and audit logs
```
---
## 8. Menu Visibility Rules
The system should calculate visibility using this order:
```text
1. Subscription entitlement
2. Company-specific assignment
3. User role
4. User status
5. Menu item status
```
A user should see a menu item only if:
```text
- The item is active
- The user belongs to a company
- The company has access through subscription or direct assignment
- The users role is allowed to see it
- The user account is active
```
---
## 9. Important Rule: Menu Visibility Is Not Security
The menu only controls what users see in navigation.
It must not be used as the only access control layer.
Backend APIs and protected pages must still check:
```text
- User authentication
- Company access
- Role permissions
- Subscription access
- Feature entitlement
```
A hidden menu item does not mean the page is secure. Users can still guess URLs, share links, or hit APIs directly.
---
## 10. Subscription Change Behavior
When a company upgrades or downgrades, the menu should update automatically.
### Upgrade Example
```text
Company moves from Basic to Pro:
- Pro default menu items become visible
- Existing company-specific menu items remain unchanged
```
### Downgrade Example
```text
Company moves from Pro to Basic:
- Pro-only default menu items become hidden
- Company-specific items remain only if they do not depend on Pro-only features
```
Do not delete menu history or company-specific assignments during subscription changes.
Instead, mark unavailable items as:
```text
Unavailable due to subscription
```
This allows website admins to understand why an item is not visible.
---
## 11. Database Model
### subscription_plans
```text
id
name
description
status
created_at
updated_at
```
### menu_items
```text
id
label
item_type
route_or_url
icon
parent_id
display_order
open_in_new_tab
is_required
status
created_by
updated_by
created_at
updated_at
```
This table stores all platform-managed menu items.
### subscription_menu_items
```text
id
subscription_plan_id
menu_item_id
display_order
status
created_at
updated_at
```
This table maps menu items to subscription plans.
### company_menu_items
```text
id
company_id
menu_item_id
display_order
status
created_at
updated_at
```
This table maps extra website-admin-assigned menu items to specific companies.
### menu_item_role_visibility
```text
id
menu_item_id
role_id
created_at
updated_at
```
This table controls which roles can see each item.
### company_subscriptions
```text
id
company_id
subscription_plan_id
status
start_date
end_date
created_at
updated_at
```
### menu_audit_logs
```text
id
admin_user_id
action_type
entity_type
entity_id
old_value
new_value
created_at
```
---
## 12. Menu Generation Logic
When a company user logs in, the backend should generate the menu.
Steps:
```text
1. Identify the user.
2. Identify the users company.
3. Get the companys active subscription.
4. Load menu items assigned to that subscription.
5. Load menu items assigned directly to the company.
6. Merge both sets.
7. Remove duplicates.
8. Filter by user role.
9. Remove inactive items.
10. Sort by display order.
11. Return the final menu.
```
Example logic:
```text
subscriptionMenu = getMenuBySubscription(company.subscription_plan_id)
companyMenu = getMenuByCompany(company.id)
finalMenu = merge(subscriptionMenu, companyMenu)
finalMenu = removeDuplicates(finalMenu)
finalMenu = filterByRole(finalMenu, user.role)
finalMenu = filterActiveItems(finalMenu)
finalMenu = sortByDisplayOrder(finalMenu)
```
---
## 13. Duplicate Handling
If the same menu item exists in both the subscription menu and the company-specific menu, the system should show it once.
Recommended priority:
```text
Company-specific assignment overrides subscription assignment for display order only.
```
This means:
```text
- The item appears once
- Website admins can customize order for that company
- The item still keeps the same underlying permissions
```
---
## 14. Admin UI Requirements
The website admin menu management screen should include:
```text
- Menu item list
- Create menu item button
- Edit menu item form
- Status toggle
- Subscription assignment selector
- Company assignment selector
- Role visibility selector
- Drag-and-drop ordering
- Preview by company
- Preview by role
- Audit history
```
---
## 15. Preview Feature
Website admins should be able to preview the menu before saving or while troubleshooting.
Preview options:
```text
- Select company
- Select user role
- View final generated menu
- See why each item is visible or hidden
```
The “why visible/hidden” detail is important.
Example:
```text
Advanced Analytics:
Visible because company has Pro subscription.
Audit Logs:
Hidden because company has Basic subscription.
Finance Portal:
Visible because item is assigned directly to this company.
```
This will save debugging time and reduce support overhead.
---
## 16. Validation Rules
The system should enforce:
```text
- Menu label is required
- Route or URL is required unless item is a parent menu
- External URLs must use HTTPS
- Display order must be valid
- Parent item must exist
- Child item cannot be its own parent
- Required system items cannot be disabled without Super Admin access
- Menu item cannot be assigned to an inactive subscription
- Menu item cannot be assigned to a deleted company
- Duplicate route under the same parent should be prevented
```
---
## 17. Audit Logging
Every website admin action should be logged.
Track:
```text
- Admin user
- Action type
- Affected menu item
- Affected company, if any
- Affected subscription, if any
- Old value
- New value
- Timestamp
```
Important actions to log:
```text
- Created menu item
- Edited menu item
- Disabled menu item
- Enabled menu item
- Assigned item to subscription
- Removed item from subscription
- Assigned item to company
- Removed item from company
- Changed display order
- Changed role visibility
```
---
## 18. Recommended MVP Scope
The first version should include:
```text
- Website admin menu item CRUD
- Assign menu items to subscription plans
- Assign extra menu items to specific companies
- Role-based visibility
- Active/inactive status
- Backend menu generation API
- Audit logging
- Company and role preview
```
Avoid these in the MVP unless absolutely necessary:
```text
- User-specific menu visibility
- Scheduled publishing
- Custom company-managed menu editing
- Approval workflows
- Menu analytics
```
User-specific visibility should be avoided in the first version because it adds significant permission complexity.
---
## 19. API Endpoints
Suggested admin endpoints:
```text
GET /admin/menu-items
POST /admin/menu-items
GET /admin/menu-items/{id}
PUT /admin/menu-items/{id}
PATCH /admin/menu-items/{id}/status
DELETE /admin/menu-items/{id}
POST /admin/menu-items/{id}/subscriptions
DELETE /admin/menu-items/{id}/subscriptions/{subscriptionPlanId}
POST /admin/menu-items/{id}/companies
DELETE /admin/menu-items/{id}/companies/{companyId}
POST /admin/menu-preview
GET /admin/menu-audit-logs
```
Suggested user endpoint:
```text
GET /me/menu
```
The user endpoint should return only the final menu available to the logged-in user.
---
## 20. Success Criteria
The feature is successful if:
```text
- Website admins can manage all menu items centrally.
- Companies receive menu items automatically based on subscription.
- Website admins can add menu items to individual companies.
- Users only see menu items available to their company and role.
- Subscription upgrades and downgrades update menus correctly.
- Hidden menu items do not create security gaps.
- Admin changes are logged.
- Support admins can preview and debug company menus.
```
---
## 21. Key Product Decision
Menu ownership belongs to the platform, not the company.
That means the core system should not include company-admin menu controls. Company-specific customization should be handled by website admins through direct company assignment.
This keeps the model simpler, safer, and easier to support.
@@ -0,0 +1,129 @@
-- CreateEnum
CREATE TYPE "MenuItemType" AS ENUM (
'INTERNAL_PAGE',
'EXTERNAL_LINK',
'PARENT_MENU',
'SECTION_LABEL',
'DIVIDER'
);
-- CreateTable
CREATE TABLE "menu_items" (
"id" TEXT NOT NULL,
"systemKey" TEXT,
"label" TEXT NOT NULL,
"itemType" "MenuItemType" NOT NULL DEFAULT 'INTERNAL_PAGE',
"routeOrUrl" TEXT,
"icon" TEXT,
"parentId" TEXT,
"displayOrder" INTEGER NOT NULL DEFAULT 0,
"openInNewTab" BOOLEAN NOT NULL DEFAULT false,
"isRequired" BOOLEAN NOT NULL DEFAULT false,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdBy" TEXT,
"updatedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "menu_items_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "subscription_menu_items" (
"id" TEXT NOT NULL,
"plan" "Plan" NOT NULL,
"menuItemId" TEXT NOT NULL,
"displayOrder" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "subscription_menu_items_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "company_menu_items" (
"id" TEXT NOT NULL,
"companyId" TEXT NOT NULL,
"menuItemId" TEXT NOT NULL,
"displayOrder" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "company_menu_items_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "menu_item_role_visibility" (
"id" TEXT NOT NULL,
"menuItemId" TEXT NOT NULL,
"role" "EmployeeRole" NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "menu_item_role_visibility_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "menu_items_systemKey_key" ON "menu_items"("systemKey");
-- CreateIndex
CREATE INDEX "menu_items_parentId_idx" ON "menu_items"("parentId");
-- CreateIndex
CREATE INDEX "menu_items_isActive_displayOrder_idx" ON "menu_items"("isActive", "displayOrder");
-- CreateIndex
CREATE UNIQUE INDEX "subscription_menu_items_plan_menuItemId_key" ON "subscription_menu_items"("plan", "menuItemId");
-- CreateIndex
CREATE INDEX "subscription_menu_items_menuItemId_idx" ON "subscription_menu_items"("menuItemId");
-- CreateIndex
CREATE INDEX "subscription_menu_items_plan_displayOrder_idx" ON "subscription_menu_items"("plan", "displayOrder");
-- CreateIndex
CREATE UNIQUE INDEX "company_menu_items_companyId_menuItemId_key" ON "company_menu_items"("companyId", "menuItemId");
-- CreateIndex
CREATE INDEX "company_menu_items_menuItemId_idx" ON "company_menu_items"("menuItemId");
-- CreateIndex
CREATE INDEX "company_menu_items_companyId_displayOrder_idx" ON "company_menu_items"("companyId", "displayOrder");
-- CreateIndex
CREATE UNIQUE INDEX "menu_item_role_visibility_menuItemId_role_key" ON "menu_item_role_visibility"("menuItemId", "role");
-- CreateIndex
CREATE INDEX "menu_item_role_visibility_role_idx" ON "menu_item_role_visibility"("role");
-- AddForeignKey
ALTER TABLE "menu_items"
ADD CONSTRAINT "menu_items_parentId_fkey"
FOREIGN KEY ("parentId") REFERENCES "menu_items"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "subscription_menu_items"
ADD CONSTRAINT "subscription_menu_items_menuItemId_fkey"
FOREIGN KEY ("menuItemId") REFERENCES "menu_items"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "company_menu_items"
ADD CONSTRAINT "company_menu_items_companyId_fkey"
FOREIGN KEY ("companyId") REFERENCES "companies"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "company_menu_items"
ADD CONSTRAINT "company_menu_items_menuItemId_fkey"
FOREIGN KEY ("menuItemId") REFERENCES "menu_items"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "menu_item_role_visibility"
ADD CONSTRAINT "menu_item_role_visibility_menuItemId_fkey"
FOREIGN KEY ("menuItemId") REFERENCES "menu_items"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,215 @@
WITH canonical_menu_items ("systemKey", "label", "itemType", "routeOrUrl", "icon", "displayOrder", "isRequired", "isActive") AS (
VALUES
('dashboard', 'Dashboard', 'INTERNAL_PAGE'::"MenuItemType", '/', 'LayoutDashboard', 10, true, true),
('fleet', 'Fleet', 'INTERNAL_PAGE'::"MenuItemType", '/fleet', 'Car', 20, true, true),
('reservations', 'Booking', 'INTERNAL_PAGE'::"MenuItemType", '/reservations', 'Calendar', 30, true, true),
('customers', 'Customers', 'INTERNAL_PAGE'::"MenuItemType", '/customers', 'Users', 40, false, true),
('subscription', 'Subscription', 'INTERNAL_PAGE'::"MenuItemType", '/subscription', 'CreditCard', 50, true, true),
('contracts', 'Contracts', 'INTERNAL_PAGE'::"MenuItemType", '/contracts', 'FileText', 60, false, true),
('notifications', 'Notifications', 'INTERNAL_PAGE'::"MenuItemType", '/notifications', 'Bell', 70, false, true),
('settings', 'Settings', 'INTERNAL_PAGE'::"MenuItemType", '/settings', 'Settings', 80, false, true),
('onlineReservations', 'Online Reservations', 'INTERNAL_PAGE'::"MenuItemType", '/online-reservations', 'Globe', 90, false, true),
('team', 'Team', 'INTERNAL_PAGE'::"MenuItemType", '/team', 'UserPlus', 100, false, true),
('offers', 'Offers', 'INTERNAL_PAGE'::"MenuItemType", '/offers', 'Tag', 110, false, true),
('reports', 'Reports', 'INTERNAL_PAGE'::"MenuItemType", '/reports', 'BarChart2', 120, false, true),
('billing', 'Customer Billing', 'INTERNAL_PAGE'::"MenuItemType", '/billing', 'CreditCard', 130, false, true),
('reviews', 'Reviews', 'INTERNAL_PAGE'::"MenuItemType", '/reviews', 'Star', 140, false, true),
('complaints', 'Complaints', 'INTERNAL_PAGE'::"MenuItemType", '/complaints', 'AlertTriangle', 150, false, true)
)
INSERT INTO "menu_items" (
"id",
"systemKey",
"label",
"itemType",
"routeOrUrl",
"icon",
"displayOrder",
"openInNewTab",
"isRequired",
"isActive",
"createdBy",
"updatedBy"
)
SELECT
'menu_' || "systemKey",
"systemKey",
"label",
"itemType",
"routeOrUrl",
"icon",
"displayOrder",
false,
"isRequired",
"isActive",
'system',
'system'
FROM canonical_menu_items
ON CONFLICT ("systemKey") DO UPDATE
SET
"label" = EXCLUDED."label",
"itemType" = EXCLUDED."itemType",
"routeOrUrl" = EXCLUDED."routeOrUrl",
"icon" = EXCLUDED."icon",
"displayOrder" = EXCLUDED."displayOrder",
"isRequired" = EXCLUDED."isRequired",
"isActive" = EXCLUDED."isActive",
"updatedBy" = 'system',
"updatedAt" = CURRENT_TIMESTAMP;
DELETE FROM "subscription_menu_items"
WHERE "menuItemId" IN (
SELECT "id"
FROM "menu_items"
WHERE "systemKey" IN (
'dashboard',
'fleet',
'reservations',
'customers',
'subscription',
'contracts',
'notifications',
'settings',
'onlineReservations',
'team',
'offers',
'reports',
'billing',
'reviews',
'complaints'
)
);
WITH canonical_subscription_assignments ("systemKey", "plan", "displayOrder") AS (
VALUES
('dashboard', 'STARTER'::"Plan", 10),
('fleet', 'STARTER'::"Plan", 20),
('reservations', 'STARTER'::"Plan", 30),
('customers', 'STARTER'::"Plan", 40),
('subscription', 'STARTER'::"Plan", 50),
('contracts', 'STARTER'::"Plan", 60),
('notifications', 'STARTER'::"Plan", 70),
('settings', 'STARTER'::"Plan", 80),
('dashboard', 'GROWTH'::"Plan", 10),
('fleet', 'GROWTH'::"Plan", 20),
('reservations', 'GROWTH'::"Plan", 30),
('customers', 'GROWTH'::"Plan", 40),
('subscription', 'GROWTH'::"Plan", 50),
('contracts', 'GROWTH'::"Plan", 60),
('notifications', 'GROWTH'::"Plan", 70),
('settings', 'GROWTH'::"Plan", 80),
('onlineReservations', 'GROWTH'::"Plan", 90),
('team', 'GROWTH'::"Plan", 100),
('offers', 'GROWTH'::"Plan", 110),
('reports', 'GROWTH'::"Plan", 120),
('billing', 'GROWTH'::"Plan", 130),
('reviews', 'GROWTH'::"Plan", 140),
('complaints', 'GROWTH'::"Plan", 150),
('dashboard', 'PRO'::"Plan", 10),
('fleet', 'PRO'::"Plan", 20),
('reservations', 'PRO'::"Plan", 30),
('customers', 'PRO'::"Plan", 40),
('subscription', 'PRO'::"Plan", 50),
('contracts', 'PRO'::"Plan", 60),
('notifications', 'PRO'::"Plan", 70),
('settings', 'PRO'::"Plan", 80),
('onlineReservations', 'PRO'::"Plan", 90),
('team', 'PRO'::"Plan", 100),
('offers', 'PRO'::"Plan", 110),
('reports', 'PRO'::"Plan", 120),
('billing', 'PRO'::"Plan", 130),
('reviews', 'PRO'::"Plan", 140),
('complaints', 'PRO'::"Plan", 150)
)
INSERT INTO "subscription_menu_items" (
"id",
"plan",
"menuItemId",
"displayOrder",
"isActive"
)
SELECT
'menu_sub_' || lower(csa."plan"::text) || '_' || csa."systemKey",
csa."plan",
mi."id",
csa."displayOrder",
true
FROM canonical_subscription_assignments csa
JOIN "menu_items" mi ON mi."systemKey" = csa."systemKey";
DELETE FROM "menu_item_role_visibility"
WHERE "menuItemId" IN (
SELECT "id"
FROM "menu_items"
WHERE "systemKey" IN (
'dashboard',
'fleet',
'reservations',
'customers',
'subscription',
'contracts',
'notifications',
'settings',
'onlineReservations',
'team',
'offers',
'reports',
'billing',
'reviews',
'complaints'
)
);
WITH canonical_role_visibilities ("systemKey", "role") AS (
VALUES
('dashboard', 'OWNER'::"EmployeeRole"),
('dashboard', 'MANAGER'::"EmployeeRole"),
('dashboard', 'AGENT'::"EmployeeRole"),
('fleet', 'OWNER'::"EmployeeRole"),
('fleet', 'MANAGER'::"EmployeeRole"),
('fleet', 'AGENT'::"EmployeeRole"),
('reservations', 'OWNER'::"EmployeeRole"),
('reservations', 'MANAGER'::"EmployeeRole"),
('reservations', 'AGENT'::"EmployeeRole"),
('customers', 'OWNER'::"EmployeeRole"),
('customers', 'MANAGER'::"EmployeeRole"),
('customers', 'AGENT'::"EmployeeRole"),
('subscription', 'OWNER'::"EmployeeRole"),
('contracts', 'OWNER'::"EmployeeRole"),
('contracts', 'MANAGER'::"EmployeeRole"),
('contracts', 'AGENT'::"EmployeeRole"),
('notifications', 'OWNER'::"EmployeeRole"),
('notifications', 'MANAGER'::"EmployeeRole"),
('notifications', 'AGENT'::"EmployeeRole"),
('settings', 'OWNER'::"EmployeeRole"),
('onlineReservations', 'OWNER'::"EmployeeRole"),
('onlineReservations', 'MANAGER'::"EmployeeRole"),
('onlineReservations', 'AGENT'::"EmployeeRole"),
('team', 'OWNER'::"EmployeeRole"),
('team', 'MANAGER'::"EmployeeRole"),
('team', 'AGENT'::"EmployeeRole"),
('offers', 'OWNER'::"EmployeeRole"),
('offers', 'MANAGER'::"EmployeeRole"),
('reports', 'OWNER'::"EmployeeRole"),
('reports', 'MANAGER'::"EmployeeRole"),
('billing', 'OWNER'::"EmployeeRole"),
('billing', 'MANAGER'::"EmployeeRole"),
('reviews', 'OWNER'::"EmployeeRole"),
('reviews', 'MANAGER'::"EmployeeRole"),
('reviews', 'AGENT'::"EmployeeRole"),
('complaints', 'OWNER'::"EmployeeRole"),
('complaints', 'MANAGER'::"EmployeeRole"),
('complaints', 'AGENT'::"EmployeeRole")
)
INSERT INTO "menu_item_role_visibility" (
"id",
"menuItemId",
"role"
)
SELECT
'menu_role_' || lower(crv."role"::text) || '_' || crv."systemKey",
mi."id",
crv."role"
FROM canonical_role_visibilities crv
JOIN "menu_items" mi ON mi."systemKey" = crv."systemKey";
+83
View File
@@ -170,6 +170,14 @@ enum EmployeeRole {
AGENT
}
enum MenuItemType {
INTERNAL_PAGE
EXTERNAL_LINK
PARENT_MENU
SECTION_LABEL
DIVIDER
}
enum VehicleStatus {
AVAILABLE
RESERVED
@@ -513,6 +521,7 @@ model Company {
pricingRules PricingRule[]
notifications Notification[] @relation("CompanyNotifications")
complaints Complaint[]
companyMenuItems CompanyMenuItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -1804,6 +1813,80 @@ model PlanFeature {
@@map("plan_features")
}
model MenuItem {
id String @id @default(cuid())
systemKey String? @unique
label String
itemType MenuItemType @default(INTERNAL_PAGE)
routeOrUrl String?
icon String?
parentId String?
parent MenuItem? @relation("MenuItemHierarchy", fields: [parentId], references: [id], onDelete: SetNull)
children MenuItem[] @relation("MenuItemHierarchy")
displayOrder Int @default(0)
openInNewTab Boolean @default(false)
isRequired Boolean @default(false)
isActive Boolean @default(true)
createdBy String?
updatedBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
subscriptionAssignments SubscriptionMenuItem[]
companyAssignments CompanyMenuItem[]
roleVisibilities MenuItemRoleVisibility[]
@@index([parentId])
@@index([isActive, displayOrder])
@@map("menu_items")
}
model SubscriptionMenuItem {
id String @id @default(cuid())
plan Plan
menuItemId String
menuItem MenuItem @relation(fields: [menuItemId], references: [id], onDelete: Cascade)
displayOrder Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([plan, menuItemId])
@@index([menuItemId])
@@index([plan, displayOrder])
@@map("subscription_menu_items")
}
model CompanyMenuItem {
id String @id @default(cuid())
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
menuItemId String
menuItem MenuItem @relation(fields: [menuItemId], references: [id], onDelete: Cascade)
displayOrder Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([companyId, menuItemId])
@@index([menuItemId])
@@index([companyId, displayOrder])
@@map("company_menu_items")
}
model MenuItemRoleVisibility {
id String @id @default(cuid())
menuItemId String
menuItem MenuItem @relation(fields: [menuItemId], references: [id], onDelete: Cascade)
role EmployeeRole
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([menuItemId, role])
@@index([role])
@@map("menu_item_role_visibility")
}
model PricingPromotion {
id String @id @default(cuid())
code String @unique