diff --git a/apps/hq-web/src/pages/Modules.tsx b/apps/hq-web/src/pages/Modules.tsx index 154ef09..81210d8 100644 --- a/apps/hq-web/src/pages/Modules.tsx +++ b/apps/hq-web/src/pages/Modules.tsx @@ -46,7 +46,7 @@ export function Modules() {
setCreating(true)}>New module : read-only (staff)} @@ -73,6 +73,7 @@ export function Modules() { )} {selected !== undefined && ( <> + modules.reload()} /> modules.reload()} /> 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. */ +/** 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([...m.allowedKinds]) + const [multi, setMulti] = useState(m.multiSubscription) + const [active, setActive] = useState(m.active) + const [error, setError] = useState() + 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 ( + <> +

Edit module — {m.code}

+
+
+ setName(e.target.value)} /> + setSac(e.target.value)} /> +
+
+
Billing kinds
+
+ {ALL_KINDS.map((k) => ( + + ))} +
+
+
+ + +
+ + + + {error !== undefined && {error}} +
+ + ) +} + function QuoteContent(props: { module: Module; isOwner: boolean; onSaved: () => void }) { const toast = useToast() const [text, setText] = useState(props.module.quoteContent.join('\n')) diff --git a/apps/hq-web/src/pages/NewDocument.tsx b/apps/hq-web/src/pages/NewDocument.tsx index 9191b38..cfcee78 100644 --- a/apps/hq-web/src/pages/NewDocument.tsx +++ b/apps/hq-web/src/pages/NewDocument.tsx @@ -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 { formatINR, fromRupees } from '@sims/domain' import { Badge, Button, Field, Notice, PageHeader, Toolbar } from '@sims/ui' @@ -92,6 +92,18 @@ export function NewDocument() { const [q, setQ] = useState('') const [hits, setHits] = useState([]) const [client, setClient] = useState() + 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) => { + 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(() => { if (client !== undefined || q.trim() === '') { setHits([]); return } const t = setTimeout(() => { getClients(q).then(setHits).catch(() => setHits([])) }, 250) @@ -188,12 +200,16 @@ export function NewDocument() { ) : (
- setQ(e.target.value)} /> + 0} aria-autocomplete="list" + onChange={(e) => setQ(e.target.value)} onKeyDown={onSearchKey} /> {hits.length > 0 && ( -
- {hits.slice(0, 8).map((c) => ( - ))}