371 lines
16 KiB
JavaScript
371 lines
16 KiB
JavaScript
// frontend/src/components/layout/Sidebar.jsx
|
||
// Primary navigation sidebar — 224px wide, fixed, full height.
|
||
|
||
import { useState } from 'react'
|
||
import { NavLink, useLocation } from 'react-router-dom'
|
||
import { useAuth } from '@/hooks/useAuth'
|
||
import logoDark from '@/assets/logos/bell_systems_horizontal_darkMode.png'
|
||
|
||
// ─── SVG file icons (vite-plugin-svgr v4 — ?react query) ─────────────────────
|
||
|
||
import IcoDevices from '@/assets/side-menu-icons/devices.svg?react'
|
||
import IcoDeviceOverview from '@/assets/side-menu-icons/device-overview.svg?react'
|
||
import IcoFleet from '@/assets/side-menu-icons/fleet.svg?react'
|
||
import IcoBlackbox from '@/assets/side-menu-icons/blackbox.svg?react'
|
||
import IcoMelodies from '@/assets/side-menu-icons/melodies.svg?react'
|
||
import IcoMelodiesEditor from '@/assets/side-menu-icons/melodies-editor.svg?react'
|
||
import IcoComposer from '@/assets/side-menu-icons/composer.svg?react'
|
||
import IcoArchetypes from '@/assets/side-menu-icons/archetypes.svg?react'
|
||
import IcoMelodySettings from '@/assets/side-menu-icons/melody-settings.svg?react'
|
||
import IcoCommunications from '@/assets/side-menu-icons/communications.svg?react'
|
||
import IcoWhatsapp from '@/assets/side-menu-icons/whatsapp.svg?react'
|
||
import IcoSms from '@/assets/side-menu-icons/sms.svg?react'
|
||
import IcoHelpdesk from '@/assets/side-menu-icons/helpdesk.svg?react'
|
||
import IcoCommsLog from '@/assets/side-menu-icons/communications-log.svg?react'
|
||
import IcoCustomers from '@/assets/side-menu-icons/customers.svg?react'
|
||
import IcoCustomerOverview from '@/assets/side-menu-icons/customer-overview.svg?react'
|
||
import IcoOrders from '@/assets/side-menu-icons/orders.svg?react'
|
||
import IcoProducts from '@/assets/side-menu-icons/products.svg?react'
|
||
import IcoCatalog from '@/assets/side-menu-icons/product-catalog.svg?react'
|
||
import IcoSnManager from '@/assets/side-menu-icons/sn-manager.svg?react'
|
||
import IcoManufacturing from '@/assets/side-menu-icons/manufacturing.svg?react'
|
||
import IcoInventory from '@/assets/side-menu-icons/inventory.svg?react'
|
||
import IcoProvisioning from '@/assets/side-menu-icons/provision.svg?react'
|
||
import IcoFirmware from '@/assets/side-menu-icons/firmware.svg?react'
|
||
import IcoApi from '@/assets/side-menu-icons/api.svg?react'
|
||
|
||
// ─── Inline-only icons (no SVG file equivalent) ───────────────────────────────
|
||
|
||
const S = ({ children, ...p }) => (
|
||
<svg
|
||
width="16" height="16" viewBox="0 0 16 16"
|
||
fill="none" stroke="currentColor" strokeWidth="1.5"
|
||
strokeLinecap="round" strokeLinejoin="round"
|
||
aria-hidden="true" focusable="false"
|
||
style={{ flexShrink: 0 }}
|
||
{...p}
|
||
>
|
||
{children}
|
||
</svg>
|
||
)
|
||
|
||
// Wrapper to normalise imported SVG file components to 16×16
|
||
function SvgIcon({ Component }) {
|
||
return (
|
||
<Component
|
||
width="16" height="16"
|
||
aria-hidden="true" focusable="false"
|
||
style={{ flexShrink: 0 }}
|
||
/>
|
||
)
|
||
}
|
||
|
||
const Icons = {
|
||
dashboard: () => <S><rect x="2" y="2" width="5" height="5" rx="1"/><rect x="9" y="2" width="5" height="5" rx="1"/><rect x="2" y="9" width="5" height="5" rx="1"/><rect x="9" y="9" width="5" height="5" rx="1"/></S>,
|
||
devices: () => <SvgIcon Component={IcoDevices} />,
|
||
deviceOverview: () => <SvgIcon Component={IcoDeviceOverview} />,
|
||
fleet: () => <SvgIcon Component={IcoFleet} />,
|
||
commandCenter: () => <S><rect x="2" y="2" width="12" height="12" rx="1.5"/><path d="M5 6l2 2-2 2M9 10h2"/></S>,
|
||
blackBox: () => <SvgIcon Component={IcoBlackbox} />,
|
||
deviceSettings: () => <SvgIcon Component={IcoMelodySettings} />,
|
||
appUsers: () => <S><circle cx="6" cy="5" r="2.5"/><path d="M2 13c0-2.2 1.8-4 4-4"/><circle cx="12" cy="7" r="2"/><path d="M9.5 13c0-1.65 1.12-3 2.5-3s2.5 1.35 2.5 3"/></S>,
|
||
melodies: () => <SvgIcon Component={IcoMelodies} />,
|
||
library: () => <SvgIcon Component={IcoMelodiesEditor} />,
|
||
composer: () => <SvgIcon Component={IcoComposer} />,
|
||
archetypes: () => <SvgIcon Component={IcoArchetypes} />,
|
||
melodySettings: () => <SvgIcon Component={IcoMelodySettings} />,
|
||
communications: () => <SvgIcon Component={IcoCommunications} />,
|
||
mail: () => <S><rect x="2" y="4" width="12" height="9" rx="1"/><path d="M2 5l6 4 6-4"/></S>,
|
||
whatsapp: () => <SvgIcon Component={IcoWhatsapp} />,
|
||
sms: () => <SvgIcon Component={IcoSms} />,
|
||
helpdesk: () => <SvgIcon Component={IcoHelpdesk} />,
|
||
commsLog: () => <SvgIcon Component={IcoCommsLog} />,
|
||
customers: () => <SvgIcon Component={IcoCustomers} />,
|
||
customerOverview: () => <SvgIcon Component={IcoCustomerOverview} />,
|
||
orders: () => <SvgIcon Component={IcoOrders} />,
|
||
quotations: () => <S><rect x="3" y="2" width="10" height="12" rx="1"/><path d="M6 5h4M6 8h3M9 11l1-1 1 1 1-3"/></S>,
|
||
products: () => <SvgIcon Component={IcoProducts} />,
|
||
catalog: () => <SvgIcon Component={IcoCatalog} />,
|
||
snManager: () => <SvgIcon Component={IcoSnManager} />,
|
||
staffLog: () => <S><rect x="4" y="2" width="8" height="2" rx="1"/><rect x="2" y="3" width="12" height="11" rx="1"/><path d="M5 8h6M5 11h4"/></S>,
|
||
manufacturing: () => <SvgIcon Component={IcoManufacturing} />,
|
||
inventory: () => <SvgIcon Component={IcoInventory} />,
|
||
provisioning: () => <SvgIcon Component={IcoProvisioning} />,
|
||
firmware: () => <SvgIcon Component={IcoFirmware} />,
|
||
api: () => <SvgIcon Component={IcoApi} />,
|
||
settings: () => <S><circle cx="8" cy="8" r="2.5"/><path d="M8 2v1.5M8 12.5V14M2 8h1.5M12.5 8H14M3.5 3.5l1 1M11.5 11.5l1 1M3.5 12.5l1-1M11.5 4.5l1-1"/></S>,
|
||
staff: () => <S><circle cx="8" cy="5" r="3"/><path d="M2 14c0-3.31 2.69-6 6-6s6 2.69 6 6"/></S>,
|
||
publicFeatures: () => <S><path d="M2 8a6 6 0 1 0 12 0A6 6 0 0 0 2 8z"/><path d="M8 2a9 9 0 0 0 0 12M8 2a9 9 0 0 1 0 12M2 8h12"/></S>,
|
||
logViewer: () => <S><rect x="2" y="2" width="12" height="12" rx="1.5"/><path d="M5 6h6M5 9h4M5 12h2"/></S>,
|
||
placeholder: () => <S><rect x="3" y="3" width="10" height="10" rx="2"/></S>,
|
||
chevronRight: () => (
|
||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor"
|
||
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"
|
||
aria-hidden="true" focusable="false" style={{ flexShrink: 0 }}
|
||
>
|
||
<path d="M4 2l4 4-4 4" />
|
||
</svg>
|
||
),
|
||
lock: () => (
|
||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor"
|
||
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"
|
||
aria-hidden="true" focusable="false" style={{ flexShrink: 0 }}
|
||
>
|
||
<rect x="2" y="5" width="8" height="6" rx="1" />
|
||
<path d="M4 5V4a2 2 0 0 1 4 0v1" />
|
||
</svg>
|
||
),
|
||
}
|
||
|
||
// ─── Nav structure ────────────────────────────────────────────────────────────
|
||
|
||
const navSections = [
|
||
{ type: 'separator', label: 'General' },
|
||
{ to: '/', label: 'Dashboard', icon: 'dashboard' },
|
||
|
||
{ type: 'separator', label: 'Bell Cloud' },
|
||
{
|
||
label: 'Devices', icon: 'devices', permission: 'devices',
|
||
children: [
|
||
{ to: '/devices/overview', label: 'Overview', icon: 'deviceOverview', placeholder: true },
|
||
{ to: '/devices', label: 'Fleet', icon: 'fleet', exact: true },
|
||
{ to: '/mqtt/commands', label: 'Command Center', icon: 'commandCenter' },
|
||
{ to: '/equipment/notes', label: 'BlackBox', icon: 'blackBox' },
|
||
{ to: '/devices/settings', label: 'Device Settings', icon: 'deviceSettings', placeholder: true },
|
||
],
|
||
},
|
||
{ to: '/users', label: 'App Users', icon: 'appUsers', permission: 'app_users' },
|
||
{
|
||
label: 'Melodies', icon: 'melodies', permission: 'melodies',
|
||
children: [
|
||
{ to: '/melodies', label: 'Library', icon: 'library', exact: true },
|
||
{ to: '/melodies/composer', label: 'Composer', icon: 'composer' },
|
||
{ to: '/melodies/archetypes', label: 'Archetypes', icon: 'archetypes' },
|
||
{ to: '/melodies/settings', label: 'Melody Settings', icon: 'melodySettings' },
|
||
],
|
||
},
|
||
|
||
{ type: 'separator', label: 'Headquarters' },
|
||
{
|
||
label: 'Communications', icon: 'communications', permission: 'crm',
|
||
children: [
|
||
{ to: '/mail', label: 'Mailbox', icon: 'mail' },
|
||
{ to: '/comms/whatsapp', label: 'WhatsApp', icon: 'whatsapp', placeholder: true },
|
||
{ to: '/comms/sms', label: 'SMS', icon: 'sms', placeholder: true },
|
||
{ to: '/crm/comms/helpdesk', label: 'Helpdesk', icon: 'helpdesk', exact: true },
|
||
{ to: '/crm/comms', label: 'Comms Log', icon: 'commsLog', exact: true },
|
||
],
|
||
},
|
||
{
|
||
label: 'Customers', icon: 'customers', permission: 'crm',
|
||
children: [
|
||
{ to: '/crm/customers', label: 'Overview', icon: 'customerOverview' },
|
||
{ to: '/crm/orders', label: 'Orders', icon: 'orders' },
|
||
{ to: '/crm/quotations', label: 'Quotations', icon: 'quotations' },
|
||
],
|
||
},
|
||
{
|
||
label: 'Products', icon: 'products', permission: 'crm',
|
||
children: [
|
||
{ to: '/crm/products', label: 'Catalog', icon: 'catalog' },
|
||
{ to: '/crm/products/sn', label: 'S/N Manager', icon: 'snManager', placeholder: true },
|
||
],
|
||
},
|
||
{ to: '/staff-log', label: 'Staff Log', icon: 'staffLog', placeholder: true },
|
||
|
||
{ type: 'separator', label: 'Engineering' },
|
||
{
|
||
label: 'Manufacturing', icon: 'manufacturing', permission: 'manufacturing',
|
||
children: [
|
||
{ to: '/manufacturing', label: 'Inventory', icon: 'inventory', exact: true },
|
||
{ to: '/manufacturing/provision', label: 'Provisioning', icon: 'provisioning' },
|
||
],
|
||
},
|
||
{ to: '/firmware', label: 'Firmware Manager', icon: 'firmware', permission: 'manufacturing' },
|
||
{ to: '/developer/api', label: 'API Reference', icon: 'api', roleRequired: ['sysadmin', 'admin'] },
|
||
]
|
||
|
||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
function isGroupActive(children, pathname) {
|
||
return children.some((child) => {
|
||
if (child.exact) return pathname === child.to
|
||
return pathname === child.to || pathname.startsWith(child.to + '/')
|
||
})
|
||
}
|
||
|
||
// ─── Section separator ────────────────────────────────────────────────────────
|
||
|
||
function SectionSeparator({ label, first }) {
|
||
return (
|
||
<div className={`nav-sep${first ? ' nav-sep--first' : ''}`}>
|
||
<span className="nav-sep-label">{label}</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── Placeholder item ─────────────────────────────────────────────────────────
|
||
|
||
function PlaceholderItem({ label, icon, child = false }) {
|
||
const IconComp = Icons[icon] ?? Icons.placeholder
|
||
return (
|
||
<div className={`nav-placeholder ${child ? 'nav-placeholder--child' : 'nav-placeholder--root'}`}>
|
||
<IconComp />
|
||
<span style={{ flex: 1 }}>{label}</span>
|
||
<span className="nav-soon-badge">soon</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── Collapsible group ────────────────────────────────────────────────────────
|
||
|
||
function CollapsibleGroup({ label, icon, children, currentPath, locked, open, onToggle }) {
|
||
const IconComp = Icons[icon] ?? Icons.placeholder
|
||
const childActive = isGroupActive(children, currentPath)
|
||
const isOpen = open || childActive
|
||
|
||
return (
|
||
<div>
|
||
<button
|
||
type="button"
|
||
onClick={() => !locked && onToggle()}
|
||
disabled={locked}
|
||
aria-expanded={isOpen}
|
||
className={`nav-group-btn${childActive ? ' nav-group-btn--active' : ''}`}
|
||
>
|
||
<IconComp />
|
||
<span style={{ flex: 1 }}>{label}</span>
|
||
{locked
|
||
? <Icons.lock />
|
||
: (
|
||
<span className={`nav-chevron${isOpen ? ' nav-chevron--open' : ''}`}>
|
||
<Icons.chevronRight />
|
||
</span>
|
||
)
|
||
}
|
||
</button>
|
||
|
||
{!locked && isOpen && (
|
||
<div className="nav-children">
|
||
{children.map((child) =>
|
||
child.placeholder ? (
|
||
<PlaceholderItem key={child.to} label={child.label} icon={child.icon} child />
|
||
) : (
|
||
<NavLink
|
||
key={child.to}
|
||
to={child.to}
|
||
end={child.exact === true}
|
||
className={({ isActive }) => `nav-child-link${isActive ? ' active' : ''}`}
|
||
>
|
||
{() => {
|
||
const ChildIcon = Icons[child.icon] ?? Icons.placeholder
|
||
return (
|
||
<>
|
||
<ChildIcon />
|
||
<span>{child.label}</span>
|
||
</>
|
||
)
|
||
}}
|
||
</NavLink>
|
||
)
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── Main Sidebar ─────────────────────────────────────────────────────────────
|
||
|
||
export default function Sidebar() {
|
||
const { hasPermission, hasRole } = useAuth()
|
||
const location = useLocation()
|
||
|
||
const [openGroup, setOpenGroup] = useState(() => {
|
||
for (const item of navSections) {
|
||
if (item.children && isGroupActive(item.children, location.pathname)) {
|
||
return item.label
|
||
}
|
||
}
|
||
return null
|
||
})
|
||
|
||
const canView = (item) => {
|
||
if (item.roleRequired) return hasRole(...item.roleRequired)
|
||
if (!item.permission) return true
|
||
return hasPermission(item.permission, 'view')
|
||
}
|
||
|
||
const handleToggle = (label) => setOpenGroup((prev) => (prev === label ? null : label))
|
||
|
||
return (
|
||
<aside aria-label="Main navigation" className="sidebar">
|
||
|
||
{/* ── Brand header ── */}
|
||
<div className="sidebar-brand">
|
||
<img
|
||
src={logoDark}
|
||
alt="BellSystems"
|
||
className="sidebar-brand-logo"
|
||
/>
|
||
</div>
|
||
|
||
{/* ── Navigation ── */}
|
||
<nav className="sidebar-nav">
|
||
{navSections.map((item, idx) => {
|
||
if (item.type === 'separator') {
|
||
return (
|
||
<SectionSeparator
|
||
key={`sep-${idx}`}
|
||
label={item.label}
|
||
first={idx === 0}
|
||
/>
|
||
)
|
||
}
|
||
|
||
if (!canView(item)) return null
|
||
|
||
if (item.placeholder) {
|
||
return (
|
||
<PlaceholderItem
|
||
key={item.to}
|
||
label={item.label}
|
||
icon={item.icon}
|
||
/>
|
||
)
|
||
}
|
||
|
||
if (item.children) {
|
||
return (
|
||
<CollapsibleGroup
|
||
key={item.label}
|
||
label={item.label}
|
||
icon={item.icon}
|
||
children={item.children}
|
||
currentPath={location.pathname}
|
||
locked={false}
|
||
open={openGroup === item.label}
|
||
onToggle={() => handleToggle(item.label)}
|
||
/>
|
||
)
|
||
}
|
||
|
||
const IconComp = Icons[item.icon] ?? Icons.placeholder
|
||
return (
|
||
<NavLink
|
||
key={item.to}
|
||
to={item.to}
|
||
end={item.to === '/'}
|
||
className={({ isActive }) => `nav-link${isActive ? ' active' : ''}`}
|
||
>
|
||
<IconComp />
|
||
{item.label}
|
||
</NavLink>
|
||
)
|
||
})}
|
||
</nav>
|
||
|
||
</aside>
|
||
)
|
||
}
|