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

195 lines
7.9 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react'
import { Navigate, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
import { LogOut, Menu, Search } from 'lucide-react'
import {
avatarColors, Badge, CommandPalette, initials, ThemeSwitcher, type PaletteItem,
} from '@sims/ui'
import { clearSession, displayName, getClients, getEmailStatus, getReminders, 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')
const [queueCounts, setQueueCounts] = useState({ queued: 0, failed: 0 })
const [healthy, setHealthy] = useState<boolean | undefined>()
const [menuOpen, setMenuOpen] = useState(false)
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 (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])
// Close the mobile drawer when the route changes.
useEffect(() => { setMenuOpen(false) }, [location.pathname])
// 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)
}, [])
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 onPaletteQuery = (q: string) => {
window.clearTimeout(queryTimer.current)
if (q.trim().length < 2) { querySeq.current++; setClientHits([]); return }
const mySeq = ++querySeq.current
queryTimer.current = window.setTimeout(() => {
getClients(q)
.then((cs) => {
if (querySeq.current !== mySeq) return
setClientHits(cs.slice(0, 8).map((c) => ({
id: `client:${c.id}`, label: `${c.code} · ${c.name}`, group: 'Clients', hint: c.status,
})))
})
.catch(() => {
if (querySeq.current !== mySeq) return
setClientHits([{ id: 'err:clients', label: 'Client search failed — is the server up?', group: 'Clients' }])
})
}, 200)
}
const onPaletteSelect = (item: PaletteItem) => {
if (item.id.startsWith('err:')) return
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` : ''}`
return (
<div className="hq-shell">
{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) => (
<NavLink key={n.to} to={n.to} 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>
{n.to === '/' && badgeCount > 0 && (
<span
className={`hq-count${queueCounts.failed > 0 ? ' err' : ''}`}
title={badgeTitle}
aria-label={badgeTitle}
>{badgeCount}</span>
)}
</NavLink>
))}
</div>
))}
<div className="hq-side-foot">
{APP_VERSION}
{healthy !== undefined && (
<span className={`hq-dot${healthy ? ' ok' : ' err'}`} title={healthy ? 'Server healthy' : 'Server unreachable'} />
)}
</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, pages</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>
)}
<ThemeSwitcher />
{/* D18 WS-E: the user chip opens the self-service profile. */}
<NavLink to="/profile" className="hq-user" title="My profile" style={{ textDecoration: 'none', color: 'inherit' }}>
<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>
</NavLink>
<button
type="button" className="wf" title="Logout" aria-label="Logout"
onClick={() => { clearSession(); nav('/login') }}
>
<LogOut size={14} />
</button>
</header>
<div className="hq-content">
<Outlet />
</div>
</div>
<CommandPalette
open={paletteOpen}
onClose={() => setPaletteOpen(false)}
items={[...clientHits, ...staticItems]}
onSelect={onPaletteSelect}
onQuery={onPaletteQuery}
placeholder="Search clients, pages, actions…"
/>
</div>
)
}