AppLayout.tsx 5.39 KB
// vibe_erp main app shell.
//
// Top bar (logo + version + user + logout) and a permanent left
// sidebar grouped by Packaged Business Capability (PBC). The
// `<Outlet />` renders whichever child route the router matched.
//
// **Why a static nav array.** The framework already exposes
// menu metadata at /api/v1/_meta/metadata, but plumbing that into
// the SPA is a Phase 3 (Tier 1 customization) concern. v1 SPA
// hard-codes a sensible default that mirrors the implemented PBCs;
// a future chunk replaces this with a metadata-driven sidebar so
// plug-ins can contribute new menus through their YAML.

import { NavLink, Outlet } from 'react-router-dom'
import { useEffect, useState } from 'react'
import { useAuth } from '@/auth/AuthContext'
import { meta } from '@/api/client'
import type { MetaInfo } from '@/types/api'

interface NavItem {
  to: string
  label: string
}
interface NavGroup {
  heading: string
  items: NavItem[]
}

const NAV: NavGroup[] = [
  {
    heading: 'Overview',
    items: [{ to: '/', label: 'Dashboard' }],
  },
  {
    heading: 'Catalog & Partners',
    items: [
      { to: '/items', label: 'Items' },
      { to: '/uoms', label: 'Units of Measure' },
      { to: '/partners', label: 'Partners' },
    ],
  },
  {
    heading: 'Inventory',
    items: [
      { to: '/locations', label: 'Locations' },
      { to: '/balances', label: 'Stock Balances' },
      { to: '/movements', label: 'Stock Movements' },
    ],
  },
  {
    heading: 'Orders',
    items: [
      { to: '/sales-orders', label: 'Sales Orders' },
      { to: '/purchase-orders', label: 'Purchase Orders' },
    ],
  },
  {
    heading: 'Production',
    items: [
      { to: '/work-orders', label: 'Work Orders' },
      { to: '/shop-floor', label: 'Shop Floor' },
    ],
  },
  {
    heading: 'Finance',
    items: [
      { to: '/accounts', label: 'Chart of Accounts' },
      { to: '/journal-entries', label: 'Journal Entries' },
    ],
  },
  {
    heading: 'System',
    items: [
      { to: '/users', label: 'Users' },
      { to: '/roles', label: 'Roles' },
    ],
  },
]

export function AppLayout() {
  const { username, logout } = useAuth()
  const [info, setInfo] = useState<MetaInfo | null>(null)

  useEffect(() => {
    meta.info().then(setInfo).catch(() => setInfo(null))
  }, [])

  return (
    <div className="flex min-h-screen bg-slate-50 text-slate-900">
      {/* ─── Sidebar ──────────────────────────────────────────── */}
      <aside className="hidden w-60 flex-shrink-0 border-r border-slate-200 bg-white sm:flex sm:flex-col">
        <div className="flex h-14 items-center px-4 border-b border-slate-200">
          <span className="text-lg font-bold text-brand-600">vibe_erp</span>
          {info && (
            <span className="ml-2 text-xs text-slate-400">
              {info.implementationVersion}
            </span>
          )}
        </div>
        <nav className="flex-1 overflow-y-auto px-3 py-4">
          {NAV.map((group) => (
            <div key={group.heading} className="mb-5">
              <div className="px-2 text-xs font-semibold uppercase tracking-wider text-slate-400">
                {group.heading}
              </div>
              <ul className="mt-1 space-y-0.5">
                {group.items.map((item) => (
                  <li key={item.to}>
                    <NavLink
                      to={item.to}
                      end={item.to === '/'}
                      className={({ isActive }) =>
                        [
                          'block rounded-md px-2 py-1.5 text-sm transition',
                          isActive
                            ? 'bg-brand-50 text-brand-700 font-semibold'
                            : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900',
                        ].join(' ')
                      }
                    >
                      {item.label}
                    </NavLink>
                  </li>
                ))}
              </ul>
            </div>
          ))}
        </nav>
        <div className="border-t border-slate-200 px-3 py-3 text-xs text-slate-400">
          {info?.activeProfiles?.length ? (
            <div>profiles: {info.activeProfiles.join(', ')}</div>
          ) : null}
          {info?.buildTime ? (
            <div>built: {new Date(info.buildTime).toLocaleString()}</div>
          ) : null}
        </div>
      </aside>

      {/* ─── Main column ─────────────────────────────────────── */}
      <div className="flex flex-1 flex-col">
        <header className="flex h-14 items-center justify-between border-b border-slate-200 bg-white px-6 sm:justify-end">
          <span className="text-lg font-bold text-brand-600 sm:hidden">vibe_erp</span>
          <div className="flex items-center gap-3">
            <span className="text-sm text-slate-500">
              {username ? (
                <>
                  Signed in as <span className="font-medium text-slate-700">{username}</span>
                </>
              ) : (
                'Signed in'
              )}
            </span>
            <button className="btn-secondary" onClick={logout}>
              Logout
            </button>
          </div>
        </header>
        <main className="flex-1 overflow-y-auto p-6">
          <Outlet />
        </main>
      </div>
    </div>
  )
}