merge: Task 9 polish sweep from isolated worktree (favicon, titles, toasts)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 5 days ago
commit 28aa08c9c9

@ -3,6 +3,8 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="theme-color" content="#115e59" />
<title>SiMS HQ</title>
</head>
<body>

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#115e59"/>
<text x="16" y="21.5" font-family="Segoe UI, Arial, sans-serif" font-size="13" font-weight="700" fill="#ffffff" text-anchor="middle">HQ</text>
</svg>

After

Width:  |  Height:  |  Size: 268 B

@ -73,6 +73,14 @@ export function Layout() {
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.
useEffect(() => {
const item = NAV_GROUPS.flatMap((g) => g.items).find((i) =>
i.to === '/' ? location.pathname === '/' : location.pathname.startsWith(i.to))
document.title = item !== undefined && item.to !== '/' ? `${item.label} · SiMS HQ` : 'SiMS HQ'
}, [location.pathname])
const staticItems = useMemo<PaletteItem[]>(() => {
const isOwner = role() === 'owner'
const navItems = NAV_GROUPS.flatMap((g) => g.items)

@ -1,5 +1,5 @@
import { useState } from 'react'
import { Button } from '@sims/ui'
import { Button, useToast } from '@sims/ui'
import { dismissReminder, getReminderPreview, sendReminder, type Reminder } from '../api'
/** Fallback kind labels for queue rows whose server `label` is empty. */
@ -23,13 +23,17 @@ export const reminderTone = (status: string): 'ok' | 'warn' | 'err' | undefined
* and the row must flip live.
*/
export function QueueActions(props: { rem: Reminder; onDone: () => void; onError: (m: string) => void }) {
const toast = useToast()
const [busy, setBusy] = useState(false)
const [mail, setMail] = useState<{ subject: string; body: string } | undefined>()
const [loading, setLoading] = useState(false)
const run = (start: () => Promise<unknown>) => {
// Toast on success only — onDone still fires on failure too (the server has
// already parked the reminder as 'failed' and the row must flip live).
const run = (start: () => Promise<unknown>, okMsg: string) => {
if (busy) return // Button has no disabled prop — guard re-entry here
setBusy(true); props.onError('')
start()
.then(() => toast.ok(okMsg))
.catch((e: Error) => props.onError(e.message))
.then(props.onDone)
.finally(() => setBusy(false))
@ -49,10 +53,10 @@ export function QueueActions(props: { rem: Reminder; onDone: () => void; onError
{SENDABLE.has(props.rem.ruleKind) && (
<>
<Button onClick={preview}>{loading ? '…' : mail !== undefined ? 'Hide' : 'Preview'}</Button>
<Button tone="primary" onClick={() => run(() => sendReminder(props.rem.id))}>{busy ? '…' : 'Send'}</Button>
<Button tone="primary" onClick={() => run(() => sendReminder(props.rem.id), 'Reminder sent')}>{busy ? '…' : 'Send'}</Button>
</>
)}
<Button onClick={() => run(() => dismissReminder(props.rem.id))}>Dismiss</Button>
<Button onClick={() => run(() => dismissReminder(props.rem.id), 'Dismissed')}>Dismiss</Button>
</div>
{mail !== undefined && (
<div style={{ border: '1px solid var(--border)', borderRadius: 6, padding: '6px 8px', background: 'var(--bg-raised)', maxWidth: 420 }}>

@ -137,7 +137,8 @@ export function ClientDetail() {
onChange={(e) => {
setActionErr(undefined)
patchClient(id, { status: e.target.value as ClientStatus })
.then(client.reload).catch((err: Error) => setActionErr(err.message))
.then(() => { toast.ok('Status updated'); client.reload() })
.catch((err: Error) => setActionErr(err.message))
}}
>
{CLIENT_STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
@ -156,7 +157,8 @@ export function ClientDetail() {
onChange={(ownerId) => {
setActionErr(undefined)
setClientOwner(id, ownerId)
.then(client.reload).catch((err: Error) => setActionErr(err.message))
.then(() => { toast.ok('Owner updated'); client.reload() })
.catch((err: Error) => setActionErr(err.message))
}}
/>
</Toolbar>

@ -284,7 +284,6 @@ function ModuleClients(props: { module: Module }) {
function QuoteContent(props: { module: Module; isOwner: boolean; onSaved: () => void }) {
const toast = useToast()
const [text, setText] = useState(props.module.quoteContent.join('\n'))
const [saved, setSaved] = useState(false)
const [error, setError] = useState<string | undefined>()
const [commitNonce, setCommitNonce] = useState(0)
const previewBody = useMemo(() => JSON.stringify({ contentLines: splitLines(text) }), [text])
@ -292,7 +291,7 @@ function QuoteContent(props: { module: Module; isOwner: boolean; onSaved: () =>
const save = () => {
setError(undefined)
patchModule(props.module.id, { quoteContent: splitLines(text) })
.then(() => { setSaved(true); toast.ok('Quote content saved'); props.onSaved() })
.then(() => { toast.ok('Quote content saved'); props.onSaved() })
.catch((e: Error) => setError(e.message))
}
@ -303,10 +302,9 @@ function QuoteContent(props: { module: Module; isOwner: boolean; onSaved: () =>
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start', flexWrap: 'wrap' }}>
<div style={{ flex: '0 0 560px', minWidth: 0 }}>
<textarea className="wf" rows={4} placeholder="One included item per line…"
value={text} onChange={(e) => { setText(e.target.value); setSaved(false) }} onBlur={() => setCommitNonce((n) => n + 1)} />
value={text} onChange={(e) => setText(e.target.value)} onBlur={() => setCommitNonce((n) => n + 1)} />
<Toolbar>
<Button tone="primary" onClick={save}>Save quote content</Button>
{saved && <Badge tone="ok">saved</Badge>}
</Toolbar>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</div>

Loading…
Cancel
Save