Initial Switch to V2. Completely Overhauled Backend, Frontend and General Structure.
This commit is contained in:
338
frontend/src/components/layout/Sidebar.jsx
Normal file
338
frontend/src/components/layout/Sidebar.jsx
Normal file
@@ -0,0 +1,338 @@
|
||||
// frontend/src/components/layout/Sidebar.jsx
|
||||
// Primary navigation sidebar — 224px wide, fixed, full height.
|
||||
//
|
||||
// Visual style (matches Stitch reference):
|
||||
// - Brand header with logo at top
|
||||
// - Section labels: plain uppercase text, generous padding, no rule lines
|
||||
// - Nav items: px-6 py-3, full width, 3px left bar + primary-subtle bg when active
|
||||
// - Collapsible groups: same row height as nav items
|
||||
// - Children: darker inset bg, deep left-indent, text-color hover
|
||||
// - Console Settings: pinned at bottom
|
||||
|
||||
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'
|
||||
|
||||
// ─── Icon set ─────────────────────────────────────────────────────────────────
|
||||
|
||||
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>
|
||||
)
|
||||
|
||||
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: () => <S><rect x="2" y="3" width="12" height="8" rx="1.5"/><path d="M5 14h6M8 11v3"/></S>,
|
||||
deviceOverview: () => <S><circle cx="8" cy="6" r="3"/><path d="M3 13c0-2.76 2.24-5 5-5s5 2.24 5 5"/></S>,
|
||||
fleet: () => <S><path d="M2 5h12M2 8h9M2 11h6"/></S>,
|
||||
commandCenter: () => <S><rect x="2" y="2" width="12" height="12" rx="1.5"/><path d="M5 6l2 2-2 2M9 10h2"/></S>,
|
||||
blackBox: () => <S><rect x="2" y="4" width="12" height="8" rx="1"/><path d="M5 8h6"/></S>,
|
||||
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: () => <S><path d="M9 3v7"/><path d="M9 3l4-1v7"/><circle cx="7" cy="10" r="2"/><circle cx="11" cy="9" r="2"/></S>,
|
||||
library: () => <S><rect x="2" y="2" width="4" height="12" rx="1"/><rect x="7" y="4" width="4" height="10" rx="1"/><rect x="12" y="2" width="2" height="12" rx="1"/></S>,
|
||||
composer: () => <S><path d="M2 12L10 4l2 2-8 8H2v-2z"/><path d="M8 6l2 2"/></S>,
|
||||
archetypes: () => <S><path d="M8 2l2 4h4l-3 3 1 4-4-2.5L4 13l1-4-3-3h4z"/></S>,
|
||||
melodySettings: () => <S><path d="M2 4h2"/><path d="M6 4h8"/><circle cx="5" cy="4" r="1.5" fill="currentColor" stroke="none"/><path d="M2 8h6"/><path d="M10 8h4"/><circle cx="9" cy="8" r="1.5" fill="currentColor" stroke="none"/><path d="M2 12h9"/><path d="M13 12h1"/><circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none"/></S>,
|
||||
communications: () => <S><path d="M2 3h12v8H2z"/><path d="M5 14l3-3 3 3"/></S>,
|
||||
mail: () => <S><rect x="2" y="4" width="12" height="9" rx="1"/><path d="M2 5l6 4 6-4"/></S>,
|
||||
whatsapp: () => <S><path d="M8 2a6 6 0 0 1 6 6c0 3.31-2.69 6-6 6a5.97 5.97 0 0 1-3.1-.86L2 14l.86-2.9A5.97 5.97 0 0 1 2 8a6 6 0 0 1 6-6z"/></S>,
|
||||
sms: () => <S><path d="M2 3h12v8H8l-3 2.5V11H2z"/></S>,
|
||||
helpdesk: () => <S><path d="M8 2a4 4 0 0 0-4 4c0 1.5.82 2.8 2 3.46V11h4V9.46A4 4 0 0 0 8 2z"/><path d="M6 13h4"/><path d="M8 11v2"/></S>,
|
||||
commsLog: () => <S><circle cx="8" cy="8" r="6"/><path d="M8 5v3l2 2"/></S>,
|
||||
customers: () => <S><rect x="3" y="2" width="10" height="7" rx="1"/><path d="M1 14c0-2.76 3.13-5 7-5s7 2.24 7 5"/></S>,
|
||||
customerOverview: () => <S><path d="M2 12l3-5 3 3 2-4 4 6"/></S>,
|
||||
orders: () => <S><rect x="3" y="2" width="10" height="12" rx="1"/><path d="M6 6h4M6 9h4M6 12h2"/></S>,
|
||||
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: () => <S><path d="M8 2L2 5v6l6 3 6-3V5z"/><path d="M8 2v9M2 5l6 3 6-3"/></S>,
|
||||
catalog: () => <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>,
|
||||
snManager: () => <S><rect x="2" y="5" width="12" height="6" rx="1"/><path d="M4 8h1M6 8h1M8 8h1M10 8h1"/></S>,
|
||||
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: () => <S><path d="M2 12l3-6 3 3 2-5 4 8H2z"/><circle cx="5" cy="5" r="1.5"/></S>,
|
||||
inventory: () => <S><rect x="2" y="7" width="5" height="7" rx="1"/><rect x="5.5" y="4" width="5" height="10" rx="1"/><rect x="9" y="2" width="5" height="12" rx="1"/></S>,
|
||||
provisioning: () => <S><path d="M8 2v8"/><path d="M5 8l3 3 3-3"/><path d="M3 13h10"/></S>,
|
||||
firmware: () => <S><rect x="3" y="4" width="10" height="8" rx="1"/><path d="M6 7h4M7 10h2"/><path d="M6 2h4M6 14h4"/></S>,
|
||||
api: () => <S><path d="M4 6l-2 2 2 2M12 6l2 2-2 2M9 4l-2 8"/></S>,
|
||||
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: 'settings', 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' : ''}`}
|
||||
>
|
||||
{({ isActive }) => {
|
||||
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"
|
||||
style={{ height: '18px', width: 'auto', objectFit: 'contain' }}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user