add editable menu items for companies
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 plan’s 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 user’s 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 user’s 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 user’s company.
|
||||
3. Get the company’s 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;
|
||||
+215
@@ -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";
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user