feat(hq-web): composer split view — server-truth totals strip, live paper, responsive

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 1 week ago
parent c2eb7acba0
commit 08e067c7cc

@ -1,10 +1,10 @@
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import { formatINR, fromRupees } from '@sims/domain'
import { Badge, Button, Field, Notice, PageHeader, Toolbar } from '@sims/ui'
import {
createDocument, getClients, getModules, getPrices, previewDocument,
KIND_LABEL, type Client, type Kind, type ModulePrice,
KIND_LABEL, type Client, type DocTotals, type Kind, type ModulePrice,
} from '../api'
import { LivePreview } from '../components/LivePreview'
import { useData } from './Clients'
@ -15,26 +15,19 @@ interface LineDraft {
moduleId: string; kind: Kind | ''; qty: string; unitRs: string; description: string
edition: string; contentText: string
}
const EMPTY_LINE: LineDraft = {
moduleId: '', kind: '', qty: '1', unitRs: '', description: '', edition: 'standard', contentText: '',
}
/** Distinct editions (packs) offered for a module, in price-book order. */
const editionsOf = (prices: ModulePrice[]): string[] => [...new Set(prices.map((p) => p.edition))]
/** Content lines are one bullet per row; blanks are dropped before posting. */
const splitContent = (s: string): string[] => s.split('\n').map((x) => x.trim()).filter((x) => x !== '')
/** Latest effective price for kind+edition on/before today — mirrors the server's priceOn. */
function latestPrice(prices: ModulePrice[], kind: Kind, edition = 'standard'): number | null {
const hit = prices
.filter((p) => p.kind === kind && p.edition === edition && p.effectiveFrom <= today())
.sort((a, b) => (a.effectiveFrom < b.effectiveFrom ? 1 : -1))[0]
return hit !== undefined ? hit.pricePaise : null
}
/** A pack's headline price for the option label — the chosen kind if set, else the latest row. */
function editionPrice(prices: ModulePrice[], edition: string, kind: Kind | ''): number | null {
if (kind !== '') return latestPrice(prices, kind, edition)
const hit = prices
@ -43,16 +36,44 @@ function editionPrice(prices: ModulePrice[], edition: string, kind: Kind | ''):
return hit !== undefined ? hit.pricePaise : null
}
/** The quotation-in-minutes composer: client → lines → Save Draft (server computes GST). */
/** matchMedia hook — the composer collapses to one column on the owner's phone. */
function useNarrow(): boolean {
const [narrow, setNarrow] = useState(() => typeof window !== 'undefined' && window.matchMedia('(max-width: 800px)').matches)
useEffect(() => {
const mq = window.matchMedia('(max-width: 800px)')
const on = () => setNarrow(mq.matches)
mq.addEventListener('change', on)
return () => mq.removeEventListener('change', on)
}, [])
return narrow
}
/** Value that flashes a highlight for 250ms when it changes (cause→effect visible). */
function Pulse({ children, on }: { children: ReactNode; on: string | number }) {
const [pulse, setPulse] = useState(false)
useEffect(() => { setPulse(true); const t = setTimeout(() => setPulse(false), 250); return () => clearTimeout(t) }, [on])
return <span style={{ transition: 'background 250ms ease', background: pulse ? 'var(--accent-soft, #dce8ff)' : 'transparent', borderRadius: 4, padding: '0 3px' }}>{children}</span>
}
/** The quotation-in-minutes composer: client → lines → live preview → Save Draft. */
export function NewDocument() {
const nav = useNavigate()
const narrow = useNarrow()
const modules = useData(getModules, [])
const [docType, setDocType] = useState<'QUOTATION' | 'PROFORMA' | 'INVOICE'>('QUOTATION')
const [lines, setLines] = useState<LineDraft[]>([{ ...EMPTY_LINE }])
const [terms, setTerms] = useState('')
const [error, setError] = useState<string | undefined>()
const [saving, setSaving] = useState(false)
// -- live preview wiring: server totals + warnings replace all client money math --
const [commitNonce, setCommitNonce] = useState(0)
const commit = () => setCommitNonce((n) => n + 1)
const [totals, setTotals] = useState<DocTotals | undefined>()
const [warnings, setWarnings] = useState<string[]>([])
const [previewBusy, setPreviewBusy] = useState(false)
const [previewErr, setPreviewErr] = useState<string | null>(null)
const [sheetOpen, setSheetOpen] = useState(false) // narrow: full-screen paper sheet
// -- client type-ahead (debounced) --
const [q, setQ] = useState('')
@ -60,41 +81,39 @@ export function NewDocument() {
const [client, setClient] = useState<Client | undefined>()
useEffect(() => {
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)
return () => clearTimeout(t)
}, [q, client])
// -- per-module price book, in state so the pack pick-list renders reactively --
const [pricesByModule, setPricesByModule] = useState<Record<string, ModulePrice[]>>({})
const loadPrices = (moduleId: string): Promise<ModulePrice[]> => {
const cached = pricesByModule[moduleId]
if (cached !== undefined) return Promise.resolve(cached)
return getPrices(moduleId).then((prices) => {
setPricesByModule((prev) => ({ ...prev, [moduleId]: prices }))
return prices
})
return getPrices(moduleId).then((prices) => { setPricesByModule((prev) => ({ ...prev, [moduleId]: prices })); return prices })
}
const prefill = (index: number, moduleId: string, kind: Kind, edition: string) => {
loadPrices(moduleId)
.then((prices) => {
loadPrices(moduleId).then((prices) => {
const paise = latestPrice(prices, kind, edition)
setLines((prev) => prev.map((l, i) =>
i === index ? { ...l, unitRs: paise !== null ? String(paise / 100) : l.unitRs } : l))
})
.catch(() => { /* no prefill — the unit price stays editable */ })
setLines((prev) => prev.map((l, i) => (i === index ? { ...l, unitRs: paise !== null ? String(paise / 100) : l.unitRs } : l)))
}).catch(() => { /* no prefill — the unit price stays editable */ })
}
const setLine = (index: number, patch: Partial<LineDraft>) =>
setLines((prev) => prev.map((l, i) => (i === index ? { ...l, ...patch } : l)))
// Client-side preview only — the server's computeBill is authoritative for tax.
const subtotalPaise = lines.reduce((sum, l) => {
const qty = Number(l.qty)
const rs = Number(l.unitRs)
return Number.isFinite(qty) && Number.isFinite(rs) && l.moduleId !== '' ? sum + Math.round(qty * fromRupees(rs)) : sum
}, 0)
/** The ONE body builder — save() and the live preview post byte-identical bodies. */
const buildDraftBody = () => ({
docType,
clientId: client?.id ?? '',
lines: lines.filter((l) => l.moduleId !== '' && l.kind !== '').map((l) => ({
moduleId: l.moduleId, kind: l.kind as Kind, qty: Number(l.qty),
...(l.edition !== 'standard' ? { edition: l.edition } : {}),
...(l.unitRs !== '' ? { unitPricePaise: fromRupees(Number(l.unitRs)) } : {}),
...(l.description !== '' ? { description: l.description } : {}),
contentLines: splitContent(l.contentText),
})),
...(terms.trim() !== '' ? { terms: terms.trim() } : {}),
})
const previewBody = useMemo(() => JSON.stringify(buildDraftBody()), [docType, client, lines, terms])
const save = () => {
if (client === undefined) { setError('Pick a client first'); return }
@ -104,68 +123,56 @@ export function NewDocument() {
if (!Number.isFinite(Number(l.qty)) || Number(l.qty) <= 0) { setError('Every line needs a positive quantity'); return }
if (l.unitRs !== '' && !Number.isFinite(Number(l.unitRs))) { setError('Unit price must be a rupee amount'); return }
}
setError(undefined)
setSaving(true)
createDocument({
docType,
clientId: client.id,
lines: ready.map((l) => ({
moduleId: l.moduleId, kind: l.kind, qty: Number(l.qty),
...(l.edition !== 'standard' ? { edition: l.edition } : {}),
...(l.unitRs !== '' ? { unitPricePaise: fromRupees(Number(l.unitRs)) } : {}),
...(l.description !== '' ? { description: l.description } : {}),
contentLines: splitContent(l.contentText),
})),
...(terms.trim() !== '' ? { terms: terms.trim() } : {}),
})
setError(undefined); setSaving(true)
createDocument(buildDraftBody())
.then((d) => nav(`/documents/${d.id}`))
.catch((e: Error) => { setError(e.message); setSaving(false) })
}
const previewBody = JSON.stringify({
docType, clientId: client?.id ?? '',
lines: lines.filter((l) => l.moduleId !== '' && l.kind !== '').map((l) => ({
moduleId: l.moduleId, kind: l.kind, qty: Number(l.qty),
...(l.edition !== 'standard' ? { edition: l.edition } : {}),
...(l.unitRs !== '' ? { unitPricePaise: fromRupees(Number(l.unitRs)) } : {}),
...(l.description !== '' ? { description: l.description } : {}),
contentLines: splitContent(l.contentText),
})),
...(terms.trim() !== '' ? { terms: terms.trim() } : {}),
})
const pendingClient = client === undefined
const stripDim = previewBusy || previewErr !== null
return (
<div style={{ display: 'flex', gap: 16, alignItems: 'stretch' }}>
<div style={{ flex: '0 0 600px', minWidth: 0 }}>
<div className="wf-page">
<PageHeader
title="New Document"
desc="Compose a quotation, proforma or invoice. GST is computed server-side on save."
const totalsStrip = (
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap', alignItems: 'baseline', padding: '8px 12px', borderTop: '1px solid var(--border)', background: 'var(--bg-raised)', opacity: stripDim ? 0.6 : 1, transition: 'opacity 120ms ease' }}>
<Cell label="Taxable" value={totals ? formatINR(totals.taxablePaise) : '—'} />
{totals && totals.igstPaise > 0
? <Cell label="IGST" value={formatINR(totals.igstPaise)} />
: <><Cell label="CGST" value={totals ? formatINR(totals.cgstPaise) : '—'} /><Cell label="SGST" value={totals ? formatINR(totals.sgstPaise) : '—'} /></>}
{totals && totals.roundOffPaise !== 0 && <Cell label="Round-off" value={formatINR(totals.roundOffPaise)} />}
<Cell label="Payable" value={totals ? formatINR(totals.payablePaise) : '—'} strong />
{pendingClient && <Badge tone="warn">split pending client</Badge>}
{warnings.length > 0 && <Badge tone="warn">{warnings.length} price gap(s)</Badge>}
</div>
)
const paper = (
<LivePreview
body={previewBody}
commitNonce={commitNonce}
fetchPreview={(b, signal) => previewDocument(JSON.parse(b), signal)}
onResult={(r) => { setTotals(r.totals); setWarnings(r.warnings ?? []) }}
onStatus={(s) => { setPreviewBusy(s.busy); setPreviewErr(s.error) }}
/>
)
const form = (
<div className="wf-page" style={{ overflow: 'auto', height: narrow ? 'auto' : 'calc(100vh - 200px)', paddingBottom: narrow ? 72 : 0 }}>
<PageHeader title="New Document" desc="Compose a quotation, proforma or invoice. GST is computed and previewed live by the server." />
<Field label="Client">
{client !== undefined ? (
<Toolbar>
<Badge tone="accent">{client.code} · {client.name}</Badge>
<Button onClick={() => { setClient(undefined); setQ('') }}>Change</Button>
<Button onClick={() => { setClient(undefined); setQ(''); commit() }}>Change</Button>
</Toolbar>
) : (
<div 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 onChange={(e) => setQ(e.target.value)} />
{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: 8,
}}>
<div style={{ position: 'absolute', zIndex: 5, top: '100%', left: 0, right: 0, background: 'var(--bg-raised)', border: '1px solid var(--border)', borderRadius: 8 }}>
{hits.slice(0, 8).map((c) => (
<button
key={c.id} type="button" className="wf"
style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none' }}
onClick={() => { setClient(c); setHits([]); setCommitNonce((n) => n + 1) }}
>
<button key={c.id} type="button" className="wf" style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none' }}
onClick={() => { setClient(c); setHits([]); commit() }}>
{c.code} · {c.name}
</button>
))}
@ -179,7 +186,7 @@ export function NewDocument() {
<Toolbar>
{(['QUOTATION', 'PROFORMA', 'INVOICE'] as const).map((t) => (
<label key={t} style={{ display: 'flex', gap: 4, alignItems: 'center', fontSize: 13 }}>
<input type="radio" name="doctype" checked={docType === t} onChange={() => setDocType(t)} />
<input type="radio" name="doctype" checked={docType === t} onChange={() => { setDocType(t); commit() }} />
{t === 'QUOTATION' ? 'Quotation' : t === 'PROFORMA' ? 'Proforma' : 'Invoice'}
</label>
))}
@ -191,97 +198,68 @@ export function NewDocument() {
const mod = modules.data?.find((m) => m.id === l.moduleId)
const prices = pricesByModule[l.moduleId] ?? []
const editions = editionsOf(prices)
const priceGap = warnings.some((w) => mod !== undefined && w.includes(mod.code))
return (
<div key={i} style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end', marginBottom: 8 }}>
<Field label="Module">
<select
className="wf" value={l.moduleId}
<select className="wf" value={l.moduleId}
onChange={(e) => {
const moduleId = e.target.value
const picked = modules.data?.find((m) => m.id === moduleId)
setLine(i, {
moduleId, kind: '', unitRs: '', edition: 'standard',
contentText: (picked?.quoteContent ?? []).join('\n'),
})
// Default to the sole/standard edition; packs with no 'standard' pick the first row.
setLine(i, { moduleId, kind: '', unitRs: '', edition: 'standard', contentText: (picked?.quoteContent ?? []).join('\n') })
if (moduleId !== '') {
loadPrices(moduleId)
.then((ps) => {
const eds = editionsOf(ps)
const def = eds.includes('standard') ? 'standard' : (eds[0] ?? 'standard')
loadPrices(moduleId).then((ps) => {
const eds = editionsOf(ps); const def = eds.includes('standard') ? 'standard' : (eds[0] ?? 'standard')
if (def !== 'standard') setLine(i, { edition: def })
})
.catch(() => { /* pricing stays manual */ })
}).catch(() => { /* pricing stays manual */ })
}
}}
>
commit()
}}>
<option value="">Module</option>
{(modules.data ?? []).filter((m) => m.active).map((m) => (
<option key={m.id} value={m.id}>{m.name}</option>
))}
{(modules.data ?? []).filter((m) => m.active).map((m) => <option key={m.id} value={m.id}>{m.name}</option>)}
</select>
</Field>
<Field label="Kind">
<select
className="wf" value={l.kind}
onChange={(e) => {
const kind = e.target.value as Kind
setLine(i, { kind })
if (l.moduleId !== '') prefill(i, l.moduleId, kind, l.edition)
}}
>
<select className="wf" value={l.kind}
onChange={(e) => { const kind = e.target.value as Kind; setLine(i, { kind }); if (l.moduleId !== '') prefill(i, l.moduleId, kind, l.edition); commit() }}>
<option value="">Kind</option>
{(mod?.allowedKinds ?? []).map((k) => <option key={k} value={k}>{KIND_LABEL[k]}</option>)}
</select>
</Field>
{editions.length > 1 && (
<Field label="Pack">
<select
className="wf" value={l.edition}
onChange={(e) => {
const edition = e.target.value
setLine(i, { edition })
if (l.moduleId !== '' && l.kind !== '') prefill(i, l.moduleId, l.kind, edition)
}}
>
{editions.map((ed) => {
const p = editionPrice(prices, ed, l.kind)
return <option key={ed} value={ed}>{ed}{p !== null ? `${formatINR(p)}` : ''}</option>
})}
<select className="wf" value={l.edition}
onChange={(e) => { const edition = e.target.value; setLine(i, { edition }); if (l.moduleId !== '' && l.kind !== '') prefill(i, l.moduleId, l.kind, edition); commit() }}>
{editions.map((ed) => { const p = editionPrice(prices, ed, l.kind); return <option key={ed} value={ed}>{ed}{p !== null ? `${formatINR(p)}` : ''}</option> })}
</select>
</Field>
)}
<Field label="Qty">
<input className="wf num" style={{ width: 70 }} value={l.qty} onChange={(e) => setLine(i, { qty: e.target.value })} />
<input className="wf num" style={{ width: 70 }} value={l.qty} onChange={(e) => setLine(i, { qty: e.target.value })} onBlur={commit} />
</Field>
<Field label="Unit ₹ (ex-GST)">
<input className="wf num" style={{ width: 120 }} value={l.unitRs} onChange={(e) => setLine(i, { unitRs: e.target.value })} />
<input className="wf num" style={{ width: 120, ...(priceGap ? { borderColor: 'var(--warn, #d08700)' } : {}) }}
placeholder={priceGap ? 'No price — enter Unit ₹' : ''} value={l.unitRs}
onChange={(e) => setLine(i, { unitRs: e.target.value })} onBlur={commit} />
</Field>
<Field label="Description (optional)">
<input className="wf" value={l.description} onChange={(e) => setLine(i, { description: e.target.value })} />
<input className="wf" value={l.description} onChange={(e) => setLine(i, { description: e.target.value })} onBlur={commit} />
</Field>
<Field label="What's included (one per line)">
<textarea
className="wf" rows={2} style={{ minWidth: 220 }}
value={l.contentText} onChange={(e) => setLine(i, { contentText: e.target.value })}
/>
<textarea className="wf" rows={2} style={{ minWidth: 220 }} value={l.contentText} onChange={(e) => setLine(i, { contentText: e.target.value })} onBlur={commit} />
</Field>
{lines.length > 1 && (
<Button tone="danger" onClick={() => setLines((prev) => prev.filter((_x, j) => j !== i))}>Remove</Button>
<Button tone="danger" onClick={() => { setLines((prev) => prev.filter((_x, j) => j !== i)); commit() }}>Remove</Button>
)}
</div>
)
})}
<Toolbar>
<Button onClick={() => setLines((prev) => [...prev, { ...EMPTY_LINE }])}>Add line</Button>
<Badge tone="accent">Subtotal {formatINR(subtotalPaise)} + GST (server-computed)</Badge>
<Button onClick={() => { setLines((prev) => [...prev, { ...EMPTY_LINE }]); commit() }}>Add line</Button>
</Toolbar>
<Field label="Terms (optional)">
<textarea
className="wf" rows={2} style={{ maxWidth: 560 }}
value={terms} onChange={(e) => setTerms(e.target.value)}
/>
<textarea className="wf" rows={2} style={{ maxWidth: 560 }} value={terms} onChange={(e) => setTerms(e.target.value)} onBlur={commit} />
</Field>
{modules.error !== undefined && <Notice tone="err">{modules.error}</Notice>}
@ -290,16 +268,47 @@ export function NewDocument() {
<Button tone="primary" onClick={save}>{saving ? 'Saving…' : 'Save Draft'}</Button>
</div>
</div>
)
if (narrow) {
return (
<>
{form}
{/* pinned bar never disappears — always shows Payable + GST + Preview */}
<div style={{ position: 'fixed', left: 0, right: 0, bottom: 0, zIndex: 20, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, padding: '8px 12px', background: 'var(--bg-raised)', borderTop: '1px solid var(--border)' }}>
<div style={{ display: 'flex', gap: 12, opacity: stripDim ? 0.6 : 1 }}>
<Cell label="GST" value={totals ? formatINR((totals.cgstPaise + totals.sgstPaise) || totals.igstPaise) : '—'} />
<Cell label="Payable" value={totals ? formatINR(totals.payablePaise) : '—'} strong />
</div>
<div style={{ flex: 1, minWidth: 0, background: 'var(--bg-sunken, #e9e9ee)', borderRadius: 8, height: 'calc(100vh - 140px)', position: 'sticky', top: 12, padding: 12 }}>
<div style={{ width: '100%', height: '100%', background: '#fff', borderRadius: 4, boxShadow: '0 1px 6px rgba(0,0,0,.15)', overflow: 'hidden' }}>
<LivePreview
body={previewBody}
commitNonce={commitNonce}
fetchPreview={(b, signal) => previewDocument(JSON.parse(b), signal)}
/>
<Button tone="primary" onClick={() => setSheetOpen(true)}>Preview</Button>
</div>
{sheetOpen && (
<div style={{ position: 'fixed', inset: 0, zIndex: 30, background: 'var(--bg-sunken, #e9e9ee)', display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: 8 }}><Button onClick={() => setSheetOpen(false)}>Close</Button></div>
<div style={{ flex: 1, margin: 12, marginTop: 0, background: '#fff', borderRadius: 4, overflow: 'hidden' }}>{paper}</div>
{totalsStrip}
</div>
)}
</>
)
}
return (
<div style={{ display: 'flex', gap: 16, alignItems: 'stretch' }}>
<div style={{ flex: '0 0 600px', minWidth: 0 }}>{form}</div>
<div style={{ flex: 1, minWidth: 0, position: 'sticky', top: 12, height: 'calc(100vh - 140px)', display: 'flex', flexDirection: 'column', background: 'var(--bg-sunken, #e9e9ee)', borderRadius: 8, padding: 12 }}>
<div style={{ flex: 1, minHeight: 0, background: '#fff', borderRadius: 4, boxShadow: '0 1px 6px rgba(0,0,0,.15)', overflow: 'hidden' }}>{paper}</div>
{totalsStrip}
</div>
</div>
)
}
function Cell({ label, value, strong }: { label: string; value: string; strong?: boolean }) {
return (
<span style={{ display: 'inline-flex', flexDirection: 'column', lineHeight: 1.2 }}>
<span style={{ fontSize: 10, textTransform: 'uppercase', color: 'var(--text-muted, #666)' }}>{label}</span>
<Pulse on={value}><span style={{ fontSize: strong ? 15 : 13, fontWeight: strong ? 700 : 500 }}>{value}</span></Pulse>
</span>
)
}

Loading…
Cancel
Save