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, getBillingSettings, getClients, getModules, getPrices, previewDocument, KIND_LABEL, type Client, type DocTotals, type Kind, type ModulePrice, } from '../api' import { LivePreview } from '../components/LivePreview' import { useData } from './Clients' const today = () => new Date().toISOString().slice(0, 10) 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: '', } const editionsOf = (prices: ModulePrice[]): string[] => [...new Set(prices.map((p) => p.edition))] const splitContent = (s: string): string[] => s.split('\n').map((x) => x.trim()).filter((x) => x !== '') 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 } function editionPrice(prices: ModulePrice[], edition: string, kind: Kind | ''): number | null { if (kind !== '') return latestPrice(prices, kind, edition) const hit = prices .filter((p) => p.edition === edition && p.effectiveFrom <= today()) .sort((a, b) => (a.effectiveFrom < b.effectiveFrom ? 1 : -1))[0] return hit !== undefined ? hit.pricePaise : null } /** 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) // D18: invoice due date — auto-filled from billing.payment_terms_days, editable. const [dueDate, setDueDate] = useState('') const [termsDays, setTermsDays] = useState() useEffect(() => { getBillingSettings().then((s) => setTermsDays(s.paymentTermsDays)).catch(() => { /* field stays manual */ }) }, []) useEffect(() => { if (docType === 'INVOICE' && dueDate === '' && termsDays !== undefined) { const d = new Date(); d.setUTCDate(d.getUTCDate() + termsDays) setDueDate(d.toISOString().slice(0, 10)) } }, [docType, termsDays]) // eslint-disable-line react-hooks/exhaustive-deps -- prefill once, never clobber edits // -- 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('') const [hits, setHits] = useState([]) const [client, setClient] = useState() useEffect(() => { if (client !== undefined || q.trim() === '') { setHits([]); return } const t = setTimeout(() => { getClients(q).then(setHits).catch(() => setHits([])) }, 250) return () => clearTimeout(t) }, [q, client]) 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 }) } 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 */ }) } const setLine = (index: number, patch: Partial) => setLines((prev) => prev.map((l, i) => (i === index ? { ...l, ...patch } : l))) /** 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() } : {}), ...(docType === 'INVOICE' && dueDate !== '' ? { dueDate } : {}), }) const previewBody = useMemo(() => JSON.stringify(buildDraftBody()), [docType, client, lines, terms, dueDate]) const save = () => { if (client === undefined) { setError('Pick a client first'); return } const ready = lines.filter((l) => l.moduleId !== '' && l.kind !== '') if (ready.length === 0) { setError('Add at least one line (module + kind)'); return } for (const l of ready) { 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(buildDraftBody()) .then((d) => nav(`/documents/${d.id}`)) .catch((e: Error) => { setError(e.message); setSaving(false) }) } const pendingClient = client === undefined const stripDim = previewBusy || previewErr !== null 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)} /> {hits.length > 0 && (
{hits.slice(0, 8).map((c) => ( ))}
)}
)}
{(['QUOTATION', 'PROFORMA', 'INVOICE'] as const).map((t) => ( ))} {docType === 'INVOICE' && ( { setDueDate(e.target.value); commit() }} /> )}

Lines

{lines.map((l, i) => { 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 (
{editions.length > 1 && ( )} setLine(i, { qty: e.target.value })} onBlur={commit} /> setLine(i, { unitRs: e.target.value })} onBlur={commit} /> setLine(i, { description: e.target.value })} onBlur={commit} />