|
|
|
|
@ -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) => {
|
|
|
|
|
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<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')
|
|
|
|
|
if (def !== 'standard') setLine(i, { edition: def })
|
|
|
|
|
})
|
|
|
|
|
.catch(() => { /* pricing stays manual */ })
|
|
|
|
|
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 */ })
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
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>
|
|
|
|
|
</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)}
|
|
|
|
|
/>
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
<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>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|