|
|
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<boolean | undefined>()
|
|
|
const [menuOpen, setMenuOpen] = useState(false)
|
|
|
const [userOpen, setUserOpen] = useState(false)
|
|
|
const userRef = useRef<HTMLSpanElement>(null)
|
|
|
const [paletteOpen, setPaletteOpen] = useState(false)
|
|
|
const [clientHits, setClientHits] = useState<PaletteItem[]>([])
|
|
|
const queryTimer = useRef<number | undefined>(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 (Reminders 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<PaletteItem[]>(() => {
|
|
|
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<string, string> = { 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 <Navigate to="/login" replace />
|
|
|
|
|
|
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<string, { n: number; tone?: 'warn' | 'err'; title: string }> = {}
|
|
|
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 (
|
|
|
<div className={`hq-shell${collapsed ? ' side-collapsed' : ''}`}>
|
|
|
<a href="#main" className="skip-link">Skip to content</a>
|
|
|
{menuOpen && <div className="hq-scrim" onClick={() => setMenuOpen(false)} />}
|
|
|
<nav className={`hq-side${menuOpen ? ' open' : ''}`}>
|
|
|
<div className="hq-brand">
|
|
|
SiMS HQ
|
|
|
<small>Ops console</small>
|
|
|
</div>
|
|
|
{groups.map((g) => (
|
|
|
<div key={g.label} className="hq-group">
|
|
|
<div className="hq-eyebrow">{g.label}</div>
|
|
|
{g.items.map((n) => {
|
|
|
const c = counts[n.to]
|
|
|
return (
|
|
|
/* Collapsed rail hides the label text (display:none → off the a11y tree),
|
|
|
so name the link explicitly then. Expanded, the visible label + count
|
|
|
badge form the name — an aria-label there would mute the badge. */
|
|
|
<NavLink key={n.to} to={n.to} title={n.label} aria-label={collapsed ? n.label : undefined} className={({ isActive }) => (isActive ? 'active' : '')} end={n.to === '/' || n.end === true}>
|
|
|
<n.icon size={15} strokeWidth={1.8} aria-hidden />
|
|
|
<span className="lbl">{n.label}</span>
|
|
|
{c !== undefined && (
|
|
|
<span
|
|
|
className={`hq-count${c.tone !== undefined ? ` ${c.tone}` : ''}`}
|
|
|
title={c.title}
|
|
|
aria-label={c.title}
|
|
|
>{c.n}</span>
|
|
|
)}
|
|
|
</NavLink>
|
|
|
)
|
|
|
})}
|
|
|
</div>
|
|
|
))}
|
|
|
<div className="hq-side-foot">
|
|
|
<span className="lbl">{APP_VERSION}</span>
|
|
|
{healthy !== undefined && (
|
|
|
<span className={`hq-dot${healthy ? ' ok' : ' err'}`} title={healthy ? 'Server healthy' : 'Server unreachable'} />
|
|
|
)}
|
|
|
<button
|
|
|
type="button" className="hq-collapse" onClick={toggleCollapsed}
|
|
|
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
|
|
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
|
|
>
|
|
|
{collapsed ? '›' : '‹'}
|
|
|
</button>
|
|
|
</div>
|
|
|
</nav>
|
|
|
<div className="hq-main">
|
|
|
{gmail === 'down' && (
|
|
|
<div className="hq-banner">
|
|
|
Gmail disconnected — sends are queued to manual. Run gmail-connect on the server.
|
|
|
</div>
|
|
|
)}
|
|
|
<header className="hq-top">
|
|
|
<button type="button" className="wf hq-burger" aria-label="Menu" aria-expanded={menuOpen} onClick={() => setMenuOpen((v) => !v)}>
|
|
|
<Menu size={16} />
|
|
|
</button>
|
|
|
<button type="button" className="hq-search" onClick={() => setPaletteOpen(true)}>
|
|
|
<Search size={14} aria-hidden />
|
|
|
<span>Search clients, documents, tickets…</span>
|
|
|
<kbd>Ctrl K</kbd>
|
|
|
</button>
|
|
|
<span style={{ flex: 1 }} />
|
|
|
{gmail !== 'unknown' && (
|
|
|
<span className={`hq-pill${gmail === 'ok' ? ' ok' : ' warn'}`}>
|
|
|
<span className={`hq-dot${gmail === 'ok' ? ' ok' : ' warn'}`} />
|
|
|
{gmail === 'ok' ? 'Gmail' : 'Gmail disconnected'}
|
|
|
</span>
|
|
|
)}
|
|
|
<ModeToggle />
|
|
|
{/* D18 WS-E: the user chip opens a small menu — Profile / Logout. */}
|
|
|
<span
|
|
|
className="hq-user-wrap"
|
|
|
ref={userRef}
|
|
|
// Close when keyboard focus leaves the chip+menu — outside-close alone
|
|
|
// only fires on mousedown, so a Tab-away would leave the menu hanging open.
|
|
|
onBlur={(e) => {
|
|
|
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setUserOpen(false)
|
|
|
}}
|
|
|
>
|
|
|
<button
|
|
|
type="button" className="hq-user" title="Account"
|
|
|
aria-haspopup="menu" aria-expanded={userOpen}
|
|
|
onClick={() => setUserOpen((v) => !v)}
|
|
|
>
|
|
|
<span className="hq-user-av" style={{ background: av.bg, color: av.fg }}>{initials(name)}</span>
|
|
|
<span className="hq-user-name">{name}</span>
|
|
|
<Badge>{role()}</Badge>
|
|
|
</button>
|
|
|
{userOpen && (
|
|
|
<div className="hq-user-menu" role="menu">
|
|
|
<button type="button" role="menuitem" className="hq-user-item" onClick={() => { setUserOpen(false); nav('/profile') }}>
|
|
|
<UserRound size={14} aria-hidden /> Profile
|
|
|
</button>
|
|
|
<button type="button" role="menuitem" className="hq-user-item" onClick={() => { setUserOpen(false); clearSession(); nav('/login') }}>
|
|
|
<LogOut size={14} aria-hidden /> Logout
|
|
|
</button>
|
|
|
</div>
|
|
|
)}
|
|
|
</span>
|
|
|
</header>
|
|
|
<main id="main" className="hq-content" tabIndex={-1}>
|
|
|
<Outlet />
|
|
|
</main>
|
|
|
</div>
|
|
|
<CommandPalette
|
|
|
open={paletteOpen}
|
|
|
onClose={() => setPaletteOpen(false)}
|
|
|
items={[...clientHits, ...staticItems]}
|
|
|
onSelect={onPaletteSelect}
|
|
|
onQuery={onPaletteQuery}
|
|
|
placeholder="Search clients, documents, tickets, pages…"
|
|
|
/>
|
|
|
</div>
|
|
|
)
|
|
|
}
|