fix(ui): edit-module details form + keyboard nav in New Document client picker

- Modules: selecting a module now shows an 'Edit module' form (owner) — name, SAC,
  billing kinds, multi-sub, active (via existing PATCH /modules; code stays the
  identity). Header hints that clicking a module opens its editors. Fills the gap where
  a module's core details couldn't be edited from the UI.
- New Document client type-ahead: arrow Up/Down move the highlight, Enter selects, Esc
  closes, hover tracks — proper combobox keyboard nav (was mouse-only). typecheck + build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 3 days ago
parent b09f545489
commit 074e810415

@ -46,7 +46,7 @@ export function Modules() {
<div className="wf-page"> <div className="wf-page">
<PageHeader <PageHeader
title="Modules" title="Modules"
desc="What we sell — each with a dated price book (later rows win from their effective date)." desc="What we sell — each with a dated price book. Click a module to edit its details, fields, quote content and prices."
actions={isOwner actions={isOwner
? <Button tone="primary" onClick={() => setCreating(true)}>New module</Button> ? <Button tone="primary" onClick={() => setCreating(true)}>New module</Button>
: <Badge>read-only (staff)</Badge>} : <Badge>read-only (staff)</Badge>}
@ -73,6 +73,7 @@ export function Modules() {
)} )}
{selected !== undefined && ( {selected !== undefined && (
<> <>
<ModuleDetails key={`md-${selected.id}`} module={selected} isOwner={isOwner} onSaved={() => modules.reload()} />
<ModuleClients key={`mc-${selected.id}`} module={selected} /> <ModuleClients key={`mc-${selected.id}`} module={selected} />
<QuoteContent key={selected.id} module={selected} isOwner={isOwner} onSaved={() => modules.reload()} /> <QuoteContent key={selected.id} module={selected} isOwner={isOwner} onSaved={() => modules.reload()} />
<FieldSpecEditor key={`fs-${selected.id}`} module={selected} isOwner={isOwner} onSaved={() => modules.reload()} /> <FieldSpecEditor key={`fs-${selected.id}`} module={selected} isOwner={isOwner} onSaved={() => modules.reload()} />
@ -315,6 +316,66 @@ function ModuleClients(props: { module: Module }) {
} }
/** Per-module "what's included" lines — prefilled into the composer, printed under the quote line. */ /** Per-module "what's included" lines — prefilled into the composer, printed under the quote line. */
/** Edit a module's core details (owner only): name, SAC, billing kinds, multi-sub, active.
* Code is the identity and is not editable. Field-spec / quote-content / prices have their
* own panels below. */
function ModuleDetails(props: { module: Module; isOwner: boolean; onSaved: () => void }) {
const toast = useToast()
const m = props.module
const [name, setName] = useState(m.name)
const [sac, setSac] = useState(m.sac)
const [kinds, setKinds] = useState<Kind[]>([...m.allowedKinds])
const [multi, setMulti] = useState(m.multiSubscription)
const [active, setActive] = useState(m.active)
const [error, setError] = useState<string | undefined>()
const [busy, setBusy] = useState(false)
const toggleKind = (k: Kind) =>
setKinds((ks) => (ks.includes(k) ? ks.filter((x) => x !== k) : [...ks, k]))
const save = () => {
if (busy) return
if (name.trim() === '') { setError('Name is required'); return }
if (kinds.length === 0) { setError('Pick at least one billing kind'); return }
setError(undefined); setBusy(true)
patchModule(m.id, { name: name.trim(), sac: sac.trim(), allowedKinds: kinds, multiSubscription: multi, active })
.then(() => { toast.ok('Module saved'); props.onSaved() })
.catch((e: Error) => setError(e.message))
.finally(() => setBusy(false))
}
if (!props.isOwner) return null
return (
<>
<h3 style={{ marginTop: 20 }}>Edit module <span className="mono">{m.code}</span></h3>
<div className="wf-card" style={{ padding: 14 }}>
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<Field label="Name"><input className="wf" style={{ width: 260 }} value={name} onChange={(e) => setName(e.target.value)} /></Field>
<Field label="SAC"><input className="wf mono" style={{ width: 120 }} value={sac} onChange={(e) => setSac(e.target.value)} /></Field>
</div>
<div style={{ marginTop: 12 }}>
<div style={{ fontSize: 12, opacity: 0.7, marginBottom: 4 }}>Billing kinds</div>
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap' }}>
{ALL_KINDS.map((k) => (
<label key={k} style={{ display: 'inline-flex', gap: 6, alignItems: 'center' }}>
<input type="checkbox" checked={kinds.includes(k)} onChange={() => toggleKind(k)} /> {KIND_LABEL[k]}
</label>
))}
</div>
</div>
<div style={{ display: 'flex', gap: 20, marginTop: 12 }}>
<label style={{ display: 'inline-flex', gap: 6, alignItems: 'center' }}><input type="checkbox" checked={multi} onChange={(e) => setMulti(e.target.checked)} /> Multi-subscription</label>
<label style={{ display: 'inline-flex', gap: 6, alignItems: 'center' }}><input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} /> Active</label>
</div>
<Toolbar>
<Button tone="primary" disabled={busy} onClick={save}>{busy ? 'Saving…' : 'Save module'}</Button>
</Toolbar>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</div>
</>
)
}
function QuoteContent(props: { module: Module; isOwner: boolean; onSaved: () => void }) { function QuoteContent(props: { module: Module; isOwner: boolean; onSaved: () => void }) {
const toast = useToast() const toast = useToast()
const [text, setText] = useState(props.module.quoteContent.join('\n')) const [text, setText] = useState(props.module.quoteContent.join('\n'))

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react' import { useEffect, useMemo, useState, type ReactNode, type KeyboardEvent as ReactKeyboardEvent } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { formatINR, fromRupees } from '@sims/domain' import { formatINR, fromRupees } from '@sims/domain'
import { Badge, Button, Field, Notice, PageHeader, Toolbar } from '@sims/ui' import { Badge, Button, Field, Notice, PageHeader, Toolbar } from '@sims/ui'
@ -92,6 +92,18 @@ export function NewDocument() {
const [q, setQ] = useState('') const [q, setQ] = useState('')
const [hits, setHits] = useState<Client[]>([]) const [hits, setHits] = useState<Client[]>([])
const [client, setClient] = useState<Client | undefined>() const [client, setClient] = useState<Client | undefined>()
const [active, setActive] = useState(0) // highlighted hit for keyboard nav
// Keep the highlight in range whenever the result list changes.
useEffect(() => { setActive(0) }, [hits])
const pickHit = (c: Client | undefined) => { if (c !== undefined) { setClient(c); setHits([]); commit() } }
const onSearchKey = (e: ReactKeyboardEvent<HTMLInputElement>) => {
const shown = hits.slice(0, 8)
if (shown.length === 0) return
if (e.key === 'ArrowDown') { e.preventDefault(); setActive((i) => Math.min(i + 1, shown.length - 1)) }
else if (e.key === 'ArrowUp') { e.preventDefault(); setActive((i) => Math.max(i - 1, 0)) }
else if (e.key === 'Enter') { e.preventDefault(); pickHit(shown[active]) }
else if (e.key === 'Escape') { e.preventDefault(); setHits([]) }
}
useEffect(() => { useEffect(() => {
if (client !== undefined || q.trim() === '') { setHits([]); return } if (client !== undefined || q.trim() === '') { setHits([]); return }
const t = setTimeout(() => { getClients(q).then(setHits).catch(() => setHits([])) }, 250) const t = setTimeout(() => { getClients(q).then(setHits).catch(() => setHits([])) }, 250)
@ -188,12 +200,16 @@ export function NewDocument() {
</Toolbar> </Toolbar>
) : ( ) : (
<div className="composer-search" style={{ position: 'relative', maxWidth: 340 }}> <div className="composer-search" style={{ position: 'relative', maxWidth: 340 }}>
<input className="wf" placeholder="Type to search clients…" value={q} autoFocus onChange={(e) => setQ(e.target.value)} /> <input className="wf" placeholder="Type to search clients…" value={q} autoFocus
role="combobox" aria-expanded={hits.length > 0} aria-autocomplete="list"
onChange={(e) => setQ(e.target.value)} onKeyDown={onSearchKey} />
{hits.length > 0 && ( {hits.length > 0 && (
<div style={{ position: 'absolute', zIndex: 5, top: '100%', left: 0, right: 0, background: 'var(--bg-raised)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', boxShadow: 'var(--shadow-lg)' }}> <div role="listbox" style={{ position: 'absolute', zIndex: 5, top: '100%', left: 0, right: 0, background: 'var(--bg-raised)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', boxShadow: 'var(--shadow-lg)' }}>
{hits.slice(0, 8).map((c) => ( {hits.slice(0, 8).map((c, i) => (
<button key={c.id} type="button" className="wf" style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none' }} <button key={c.id} type="button" className="wf" role="option" aria-selected={i === active}
onClick={() => { setClient(c); setHits([]); commit() }}> style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none', background: i === active ? 'var(--accent-soft, #dce8ff)' : 'transparent' }}
onMouseEnter={() => setActive(i)}
onClick={() => pickHit(c)}>
{c.code} · {c.name} {c.code} · {c.name}
</button> </button>
))} ))}

Loading…
Cancel
Save