From 08e067c7cc2c9bed3e78cc0960da446348a39aaf Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Mon, 13 Jul 2026 16:19:45 +0530 Subject: [PATCH] =?UTF-8?q?feat(hq-web):=20composer=20split=20view=20?= =?UTF-8?q?=E2=80=94=20server-truth=20totals=20strip,=20live=20paper,=20re?= =?UTF-8?q?sponsive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- apps/hq-web/src/pages/NewDocument.tsx | 291 +++++++++++++------------- 1 file changed, 150 insertions(+), 141 deletions(-) diff --git a/apps/hq-web/src/pages/NewDocument.tsx b/apps/hq-web/src/pages/NewDocument.tsx index ab9cde0..4a473c5 100644 --- a/apps/hq-web/src/pages/NewDocument.tsx +++ b/apps/hq-web/src/pages/NewDocument.tsx @@ -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 {children} +} + +/** 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([{ ...EMPTY_LINE }]) const [terms, setTerms] = useState('') const [error, setError] = useState() 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() + const [warnings, setWarnings] = useState([]) + const [previewBusy, setPreviewBusy] = useState(false) + const [previewErr, setPreviewErr] = useState(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() 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>({}) const loadPrices = (moduleId: string): Promise => { 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) => { - 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 */ }) + 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 */ }) } - const setLine = (index: number, patch: Partial) => 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 ( -
-
-
- + const totalsStrip = ( +
+ + {totals && totals.igstPaise > 0 + ? + : <>} + {totals && totals.roundOffPaise !== 0 && } + + {pendingClient && split pending client} + {warnings.length > 0 && {warnings.length} price gap(s)} +
+ ) + + const paper = ( + previewDocument(JSON.parse(b), signal)} + onResult={(r) => { setTotals(r.totals); setWarnings(r.warnings ?? []) }} + onStatus={(s) => { setPreviewBusy(s.busy); setPreviewErr(s.error) }} + /> + ) + + const form = ( +
+ {client !== undefined ? ( {client.code} · {client.name} - + ) : (
- setQ(e.target.value)} - /> + setQ(e.target.value)} /> {hits.length > 0 && ( -
+
{hits.slice(0, 8).map((c) => ( - ))} @@ -179,7 +186,7 @@ export function NewDocument() { {(['QUOTATION', 'PROFORMA', 'INVOICE'] as const).map((t) => ( ))} @@ -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 (
- - { const kind = e.target.value as Kind; setLine(i, { kind }); if (l.moduleId !== '') prefill(i, l.moduleId, kind, l.edition); commit() }}> {(mod?.allowedKinds ?? []).map((k) => )} {editions.length > 1 && ( - { 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 })} )} - setLine(i, { qty: e.target.value })} /> + setLine(i, { qty: e.target.value })} onBlur={commit} /> - setLine(i, { unitRs: e.target.value })} /> + setLine(i, { unitRs: e.target.value })} onBlur={commit} /> - setLine(i, { description: e.target.value })} /> + setLine(i, { description: e.target.value })} onBlur={commit} /> -