Files
bellsystems-cp/frontend/src/components/layout/Sidebar.jsx

371 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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>
)
}