import { useEffect, useMemo, useRef, useState } from 'react' import { Navigate, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom' import { LogOut, Menu, Search, UserRound } from 'lucide-react' import { avatarColors, Badge, CommandPalette, initials, ModeToggle, type PaletteItem, } from '@sims/ui' import { clearSession, displayName, getClients, getDocuments, getEmailStatus, getModules, getPipeline, getReminders, getRenewals, getSearch, getSmsBalances, getTickets, hasSession, role, } from './api' import { NAV_GROUPS } from './nav' const APP_VERSION = 'v0.1.0' /** App shell: grouped sidebar + top bar + Ctrl+K palette + Gmail-health banner. */ export function Layout() { const nav = useNavigate() const location = useLocation() const [gmail, setGmail] = useState<'ok' | 'down' | 'unknown'>('unknown') // Sidebar collapse (D18 WS-E): icon rail on demand, remembered across reloads. const [collapsed, setCollapsed] = useState(() => localStorage.getItem('hq.sideCollapsed') === '1') const toggleCollapsed = () => setCollapsed((v) => { localStorage.setItem('hq.sideCollapsed', v ? '0' : '1') return !v }) const [queueCounts, setQueueCounts] = useState({ queued: 0, failed: 0 }) // Section-badge counts — fetched once on mount (best-effort, stay 0 on failure). const [sectionCounts, setSectionCounts] = useState({ pipeline: 0, tickets: 0, renewals: 0, smsLow: 0, clients: 0, docDrafts: 0, modules: 0, }) const [healthy, setHealthy] = useState() const [menuOpen, setMenuOpen] = useState(false) const [userOpen, setUserOpen] = useState(false) const userRef = useRef(null) const [paletteOpen, setPaletteOpen] = useState(false) const [clientHits, setClientHits] = useState([]) const queryTimer = useRef(undefined) const querySeq = useRef(0) useEffect(() => { if (!hasSession()) return getEmailStatus() .then((s) => setGmail(!s.connected || s.dead ? 'down' : 'ok')) .catch(() => { /* 401 already redirected; other failures keep it unknown */ }) fetch('/api/health').then((r) => setHealthy(r.ok)).catch(() => setHealthy(false)) }, []) // Reminder-queue badge (Dashboard nav item) — refreshed on every route change. // Uses the honest scoped `total` (never rows.length): staff badge counts match // their own queue, and pagination cannot under-count. useEffect(() => { if (!hasSession()) return Promise.all([getReminders({ status: 'queued' }), getReminders({ status: 'failed' })]) .then(([q, f]) => setQueueCounts({ queued: q.total, failed: f.total })) .catch(() => { /* badge is best-effort */ }) }, [location.pathname]) // Section count badges — fetched once; each is best-effort (badge stays 0 on failure). useEffect(() => { if (!hasSession()) return getPipeline({ filter: 'all' }).then((r) => setSectionCounts((c) => ({ ...c, pipeline: r.total }))).catch(() => { /* best-effort */ }) getTickets({ status: 'open' }).then((r) => setSectionCounts((c) => ({ ...c, tickets: r.total }))).catch(() => { /* best-effort */ }) getRenewals().then((rows) => setSectionCounts((c) => ({ ...c, renewals: rows.length }))).catch(() => { /* best-effort */ }) getSmsBalances().then((r) => setSectionCounts((c) => ({ ...c, smsLow: r.lowCount }))).catch(() => { /* best-effort */ }) getClients().then((cs) => setSectionCounts((c) => ({ ...c, clients: cs.length }))).catch(() => { /* best-effort */ }) getDocuments({ status: 'draft', pageSize: 1 }).then((r) => setSectionCounts((c) => ({ ...c, docDrafts: r.total }))).catch(() => { /* best-effort */ }) getModules().then((ms) => setSectionCounts((c) => ({ ...c, modules: ms.length }))).catch(() => { /* best-effort */ }) }, []) // Close the mobile drawer and the user menu when the route changes. useEffect(() => { setMenuOpen(false); setUserOpen(false) }, [location.pathname]) // User menu dismissal: outside click or Escape. useEffect(() => { if (!userOpen) return const onDown = (e: MouseEvent) => { if (userRef.current !== null && !userRef.current.contains(e.target as Node)) setUserOpen(false) } const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setUserOpen(false) } document.addEventListener('mousedown', onDown) document.addEventListener('keydown', onKey) return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey) } }, [userOpen]) // Cancel any pending palette query on unmount. useEffect(() => () => window.clearTimeout(queryTimer.current), []) // Ctrl+K / Cmd+K opens the palette. useEffect(() => { const onKey = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') { if (e.repeat) return e.preventDefault() setPaletteOpen((v) => !v) } } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }, []) // Tab title tracks the active nav section (spec §3); record routes fall back to // their section title since they aren't separate nav items. Exact match wins // before prefix match so /documents/new titles as 'New Document', not 'Documents'. useEffect(() => { const items = NAV_GROUPS.flatMap((g) => g.items) const item = items.find((i) => location.pathname === i.to) ?? items.find((i) => i.to !== '/' && location.pathname.startsWith(i.to)) document.title = item !== undefined ? `${item.label} · SiMS HQ` : 'SiMS HQ' }, [location.pathname]) const staticItems = useMemo(() => { const isOwner = role() === 'owner' const navItems = NAV_GROUPS.flatMap((g) => g.items) .filter((i) => i.ownerOnly !== true || isOwner) .map((i) => ({ id: `nav:${i.to}`, label: i.label, group: 'Go to' })) return [ ...navItems, { id: 'act:/documents/new', label: 'New document', group: 'Actions', hint: 'quotation · proforma · invoice' }, { id: 'act:/clients?new=1', label: 'New client', group: 'Actions' }, ] }, []) const GROUP: Record = { client: 'Clients', document: 'Documents', ticket: 'Tickets' } const onPaletteQuery = (q: string) => { window.clearTimeout(queryTimer.current) if (q.trim().length < 2) { querySeq.current++; setClientHits([]); return } const mySeq = ++querySeq.current queryTimer.current = window.setTimeout(() => { // One backend call spans clients, issued documents and tickets (D28); each hit // carries its own route, so selection just navigates to hit.to. getSearch(q) .then((r) => { if (querySeq.current !== mySeq) return setClientHits(r.hits.map((h) => ({ id: `go:${h.to}`, label: h.label, group: GROUP[h.type] ?? 'Results', hint: h.sub, }))) }) .catch(() => { if (querySeq.current !== mySeq) return setClientHits([{ id: 'err:search', label: 'Search failed — is the server up?', group: 'Results' }]) }) }, 200) } const onPaletteSelect = (item: PaletteItem) => { if (item.id.startsWith('err:')) return if (item.id.startsWith('go:')) nav(item.id.slice(3)) else if (item.id.startsWith('nav:')) nav(item.id.slice(4)) else if (item.id.startsWith('act:')) nav(item.id.slice(4)) else if (item.id.startsWith('client:')) nav(`/clients/${item.id.slice(7)}`) } if (!hasSession()) return const isOwner = role() === 'owner' const groups = NAV_GROUPS .map((g) => ({ ...g, items: g.items.filter((i) => i.ownerOnly !== true || isOwner) })) .filter((g) => g.items.length > 0) const name = displayName() const av = avatarColors(name) const badgeCount = queueCounts.queued + queueCounts.failed const badgeTitle = `${queueCounts.queued} queued${queueCounts.failed > 0 ? `, ${queueCounts.failed} failed` : ''}` // Per-section count badges, keyed by route. Neutral by default; amber for SMS low, // red for failed reminder sends. Only shown for a count > 0, and hidden when the rail // is collapsed (CSS). The reminder queue sits on Reminders now, not Dashboard. const counts: Record = {} if (badgeCount > 0) counts['/reminders'] = { n: badgeCount, title: badgeTitle, ...(queueCounts.failed > 0 ? { tone: 'err' as const } : {}) } if (sectionCounts.pipeline > 0) counts['/pipeline'] = { n: sectionCounts.pipeline, title: `${sectionCounts.pipeline} in pipeline` } if (sectionCounts.tickets > 0) counts['/tickets'] = { n: sectionCounts.tickets, title: `${sectionCounts.tickets} open` } if (sectionCounts.renewals > 0) counts['/renewals'] = { n: sectionCounts.renewals, title: `${sectionCounts.renewals} upcoming` } if (sectionCounts.smsLow > 0) counts['/sms-balances'] = { n: sectionCounts.smsLow, tone: 'warn', title: `${sectionCounts.smsLow} low on balance` } if (sectionCounts.clients > 0) counts['/clients'] = { n: sectionCounts.clients, title: `${sectionCounts.clients} client${sectionCounts.clients === 1 ? '' : 's'}` } if (sectionCounts.docDrafts > 0) counts['/documents'] = { n: sectionCounts.docDrafts, title: `${sectionCounts.docDrafts} draft${sectionCounts.docDrafts === 1 ? '' : 's'} to finish` } if (sectionCounts.modules > 0) counts['/modules'] = { n: sectionCounts.modules, title: `${sectionCounts.modules} module${sectionCounts.modules === 1 ? '' : 's'}` } return (
Skip to content {menuOpen &&
setMenuOpen(false)} />}
{gmail === 'down' && (
Gmail disconnected — sends are queued to manual. Run gmail-connect on the server.
)}
{gmail !== 'unknown' && ( {gmail === 'ok' ? 'Gmail' : 'Gmail disconnected'} )} {/* D18 WS-E: the user chip opens a small menu — Profile / Logout. */} { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setUserOpen(false) }} > {userOpen && (
)}
setPaletteOpen(false)} items={[...clientHits, ...staticItems]} onSelect={onPaletteSelect} onQuery={onPaletteQuery} placeholder="Search clients, documents, tickets, pages…" />
) }