You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq-web/src/Layout.tsx

293 lines
14 KiB
TypeScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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>
)
}